diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..843be105 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,20 @@ +# OpenStrap + +## Widget gallery + +A debug-only screen that hosts each home screen widget at its three sizes, with forced moods, moments and strap +states. Its code stays in `app/src/debug/kotlin/org/openstrap/app/widget/debug/WidgetGalleryActivity.kt` (it still +compiles), but its activity is commented out in `app/src/debug/AndroidManifest.xml`, so it isn't installed: while +open, its widgets count as placed and refresh along with the real ones. + +To test widgets again: + +1. Uncomment the `WidgetGalleryActivity` `` in `app/src/debug/AndroidManifest.xml`. +2. Build and install the debug app (check no workout is recording first: a `workout` notification channel in + `adb shell dumpsys notification --noredact | grep "pkg=org.openstrap.app"`). +3. `adb shell appwidget grantbind --package org.openstrap.app --user 0` +4. `adb shell am start -f 0x00020000 -n org.openstrap.app/.widget.debug.WidgetGalleryActivity --es widget moment` + Extras: `widget` recovery|steps|sleep|start|moment, `mood` day|evening|night|auto, `cells` oneui|min, + `moment` a moment name or auto, `placed` kinds or none|auto, `strap` notpaired|low|stale|yesterday|week|syncing|none|auto, + `pin` a kind. A render takes 15 to 20 s. +5. When done, close it with Back (so it unbinds its widgets), then comment the activity out again. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf813f58..1ba7199f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -105,6 +105,9 @@ dependencies { implementation(libs.haze.blur) // Workout maps: open source, no API key; tiles from OpenFreeMap (README, Network use). implementation(libs.maplibre) + // Home screen widgets (docs/design/widgets/spec.md 6.1). + implementation(libs.glance.appwidget) + implementation(libs.work.runtime.ktx) testImplementation(libs.junit) // Android's org.json is a stub in plain JVM unit tests. diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 9bd0f257..657691f3 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -6,5 +6,18 @@ + diff --git a/app/src/debug/kotlin/org/openstrap/app/DemoData.kt b/app/src/debug/kotlin/org/openstrap/app/DemoData.kt index 50a5ce1e..909af0fc 100644 --- a/app/src/debug/kotlin/org/openstrap/app/DemoData.kt +++ b/app/src/debug/kotlin/org/openstrap/app/DemoData.kt @@ -21,8 +21,13 @@ import org.openstrap.app.data.db.SleepSession import org.openstrap.app.data.db.SleepStage import org.openstrap.app.data.db.Spo2Sample import org.openstrap.app.data.db.StressSample +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.AppWorkoutHeartRate +import org.openstrap.app.data.db.AppWorkoutRoutePoint import java.time.LocalDate import java.time.ZoneId +import kotlin.math.cos +import kotlin.math.hypot import kotlin.math.sin import kotlin.random.Random @@ -135,5 +140,47 @@ object DemoData { dao.insertSpo2(listOf(Spo2Sample(midnight + 3 * 3600, 96 + rnd.nextInt(0, 4)))) dao.insertPai(listOf(PaiSample(endOfData - 60, rnd.nextFloat() * 20, 40f + back, 58, 192))) } + phoneRun(dao, LocalDate.now().minusDays(1).atStartOfDay(zone).toEpochSecond() + 7 * 3600, rnd) + } + + /** + * A run recorded on the phone with GPS, its route kept: once round Hyde Park in London, a public place, so the + * workout page has a map to show without anyone's real location. + */ + private suspend fun phoneRun(dao: HealthDao, start: Long, rnd: Random) { + // The park's paths, corner to corner, rounded off by cutting each corner twice. + var path = listOf(51.5108 to -0.1747, 51.5126 to -0.1600, 51.5100 to -0.1585, 51.5029 to -0.1528, + 51.5024 to -0.1610, 51.5030 to -0.1745, 51.5108 to -0.1747) + repeat(3) { + path = path.zipWithNext { (a, b), (c, d) -> listOf((0.75 * a + 0.25 * c) to (0.75 * b + 0.25 * d), (0.25 * a + 0.75 * c) to (0.25 * b + 0.75 * d)) } + .flatten().let { it + it.first() } + } + fun metres(p: Pair, q: Pair) = + hypot((q.first - p.first) * 111_320, (q.second - p.second) * 111_320 * cos(Math.toRadians(p.first))) + val legs = path.zipWithNext { p, q -> metres(p, q) } + val total = legs.sum() + // About 5:20 a kilometre, a fix every 3 seconds, a little faster in the middle. + val seconds = (total / 1000 * 320).toLong() + var leg = 0 + var done = 0.0 + val points = (0..seconds step 3L).map { s -> + val along = total * s / seconds + while (leg < legs.lastIndex && done + legs[leg] < along) done += legs[leg++] + val f = ((along - done) / legs[leg]).coerceIn(0.0, 1.0) + val (a, b) = path[leg] + val (c, d) = path[leg + 1] + AppWorkoutRoutePoint(start, start + s, a + (c - a) * f + rnd.nextDouble(-4e-6, 4e-6), b + (d - b) * f + rnd.nextDouble(-6e-6, 6e-6), + accuracy = 4f + rnd.nextFloat() * 3, speed = (3.1 + 0.25 * sin(s * Math.PI / seconds)).toFloat()) + } + val hr = (0..seconds step 5L).map { s -> + AppWorkoutHeartRate(start, start + s, (118 + 34 * (s / 300.0).coerceAtMost(1.0) + 6 * sin(s / 200.0) + rnd.nextInt(-2, 3)).toInt()) + } + dao.insertAppWorkouts(listOf(AppWorkout( + start, start + seconds, sportType = 1, averageHeartRate = hr.map { it.bpm }.average().toInt(), maxHeartRate = hr.maxOf { it.bpm }, + useGps = true, keepRoute = true, distanceMeters = total.toFloat(), laps = (1..total.toInt() / 1000).joinToString(",") { "${start + it * 320L}" }, + epoc = 82f, aerobicTrainingEffect = 3.4f, recoveryMinutes = 16 * 60, vo2max = 48f, + ))) + dao.insertAppWorkoutHeartRate(hr) + dao.insertRoutePoints(points) } } diff --git a/app/src/debug/kotlin/org/openstrap/app/widget/debug/WidgetGalleryActivity.kt b/app/src/debug/kotlin/org/openstrap/app/widget/debug/WidgetGalleryActivity.kt new file mode 100644 index 00000000..de32d70f --- /dev/null +++ b/app/src/debug/kotlin/org/openstrap/app/widget/debug/WidgetGalleryActivity.kt @@ -0,0 +1,304 @@ +package org.openstrap.app.widget.debug + +import android.appwidget.AppWidgetHost +import android.appwidget.AppWidgetHostView +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProviderInfo +import android.content.ComponentName +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.util.SizeF +import android.util.TypedValue +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.device.WidgetUpdater +import org.openstrap.app.widget.WidgetKind +import org.openstrap.app.widget.WidgetRefresh +import org.openstrap.app.widget.WidgetSizes + +/** + * Debug builds only: a real widget hosted at exactly its three sizes (Small, Wide, Large) on black, for checking on + * the phone over adb. Binding needs the permission first: + * + * adb shell appwidget grantbind --package org.openstrap.app --user 0 + * adb shell am start -n org.openstrap.app/.widget.debug.WidgetGalleryActivity --es widget steps --es mood night + * + * `widget` is the kind to host (moment, recovery, steps, sleep, start), Recovery without it. `mood` is day, evening, night or auto + * (the moment's). Sleep is always night and ignores it. `--es pin sleep` asks the launcher to pin a widget of that + * kind to the home screen. `--es cells oneui` hosts at One UI's real cells (2 × 2 about 180 × 220, 4 × 2 about + * 353 × 220, 4 × 3 about 353 × 330) instead of the three minimums, to see what people see; `--es cells min` goes + * back. For Moment, `--es moment Evening` shows that moment (a Moment name, `auto` for the clock's) and + * `--es placed recovery,sleep` takes those widgets as placed for the duplicate rule (`none` for none, `auto` for the + * launcher's). `--es strap stale` shows every widget's strap status line in a state (notpaired, low, stale, yesterday, + * week, syncing, none for no line, auto for the strap's). The gallery's own hosted widgets count as placed too while it shows them. The ids it binds are deleted + * when it closes or hosts another kind. + */ +class WidgetGalleryActivity : ComponentActivity() { + private lateinit var host: AppWidgetHost + private val bound = mutableListOf() + private lateinit var column: LinearLayout + private lateinit var hosted: LinearLayout + private var kind = WidgetKind.Recovery + private var sizes = SIZES + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + host = AppWidgetHost(this, HOST_ID) + // Ids from a run that never reached onDestroy (an install kills the process) stay bound and count as + // placed, so every widget update would render them all: clear the host's leftovers first. + host.deleteHost() + applyMood(intent.getStringExtra(EXTRA_MOOD)) + applyCells(intent.getStringExtra(EXTRA_CELLS)) + applyMoment(intent) + applyStrap(intent) + kind = kindOf(intent.getStringExtra(EXTRA_WIDGET)) ?: WidgetKind.Recovery + column = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(16), dp(48), dp(16), dp(48)) + } + setContentView(ScrollView(this).apply { + setBackgroundColor(android.graphics.Color.BLACK) + addView(column) + }) + column.addView(controls()) + hosted = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + column.addView(hosted) + bindAll() + kindOf(intent.getStringExtra(EXTRA_PIN))?.let(::requestPin) + } + + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + val next = kindOf(intent.getStringExtra(EXTRA_WIDGET)) + if (intent.hasExtra(EXTRA_MOOD)) applyMood(intent.getStringExtra(EXTRA_MOOD)) + val cellsChanged = intent.hasExtra(EXTRA_CELLS) && applyCells(intent.getStringExtra(EXTRA_CELLS)) + val momentChanged = applyMoment(intent) or applyStrap(intent) + when { + next != null && next != kind -> show(next) + cellsChanged -> show(kind) + intent.hasExtra(EXTRA_MOOD) || momentChanged -> refresh() + } + kindOf(intent.getStringExtra(EXTRA_PIN))?.let(::requestPin) + } + + override fun onStart() { + super.onStart() + host.startListening() + } + + override fun onStop() { + host.stopListening() + super.onStop() + } + + override fun onDestroy() { + unbindAll() + // Placed widgets go back to the moment's mood, and Moment to the clock's moment and the launcher's widgets. + if (WidgetRefresh.debugMood != null || WidgetRefresh.debugMoment != null || WidgetRefresh.debugPlaced != null || WidgetRefresh.debugStrap != null) { + WidgetRefresh.debugMood = null + WidgetRefresh.debugMoment = null + WidgetRefresh.debugPlaced = null + WidgetRefresh.debugStrap = null + WidgetUpdater.changedAll(applicationContext) + } + super.onDestroy() + } + + /** The kind named [name] ("steps", "recovery", "sleep", "start") that has a widget yet; null for none. */ + private fun kindOf(name: String?): WidgetKind? = + WidgetKind.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }?.takeIf { WidgetUpdater.registration(it) != null } + + /** Hosts [next] in place of the kind shown. */ + private fun show(next: WidgetKind) { + unbindAll() + kind = next + bindAll() + } + + private fun unbindAll() { + bound.forEach { host.deleteAppWidgetId(it) } + bound.clear() + hosted.removeAllViews() + } + + /** Binds an id per size and hosts its view at that size exactly. */ + private fun bindAll() { + val manager = AppWidgetManager.getInstance(this) + val registration = WidgetUpdater.registration(kind) ?: run { note("No $kind widget yet."); return } + val provider = ComponentName(this, registration.receiver) + val info: AppWidgetProviderInfo? = manager.getInstalledProvidersForPackage(packageName, null).firstOrNull { it.provider == provider } + if (info == null) { note("The $kind widget's provider isn't installed."); return } + for ((name, size) in sizes) { + val id = host.allocateAppWidgetId() + if (!manager.bindAppWidgetIdIfAllowed(id, provider)) { + host.deleteAppWidgetId(id) + note("Not allowed to bind. Run: adb shell appwidget grantbind --package $packageName --user 0") + return + } + bound += id + manager.updateAppWidgetOptions(id, sizeOptions(size)) + val view: AppWidgetHostView = host.createView(applicationContext, id, info).apply { setPadding(0, 0, 0, 0) } + label("$kind $name ${size.width.value.toInt()} × ${size.height.value.toInt()}") + hosted.addView(view, LinearLayout.LayoutParams(dp(size.width.value), dp(size.height.value)).apply { bottomMargin = dp(24) }) + } + refresh() + } + + /** The size bundle Glance reads to pick its layout: Android 12's size list, and the min and max before it. */ + private fun sizeOptions(size: DpSize): Bundle = Bundle().apply { + val w = size.width.value.toInt() + val h = size.height.value.toInt() + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, w) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, w) + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, h) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, h) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + putParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, arrayListOf(SizeF(w.toFloat(), h.toFloat()))) + } + } + + /** Loads the widget again and renders every bound id. */ + private fun refresh() { + val ids = bound.toList() + val shown = kind + val widget = WidgetUpdater.registration(shown)?.widget ?: return + lifecycleScope.launch { + try { + WidgetRefresh.bump(shown) + val glance = GlanceAppWidgetManager(applicationContext) + ids.forEach { widget().update(applicationContext, glance.getGlanceIdBy(it)) } + } catch (e: Exception) { + Log.w(TAG, "widget gallery: update failed: $e") + } + } + } + + /** "oneui" for One UI's cells, anything else for the minimums; true when that changed what's hosted. */ + private fun applyCells(name: String?): Boolean { + val next = if (name.equals("oneui", ignoreCase = true)) ONE_UI_SIZES else SIZES + val changed = next != sizes + sizes = next + return changed + } + + /** The `moment` and `placed` extras, for Moment; true when either was given. */ + private fun applyMoment(intent: android.content.Intent): Boolean { + val moment = intent.getStringExtra(EXTRA_MOMENT) + val placed = intent.getStringExtra(EXTRA_PLACED) + if (moment != null) { + WidgetRefresh.debugMoment = org.openstrap.app.data.Moment.entries.firstOrNull { it.name.equals(moment, ignoreCase = true) } + } + if (placed != null) { + WidgetRefresh.debugPlaced = when (placed.lowercase()) { + "auto" -> null + "none", "" -> emptySet() + else -> placed.split(',').mapNotNull { n -> WidgetKind.entries.firstOrNull { it.name.equals(n.trim(), ignoreCase = true) } }.toSet() + } + } + return moment != null || placed != null + } + + /** The `strap` extra: the status line every widget shows; true when it was given. */ + private fun applyStrap(intent: android.content.Intent): Boolean { + val strap = intent.getStringExtra(EXTRA_STRAP) ?: return false + WidgetRefresh.debugStrap = strap.takeUnless { it.equals("auto", ignoreCase = true) } + return true + } + + private fun applyMood(name: String?) { + WidgetRefresh.debugMood = when (name?.lowercase()) { + null, "", "auto" -> null + else -> GlowMood.entries.firstOrNull { it.name.equals(name, ignoreCase = true) } + } + } + + /** Asks the launcher to pin a widget of [pin] to the home screen. */ + private fun requestPin(pin: WidgetKind) { + val manager = AppWidgetManager.getInstance(this) + val registration = WidgetUpdater.registration(pin) ?: return + if (!manager.isRequestPinAppWidgetSupported) { + Toast.makeText(this, "This launcher can't pin widgets", Toast.LENGTH_SHORT).show() + return + } + manager.requestPinAppWidget(ComponentName(this, registration.receiver), null, null) + } + + /** A button per kind, mood buttons and Pin, for checking by hand. */ + private fun controls() = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(LinearLayout(this@WidgetGalleryActivity).apply { + orientation = LinearLayout.HORIZONTAL + WidgetKind.entries.filter { WidgetUpdater.registration(it) != null }.forEach { k -> + addView(Button(this@WidgetGalleryActivity).apply { + text = k.name + setOnClickListener { if (k != kind) show(k) } + }) + } + }) + addView(moodRow()) + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = dp(16) } + } + + private fun moodRow() = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + listOf("Auto" to "auto", "Day" to "day", "Evening" to "evening", "Night" to "night").forEach { (text, mood) -> + addView(Button(this@WidgetGalleryActivity).apply { + this.text = text + setOnClickListener { applyMood(mood); refresh() } + }) + } + addView(Button(this@WidgetGalleryActivity).apply { + text = "Pin" + setOnClickListener { requestPin(kind) } + }) + } + + private fun label(text: String) = hosted.addView(TextView(this).apply { + this.text = text + setTextColor(0x99FFFFFF.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + setPadding(0, 0, 0, dp(8)) + }) + + private fun note(text: String) { + Log.w(TAG, "widget gallery: $text") + label(text) + } + + /** Rounded up, so a hosted view is never a fraction of a dp short of its size and laid out as a smaller one. */ + private fun dp(v: Float): Int = kotlin.math.ceil(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, resources.displayMetrics)).toInt() + private fun dp(v: Int): Int = dp(v.toFloat()) + + private companion object { + const val TAG = "OpenStrap" + const val HOST_ID = 0x5753 + const val EXTRA_MOOD = "mood" + const val EXTRA_PIN = "pin" + const val EXTRA_WIDGET = "widget" + const val EXTRA_CELLS = "cells" + const val EXTRA_MOMENT = "moment" + const val EXTRA_PLACED = "placed" + const val EXTRA_STRAP = "strap" + val SIZES = listOf("Small" to WidgetSizes.Small, "Wide" to WidgetSizes.Wide, "Large" to WidgetSizes.Large) + + /** One UI's cells on the phone, roughly (spec 2.1): what the three layouts stretch to there. */ + val ONE_UI_SIZES = listOf( + "Small (One UI 2 × 2)" to DpSize(180.dp, 220.dp), + "Wide (One UI 4 × 2)" to DpSize(353.dp, 220.dp), + "Large (One UI 4 × 3)" to DpSize(353.dp, 330.dp), + ) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ebe24a37..1bc3c591 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,9 @@ + + @@ -115,6 +118,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/org/openstrap/app/MainActivity.kt b/app/src/main/kotlin/org/openstrap/app/MainActivity.kt index b065b94d..379d6a6d 100644 --- a/app/src/main/kotlin/org/openstrap/app/MainActivity.kt +++ b/app/src/main/kotlin/org/openstrap/app/MainActivity.kt @@ -92,6 +92,12 @@ class MainActivity : ComponentActivity() { /** The latest `open_metric` link, until the app root has opened it. */ private val metricRequests = kotlinx.coroutines.flow.MutableStateFlow(null) + /** The Moment widget's tap: Today, at its root, until the app root has opened it. */ + private val todayRequests = kotlinx.coroutines.flow.MutableStateFlow(false) + + /** A widget's strap status line (not paired, battery low): the Strap page, until the app root has opened it. */ + private val strapRequests = kotlinx.coroutines.flow.MutableStateFlow(false) + /** Debug: the moment forced by the `moment` extra, null to follow the clock. */ private val forcedMoment = kotlinx.coroutines.flow.MutableStateFlow(null) @@ -109,7 +115,7 @@ class MainActivity : ComponentActivity() { navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), ) val container = (application as OpenStrapApp).container - setContent { OpenStrapTheme { AppRoot(container, metricRequests, forcedMoment, strapPreview, dataPreview) } } + setContent { OpenStrapTheme { AppRoot(container, metricRequests, todayRequests, strapRequests, forcedMoment, strapPreview, dataPreview) } } } override fun onStart() { @@ -146,6 +152,15 @@ class MainActivity : ComponentActivity() { * metric's screen for its day. */ private fun showWorkoutFromIntent() { + // The Moment widget: Today as it is now, never a forced moment. + if (intent.getStringExtra(EXTRA_MOMENT) == MOMENT_NOW) { + intent.removeExtra(EXTRA_MOMENT) + todayRequests.value = true + } + if (intent.getBooleanExtra(EXTRA_OPEN_STRAP, false)) { + intent.removeExtra(EXTRA_OPEN_STRAP) + strapRequests.value = true + } intent.getStringExtra(EXTRA_OPEN_METRIC)?.let { route -> val day = intent.getStringExtra(EXTRA_METRIC_DAY)?.let { runCatching { java.time.LocalDate.parse(it) }.getOrNull() } intent.removeExtra(EXTRA_OPEN_METRIC) @@ -162,6 +177,10 @@ class MainActivity : ComponentActivity() { intent.removeExtra(EXTRA_READY_SPORT) (application as OpenStrapApp).container.workout.readyRequest.value = ready } + if (intent.getBooleanExtra(EXTRA_READY_PICK, false)) { + intent.removeExtra(EXTRA_READY_PICK) + (application as OpenStrapApp).container.workout.readyPickRequest.value = true + } if (!intent.getBooleanExtra(EXTRA_SHOW_WORKOUT, false)) return intent.removeExtra(EXTRA_SHOW_WORKOUT) (application as OpenStrapApp).container.workout.minimized.value = false @@ -170,6 +189,9 @@ class MainActivity : ComponentActivity() { companion object { const val EXTRA_SHOW_WORKOUT = "show_workout" const val EXTRA_READY_SPORT = "ready_sport" + + /** True opens Ready on the sport picker, as All sports does: the Start widget's All sports. */ + const val EXTRA_READY_PICK = "ready_pick" const val EXTRA_OPEN_WORKOUT = "open_workout" /** A metric's detail by [org.openstrap.app.ui.dashboard.Metric] name ("Hrv"), or "Recovery" or "Sleep". */ @@ -178,8 +200,15 @@ class MainActivity : ComponentActivity() { /** The day for [EXTRA_OPEN_METRIC], as an ISO date ("2026-10-23"); without it the day showing stays. */ const val EXTRA_METRIC_DAY = "metric_day" - /** Debug: a [org.openstrap.app.data.Moment] name to force on Today, or "auto" to follow the clock again. */ + /** + * [MOMENT_NOW] opens Today at its root, as it is now: the Moment widget's tap (widgets spec 4). Debug: a + * [org.openstrap.app.data.Moment] name to force on Today, or "auto" to follow the clock again. + */ const val EXTRA_MOMENT = "moment" + const val MOMENT_NOW = "now" + + /** True opens the Strap page over the tab showing: a widget's status line while the strap isn't paired or is low. */ + const val EXTRA_OPEN_STRAP = "open_strap" /** Debug: a [StrapPreview] name ("stale", "stuck", "disconnected", "charging", "notpaired", "recoveryfail") to show, or "auto". */ const val EXTRA_STRAP_STATE = "strap_state" @@ -250,7 +279,7 @@ class MainActivity : ComponentActivity() { // Debug: `--es moment Evening` forces Today's moment (a Moment name), so each can be checked on a phone; // `--es moment auto` goes back to the clock. val moment = intent.getStringExtra(EXTRA_MOMENT) - if (BuildConfig.DEBUG && moment != null) { + if (BuildConfig.DEBUG && moment != null && moment != MOMENT_NOW) { intent.removeExtra(EXTRA_MOMENT) if (moment.equals("auto", ignoreCase = true)) forcedMoment.value = null else org.openstrap.app.data.Moment.entries.firstOrNull { it.name.equals(moment, ignoreCase = true) } @@ -364,6 +393,8 @@ private const val FIRST_SCORE_LOADING = Long.MIN_VALUE private fun AppRoot( container: AppContainer, metricRequests: kotlinx.coroutines.flow.MutableStateFlow, + todayRequests: kotlinx.coroutines.flow.MutableStateFlow, + strapRequests: kotlinx.coroutines.flow.MutableStateFlow, forcedMoment: kotlinx.coroutines.flow.StateFlow, strapPreview: kotlinx.coroutines.flow.StateFlow, dataPreviews: kotlinx.coroutines.flow.StateFlow, @@ -476,6 +507,39 @@ private fun AppRoot( container.workout.readyRequest.value = null } } + // The Start widget's All sports: Ready on the sport picker, as the Activity tab's All sports opens it. + val readyPickRequest by container.workout.readyPickRequest.collectAsStateWithLifecycle() + LaunchedEffect(readyPickRequest) { + if (readyPickRequest) { + if (workoutState !is org.openstrap.app.device.WorkoutState.Recording) { + readySport = readySport ?: org.openstrap.app.ui.glow.topSports(workouts).first().sportType + readyPicking = true + } + container.workout.readyPickRequest.value = false + } + } + // The Moment widget: Today at its root, on today, over whatever showed. + val todayRequest by todayRequests.collectAsStateWithLifecycle() + LaunchedEffect(todayRequest) { + if (todayRequest) { + closePages() + allMetricsOpen = false + if (workoutState !is org.openstrap.app.device.WorkoutState.Recording) readySport = null + todayStack = emptyList() + dayVm.select(java.time.LocalDate.now()) + nav = nav.select(Tab.Today.ordinal) + todayRequests.value = false + } + } + // A widget's strap status line: the Strap page, over the tab showing, as the header's strap chip opens it. + val strapRequest by strapRequests.collectAsStateWithLifecycle() + LaunchedEffect(strapRequest) { + if (strapRequest) { + allMetricsOpen = false + if (pages.lastOrNull() != Page.Strap && (pages.isEmpty() || closePages())) openPage(Page.Strap) + strapRequests.value = false + } + } // An auto-detected workout's notification: that workout, on the Activity tab, its home. val openRequest by container.workout.openRequest.collectAsStateWithLifecycle() LaunchedEffect(openRequest) { @@ -773,7 +837,14 @@ private fun AppRoot( containerColor = MaterialTheme.colorScheme.background, ) { padding -> if (editGoals) { - org.openstrap.app.ui.device.GoalsSheet(goals, onSave = { g -> scope.launch { container.settings.saveGoals(g) }; editGoals = false }, onDismiss = { editGoals = false }) + org.openstrap.app.ui.device.GoalsSheet(goals, onSave = { g -> + scope.launch { + container.settings.saveGoals(g) + // The Steps widget's goal, and the evening sentence's on Moment (widgets spec 7). + org.openstrap.app.device.WidgetUpdater.changed(container.app, setOf(org.openstrap.app.widget.WidgetKind.Steps, org.openstrap.app.widget.WidgetKind.Moment)) + } + editGoals = false + }, onDismiss = { editGoals = false }) } // The header on every tab: the moment on Today (a past day's name for a past day), the tab's name elsewhere. val header: @Composable (Tab) -> Unit = { t -> @@ -1262,7 +1333,13 @@ private fun androidx.compose.foundation.layout.BoxScope.WorkoutLayer( org.openstrap.app.ui.glow.GlowWorkoutReadyScreen( sport, readyPicking, connection, live, padding, recentSports = recentSports, sides = sides, - onSide = { s, indoor -> viewScope.launch { container.settings.setWorkoutSide(s, indoor) } }, + onSide = { s, indoor -> + viewScope.launch { + container.settings.setWorkoutSide(s, indoor) + // The Start widget names the side Ready opens on and says whether GPS is on (widgets spec 7). + org.openstrap.app.device.WidgetUpdater.changed(container.app, setOf(org.openstrap.app.widget.WidgetKind.Start)) + } + }, onTurnOnLocation = requestLocation, onSport = onReadySport, onStart = { sport, askToEnd, target, useGps, keepRoute, rest, setsTarget -> diff --git a/app/src/main/kotlin/org/openstrap/app/OpenStrapApp.kt b/app/src/main/kotlin/org/openstrap/app/OpenStrapApp.kt index 654e3466..24d3a28d 100644 --- a/app/src/main/kotlin/org/openstrap/app/OpenStrapApp.kt +++ b/app/src/main/kotlin/org/openstrap/app/OpenStrapApp.kt @@ -28,10 +28,14 @@ class AppContainer(val app: Application) { val workout = org.openstrap.app.device.WorkoutSession(app, database.healthDao(), settings, strap, callRinging = { phoneAlerts.callRinging }) } -class OpenStrapApp : Application() { +class OpenStrapApp : Application(), androidx.work.Configuration.Provider { lateinit var container: AppContainer private set + /** WorkManager as Glance's widget sessions need it: rendering on their own thread ([WidgetWork]). */ + override fun getWorkManagerConfiguration(): androidx.work.Configuration = + androidx.work.Configuration.Builder().setWorkerFactory(org.openstrap.app.widget.WidgetWork).build() + override fun onCreate() { super.onCreate() container = AppContainer(this) @@ -41,5 +45,9 @@ class OpenStrapApp : Application() { // Android restarts the process on its own (companion presence, a reboot): reconnect without the screen. // A no-op when nothing is paired, a connection is already up, or the user tapped Disconnect. if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) container.strap.backgroundConnect() + // Widgets turn the day over at midnight; alarms don't survive a reboot or an update, so set it on each start. + org.openstrap.app.device.WidgetUpdater.scheduleMidnight(this) + // Android 15 and later: the widget picker's previews, from the real layouts (spec 6.4). + org.openstrap.app.device.WidgetUpdater.publishPreviews(this) } } diff --git a/app/src/main/kotlin/org/openstrap/app/data/HealthRepository.kt b/app/src/main/kotlin/org/openstrap/app/data/HealthRepository.kt index 60ce5dd7..75c93aae 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/HealthRepository.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/HealthRepository.kt @@ -61,6 +61,9 @@ class HealthRepository(private val dao: HealthDao) { return r } + /** When you usually go to sleep as of [date] ([RecoveryResult.usualBedtime]), without working out Recovery. */ + suspend fun usualBedtime(date: LocalDate): java.time.LocalTime? = recoveryEngine.usualBedtime(date) + /** The weekly Healthspan estimate shown on [date] (made the Sunday on or before it), or why it's withheld; heavy. */ suspend fun healthspan(date: LocalDate, profile: org.openstrap.app.data.settings.UserProfile): Healthspan.Outcome = healthspanEngine.compute(date, profile) diff --git a/app/src/main/kotlin/org/openstrap/app/data/MomentLead.kt b/app/src/main/kotlin/org/openstrap/app/data/MomentLead.kt new file mode 100644 index 00000000..0cccb674 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/MomentLead.kt @@ -0,0 +1,45 @@ +package org.openstrap.app.data + +import org.openstrap.app.widget.WidgetKind + +/** What the Moment widget leads with (widgets spec 3.1): its number and word, or what stands in for them. */ +enum class MomentLead { + /** Recovery's score and status word, the waking lead. */ + Recovery, + + /** Last night's sleep score and word, "Slept 7 h 40": waking with a Recovery widget placed. */ + LastNight, + + /** Energy left. */ + Energy, + + /** The Start button: the workout window's point. */ + Start, + + /** The sport and "Recording since 17:42". */ + Workout, + + /** Minutes since the workout. */ + AfterWorkout, + + /** The moment's sentence (evening and night). */ + Sentence, +} + +/** + * What Moment leads with at [moment] when the widgets in [placed] are on the home screen (widgets spec 3.1, the + * duplicate rule): never a metric that has its own widget placed, but the moment's next lead instead. Only waking + * has one to give way: Recovery falls back to last night, and with Sleep placed too, to Energy, which has no widget. + */ +fun momentLead(moment: Moment, placed: Set): MomentLead = when (moment) { + Moment.Waking -> when { + WidgetKind.Recovery !in placed -> MomentLead.Recovery + WidgetKind.Sleep !in placed -> MomentLead.LastNight + else -> MomentLead.Energy + } + Moment.Midday -> MomentLead.Energy + Moment.WorkoutWindow -> MomentLead.Start + Moment.During -> MomentLead.Workout + Moment.After -> MomentLead.AfterWorkout + Moment.Evening, Moment.Night -> MomentLead.Sentence +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/MomentResolver.kt b/app/src/main/kotlin/org/openstrap/app/data/MomentResolver.kt index 1478212f..59bd2a9e 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/MomentResolver.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/MomentResolver.kt @@ -80,6 +80,94 @@ object MomentResolver { return Moment.Midday } + /** How far ahead [nextChange] looks. Without a recording some moment always ends within a day. */ + const val NEXT_CHANGE_HORIZON_SECONDS = 2 * 86_400L + + /** + * When the moment [resolve] gives at [now] next changes, from the same inputs and rules, for the Moment widget's + * alarm (widgets spec 7). Null while [recording] (a recording has no known end, and its end updates the widget + * anyway), for a past [day] (always [Moment.Waking]), and when nothing changes within + * [NEXT_CHANGE_HORIZON_SECONDS]. + * + * Past local midnight it reads the new day as Today does once the clock moves on: the day is the new date and + * [sleepEnd], today's main sleep, no longer applies, so the morning starts at [WAKE_FALLBACK] until a sync brings + * the night in (which updates the widgets on its own). + * + * Every instant where any rule can flip is a candidate, and the first candidate where [resolve] gives another + * moment is the answer, so the two can't disagree: just before it the moment is still the one at [now]. + */ + fun nextChange( + now: Instant, + zone: ZoneId, + day: LocalDate, + recording: Boolean, + lastWorkoutEnd: Long?, + sleepEnd: Long?, + usualBedtime: LocalTime?, + workoutStarts: List, + ): Instant? { + val today = now.atZone(zone).toLocalDate() + if (recording || day < today) return null + val t = now.epochSecond + val until = t + NEXT_CHANGE_HORIZON_SECONDS + fun at(s: Long): Moment { + val date = Instant.ofEpochSecond(s).atZone(zone).toLocalDate() + val later = date > day + return resolve( + Instant.ofEpochSecond(s), zone, if (later) date else day, false, lastWorkoutEnd, + if (later) null else sleepEnd, usualBedtime, workoutStarts, + ) + } + val current = at(t) + return changeCandidates(t, until, zone, lastWorkoutEnd, sleepEnd, usualBedtime, workoutStarts) + .firstOrNull { at(it) != current }?.let(Instant::ofEpochSecond) + } + + /** Every instant in (from, until] where a rule in [resolve] can start or stop holding, in order. */ + private fun changeCandidates( + from: Long, until: Long, zone: ZoneId, lastWorkoutEnd: Long?, sleepEnd: Long?, usualBedtime: LocalTime?, + workoutStarts: List, + ): List { + val out = mutableListOf() + // After a workout, and the morning after today's main sleep. + lastWorkoutEnd?.let { out += it; out += it + AFTER_SECONDS } + sleepEnd?.let { out += it; out += it + WAKING_SECONDS } + // A workout start leaving the usual time's look-back changes the usual time, and so the window. + val dropouts = workoutStarts.map { it + USUAL_WORKOUT_DAYS * 86_400L + 1 }.filter { it in from + 1..until } + out += dropouts + val first = Instant.ofEpochSecond(from).atZone(zone).toLocalDate().minusDays(1) + val last = Instant.ofEpochSecond(until).atZone(zone).toLocalDate() + val days = generateSequence(first) { it.plusDays(1) }.takeWhile { it <= last }.toList() + val midnights = days.map { it.atStartOfDay(zone).toEpochSecond() } + // The usual workout time follows the weekday, so it can move at each midnight and at each dropout. + val usuals = (listOf(from) + midnights + dropouts) + .mapNotNull { usualWorkoutTime(workoutStarts, Instant.ofEpochSecond(it), zone) }.toSet() + // Clock minutes where evening, night, the window and the fallback morning start or end. + val bed = afterNoon(usualBedtime ?: BEDTIME_FALLBACK) + val minutes = buildSet { + listOf(0, bed - EVENING_MINUTES, bed - NIGHT_LEAD_MINUTES, afterNoon(NIGHT_ENDS)) + .forEach { add(Math.floorMod(it.coerceAtLeast(0) + 720, 1440)) } + usuals.forEach { u -> + val m = u.toSecondOfDay() / 60 + add(Math.floorMod(m - WINDOW_MINUTES, 1440)); add(Math.floorMod(m + WINDOW_MINUTES, 1440)) + } + } + out += midnights + days.forEach { d -> + val fallback = d.atTime(WAKE_FALLBACK).atZone(zone).toEpochSecond() + out += fallback; out += fallback + WAKING_SECONDS + minutes.forEach { m -> + // A clock time the clocks going back pass twice is a boundary both times. + val local = d.atTime(LocalTime.ofSecondOfDay(m * 60L)).atZone(zone) + out += local.withEarlierOffsetAtOverlap().toEpochSecond(); out += local.withLaterOffsetAtOverlap().toEpochSecond() + } + } + // The clocks going forward skip clock times: a boundary inside the gap passes at the jump. + generateSequence(zone.rules.nextTransition(Instant.ofEpochSecond(from))) { zone.rules.nextTransition(it.instant) } + .takeWhile { it.instant.epochSecond <= until }.forEach { out += it.instant.epochSecond } + return out.filter { it in from + 1..until }.distinct().sorted() + } + /** * Your usual workout time at [now]: the median start, as a clock time, of the workouts in the last * [USUAL_WORKOUT_DAYS] days on [now]'s weekday when there are [USUAL_WORKOUT_MIN] or more, else of all of them when @@ -109,3 +197,22 @@ object MomentResolver { /** Minutes since noon: 12:00 is 0, 04:00 the next morning is 960. */ private fun afterNoon(t: LocalTime): Int = Math.floorMod(t.toSecondOfDay() / 60 - 720, 1440) } + +/** + * What [MomentResolver] reads, kept together for the Moment widget: it renders from these and schedules its next + * update from the same ones ([org.openstrap.app.device.WidgetUpdater.scheduleMoment]). + */ +data class MomentInputs( + val day: LocalDate, + val recording: Boolean, + val lastWorkoutEnd: Long?, + val sleepEnd: Long?, + val usualBedtime: LocalTime?, + val workoutStarts: List, +) { + fun resolve(now: Instant, zone: ZoneId): Moment = + MomentResolver.resolve(now, zone, day, recording, lastWorkoutEnd, sleepEnd, usualBedtime, workoutStarts) + + fun nextChange(now: Instant, zone: ZoneId): Instant? = + MomentResolver.nextChange(now, zone, day, recording, lastWorkoutEnd, sleepEnd, usualBedtime, workoutStarts) +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/RecoveryEngine.kt b/app/src/main/kotlin/org/openstrap/app/data/RecoveryEngine.kt index f8a04be6..a486dae1 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/RecoveryEngine.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/RecoveryEngine.kt @@ -63,6 +63,17 @@ data class RecoveryResult( /** Loads what [Recovery] needs from the database and runs it. All work happens off the main thread by the caller. */ class RecoveryEngine(private val dao: HealthDao) { + /** + * [RecoveryResult.usualBedtime] for [date] without the whole model: the same main sleeps over the same nights + * [compute] reads. For the widgets' moment (widgets spec 2.2), which the widgets that don't show Recovery need too. + */ + suspend fun usualBedtime(date: LocalDate, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalTime? { + val day = DayWindow.of(date, zone) + val from = DayWindow.of(date.minusDays(Recovery.BASELINE_NIGHTS.toLong() + 1), zone).from + val mains = mainsByMorning(dao.sleepSessionsOnce(from, day.to), zone) + return usualBedtime(mains.filterKeys { it <= day.from }.values, date, zone) + } + suspend fun compute(date: LocalDate, profile: UserProfile, zone: ZoneId = ZoneId.systemDefault(), now: Long = System.currentTimeMillis() / 1000): RecoveryResult { val day = DayWindow.of(date, zone) val from = DayWindow.of(date.minusDays(Recovery.BASELINE_NIGHTS.toLong() + 1), zone).from @@ -85,10 +96,7 @@ class RecoveryEngine(private val dao: HealthDao) { Instant.ofEpochSecond(it.belongTime + 2 * 3600).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() } - // One main sleep per morning: the longest non-nap session ending that day. - val mains = sessions.filter { !it.nap } - .groupBy { morningOf(it, zone) } - .mapValues { (_, v) -> v.maxBy { it.end - it.start } } + val mains = mainsByMorning(sessions, zone) val strapNights = dao.strapReadinessOnce(from, to).associateBy { it.day } // Naps count toward a day's total sleep for the debt; a nap belongs to the day it started. val napsByDay = sessions.filter { it.nap }.groupBy { Instant.ofEpochSecond(it.start).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() } @@ -395,6 +403,12 @@ class RecoveryEngine(private val dao: HealthDao) { if (mains.isEmpty()) null else usualSleep(mains, date, zone).firstOrNull()?.let { Instant.ofEpochSecond(it.first).atZone(zone).toLocalTime() } + /** One main sleep per morning ([morningOf]): the longest non-nap session ending that day. */ + internal fun mainsByMorning(sessions: List, zone: ZoneId): Map = + sessions.filter { !it.nap } + .groupBy { morningOf(it, zone) } + .mapValues { (_, v) -> v.maxBy { it.end - it.start } } + /** Asleep minutes: deep + light + REM, else time in bed less awake. */ fun asleepMinutes(s: SleepSession): Int = listOfNotNull(s.deepMinutes, s.lightMinutes, s.remMinutes).takeIf { it.isNotEmpty() }?.sum() ?: (((s.end - s.start) / 60).toInt() - (s.awakeMinutes ?: 0)) diff --git a/app/src/main/kotlin/org/openstrap/app/data/SleepHistory.kt b/app/src/main/kotlin/org/openstrap/app/data/SleepHistory.kt index 8bad4ada..b25272ca 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/SleepHistory.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/SleepHistory.kt @@ -24,6 +24,20 @@ object SleepHistory { .groupBy { nightOf(it.end, zone) } .mapValues { (_, s) -> s.maxBy { it.end - it.start } } + /** + * The main sleep of the night in [night] (a [DayWindow.nightEndingOn]): the longest session that isn't a nap and + * ended in it. What the Sleep tab's hero and the Sleep widget show. + */ + fun mainSleep(sessions: List, night: DayWindow): SleepSession? = + sessions.filter { !it.nap && it.end in night.from..night.to }.maxByOrNull { it.end - it.start } + + /** + * Time asleep on the week's other nights, for the longest or shortest rule ([org.openstrap.app.ui.dashboard.TabCopy.night]): + * the [WEEK_NIGHTS] nights ending on [date] ([nights] by [mainSleeps]) without [date] itself, those with a sleep. + */ + fun weekOthers(nights: Map, date: LocalDate): List = + (WEEK_NIGHTS - 1 downTo 1).mapNotNull { nights[date.minusDays(it.toLong())] }.map(::asleepSeconds) + /** Time asleep from the strap's stage totals, or the whole session when it didn't send them. */ fun asleepSeconds(s: SleepSession): Long { val staged = listOfNotNull(s.deepMinutes, s.lightMinutes, s.remMinutes) diff --git a/app/src/main/kotlin/org/openstrap/app/data/Summaries.kt b/app/src/main/kotlin/org/openstrap/app/data/Summaries.kt index 155248c2..02e90399 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Summaries.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Summaries.kt @@ -18,6 +18,15 @@ object Summaries { return buckets.toList() } + /** + * The day's running total at the end of each hour from its [hourly] steps, the Steps card's line: null for the + * hours after [upTo] (the hour now, on today), every hour on a past day ([upTo] null). + */ + fun runningSteps(hourly: List, upTo: Int?): List { + var sum = 0f + return hourly.mapIndexed { i, v -> sum += v; if (upTo != null && i > upTo) null else sum } + } + /** Hours of the day with at least [threshold] steps — a simple "stay active" measure. */ fun activeHours(hourly: List, threshold: Int = 250): Int = hourly.count { it >= threshold } diff --git a/app/src/main/kotlin/org/openstrap/app/data/Workouts.kt b/app/src/main/kotlin/org/openstrap/app/data/Workouts.kt index e26d6753..b710d29e 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Workouts.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Workouts.kt @@ -102,6 +102,20 @@ object Workouts { ) } + /** + * The strap's workouts [ws] and the phone's [phone] as the workout lists show them, newest first: a strap workout's + * end placed by its [last] HR sample or its effect, a phone strength workout with its count of finished [sets] + * (by start). Zones are left empty: the lists fill them in apart. + */ + fun listed( + ws: List, effects: List, last: List, phone: List, + sets: Map, maxHr: Int, + ): List { + val lastByWorkout = last.associateBy { it.workoutStart } + val strap = ws.map { w -> analyse(w, listOfNotNull(lastByWorkout[w.start]), effects, maxHr) } + return (strap + phone.map { WorkoutRecorder.analyse(it, emptyList(), maxHr).copy(sets = sets[it.start]) }).sortedByDescending { it.workout.start } + } + /** The sport effect shown with [w]: the first that ends after its start, within [EFFECT_WINDOW_S]. */ fun effectOf(w: Workout, effects: List): SportEffectRow? = effects.filter { it.end > w.start && it.end <= w.start + EFFECT_WINDOW_S }.minByOrNull { it.end } diff --git a/app/src/main/kotlin/org/openstrap/app/data/settings/SettingsStore.kt b/app/src/main/kotlin/org/openstrap/app/data/settings/SettingsStore.kt index a01f234a..c82f40a3 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/settings/SettingsStore.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/settings/SettingsStore.kt @@ -74,6 +74,12 @@ data class UserProfile( val isEmpty get() = birthYear == null && sex == null && weightKg == null && heightCm == null } +/** + * The strap as last seen, kept for widgets: [battery] and [charging] from the last connection (null battery before + * one), [lastSyncDone] when a history sync last finished (epoch seconds), null before one. + */ +data class StrapSnapshot(val paired: Boolean, val battery: Int?, val charging: Boolean, val lastSyncDone: Long?) + /** Notification buzz patterns: alternating vibrate and pause milliseconds for the strap's motor. */ enum class BuzzPattern(val label: String, val millis: List) { ONE_SHORT("Single", listOf(200)), @@ -369,6 +375,31 @@ class SettingsStore(private val context: Context) { context.dataStore.edit { it[buzzNotifications] = enabled } } + private val strapBattery = androidx.datastore.preferences.core.intPreferencesKey("strap_battery") + private val strapCharging = androidx.datastore.preferences.core.booleanPreferencesKey("strap_charging") + private val syncDone = longPreferencesKey("strap_last_sync_done") + + /** + * What a widget's strap status line reads with the strap away (widgets spec 5): pairing, the battery and charging + * as last connected, and when a sync last finished. + */ + val strapSnapshot: Flow = context.dataStore.data.map { prefs -> + StrapSnapshot(prefs[address] != null && prefs[authKey] != null, prefs[strapBattery], prefs[strapCharging] == true, prefs[syncDone]) + } + + /** The connected strap's battery and charging, written when either changes. */ + suspend fun setStrapBattery(percent: Int, charging: Boolean) { + context.dataStore.edit { + it[strapBattery] = percent + it[strapCharging] = charging + } + } + + /** When a history sync last finished (epoch seconds). Per type cursors are [lastSync]. */ + suspend fun setLastSyncDone(epochSeconds: Long) { + context.dataStore.edit { it[syncDone] = epochSeconds } + } + /** Last synced timestamp (epoch seconds) for a history data type. */ suspend fun lastSync(type: String): Long? = context.dataStore.data.first()[longPreferencesKey("last_sync_$type")] diff --git a/app/src/main/kotlin/org/openstrap/app/device/StrapManager.kt b/app/src/main/kotlin/org/openstrap/app/device/StrapManager.kt index 8897b013..55deaa51 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/StrapManager.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/StrapManager.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive @@ -26,7 +27,10 @@ import org.openstrap.app.data.db.HealthAlertRow import org.openstrap.app.data.db.HealthDao import org.openstrap.app.data.db.WearEventRow import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.data.settings.StrapSnapshot import org.openstrap.app.data.settings.UserProfile +import org.openstrap.app.widget.strapStateChanged +import org.openstrap.app.widget.syncStale import org.openstrap.protocol.services.Gender import org.openstrap.protocol.services.HealthAlert import org.openstrap.protocol.services.UserProfile as StrapUserProfile @@ -375,9 +379,31 @@ class StrapManager( var previous: SyncState = SyncState.Idle sync.collect { s -> syncRecord.update { it.next(previous, s, System.currentTimeMillis() / 1000) } + // A sync starting while the widgets say the last one is old: their line turns to Syncing (widgets spec 5). + if (s is SyncState.Running && previous !is SyncState.Running) launch { + val done = runCatching { settings.strapSnapshot.first().lastSyncDone }.getOrNull() + if (syncStale(done, System.currentTimeMillis() / 1000)) WidgetUpdater.changedAll(context) + } previous = s } } + // For the widgets' status line with the strap away: the battery and charging as last connected. + scope.launch { + connection.mapNotNull { c -> (c as? ConnectionState.Connected)?.let { it.batteryPercent?.let { b -> b to it.charging } } } + .distinctUntilChanged() + .collect { (b, ch) -> runCatching { settings.setStrapBattery(b, ch) }.onFailure { log("strap battery not kept: $it") } } + } + // And the widgets told when that line could change: pairing, the low battery edge, charging (widgets spec 7). + scope.launch { + var before: StrapSnapshot? = null + settings.strapSnapshot.collect { now -> + val was = before + before = now + if (was != null && strapStateChanged(now.paired, now.battery, now.charging, was.paired, was.battery, was.charging)) { + WidgetUpdater.changedAll(context) + } + } + } } /** @@ -579,6 +605,7 @@ class StrapManager( log("steps today=${it.steps} dist=${it.distanceMeters} kcal=${it.calories}" + if (!it.isPlausible) " (total doesn't match distance: counting from synced history)" else "") } todaySteps.value = it + WidgetUpdater.stepsChanged(context) } } } @@ -691,9 +718,12 @@ class StrapManager( val only = takeNext() if (only != null && only.isEmpty()) break HistorySync(s, client, dao, settings, sync, ::log, autoDetected::show).run(only) + (sync.value as? SyncState.Done)?.let { d -> runCatching { settings.setLastSyncDone(d.atEpochSeconds) }.onFailure { log("sync time not kept: $it") } } } runCatching { notifyBodySignals() }.onFailure { log("body signals: $it") } runCatching { WorkoutGapFill(dao) { settings.profile.first() }.run()?.let { log(it) } }.onFailure { log("workout gap fill: $it") } + // Everything a widget shows may have moved (widgets spec 7). + WidgetUpdater.changedAll(context) } finally { syncLock.unlock() } diff --git a/app/src/main/kotlin/org/openstrap/app/device/WidgetUpdater.kt b/app/src/main/kotlin/org/openstrap/app/device/WidgetUpdater.kt new file mode 100644 index 00000000..fd408fbb --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/device/WidgetUpdater.kt @@ -0,0 +1,314 @@ +package org.openstrap.app.device + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.updateAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.openstrap.app.OpenStrapApp +import org.openstrap.app.data.MomentInputs +import org.openstrap.app.widget.MomentWidget +import org.openstrap.app.widget.MomentWidgetReceiver +import org.openstrap.app.widget.RecoveryWidget +import org.openstrap.app.widget.RecoveryWidgetReceiver +import org.openstrap.app.widget.SleepWidget +import org.openstrap.app.widget.SleepWidgetReceiver +import org.openstrap.app.widget.StartWidget +import org.openstrap.app.widget.StartWidgetReceiver +import org.openstrap.app.widget.StepsWidget +import org.openstrap.app.widget.StepsWidgetReceiver +import org.openstrap.app.widget.WidgetKind +import org.openstrap.app.widget.WidgetRefresh +import org.openstrap.app.widget.SYNC_STALE_SECONDS +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * The one place that asks home screen widgets to refresh (widgets spec 7). Everything that changes what a widget + * shows calls [changed]; nothing refreshes on a timer it doesn't need. Calls are cheap, return at once and never + * throw: the work runs on its own scope and failures are logged. + * + * Only widgets that are placed are updated, and the moment and midnight alarms are set only while one is. A kind + * not registered in [widgets] yet is never placed. + */ +object WidgetUpdater { + /** A widget's receiver, which the launcher knows it by, and its Glance widget. */ + class Registration(val receiver: Class, val widget: () -> GlanceAppWidget) + + /** Each widget, once it exists. */ + private val widgets: Map = mapOf( + WidgetKind.Moment to Registration(MomentWidgetReceiver::class.java) { MomentWidget() }, + WidgetKind.Sleep to Registration(SleepWidgetReceiver::class.java) { SleepWidget() }, + WidgetKind.Steps to Registration(StepsWidgetReceiver::class.java) { StepsWidget() }, + WidgetKind.Recovery to Registration(RecoveryWidgetReceiver::class.java) { RecoveryWidget() }, + WidgetKind.Start to Registration(StartWidgetReceiver::class.java) { StartWidget() }, + ) + + /** [kind]'s receiver and widget, null before it exists: for the debug widget gallery. */ + internal fun registration(kind: WidgetKind): Registration? = widgets[kind] + + /** Steps change by the second while the strap is connected; the widget follows at most this often. */ + const val STEPS_EVERY_MS = 15 * 60_000L + + /** Without exact alarms a moment may change this late on a widget (spec 7: a few minutes late is fine). */ + const val ALARM_WINDOW_MS = 5 * 60_000L + + private const val TAG = "OpenStrap" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val steps = UpdateThrottle(STEPS_EVERY_MS) + + /** Refreshes the placed widgets of [kinds]. */ + fun changed(context: Context, kinds: Set) { + val app = context.applicationContext + scope.launch { update(app, kinds) } + } + + /** Refreshes every placed widget: a sync ended, a new day, the strap's state changed. */ + fun changedAll(context: Context) = changed(context, WidgetKind.entries.toSet()) + + /** Today's steps moved: the Steps widget follows, at most every [STEPS_EVERY_MS]. */ + fun stepsChanged(context: Context) { + val app = context.applicationContext + val wait = steps.request(System.currentTimeMillis()) ?: return + if (wait == 0L) { changed(app, setOf(WidgetKind.Steps)); return } + scope.launch { + delay(wait) + steps.ran(System.currentTimeMillis()) + update(app, setOf(WidgetKind.Steps)) + } + } + + /** The widgets on the home screen, any page: a kind counts once its receiver has an id (spec 3.1). */ + fun placed(context: Context): Set = try { + val manager = AppWidgetManager.getInstance(context) ?: return emptySet() + widgets.filter { (_, r) -> manager.getAppWidgetIds(ComponentName(context, r.receiver)).isNotEmpty() }.keys + } catch (e: Exception) { + Log.w(TAG, "widgets: placed check failed: $e") + emptySet() + } + + /** + * Sets the alarm for the moment's next change ([MomentInputs.nextChange]), from the inputs the Moment widget just + * rendered with, so the two can't drift apart, or for [tick] when that comes first: the time something the widget + * shows moves on by itself within the moment ("38 min since your run"). When it fires Moment refreshes, and its + * render schedules the next. Clears the alarm when there's neither or no Moment widget ([momentAlarm]). A tick + * never wakes the phone ([momentWakes]): it's only worth drawing while the screen is on, so it waits for that. + */ + fun scheduleMoment(context: Context, inputs: MomentInputs, tick: Instant? = null, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()) { + try { + val next = inputs.nextChange(now, zone) + val at = momentAlarm(WidgetKind.Moment in placed(context), next, tick) + if (at == null) cancel(context, ACTION_MOMENT) else setAlarm(context, ACTION_MOMENT, at.toEpochMilli(), wake = momentWakes(next, tick)) + } catch (e: Exception) { + Log.w(TAG, "widgets: moment alarm not set: $e") + } + } + + /** + * When Moment's alarm should next fire: the earlier of the moment's [nextChange] and the shown content's [tick], + * only while a Moment widget is [placed]. Null clears it. + */ + fun momentAlarm(placed: Boolean, nextChange: Instant?, tick: Instant?): Instant? = + if (!placed) null else listOfNotNull(nextChange, tick).minOrNull() + + /** Whether Moment's alarm wakes the phone: only for the moment's [nextChange], not for a [tick] that comes first. */ + fun momentWakes(nextChange: Instant?, tick: Instant?): Boolean = + tick == null || (nextChange != null && nextChange <= tick) + + /** + * A widget was placed or removed (any kind): midnight's alarm follows whether any is placed, Moment's is cleared + * once none is (a placed one sets its own as it renders), and Moment refreshes for the duplicate rule (spec 3.1). + */ + fun placedChanged(context: Context) { + val app = context.applicationContext + scheduleMidnight(app) + try { + if (WidgetKind.Moment !in placed(app)) cancel(app, ACTION_MOMENT) + } catch (e: Exception) { + Log.w(TAG, "widgets: moment alarm not cleared: $e") + } + changed(app, setOf(WidgetKind.Moment)) + } + + /** + * Android 15 and later: gives the launcher's picker each widget's generated preview ([GlanceAppWidget.providePreview]), + * the real layout with sample values (spec 6.4), once per app version. The system limits how often this may be + * called, so a limited or failed call is tried again on the next start; older systems use the provider XML's + * `previewLayout` and `previewImage`. + */ + fun publishPreviews(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return + val app = context.applicationContext + val prefs = app.getSharedPreferences(PREVIEW_PREFS, Context.MODE_PRIVATE) + val version = org.openstrap.app.BuildConfig.VERSION_CODE + if (prefs.getInt(PREVIEW_VERSION, -1) == version) return + scope.launch { + try { + val manager = androidx.glance.appwidget.GlanceAppWidgetManager(app) + val results = widgets.values.map { manager.setWidgetPreviews(it.receiver.kotlin) } + if (results.all { it == androidx.glance.appwidget.GlanceAppWidgetManager.SET_WIDGET_PREVIEWS_RESULT_SUCCESS }) { + prefs.edit().putInt(PREVIEW_VERSION, version).apply() + } else { + Log.i(TAG, "widgets: previews not all set ($results), trying again next start") + } + } catch (e: Exception) { + Log.w(TAG, "widgets: previews not set: $e") + } + } + } + + private const val PREVIEW_PREFS = "widgets" + private const val PREVIEW_VERSION = "previews_version" + + /** Sets the alarm for the next local midnight while any widget is placed, and clears it when none is. */ + fun scheduleMidnight(context: Context, zone: ZoneId = ZoneId.systemDefault()) { + try { + if (placed(context).isEmpty()) { cancel(context, ACTION_MIDNIGHT); return } + val midnight = LocalDate.now(zone).plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + setAlarm(context, ACTION_MIDNIGHT, midnight) + } catch (e: Exception) { + Log.w(TAG, "widgets: midnight alarm not set: $e") + } + } + + internal suspend fun update(context: Context, kinds: Set) { + val shown = placed(context).intersect(kinds) + for (kind in shown) { + try { + // A running session loads again on its version; one that isn't loads as it starts. + WidgetRefresh.bump(kind) + widgets.getValue(kind).widget().updateAll(context) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.w(TAG, "widgets: $kind not updated: $e") + } + } + if (shown.isNotEmpty()) { + scheduleMidnight(context) + scheduleStale(context) + } + } + + /** + * Sets the alarm for the moment the last sync turns stale ([staleAt]), so the status line says so with the strap + * away and nothing else refreshing (spec 5), while any widget is placed; clears it otherwise. + */ + suspend fun scheduleStale(context: Context) { + try { + val done = (context.applicationContext as OpenStrapApp).container.settings.strapSnapshot.first().lastSyncDone + val at = staleAt(placed(context).isNotEmpty(), done, System.currentTimeMillis() / 1000) + if (at == null) cancel(context, ACTION_STALE) else setAlarm(context, ACTION_STALE, at * 1000) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "widgets: stale alarm not set: $e") + } + } + + /** + * When the last sync, done at [lastSyncDone] (epoch seconds), turns stale: a second past [SYNC_STALE_SECONDS], + * while widgets are [placed] and that's still ahead of [now]. Null for no alarm. + */ + fun staleAt(placed: Boolean, lastSyncDone: Long?, now: Long): Long? = + lastSyncDone?.let { it + SYNC_STALE_SECONDS + 1 }?.takeIf { placed && it > now } + + /** + * Exact while the app may set exact alarms (Android 11 and older always, later with "Alarms and reminders"), + * otherwise inside [ALARM_WINDOW_MS]. Never `USE_EXACT_ALARM`: that's for clocks and calendars (spec 7). + */ + /** Sets [action]'s alarm for [atMs]; one that doesn't [wake] is held until the phone is awake for something else. */ + private fun setAlarm(context: Context, action: String, atMs: Long, wake: Boolean = true) { + val alarms = context.getSystemService(AlarmManager::class.java) ?: return + val pending = pendingIntent(context, action) + if (!wake) { + alarms.setWindow(AlarmManager.RTC, atMs, ALARM_WINDOW_MS, pending) + return + } + val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms() + try { + if (exact) alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMs, pending) + else alarms.setWindow(AlarmManager.RTC_WAKEUP, atMs, ALARM_WINDOW_MS, pending) + } catch (e: SecurityException) { + // The exact-alarm access was taken back between the check and the call. + alarms.setWindow(AlarmManager.RTC_WAKEUP, atMs, ALARM_WINDOW_MS, pending) + } + } + + private fun cancel(context: Context, action: String) { + context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, action)) + } + + private fun pendingIntent(context: Context, action: String): PendingIntent = PendingIntent.getBroadcast( + context, when (action) { ACTION_MOMENT -> 1; ACTION_MIDNIGHT -> 2; else -> 3 }, + Intent(context, WidgetAlarmReceiver::class.java).setAction(action), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + internal const val ACTION_MOMENT = "org.openstrap.app.widget.MOMENT" + internal const val ACTION_MIDNIGHT = "org.openstrap.app.widget.MIDNIGHT" + internal const val ACTION_STALE = "org.openstrap.app.widget.STALE" +} + +/** Where [WidgetUpdater]'s alarms land: the moment changed, or a new day began. Not a widget, and not exported. */ +class WidgetAlarmReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val kinds = when (intent.action) { + WidgetUpdater.ACTION_MOMENT -> setOf(WidgetKind.Moment) + WidgetUpdater.ACTION_MIDNIGHT, WidgetUpdater.ACTION_STALE -> WidgetKind.entries.toSet() + else -> return + } + val app = context.applicationContext + val done = goAsync() + CoroutineScope(Dispatchers.Default).launch { + try { + WidgetUpdater.update(app, kinds) + // The update sets the next midnight only when something was placed; this one has passed either way. + if (intent.action == WidgetUpdater.ACTION_MIDNIGHT) WidgetUpdater.scheduleMidnight(app) + } catch (e: Exception) { + Log.w("OpenStrap", "widgets: alarm ${intent.action} failed: $e") + } finally { + done.finish() + } + } + } +} + +/** + * At most one update every [everyMs], and none lost: a request inside the gap waits for its end, and requests while + * one waits join it. + */ +class UpdateThrottle(private val everyMs: Long) { + private var last: Long? = null + private var waiting = false + + /** A request at [now] (ms): 0 to update now, the ms to wait before updating, or null when one already waits. */ + @Synchronized + fun request(now: Long): Long? { + if (waiting) return null + val l = last + if (l == null || now - l >= everyMs) { last = now; return 0 } + waiting = true + return l + everyMs - now + } + + /** The waiting update ran at [now] (ms). */ + @Synchronized + fun ran(now: Long) { + last = now + waiting = false + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/device/WorkoutSession.kt b/app/src/main/kotlin/org/openstrap/app/device/WorkoutSession.kt index bcad92e0..d3bf9afb 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/WorkoutSession.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/WorkoutSession.kt @@ -29,6 +29,7 @@ import org.openstrap.app.data.db.AppWorkoutRoutePoint import org.openstrap.app.data.Route import org.openstrap.app.data.db.HealthDao import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.widget.WidgetKind import kotlin.math.roundToInt sealed interface WorkoutState { @@ -112,6 +113,9 @@ class WorkoutSession( /** A sport a launcher shortcut or the Quick Settings tile asked the ready screen to open with; the screen clears it. */ val readyRequest = MutableStateFlow(null) + /** The Start widget's All sports asked for the ready screen on its sport picker; the screen clears it. */ + val readyPickRequest = MutableStateFlow(false) + /** The start of a strap workout a notification asked to open on the Activity tab; the screen clears it. */ val openRequest = MutableStateFlow(null) @@ -130,6 +134,31 @@ class WorkoutSession( private var lastSecond = -1L private var wake: PowerManager.WakeLock? = null + init { + // A workout starting or ending changes the moment and the Start widget (widgets spec 7). + scope.launch { + var was: Boolean? = null + var wasKind: Class<*>? = null + var wasPaused: Boolean? = null + _state.collect { st -> + val recording = st is WorkoutState.Recording + val paused = (st as? WorkoutState.Recording)?.paused + // A summary closing can follow a discard, which takes a workout out of Start's week and Moment's + // after a workout. A pause or a resume stops or restarts Start's timer. + val kinds = when { + was != null && was != recording -> setOf(WidgetKind.Moment, WidgetKind.Start) + wasKind != null && wasKind != st::class.java -> setOf(WidgetKind.Moment, WidgetKind.Start) + paused != null && wasPaused != null && paused != wasPaused -> setOf(WidgetKind.Start) + else -> emptySet() + } + if (kinds.isNotEmpty()) WidgetUpdater.changed(context, kinds) + was = recording + wasKind = st::class.java + wasPaused = paused + } + } + } + /** Picks up a workout left recording when the process died, then starts watching for auto-start. Called once at app start. */ fun restore() { scope.launch { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/components/Charts.kt b/app/src/main/kotlin/org/openstrap/app/ui/components/Charts.kt index 7a9c2a47..c16c0a95 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/components/Charts.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/components/Charts.kt @@ -287,34 +287,7 @@ fun LineChart( * A smooth path through [pts] using monotone cubic interpolation (Fritsch–Carlson): curves like a spline * but never overshoots the data, so a smoothed heart rate can't show a peak or dip that didn't happen. */ -internal fun smoothPath(pts: List): Path { - val path = Path() - if (pts.isEmpty()) return path - path.moveTo(pts[0].x, pts[0].y) - if (pts.size == 2) { path.lineTo(pts[1].x, pts[1].y); return path } - val n = pts.size - val d = FloatArray(n - 1) { i -> (pts[i + 1].y - pts[i].y) / (pts[i + 1].x - pts[i].x).coerceAtLeast(0.001f) } - val m = FloatArray(n) - m[0] = d[0] - m[n - 1] = d[n - 2] - for (i in 1 until n - 1) m[i] = if (d[i - 1] * d[i] <= 0f) 0f else (d[i - 1] + d[i]) / 2f - for (i in 0 until n - 1) { - if (d[i] == 0f) { m[i] = 0f; m[i + 1] = 0f; continue } - val a = m[i] / d[i] - val b = m[i + 1] / d[i] - val s = a * a + b * b - if (s > 9f) { - val t = 3f / kotlin.math.sqrt(s) - m[i] = t * a * d[i] - m[i + 1] = t * b * d[i] - } - } - for (i in 0 until n - 1) { - val dx = (pts[i + 1].x - pts[i].x) / 3f - path.cubicTo(pts[i].x + dx, pts[i].y + m[i] * dx, pts[i + 1].x - dx, pts[i + 1].y - m[i + 1] * dx, pts[i + 1].x, pts[i + 1].y) - } - return path -} +internal fun smoothPath(pts: List): Path = org.openstrap.app.ui.glow.draw.composePath(org.openstrap.app.ui.glow.draw.smoothOps(pts)) private fun niceStep(raw: Float): Float { val candidates = floatArrayOf(1f, 2f, 5f, 10f, 20f, 25f, 50f, 100f) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/ActivityCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/ActivityCopy.kt index 2ef3c140..92cf268f 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/ActivityCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/ActivityCopy.kt @@ -36,9 +36,9 @@ internal object ActivityCopy { return UsualSteps.pace(steps, if (today) UsualSteps.at(u, hour, minute) else u[23]) } - /** How far toward the goal: "Goal reached" or "62% of your goal". */ + /** How far toward the goal: "Goal met" or "62% of your goal". */ fun goalShare(steps: Int, goal: Int): String = - if (steps >= goal) "Goal reached" else "${steps * 100 / goal.coerceAtLeast(1)}% of your goal" + if (steps >= goal) "Goal met" else "${steps * 100 / goal.coerceAtLeast(1)}% of your goal" /** Today's Activity card under the steps: the goal share, then your usual by now when there is one. */ fun activityLine(steps: Int, goal: Int, pace: UsualSteps.Pace?, today: Boolean): String { @@ -85,7 +85,7 @@ internal object ActivityCopy { /* ------------------------------------------- Active minutes ----------------------------------------- */ /** The status word beside the week's active minutes; null under the goal. */ - fun activeWord(week: Int): String? = if (week >= Activity.MVPA_WEEKLY_TARGET) "Goal reached" else null + fun activeWord(week: Int): String? = if (week >= Activity.MVPA_WEEKLY_TARGET) "Goal met" else null /** The week's sentence, under the week's minutes: how far to the 150, or by how much it's past. */ fun activeWeek(week: Int): String { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayViewModel.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayViewModel.kt index 2e6f7e73..9f26c4d2 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayViewModel.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayViewModel.kt @@ -251,8 +251,8 @@ class DayViewModel( val nightBefore = DayWindow.nightEndingOn(date.minusDays(1)) val sleep = repo.sleepSessions(DayWindow(nightBefore.from, day.to + 12 * 3600)).flatMapLatest { sessions -> // Main sleep = the longest non-nap session that ended in the night window. - val main = sessions.filter { !it.nap && it.end in night.from..night.to }.maxByOrNull { it.end - it.start } - val before = sessions.filter { !it.nap && it.end in nightBefore.from..nightBefore.to }.maxByOrNull { it.end - it.start } + val main = SleepHistory.mainSleep(sessions, night) + val before = SleepHistory.mainSleep(sessions, nightBefore) // Naps belong to the calendar day they started on, not the night window (which ends at 18:00). val naps = sessions.filter { it.nap && it.start in day.from..day.to } val mainStages = if (main == null) flowOf(emptyList()) else repo.sleepStages(main.start) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Routes.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Routes.kt index 9e7b9c28..9976ec59 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Routes.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Routes.kt @@ -102,12 +102,16 @@ sealed interface Detail { } /** - * The screen an `open_metric` link names: a [Metric] by name ("Hrv"), or "Recovery" or "Sleep" for those - * screens. Null for anything else. + * The screen an `open_metric` link names: a [Metric] by name ("Hrv"), "Recovery" or "Sleep" for those + * screens, or [SLEEP_SCORE_ROUTE] for the Sleep score's detail. Null for anything else. */ + /** The Sleep score's detail for the night of the link's day, as the Sleep tab's hero opens it. */ + const val SLEEP_SCORE_ROUTE = "SleepScore" + fun fromRoute(route: String): Detail? = when (route) { "Recovery" -> Recovery "Sleep" -> Sleep + SLEEP_SCORE_ROUTE -> OfScore(Score.Sleep) else -> Metric.entries.firstOrNull { it.name == route }?.let { OfMetric(it) } } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabCopy.kt index 65c6c5e3..edaab69a 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabCopy.kt @@ -166,6 +166,24 @@ object TabCopy { else -> "Poor" } + /** The score's line beside the night's dotted score: "Sleep score, good", or why there's no score. */ + fun sleepScoreLine(score: Int?): String = score?.let { "Sleep score, ${sleepScoreWord(it).lowercase()}" } ?: NO_STRAP_SCORE + + /** A night the strap gave no score. */ + const val NO_STRAP_SCORE = "No score from the strap" + + /** A stage's total on the stage bar's legend: "Deep 1 h 32", "Awake 18 min". */ + fun stageTotal(stage: Int, seconds: Long): String = "${SLEEP_NAMES[stage].orEmpty()} ${TodayCopy.duration(seconds)}" + + /** "7 h 40 asleep". */ + fun asleep(seconds: Long): String = "${TodayCopy.duration(seconds)} asleep" + + /** When the night ran, bedtime to waking: "22:58–06:56" (an en dash, a time range). */ + fun nightTimes(start: Long, end: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): String { + fun clock(epoch: Long) = java.time.Instant.ofEpochSecond(epoch).atZone(zone).format(DateTimeFormatter.ofPattern("HH:mm")) + return "${clock(start)}–${clock(end)}" + } + /** "Woke 3 times", "Woke once", "Slept through"; null when the strap didn't say. */ fun woke(count: Int?): String? = when (count) { null -> null diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/EdgeCard.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/EdgeCard.kt index af1291a0..bb018e7b 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/EdgeCard.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/EdgeCard.kt @@ -16,18 +16,17 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlin.math.exp import org.openstrap.app.ui.components.LocalCardNumbers import org.openstrap.app.ui.components.cardOpening import org.openstrap.app.ui.components.pressClickable +import org.openstrap.app.ui.glow.draw.brush +import org.openstrap.app.ui.glow.draw.drawEdge +import org.openstrap.app.ui.glow.draw.edgeLevel +import org.openstrap.app.ui.glow.draw.edgePaint /* * The middle tier of the glow language: a near-black card with a soft glow coming in from one edge in the metric's @@ -104,8 +103,6 @@ data class EdgeOverride(val edge: Edge? = null, val color: Color? = null) val LocalEdgeOverride = staticCompositionLocalOf { EdgeOverride() } private val EdgeShape = GlowLayout.CardShape -private val Surface = Color(0xFF0A0A0B) -private val Hairline = Color.White.copy(alpha = GlowLayout.HairlineAlpha) /** * A card in the edge light tier, lit in [color] from the edge [edgeLight] picks for [status] and [kind], unless @@ -127,7 +124,7 @@ fun EdgeCard( val override = LocalEdgeOverride.current // A level always fills from the left, so only its colour can be overridden. val light = edgeLight(status, kind).let { l -> override.edge?.takeIf { kind != EdgeKind.Level }?.let { l.copy(edge = it) } ?: l } - val level = progress?.takeIf { kind == EdgeKind.Level }?.coerceIn(MinLevel, 1f) + val level = edgeLevel(progress?.takeIf { kind == EdgeKind.Level }) val tint = override.color ?: color val mood = LocalGlowMood.current // Tapped, the card grows into what it opens (spec 3.5). @@ -137,47 +134,10 @@ fun EdgeCard( .then(opening?.modifier ?: Modifier) .let { if (onClick != null) it.pressClickable(opening?.onClick ?: onClick, EdgeShape) else it } .drawWithCache { - val glow = if (light.strong) StrongGlow else SoftGlow - val depth = glow.depth.toPx() - // From the lit edge inward, the colour falling off like the blurred edge of a CSS inset shadow. - val (from, to) = when (light.edge) { - Edge.Top -> Offset(0f, 0f) to Offset(0f, depth) - Edge.Bottom -> Offset(0f, size.height) to Offset(0f, size.height - depth) - Edge.Left -> Offset(0f, 0f) to Offset(depth, 0f) - Edge.Right -> Offset(size.width, 0f) to Offset(size.width - depth, 0f) - } - val stops = glow.stops(mood.edge).map { (at, alpha) -> at to tint.copy(alpha = alpha) }.toTypedArray() - val brush = if (level == null) Brush.linearGradient(*stops, start = from, end = to) else { - // The light fills across to the level, 46% of the ink at the left and 30% at the value, then fades - // out over LevelFade past it on a smoothstep, so it never stops in a line (spec 2.1, Levels). - val w = size.width - val end = w * level - val fade = LevelFade.toPx() - val strength = mood.edge * (if (light.strong) 1.2f else 1f) - val atValue = 0.30f * strength - val past = List(LEVEL_FADE_STEPS) { i -> - val t = (i + 1) / LEVEL_FADE_STEPS.toFloat() - ((end + fade * t) / w).coerceIn(0f, 1f) to tint.copy(alpha = (atValue * (1f - t * t * (3f - 2f * t))).coerceIn(0f, 1f)) - } - Brush.horizontalGradient( - 0f to tint.copy(alpha = (0.46f * strength).coerceIn(0f, 1f)), - (end / w).coerceIn(0f, 1f) to tint.copy(alpha = atValue.coerceIn(0f, 1f)), - *past.toTypedArray(), - ) - } - val radius = CornerRadius(GlowLayout.CardCorner.toPx()) - val stroke = 1.dp.toPx() - onDrawBehind { - drawRoundRect(Surface, cornerRadius = radius) - drawRoundRect(brush, cornerRadius = radius) - drawRoundRect( - Hairline, - topLeft = Offset(stroke / 2, stroke / 2), - size = Size(size.width - stroke, size.height - stroke), - cornerRadius = CornerRadius(radius.x - stroke / 2), - style = Stroke(stroke), - ) - } + // The shared edge light ([edgePaint]), as a widget's background draws it. + val paint = edgePaint(light, tint, level, mood, size.width, size.height, density) + val glow = paint.glow.brush() + onDrawBehind { drawEdge(paint, glow) } } .padding(contentPadding), ) { @@ -189,12 +149,8 @@ fun EdgeCard( } } -/** A level card's lowest light, so an empty one still shows where it fills from. */ -private const val MinLevel = 0.04f - /** How far past its level a level card's light fades out. */ internal val LevelFade = 40.dp -private const val LEVEL_FADE_STEPS = 6 /** The 4 dp bar inside a progress edge card. */ @Composable diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowActivityScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowActivityScreen.kt index 37d47479..ea28ad46 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowActivityScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowActivityScreen.kt @@ -75,6 +75,8 @@ import org.openstrap.app.ui.theme.AppIcons import org.openstrap.app.ui.theme.MetricColors import org.openstrap.app.ui.workout.SportIcons import org.openstrap.app.ui.workout.ZONE_COLORS +import org.openstrap.app.ui.glow.draw.drawHourPills +import org.openstrap.app.ui.glow.draw.hourPills /** Heart points' weekly aim: 100 or more over the last seven days. */ private const val HEART_POINTS_TARGET = ActivityCopy.HEART_POINTS_TARGET @@ -268,8 +270,6 @@ private fun StepsGlow(state: DayUiState, usual: List?, tag: TitleTag?, on */ @Composable private fun HourPills(hourly: List, picked: Int?, onPick: (Int?) -> Unit) { - val top = hourly.maxOrNull()?.takeIf { it > 0f } ?: 1f - val last = picked ?: hourly.indexOfLast { it > 0f } Column { Canvas( Modifier.fillMaxWidth().height(HourPillsHeight).pointerInput(hourly, picked) { @@ -281,13 +281,8 @@ private fun HourPills(hourly: List, picked: Int?, onPick: (Int?) -> Unit) } }, ) { - if (hourly.isEmpty()) return@Canvas - val slot = size.width / hourly.size - barBaseline(size.height - 0.5.dp.toPx()) - hourly.forEachIndexed { i, v -> - if (v <= 0f) return@forEachIndexed - pillBar(if (i == last) Color.White else Color.White.copy(alpha = 0.45f), slot * i, slot, size.height, size.height * v / top) - } + // The shared pill bars ([hourPills]), as a widget's chart draws them. + hourPills(hourly, picked, size.width, size.height, density)?.let { drawHourPills(it) } } Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { listOf("00", "06", "12", "18", "24").forEach { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCard.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCard.kt index b49c511e..f39a7fe7 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCard.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCard.kt @@ -1,11 +1,7 @@ package org.openstrap.app.ui.glow -import android.graphics.BitmapShader -import android.graphics.BlurMaskFilter import android.graphics.Paint -import android.graphics.Path import android.graphics.RectF -import android.graphics.Shader import android.provider.Settings import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -34,8 +30,6 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.TransformOrigin @@ -45,7 +39,6 @@ import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -53,7 +46,6 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import kotlin.math.PI -import kotlin.math.abs import kotlin.math.cos import kotlin.math.exp import kotlin.math.floor @@ -66,6 +58,12 @@ import kotlinx.coroutines.withContext import org.openstrap.app.ui.components.LocalCardNumbers import org.openstrap.app.ui.components.cardOpening import org.openstrap.app.ui.components.pressClickable +import org.openstrap.app.ui.glow.draw.brush +import org.openstrap.app.ui.glow.draw.drawGlowBase +import org.openstrap.app.ui.glow.draw.drawGlowCore +import org.openstrap.app.ui.glow.draw.glowPaint +import org.openstrap.app.ui.glow.draw.grainShader +import org.openstrap.app.ui.glow.draw.randomGrainSeed /** * A card in the glow language (see [GlowStyle]). Layers, back to front: rim-coloured fill, rim light, @@ -106,7 +104,7 @@ fun GlowCard( val core = remember(lit) { lit.light().core } val slab = remember(palette) { lerp(palette.mid, Color.Black, 0.55f) } // Each card samples the grain tile from its own spot, so neighbouring cards never share a pattern. - val grainSeed = remember { Random.nextInt(GRAIN_TILE * GRAIN_TILE) } + val grainSeed = remember { randomGrainSeed(Random) } val drift = remember { CoreDrift(Random) } val seconds = remember { mutableFloatStateOf(0f) } // Beats so far, counted frame by frame at the bpm of the moment, so a new reading changes the pace from here on @@ -217,33 +215,21 @@ private fun BoxScope.CoreLayer(b: GlowBitmaps, style: GlowStyle, core: CoreBox, private val FILTER = Paint(Paint.FILTER_BITMAP_FLAG) -/** An outline card's edge. */ -private val OUTLINE_WIDTH = 1.5.dp -private const val OUTLINE_ALPHA = 0.7f - -/** Grain and the hairline edge with its gloss. */ +/** Grain and the hairline edge with its gloss, from the shared light ([glowPaint], as a widget's background draws them). */ private fun CacheDrawScope.overlay(style: GlowStyle, grainSeed: Int): DrawResult { + val spec = glowPaint(style, size.width, size.height, density) + val outline = RoundedCornerShape(style.radius).createOutline(size, layoutDirection, this) + // On hold: only the edge, in the card's colour. + spec.outline?.let { color -> return onDrawBehind { drawOutline(outline, color, style = Stroke(spec.outlineWidth)) } } val grainPaint = Paint().apply { shader = grainShader(grainSeed) - alpha = (style.grain.coerceIn(0f, 1f) * 255).toInt() + alpha = spec.grainAlpha } - val hairline = Stroke(1.dp.toPx()) - val outline = RoundedCornerShape(style.radius).createOutline(size, layoutDirection, this) - // A glossy edge: a lighter hairline along the top fading out down the sides. On a level card it follows the - // light instead: glossy along the lit edge, plain hairline on the unlit side. - val light = style.light() - val gloss = if (light.across) { - Brush.horizontalGradient(0f to Color.White.copy(alpha = 0.35f), 0.25f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.04f)) - } else if (light.level != null && !light.fromAbove) { - Brush.verticalGradient(0f to Color.White.copy(alpha = 0.04f), 0.75f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.35f)) - } else { - Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 0.25f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.04f)) - } - // On hold: only the edge, in the card's colour. - if (style.outline) return onDrawBehind { drawOutline(outline, style.color.copy(alpha = OUTLINE_ALPHA), style = Stroke(OUTLINE_WIDTH.toPx())) } + val hairline = Stroke(spec.hairlineWidth) + val gloss = spec.gloss.brush() return onDrawBehind { - if (style.grain > 0f) drawIntoCanvas { it.nativeCanvas.drawPaint(grainPaint) } - drawOutline(outline, Color.White.copy(alpha = GlowLayout.HairlineAlpha), style = hairline) + if (spec.grainAlpha > 0) drawIntoCanvas { it.nativeCanvas.drawPaint(grainPaint) } + drawOutline(outline, spec.hairline, style = hairline) drawOutline(outline, gloss, style = hairline) } } @@ -279,102 +265,16 @@ private object GlowCache { private const val MAX_ENTRIES = 10 } -/** Renders the base and the core layer at half resolution (blur hides it), as GPU bitmaps. */ -private fun render(style: GlowStyle, w: Float, h: Float, density: Density): GlowBitmaps = with(density) { - val p = GlowPalette(style) - val light = style.light() - val r = style.radius.toPx() - val area = Size(w, h) - - val base = halfResBitmap(w, h, 0f) { canvas -> - canvas.drawRect(0f, 0f, w, h, Paint().apply { shader = fillShader(light, p, w, h) }) - if (light.rim > 0f) { - // On a level card the rim lights only the lit part: clipped where the core is fully dark, so the unlit - // side keeps just its hairline. - light.level?.let { level -> - val dark = levelEdge(level) * (if (light.across) w else h) - LEVEL_STEP.toPx() - when { - light.across -> canvas.clipRect(0f, 0f, w - dark, h) - light.fromAbove -> canvas.clipRect(0f, 0f, w, h - dark) - else -> canvas.clipRect(0f, dark, w, h) - } - } - val k = light.rim - // A rim past full (the workout window's 120%) can't get brighter at the edge, so it reaches further in. - val g = style.glowSize.toPx() * k.coerceAtLeast(1f) - // Rim light: a CSS-style inset shadow leaning toward the lit edge, so the rim is full on one side (L4) and - // gone on the other. Light all the way round only when the style has no side. - if (light.angle != null) { - // The hole shifts away from the light, so the lit side shows the most. - val (vx, vy) = lightVector(light.angle) - insetShadow(area, r, -vx * g * 0.6f, -vy * g * 0.6f, blur = g * 1.2f, spread = -g * 0.5f, p.light.copy(alpha = k.coerceAtMost(1f))).let { (path, paint) -> canvas.drawPath(path, paint) } - } else { - insetShadow(area, r, 0f, 0f, blur = g, spread = g * 0.1f, p.light.copy(alpha = (0.6f * k).coerceAtMost(1f))).let { (path, paint) -> canvas.drawPath(path, paint) } - } - // Bevel: a thick band of light all the way round, like the edge of a glossy slab. - if (style.bevel > 0f) { - val b = minOf(w, h) * 0.075f * style.bevel - insetShadow(area, r, 0f, 0f, blur = b * 1.6f, spread = b * 0.45f, p.light.copy(alpha = (0.85f * k).coerceAtMost(1f))).let { (path, paint) -> canvas.drawPath(path, paint) } - } - } - } - - // Dark core and the second-tone band around it, pre-blurred. - val cw = w * light.core.width - val ch = h * light.core.height - val core = RectF(w * light.core.x - cw / 2, h * light.core.y - ch / 2, 0f, 0f).apply { right = left + cw; bottom = top + ch } - val coreRadius = (r - 8.dp.toPx()).coerceAtLeast(0f) - val band = if (light.tone) style.toneWidth.toPx() else 0f - val coreBlur = style.coreBlur.toPx() - // Just enough room round the card for the core to drift and swell into view. - val margin = maxOf(w, h) * 0.08f - val coreLayer = halfResBitmap(w, h, margin) { canvas -> - light.level?.let { level -> - // A level card: lit at full up to the level, then the core colour fading in over LEVEL_STEP past it (a - // smoothstep), like light rising in a glass. Only the core's alpha changes, so nothing is ever darker than - // the unlit part and there's never a hard edge. - // Measured from the unlit side: the top, the bottom when lit from above, the right when lit from the left. - val edge = levelEdge(level) * (if (light.across) w else h) - val step = LEVEL_STEP.toPx() - val (y0, y1) = when { - light.across -> w + margin to -margin - light.fromAbove -> h + margin to -margin - else -> -margin to h + margin - } - val span = abs(y1 - y0) - fun at(fromUnlit: Float) = ((fromUnlit + margin) / span).coerceIn(0f, 1f) - val steps = 10 - val colors = IntArray(steps + 3) - val stops = FloatArray(steps + 3) - colors[0] = p.core.toArgb(); stops[0] = 0f - for (i in 0..steps) { - val t = i / steps.toFloat() - val fade = t * t * (3f - 2f * t) - colors[i + 1] = p.core.copy(alpha = 1f - fade).toArgb() - stops[i + 1] = at(edge - step + step * t) - } - colors[steps + 2] = Color.Transparent.toArgb(); stops[steps + 2] = 1f - val paint = Paint().apply { - shader = if (light.across) android.graphics.LinearGradient(y0, 0f, y1, 0f, colors, stops, Shader.TileMode.CLAMP) - else android.graphics.LinearGradient(0f, y0, 0f, y1, colors, stops, Shader.TileMode.CLAMP) - } - canvas.drawRect(-margin, -margin, w + margin, h + margin, paint) - return@halfResBitmap - } - val blur = blurFilter(coreBlur) - val tonePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = p.tone.toArgb(); maskFilter = blur } - val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = p.core.toArgb(); maskFilter = blur } - val toneRect = RectF(core.left - band, core.top - band, core.right + band, core.bottom + band) - if (band > 0f) when (style.coreShape) { - CoreShape.Rounded -> canvas.drawRoundRect(toneRect, coreRadius + band, coreRadius + band, tonePaint) - CoreShape.Radial -> canvas.drawOval(toneRect, tonePaint) - } - when (style.coreShape) { - CoreShape.Rounded -> canvas.drawRoundRect(core, coreRadius, coreRadius, corePaint) - CoreShape.Radial -> canvas.drawOval(core, corePaint) - } - } - GlowBitmaps( +/** + * Renders the base and the core layer at half resolution (blur hides it), as GPU bitmaps, with the shared light + * drawing ([glowPaint], [drawGlowBase], [drawGlowCore]) a widget's background uses too. + */ +private fun render(style: GlowStyle, w: Float, h: Float, density: Density): GlowBitmaps { + val paint = glowPaint(style, w, h, density.density) + val base = halfResBitmap(w, h, 0f) { canvas -> drawGlowBase(canvas, paint) } + val margin = paint.coreMargin + val coreLayer = halfResBitmap(w, h, margin) { canvas -> drawGlowCore(canvas, paint) } + return GlowBitmaps( base = base, core = coreLayer, baseDst = RectF(0f, 0f, w, h), @@ -398,48 +298,6 @@ private fun halfResBitmap(w: Float, h: Float, margin: Float, draw: (android.grap return bmp.copy(android.graphics.Bitmap.Config.HARDWARE, false)?.also { bmp.recycle() } ?: bmp } -/** - * The card's base fill: the light colour at the lit edge melting into the mid (body) colour along the light's angle, - * linearly through the level's [GlowLight.fillStops] (spec 2.1): on L4 the light holds for the first part of the card - * and reaches the body by 45%, on L3 it starts at 70% and is gone by 28%. - */ -private fun fillShader(light: GlowLight, p: GlowPalette, w: Float, h: Float): Shader { - val angle = light.angle ?: return android.graphics.LinearGradient(0f, 0f, 0f, h, p.mid.toArgb(), p.mid.toArgb(), Shader.TileMode.CLAMP) - val points = light.fillStops.map { it.at.coerceIn(0f, 1f) to it.light.coerceIn(0f, 1f) } - val colors = points.map { (_, k) -> lerp(p.mid, p.light, k).toArgb() } + p.mid.toArgb() - val stops = points.map { it.first } + 1f - // Like a CSS linear-gradient: the line runs through the centre, just long enough to reach both far corners. - val (vx, vy) = lightVector(angle) - val half = (abs(w * vx) + abs(h * vy)) / 2f - return android.graphics.LinearGradient( - w / 2 + vx * half, h / 2 + vy * half, w / 2 - vx * half, h / 2 - vy * half, - colors.toIntArray(), stops.toFloatArray(), Shader.TileMode.CLAMP, - ) -} - -/** - * The equivalent of CSS `box-shadow: inset [dx] [dy] [blur] [spread] [color]`: everything outside the - * card's shape (shifted by [dx], [dy], shrunk by [spread]), blurred and clipped to the card. - */ -private fun insetShadow(size: Size, radius: Float, dx: Float, dy: Float, blur: Float, spread: Float, color: Color): Pair { - val pad = blur * 2 + abs(dx) + abs(dy) + abs(spread) + 4f - val holeRadius = (radius - spread).coerceAtLeast(0f) - val path = Path().apply { - fillType = Path.FillType.EVEN_ODD - addRect(-pad, -pad, size.width + pad, size.height + pad, Path.Direction.CW) - addRoundRect(spread + dx, spread + dy, size.width - spread + dx, size.height - spread + dy, holeRadius, holeRadius, Path.Direction.CW) - } - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - this.color = color.toArgb() - maskFilter = blurFilter(blur / 2f) // CSS shadow blur is twice the standard deviation - } - return path to paint -} - -/** A Gaussian blur with standard deviation [sigma] px (Android's radius is ~1.73 × sigma). */ -private fun blurFilter(sigma: Float): BlurMaskFilter? = - if (sigma > 0.5f) BlurMaskFilter((sigma - 0.5f) / 0.57735f, BlurMaskFilter.Blur.NORMAL) else null - /** How the dark core sits: offset (fractions of the card), scale, and [fade] on top of the style's core opacity. */ private class Drift(val dx: Float, val dy: Float, val scale: Float, val fade: Float = 1f, val squash: Float = 0f) @@ -512,30 +370,3 @@ private fun beatAngle(n: Int): Float { h = h xor (h ushr 13) return (h ushr 8) / (1 shl 24).toFloat() * 2f * PI.toFloat() } - -private const val GRAIN_TILE = 512 - -/** - * Film grain: a large tile of sparse specks, one device pixel each, so it reads as texture rather - * than dots. [seed] shifts where each card starts in the tile, which hides the repeat. - */ -private fun grainShader(seed: Int): Shader = - BitmapShader(grainBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT).apply { - setLocalMatrix(android.graphics.Matrix().apply { - setTranslate((seed % GRAIN_TILE).toFloat(), (seed / GRAIN_TILE).toFloat()) - }) - } - -private val grainBitmap: android.graphics.Bitmap by lazy { - val n = GRAIN_TILE - val random = Random(7) - val pixels = IntArray(n * n) { - // Most pixels stay clear; the rest get a random strength, and some darken instead of lighten. - val v = random.nextFloat() - if (v < 0.55f) 0 else { - val alpha = (((v - 0.55f) / 0.45f) * 255).toInt().coerceIn(0, 255) - (alpha shl 24) or (if (random.nextFloat() < 0.35f) 0x000000 else 0xFFFFFF) - } - } - android.graphics.Bitmap.createBitmap(pixels, n, n, android.graphics.Bitmap.Config.ARGB_8888) -} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCharts.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCharts.kt index 3c657cb3..2c3c9fb8 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCharts.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCharts.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.lerp -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp @@ -44,9 +43,20 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.openstrap.app.ui.components.rememberScrubHaptics import org.openstrap.app.ui.components.scrub -import org.openstrap.app.ui.components.smoothPath import org.openstrap.app.ui.theme.FunnelSans import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.glow.draw.BAR_MAX_WIDTH_DP +import org.openstrap.app.ui.glow.draw.ChartInk +import org.openstrap.app.ui.glow.draw.chartHairline +import org.openstrap.app.ui.glow.draw.drawGhostLine +import org.openstrap.app.ui.glow.draw.drawHypnogramBands +import org.openstrap.app.ui.glow.draw.drawLineChartBody +import org.openstrap.app.ui.glow.draw.drawLineChartEnd +import org.openstrap.app.ui.glow.draw.drawPillBar +import org.openstrap.app.ui.glow.draw.ghostLineGeometry +import org.openstrap.app.ui.glow.draw.hypnogramGeometry +import org.openstrap.app.ui.glow.draw.lineChartGeometry +import org.openstrap.app.ui.glow.draw.pillBar import kotlin.math.roundToInt import kotlin.math.sin @@ -58,33 +68,26 @@ import kotlin.math.sin private val Ink = Color.White private val Muted = Color.White.copy(alpha = 0.55f) /** Grid rows and stage guides: solid, white at 8%, 1 dp. */ -private val Grid = Color.White.copy(alpha = GlowLayout.HairlineAlpha) +private val Grid = ChartInk.Grid /** A target, goal or average across a chart: solid, white at 40%, 1 dp. */ -private val Target = Color.White.copy(alpha = 0.40f) +private val Target = ChartInk.Target private val axisText = TextStyle(color = Muted, fontSize = 10.sp, fontFamily = FunnelSans) private val valueText = TextStyle(color = Ink, fontSize = 11.sp, fontFamily = FunnelSans) private val readoutText = TextStyle(color = Ink, fontSize = 12.sp, fontFamily = FunnelSans) /** A solid horizontal hairline across the chart: a grid row by default, or a target in [color]. */ -private fun DrawScope.gridLine(y: Float, color: Color = Grid, x0: Float = 0f, x1: Float = size.width) = - drawLine(color, Offset(x0, y), Offset(x1, y), strokeWidth = 1.dp.toPx()) - -/** The hairline under a chart's bars, for bar charts drawn outside this file. */ -internal fun DrawScope.barBaseline(y: Float) = gridLine(y) +private fun DrawScope.gridLine(y: Float, color: Color = Grid, x0: Float = 0f, x1: Float = size.width) = chartHairline(y, color, x0, x1) /** The widest a bar gets (spec 2.7, pill bars): a wide card spaces its bars out but keeps them thin. */ -internal val BarMaxWidth = 6.dp +internal val BarMaxWidth = BAR_MAX_WIDTH_DP.dp /** * One pill bar in a column [pitch] wide starting at [x], standing on [bottom], [height] tall for its value. At most * [BarMaxWidth] wide, and never shorter than twice its width, so a small value still reads as a bar, not a dot. */ -internal fun DrawScope.pillBar(color: Color, x: Float, pitch: Float, bottom: Float, height: Float) { - val w = (pitch * 0.56f).coerceAtMost(BarMaxWidth.toPx()) - val h = height.coerceAtLeast(w * 2) - drawRoundRect(color, topLeft = Offset(x + (pitch - w) / 2, bottom - h), size = Size(w, h), cornerRadius = CornerRadius(w / 2)) -} +internal fun DrawScope.pillBar(color: Color, x: Float, pitch: Float, bottom: Float, height: Float) = + drawPillBar(pillBar(x, pitch, bottom, height, BarMaxWidth.toPx()), color) private fun DrawScope.text(m: TextMeasurer, s: String, style: TextStyle, x: Float, y: Float, align: Float = 0f) { val r = m.measure(s, style) @@ -162,51 +165,18 @@ fun GlowLineChart( } }, ) { - val known = values.filterNotNull() - if (known.isEmpty()) return@Canvas - val axisH = if (labels.isEmpty()) 0f else 18.dp.toPx() - val top = 10.dp.toPx() - val h = size.height - axisH - val lo = yRange?.start ?: minOf(known.min(), band?.start ?: Float.MAX_VALUE) - val hi = (yRange?.endInclusive ?: maxOf(known.max(), band?.endInclusive ?: -Float.MAX_VALUE)).let { if (it - lo < 1e-3f) lo + 1f else it } - // Inset by the end dot's halo, so a reading at the very edge isn't cut off. - fun xOf(i: Int) = inset + i / (values.size - 1f).coerceAtLeast(1f) * (size.width - inset * 2) - fun yOf(v: Float) = top + (h - top) * (1f - (v - lo) / (hi - lo)) - - listOf(0f, 0.5f, 1f).forEach { gridLine(top + (h - top) * it) } - band?.let { b -> - val y0 = yOf(b.endInclusive) - val y1 = yOf(b.start) - drawRect(color.copy(alpha = NormalBandAlpha), Offset(0f, y0), Size(size.width, y1 - y0)) - } - - // Runs of consecutive readings; a gap starts a new run. - val runs = mutableListOf>(mutableListOf()) - values.forEachIndexed { i, v -> - if (v == null) { if (runs.last().isNotEmpty()) runs += mutableListOf() } else runs.last() += Offset(xOf(i), yOf(v)) - } - // A lone reading between gaps is a dot, so a sparse week still shows its days. - runs.filter { it.size == 1 }.forEach { drawCircle(color, 2.6.dp.toPx(), it.first()) } - runs.filter { it.size >= 2 }.forEach { pts -> - val line = smoothPath(pts) - val fill = Path().apply { addPath(line); lineTo(pts.last().x, h); lineTo(pts.first().x, h); close() } - drawPath(fill, Brush.verticalGradient(listOf(color.copy(alpha = 0.14f), Color.Transparent), startY = top, endY = h)) - // A faint halo just wider than the line. - drawPath(line, color.copy(alpha = 0.08f), style = Stroke(4.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) - drawPath(line, color, style = Stroke(1.8.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) - } + // The shared line geometry ([lineChartGeometry]), as a widget's chart draws it. + val g = lineChartGeometry(values, size.width, size.height, density, inset, axis = labels.isNotEmpty(), yRange = yRange, band = band) ?: return@Canvas + val h = g.plotBottom + drawLineChartBody(g, color) if (showAverage) { - val avg = known.average().toFloat() - val y = yOf(avg) + val y = g.averageY gridLine(y, Target) - text(m, "avg ${format(avg)}", axisText.copy(color = Ink.copy(alpha = 0.8f)), size.width, y - 16.dp.toPx(), align = 1f) + text(m, "avg ${format(g.average)}", axisText.copy(color = Ink.copy(alpha = 0.8f)), size.width, y - 16.dp.toPx(), align = 1f) } - val lastIndex = values.indexOfLast { it != null } - val last = Offset(xOf(lastIndex), yOf(values[lastIndex]!!)) - drawCircle(color.copy(alpha = 0.2f), 6.5.dp.toPx(), last) - drawCircle(color, 3.5.dp.toPx(), last) + drawLineChartEnd(g, color) axis(m, labels, h + 4.dp.toPx()) @@ -215,7 +185,7 @@ fun GlowLineChart( if (scrubbing && tx != null) { val i = nearestReading(values, tx, size.width, inset) if (i != null) { - val p = Offset(xOf(i), yOf(values[i]!!)) + val p = Offset(g.xOf(i), g.yOf(values[i]!!)) guide(p.x, h) drawCircle(Color.Black.copy(alpha = 0.6f), 6.dp.toPx(), p) drawCircle(color, 4.dp.toPx(), p) @@ -226,7 +196,7 @@ fun GlowLineChart( } /** Your normal behind a chart: a band of the ink at 12% (spec 2.7). */ -private const val NormalBandAlpha = 0.12f +private const val NormalBandAlpha = ChartInk.NORMAL_BAND_ALPHA /** The reading nearest [x] on a line chart [width] wide, its ends [inset] in; gaps are skipped, not bridged. */ internal fun nearestReading(values: List, x: Float, width: Float, inset: Float): Int? { @@ -533,49 +503,31 @@ fun GlowHypnogram( } else it }, ) { - if (stages.isEmpty()) return@Canvas - val axisH = if (labels.isEmpty()) 0f else 18.dp.toPx() val nameW = if (showNames) 46.dp.toPx() else 0f - val h = size.height - axisH - val w = size.width - nameW - val rowH = h / rowNames.size - val pillH = rowH * 0.46f - fun yOf(row: Int) = rowH * row + rowH / 2 - fun xOf(f: Float) = nameW + f * w + // The shared hypnogram geometry ([hypnogramGeometry]), as a widget's chart draws it. + val g = hypnogramGeometry(stages, rowNames.size, size.width, size.height, density, nameW, axis = labels.isNotEmpty()) ?: return@Canvas + val h = g.plotBottom rowNames.forEachIndexed { row, name -> - gridLine(yOf(row), x0 = nameW) - if (showNames) text(m, name, axisText, 0f, yOf(row) - 7.dp.toPx()) - } - val sorted = stages.sortedBy { it.from } - if (rowColors == null) for (i in 1 until sorted.size) { - val a = sorted[i - 1] - val b = sorted[i] - if (a.row != b.row) drawLine(Ink.copy(alpha = 0.3f), Offset(xOf(b.from), yOf(a.row)), Offset(xOf(b.from), yOf(b.row)), 1.dp.toPx()) - } - sorted.forEach { s -> - val alpha = 0.55f + 0.45f * s.row / (rowNames.size - 1).coerceAtLeast(1) - drawRoundRect( - rowColors?.getOrNull(s.row) ?: Ink.copy(alpha = alpha), - topLeft = Offset(xOf(s.from), yOf(s.row) - pillH / 2), - size = Size((xOf(s.to) - xOf(s.from)).coerceAtLeast(pillH), pillH), - cornerRadius = CornerRadius(pillH / 2), - ) + gridLine(g.rowY[row], x0 = nameW) + if (showNames) text(m, name, axisText, 0f, g.rowY[row] - 7.dp.toPx()) } + drawHypnogramBands(g, rowColors) if (labels.isNotEmpty()) { labels.forEachIndexed { i, l -> val f = if (labels.size == 1) 0f else i / (labels.size - 1f) - text(m, l, axisText, xOf(f), h + 4.dp.toPx(), align = f) + text(m, l, axisText, g.xOf(f), h + 4.dp.toPx(), align = f) } } val tx = touchX if (describe != null && tx != null) { val x = tx.coerceIn(nameW, size.width) - val f = (x - nameW) / w + val f = (x - nameW) / (size.width - nameW) // The stage under the finger, or the nearest one when the finger is in a gap. - val s = sorted.firstOrNull { f >= it.from && f < it.to } ?: sorted.minBy { minOf(kotlin.math.abs(it.from - f), kotlin.math.abs(it.to - f)) } + val band = g.bands.firstOrNull { f >= it.stage.from && f < it.stage.to } + ?: g.bands.minBy { minOf(kotlin.math.abs(it.stage.from - f), kotlin.math.abs(it.stage.to - f)) } guide(x, h) - readout(m, describe(s), x, yOf(s.row) - pillH / 2) + readout(m, describe(band.stage), x, band.topLeft.y) } } } @@ -590,24 +542,8 @@ fun GlowHypnogram( @Composable fun GhostLine(today: List, color: Color, max: Float, modifier: Modifier = Modifier, usual: List? = null, height: Dp = 56.dp) { Canvas(modifier.fillMaxWidth().height(height)) { - if (today.size < 2) return@Canvas - val r = 4.5.dp.toPx() - val top = r - val h = size.height - r - val hi = max.coerceAtLeast(1f) - fun xOf(i: Int, n: Int) = r + i / (n - 1f) * (size.width - r * 2) - fun yOf(v: Float) = top + (h - top) * (1f - (v / hi).coerceIn(0f, 1f)) - usual?.takeIf { it.size >= 2 }?.let { u -> - val path = Path().apply { u.forEachIndexed { i, v -> if (i == 0) moveTo(xOf(i, u.size), yOf(v)) else lineTo(xOf(i, u.size), yOf(v)) } } - drawPath(path, Ink.copy(alpha = 0.35f), style = Stroke(1.5.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) - } - val pts = today.mapIndexedNotNull { i, v -> v?.let { Offset(xOf(i, today.size), yOf(it)) } } - if (pts.isEmpty()) return@Canvas - if (pts.size >= 2) drawPath( - Path().apply { pts.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) } }, - color, style = Stroke(2.6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round), - ) - drawCircle(color, r, pts.last()) + // The shared ghost line geometry ([ghostLineGeometry]), as a widget's chart draws it. + ghostLineGeometry(today, usual, max, size.width, size.height, density)?.let { drawGhostLine(it, color) } } } @@ -778,14 +714,9 @@ fun ZoneBar(zoneSeconds: List, colors: List, modifier: Modifier = Mo @Composable fun StageStrip(spans: List, modifier: Modifier = Modifier, height: Dp = 6.dp) { Canvas(modifier.fillMaxWidth().height(height)) { - if (spans.isEmpty()) return@Canvas - val start = spans.minOf { it.start } - val end = spans.maxOf { it.end }.coerceAtLeast(start + 1) - val w = size.height - spans.sortedBy { it.start }.forEach { s -> - val x0 = w / 2 + (size.width - w) * (s.start - start) / (end - start) - val x1 = w / 2 + (size.width - w) * (s.end - start) / (end - start) - drawLine(stageColor(s.stage), Offset(x0, size.height / 2), Offset(x1.coerceAtLeast(x0 + 0.5f), size.height / 2), w, StrokeCap.Round) + // The same geometry a widget's strip is drawn from (widgets spec 6.2). + org.openstrap.app.ui.glow.draw.stageStripSegments(spans, size.width, size.height).forEach { s -> + drawLine(stageColor(s.stage), Offset(s.x0, size.height / 2), Offset(s.x1, size.height / 2), size.height, StrokeCap.Round) } } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowParts.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowParts.kt index 231c1d7f..1de0691f 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowParts.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowParts.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp @@ -25,36 +24,14 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.openstrap.app.ui.glow.draw.DotFont +import org.openstrap.app.ui.glow.draw.dotLayout +import org.openstrap.app.ui.glow.draw.drawDots /* ------------------------------------------------------------------------------------------------ */ /* Dot-matrix numerals */ /* ------------------------------------------------------------------------------------------------ */ -/** 5×7 glyphs, one string per row. Anything not listed draws as a space. */ -private val GLYPHS: Map> = mapOf( - '0' to listOf(".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."), - '1' to listOf("..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."), - '2' to listOf(".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"), - '3' to listOf("####.", "....#", "....#", ".###.", "....#", "....#", "####."), - '4' to listOf("...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."), - '5' to listOf("#####", "#....", "####.", "....#", "....#", "#...#", ".###."), - '6' to listOf("..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###."), - '7' to listOf("#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."), - '8' to listOf(".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."), - '9' to listOf(".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##.."), - '.' to listOf(".", ".", ".", ".", ".", ".", "#"), - ',' to listOf(".", ".", ".", ".", ".", "#", "#"), - ':' to listOf(".", ".", "#", ".", ".", "#", "."), - '-' to listOf("...", "...", "...", "###", "...", "...", "..."), - // The real minus sign of signed values (spec 2.3), drawn as the dash is. - '\u2212' to listOf("...", "...", "...", "###", "...", "...", "..."), - '+' to listOf("...", "...", ".#.", "###", ".#.", "...", "..."), - 'k' to listOf("#....", "#....", "#..#.", "#.#..", "##...", "#.#..", "#..#."), - '%' to listOf("##...", "##..#", "...#.", "..#..", ".#...", "#..##", "...##"), - ' ' to listOf("..", "..", "..", "..", "..", "..", ".."), -) -private const val ROWS = 7 - /** * A number drawn as round dots, like an LED matrix (the "3.3" on the reference cards), in one of the six [DotSize]s * for where it sits. Digits, `.`, `,`, `:`, `-`, `−`, `+` and `%` are supported. @@ -68,28 +45,15 @@ fun DotMatrixText( /** Shrinks the dots below [dot] where the number must fit a space (the live workout's arc), 0..1. */ scale: Float = 1f, ) { - val size = dot.dot * scale - val gap = size * DotSize.DOT_GAP - val glyphs = text.map { GLYPHS[it] ?: GLYPHS.getValue(' ') } - // One empty column between glyphs. - val columns = glyphs.sumOf { it[0].length } + (glyphs.size - 1).coerceAtLeast(0) - val pitch = size + gap + // The shared dot geometry ([dotLayout]), as a widget's number draws it. + val dotDp = (dot.dot * scale).value + val box = dotLayout(text, dotDp, 1f) Canvas( modifier - .size(pitch * columns - gap, pitch * ROWS - gap) + .size(box.width.dp, box.height.dp) .semantics { contentDescription = text }, ) { - val d = size.toPx() - val step = pitch.toPx() - var x0 = 0f - glyphs.forEach { rows -> - rows.forEachIndexed { row, line -> - line.forEachIndexed { col, c -> - if (c == '#') drawCircle(color, d / 2, Offset(x0 + col * step + d / 2, row * step + d / 2)) - } - } - x0 += (rows[0].length + 1) * step - } + drawDots(dotLayout(text, dotDp, density), color) } } @@ -131,6 +95,6 @@ fun GlowSwitch(checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: M /** How wide a number of [digits] digits is in [dot] dots, so a space can be sized for the widest it will show. */ internal fun dotNumberWidth(digits: Int, dot: DotSize): Dp { - val columns = digits * 5 + (digits - 1).coerceAtLeast(0) + val columns = digits * DotFont.DIGIT_COLUMNS + (digits - 1).coerceAtLeast(0) return (dot.dot + dot.gap) * columns - dot.gap } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowSleepScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowSleepScreen.kt index e27dbf9d..70172f3a 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowSleepScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowSleepScreen.kt @@ -56,12 +56,13 @@ import org.openstrap.app.ui.dashboard.SleepCard import org.openstrap.app.ui.dashboard.SleepFacts import org.openstrap.app.ui.dashboard.SleepWindows import org.openstrap.app.ui.dashboard.TabCopy -import org.openstrap.app.ui.dashboard.TodayCopy import org.openstrap.app.ui.dashboard.nightVitals import org.openstrap.app.ui.dashboard.sleepCards import org.openstrap.app.ui.dashboard.sleepWeek import org.openstrap.app.ui.theme.AppIcons import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.glow.draw.StageBarLook +import org.openstrap.app.ui.glow.draw.stageTotals /* * The Sleep tab (spec 5.2): "how did I sleep, and is it trending right". The night glows with its stages, the @@ -129,12 +130,6 @@ internal val SleepGlow = GlowStyle.Midnight.copy( private fun clock(epoch: Long) = Instant.ofEpochSecond(epoch).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm")) -/** The stages in the order the hero adds them up: deepest first, awake last. */ -private val STAGE_ORDER = listOf( - org.openstrap.app.data.db.SleepStage.DEEP, org.openstrap.app.data.db.SleepStage.REM, - org.openstrap.app.data.db.SleepStage.LIGHT, org.openstrap.app.data.db.SleepStage.AWAKE, -) - /** * The night (spec 5.2, the sleep hero): how long, against the rest of the week, then the score in dots with its word * and how often you woke beside it, the one dotted number on the card. The stages to scrub in their colours, then @@ -147,21 +142,21 @@ private fun NightHero(state: DayUiState, firstNights: Boolean, onOpen: () -> Uni val isToday = state.date == LocalDate.now() GlowCard(SleepGlow, Modifier.fillMaxWidth().gridSize(CardSize.Wide), onClick = onOpen) { Column { - GlowHeader("Last night", trailing = session?.let { "${clock(it.start)}–${clock(it.end)}" }) + GlowHeader("Last night", trailing = session?.let { TabCopy.nightTimes(it.start, it.end) }) if (session == null) { // Nothing said until the night has loaded, so a cold start doesn't open on "No sleep recorded". if (state.loaded) HeroSentence(if (firstNights) TabCopy.FIRST_NIGHT_SLEEP else TabCopy.noNight(state.naps.isNotEmpty(), isToday)) return@Column } val asleep = state.asleepSeconds ?: (session.end - session.start) - val others = state.nights.takeLast(SleepHistory.WEEK_NIGHTS).filter { it.date != state.date }.mapNotNull { it.asleepSeconds } + val others = SleepHistory.weekOthers(state.nights.mapNotNull { n -> n.session?.let { n.date to it } }.toMap(), state.date) HeroSentence(TabCopy.night(asleep, others)) Spacer(Modifier.height(16.dp)) val score = session.score HeroNumber( score?.toString(), listOfNotNull( - score?.let { "Sleep score, ${TabCopy.sleepScoreWord(it).lowercase()}" } ?: "No score from the strap", + TabCopy.sleepScoreLine(score), TabCopy.woke(session.wakeCount), ), ) @@ -171,7 +166,7 @@ private fun NightHero(state: DayUiState, firstNights: Boolean, onOpen: () -> Uni } state.sleepBreakdown?.let { b -> // Stage totals as text, never dots (spec 2.3): a stage under a minute leaves its segment and legend out. - val totals = STAGE_ORDER.mapNotNull { st -> (b.byStage[st] ?: 0L).takeIf { it >= 60 }?.let { st to it } } + val totals = stageTotals(b.byStage) if (totals.isNotEmpty()) { Spacer(Modifier.height(16.dp)) StageBar(totals) @@ -183,12 +178,15 @@ private fun NightHero(state: DayUiState, firstNights: Boolean, onOpen: () -> Uni } } -/** The stage bar (chart kit): one 8 dp bar split by stage, deepest first, with 2 dp gaps. */ +/** + * The stage bar (chart kit): one 8 dp bar split by stage, deepest first, with 2 dp gaps, sized by [StageBarLook] as a + * widget's bar is ([stageBarSegments] lays that one out in whole pixels the way this Row does). + */ @Composable private fun StageBar(totals: List>) { - Row(Modifier.fillMaxWidth().height(8.dp), horizontalArrangement = Arrangement.spacedBy(2.dp)) { + Row(Modifier.fillMaxWidth().height(StageBarLook.HEIGHT_DP.dp), horizontalArrangement = Arrangement.spacedBy(StageBarLook.GAP_DP.dp)) { totals.forEach { (stage, seconds) -> - Box(Modifier.weight(seconds.toFloat()).fillMaxHeight().background(stageColor(stage), RoundedCornerShape(3.dp))) + Box(Modifier.weight(seconds.toFloat()).fillMaxHeight().background(stageColor(stage), RoundedCornerShape(StageBarLook.CORNER_DP.dp))) } } } @@ -203,7 +201,7 @@ private fun StageLegend(totals: List>) { Box(Modifier.size(8.dp).background(stageColor(stage), RoundedCornerShape(2.dp))) Spacer(Modifier.width(6.dp)) Text( - "${SLEEP_NAMES[stage].orEmpty()} ${TodayCopy.duration(seconds)}", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + TabCopy.stageTotal(stage, seconds), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1, ) } @@ -222,12 +220,10 @@ internal fun GlowSleepStages(spans: List, height: Dp, compact: Boolea val rows = DayViewModel.SLEEP_STAGE_ROWS val byRow = rows.entries.associate { it.value to (SLEEP_NAMES[it.key] ?: "") } val names = (0 until rows.size).map { byRow[it] ?: "" } - val stages = remember(spans) { - spans.map { GlowStage((it.start - start).toFloat() / (end - start), (it.end - start).toFloat() / (end - start), rows[it.stage] ?: 0) } - } + val stages = remember(spans) { sleepHypnogramStages(spans) } fun at(f: Float) = clock(start + ((end - start) * f).toLong()) // Compact, as on Today's Last night card, the rows are too close for their names and the card's title has the times. - val colors = (0 until rows.size).map { row -> stageColor(rows.entries.first { it.value == row }.key) } + val colors = SleepRowColors GlowHypnogram( stages, names, height = height, labels = if (compact) emptyList() else listOf(clock(start), clock(end)), showNames = !compact, rowColors = colors, @@ -237,6 +233,23 @@ internal fun GlowSleepStages(spans: List, height: Dp, compact: Boolea } } +/** + * A night's stage [spans] as hypnogram stages: rows Awake (0) to Deep ([DayViewModel.SLEEP_STAGE_ROWS]), times as + * fractions of the night. The Sleep tab's, Today's and the Sleep widget's hypnogram. + */ +internal fun sleepHypnogramStages(spans: List): List { + if (spans.isEmpty()) return emptyList() + val start = spans.minOf { it.start } + val end = spans.maxOf { it.end }.coerceAtLeast(start + 1) + val rows = DayViewModel.SLEEP_STAGE_ROWS + return spans.map { GlowStage((it.start - start).toFloat() / (end - start), (it.end - start).toFloat() / (end - start), rows[it.stage] ?: 0) } +} + +/** Each hypnogram row's stage colour, Awake at the top, Deep at the foot. */ +internal val SleepRowColors: List = DayViewModel.SLEEP_STAGE_ROWS.let { rows -> + (0 until rows.size).map { row -> stageColor(rows.entries.first { it.value == row }.key) } +} + /** The vitals while asleep against your previous nights, each opening its metric scoped to the night. */ @Composable private fun VitalsList(state: DayUiState, firstNights: Boolean, onOpen: (Metric) -> Unit) { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTodayScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTodayScreen.kt index 3ba8be9a..eaab440c 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTodayScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTodayScreen.kt @@ -415,18 +415,19 @@ private enum class HeroKind { Pair, Stuck, Waking, NoSleep, Midday, Window, Afte /** * The hero's recipe, L4 (spec 2.1): the rim at full from the one side the moment places, no bevel, and the dark core - * opposite the light. [rim] raises or calms it for the moment (spec 4.2: 120% before a workout, 80% after one). + * opposite the light. [rim] raises or calms it for the moment (spec 4.2: 120% before a workout, 80% after one). The + * Moment widget lights its background with the same recipe (widgets spec 3.1). */ -private fun heroStyle(color: Color, lightAngle: Float?, rim: Float = 1f) = GlowStyle.Bloom.copy( +internal fun heroStyle(color: Color, lightAngle: Float?, rim: Float = 1f) = GlowStyle.Bloom.copy( lightAngle = lightAngle, color = color, coreShape = CoreShape.Radial, coreX = 0.5f, coreY = 0.6f, coreWidth = 0.95f, coreHeight = 0.75f, coreBlur = 30.dp, toneWidth = 16.dp, toneShift = -10f, glowStrength = rim, bevel = 0f, ) /** The workout window's rim, raised for effort, and the calmer one after a workout (spec 4.2). */ -private const val WINDOW_RIM = 1.2f -private const val AFTER_RIM = 0.8f -private const val EVENING_BODY = 0.46f +internal const val WINDOW_RIM = 1.2f +internal const val AFTER_RIM = 0.8f +internal const val EVENING_BODY = 0.46f /** * Waking up: how you slept and what Recovery says, the score in dots with its word, and Charge at waking as text diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutMode.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutMode.kt index c050dd97..01606352 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutMode.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutMode.kt @@ -639,7 +639,7 @@ fun GlowWorkoutReadyScreen( } /** Whether the phone's location can be used now: allowed, and turned on. */ -private fun locationAvailable(context: Context): Boolean = +internal fun locationAvailable(context: Context): Boolean = WorkoutLocation.granted(context) && context.getSystemService(LocationManager::class.java)?.isLocationEnabled == true /** diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/TodayCards.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/TodayCards.kt index 04e94a15..d9172082 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/TodayCards.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/TodayCards.kt @@ -374,10 +374,7 @@ internal fun ActivityCard( val goal = org.openstrap.app.ui.dashboard.LocalGoals.current.steps val isToday = state.date == java.time.LocalDate.now() val hour = java.time.LocalTime.now().hour - val line = remember(state.hourlySteps, isToday, hour) { - var sum = 0f - state.hourlySteps.mapIndexed { i, v -> sum += v; if (isToday && i > hour) null else sum } - } + val line = remember(state.hourlySteps, isToday, hour) { org.openstrap.app.data.Summaries.runningSteps(state.hourlySteps, if (isToday) hour else null) } val now = java.time.LocalTime.now() val pace = org.openstrap.app.ui.dashboard.ActivityCopy.paceOf(state.steps, usual, isToday, now.hour, now.minute) val ghost = usual?.takeIf { it.size == 24 } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/CanvasDraw.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/CanvasDraw.kt new file mode 100644 index 00000000..b4b627ae --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/CanvasDraw.kt @@ -0,0 +1,395 @@ +package org.openstrap.app.ui.glow.draw + +import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.BlurMaskFilter +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Shader +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorGroup +import androidx.compose.ui.graphics.vector.VectorPath +import androidx.compose.ui.graphics.vector.toPath +import androidx.compose.ui.graphics.toArgb +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.random.Random +import org.openstrap.app.ui.glow.CoreShape +import org.openstrap.app.ui.glow.DotSize +import org.openstrap.app.ui.glow.GlowStyle +import org.openstrap.app.ui.glow.stageColor + +/* + * The shared drawing on an android.graphics.Canvas: the same light, dots and charts the app's cards draw, for places + * Compose can't draw, the home screen widgets (widgets spec 6.2). GlowCard renders its cached layers with these very + * functions; the rest paint the same specs the Compose side does. Everything is in px. + */ + +/* ------------------------------------------------------------------------------------------------ */ +/* Glow light */ +/* ------------------------------------------------------------------------------------------------ */ + +/** The glow card's base on [canvas], in card coordinates: the fill, then the rim light and bevel. */ +fun drawGlowBase(canvas: Canvas, paint: GlowPaint) { + canvas.drawRect(0f, 0f, paint.width, paint.height, Paint().apply { shader = paint.fill.shader() }) + if (paint.rims.isEmpty()) return + paint.rimClip?.let { canvas.clipRect(it.left, it.top, it.right, it.bottom) } + paint.rims.forEach { rim -> + val (path, p) = insetShadow(paint.width, paint.height, paint.radius, rim) + canvas.drawPath(path, p) + } +} + +/** + * The glow card's dark core and its second-tone band on [canvas], in card coordinates; the layer reaches + * [GlowPaint.coreMargin] past the card. Drawn at rest: the heartbeat's motion is the card's own. + */ +fun drawGlowCore(canvas: Canvas, paint: GlowPaint) { + when (val core = paint.core) { + is GlowCore.Level -> { + val m = paint.coreMargin + canvas.drawRect(-m, -m, paint.width + m, paint.height + m, Paint().apply { shader = core.gradient.shader() }) + } + is GlowCore.Shapes -> { + val blur = blurFilter(core.blur) + val tonePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = core.toneColor.toArgb(); maskFilter = blur } + val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = core.coreColor.toArgb(); maskFilter = blur } + core.tone?.let { t -> + val toneRect = RectF(t.left, t.top, t.right, t.bottom) + when (core.shape) { + CoreShape.Rounded -> canvas.drawRoundRect(toneRect, core.toneRadius, core.toneRadius, tonePaint) + CoreShape.Radial -> canvas.drawOval(toneRect, tonePaint) + } + } + val box = RectF(core.core.left, core.core.top, core.core.right, core.core.bottom) + when (core.shape) { + CoreShape.Rounded -> canvas.drawRoundRect(box, core.coreRadius, core.coreRadius, corePaint) + CoreShape.Radial -> canvas.drawOval(box, corePaint) + } + } + } +} + +/** + * The glow card's top layer on [canvas]: film grain from [grainSeed]'s spot in the tile, the hairline and its gloss; + * on hold only the outline. The card's clip is the caller's. + */ +fun drawGlowEdge(canvas: Canvas, paint: GlowPaint, grainSeed: Int = 0) { + val w = paint.width + val h = paint.height + val r = paint.radius + paint.outline?.let { color -> + canvas.drawRoundRect(0f, 0f, w, h, r, r, stroke(color, paint.outlineWidth)) + return + } + if (paint.grainAlpha > 0) canvas.drawPaint(Paint().apply { shader = grainShader(grainSeed); alpha = paint.grainAlpha }) + canvas.drawRoundRect(0f, 0f, w, h, r, r, stroke(paint.hairline, paint.hairlineWidth)) + val g = paint.gloss + val gloss = LinearGradient(0f, 0f, if (g.across) w else 0f, if (g.across) 0f else h, g.stops.map { it.second.toArgb() }.toIntArray(), g.stops.map { it.first }.toFloatArray(), Shader.TileMode.CLAMP) + canvas.drawRoundRect(0f, 0f, w, h, r, r, Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = paint.hairlineWidth; shader = gloss }) +} + +/** + * A whole glow card's background, [style] on a card [width] by [height] px at [density], clipped to its corners: + * what a GlowCard shows behind its content, the core at rest. The style's mood must be set. + */ +fun drawGlow(canvas: Canvas, style: GlowStyle, width: Float, height: Float, density: Float, grainSeed: Int = 0) { + val paint = glowPaint(style, width, height, density) + canvas.save() + canvas.clipPath(Path().apply { addRoundRect(0f, 0f, width, height, paint.radius, paint.radius, Path.Direction.CW) }) + if (paint.outline != null) { + canvas.drawColor(android.graphics.Color.BLACK) + } else { + canvas.save() + drawGlowBase(canvas, paint) + canvas.restore() + val layered = paint.coreOpacity < 1f + if (layered) canvas.saveLayerAlpha(null, (paint.coreOpacity.coerceIn(0f, 1f) * 255).toInt()) + drawGlowCore(canvas, paint) + if (layered) canvas.restore() + } + drawGlowEdge(canvas, paint, grainSeed) + canvas.restore() +} + +/** The everything-outside-the-hole path of an [InsetShadow] on a card [w] by [h] px with corners [radius], and its paint. */ +private fun insetShadow(w: Float, h: Float, radius: Float, s: InsetShadow): Pair { + val pad = s.blur * 2 + abs(s.dx) + abs(s.dy) + abs(s.spread) + 4f + val holeRadius = (radius - s.spread).coerceAtLeast(0f) + val path = Path().apply { + fillType = Path.FillType.EVEN_ODD + addRect(-pad, -pad, w + pad, h + pad, Path.Direction.CW) + addRoundRect(s.spread + s.dx, s.spread + s.dy, w - s.spread + s.dx, h - s.spread + s.dy, holeRadius, holeRadius, Path.Direction.CW) + } + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = s.color.toArgb() + maskFilter = blurFilter(s.blur / 2f) // CSS shadow blur is twice the standard deviation + } + return path to paint +} + +/** A Gaussian blur with standard deviation [sigma] px (Android's radius is ~1.73 × sigma). */ +private fun blurFilter(sigma: Float): BlurMaskFilter? = + if (sigma > 0.5f) BlurMaskFilter((sigma - 0.5f) / 0.57735f, BlurMaskFilter.Blur.NORMAL) else null + +/** The Android shader for a gradient spec. */ +fun LinearGradientSpec.shader(): Shader = + if (stops == null && colors.size == 2) LinearGradient(x0, y0, x1, y1, colors[0].toArgb(), colors[1].toArgb(), Shader.TileMode.CLAMP) + else LinearGradient(x0, y0, x1, y1, colors.map { it.toArgb() }.toIntArray(), stops?.toFloatArray(), Shader.TileMode.CLAMP) + +private const val GRAIN_TILE = 512 + +/** Where a card starts in the grain tile, so neighbouring cards never share a pattern. */ +fun randomGrainSeed(random: Random = Random): Int = random.nextInt(GRAIN_TILE * GRAIN_TILE) + +/** + * Film grain: a large tile of sparse specks, one device pixel each, so it reads as texture rather than dots. [seed] + * shifts where each card starts in the tile, which hides the repeat. + */ +internal fun grainShader(seed: Int): Shader = + BitmapShader(grainBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT).apply { + setLocalMatrix(Matrix().apply { setTranslate((seed % GRAIN_TILE).toFloat(), (seed / GRAIN_TILE).toFloat()) }) + } + +private val grainBitmap: Bitmap by lazy { + val n = GRAIN_TILE + val random = Random(7) + val pixels = IntArray(n * n) { + // Most pixels stay clear; the rest get a random strength, and some darken instead of lighten. + val v = random.nextFloat() + if (v < 0.55f) 0 else { + val alpha = (((v - 0.55f) / 0.45f) * 255).toInt().coerceIn(0, 255) + (alpha shl 24) or (if (random.nextFloat() < 0.35f) 0x000000 else 0xFFFFFF) + } + } + Bitmap.createBitmap(pixels, n, n, Bitmap.Config.ARGB_8888) +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Edge light */ +/* ------------------------------------------------------------------------------------------------ */ + +/** An edge card's background on [canvas]: the surface, the light, the hairline. */ +fun drawEdge(canvas: Canvas, paint: EdgePaint) { + val w = paint.width + val h = paint.height + val r = paint.radius + val fill = Paint(Paint.ANTI_ALIAS_FLAG) + canvas.drawRoundRect(0f, 0f, w, h, r, r, fill.apply { color = paint.surface.toArgb() }) + canvas.drawRoundRect(0f, 0f, w, h, r, r, Paint(Paint.ANTI_ALIAS_FLAG).apply { shader = paint.glow.shader() }) + val s = paint.hairlineWidth + canvas.drawRoundRect(s / 2, s / 2, w - s / 2, h - s / 2, r - s / 2, r - s / 2, stroke(paint.hairline, s)) +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Dots */ +/* ------------------------------------------------------------------------------------------------ */ + +/** [layout]'s dots on [canvas] in [color], its top left at ([left], [top]) px. */ +fun drawDots(canvas: Canvas, layout: DotLayout, color: Color, left: Float = 0f, top: Float = 0f) { + val p = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color.toArgb() } + layout.centers.forEach { c -> canvas.drawCircle(left + c.x, top + c.y, layout.radius, p) } +} + +/** [text] in [dot] dots at [density] as a bitmap just big enough for it. */ +fun renderDots(text: String, dot: DotSize, density: Float, color: Color = Color.White): Bitmap = renderDots(text, dot.dot.value, density, color) + +/** [text] in dots [dot] dp across (a [DotSize]'s, or one shrunk to fit) at [density], as a bitmap just big enough for it. */ +fun renderDots(text: String, dot: Float, density: Float, color: Color = Color.White): Bitmap { + val layout = dotLayout(text, dot, density) + return renderToBitmap(layout.width * density, layout.height * density) { drawDots(it, layout, color) } +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Charts */ +/* ------------------------------------------------------------------------------------------------ */ + +/** The Android path for [ops]. */ +fun androidPath(ops: List): Path = Path().apply { + ops.forEach { op -> + when (op) { + is PathOp.Move -> moveTo(op.x, op.y) + is PathOp.Line -> lineTo(op.x, op.y) + is PathOp.Cubic -> cubicTo(op.x1, op.y1, op.x2, op.y2, op.x3, op.y3) + } + } +} + +/** + * A day's line (GlowLineChart without its text): grid rows, your normal's band, the line with its halo and soft fill, + * the dot on the latest reading, in [color]. [showAverage] adds the solid average line. + */ +fun drawLineChart(canvas: Canvas, g: LineChartGeometry, color: Color, density: Float, showAverage: Boolean = false) { + val hair = ChartInk.HAIRLINE_DP * density + g.grid.forEach { y -> canvas.drawLine(0f, y, g.width, y, stroke(ChartInk.Grid, hair)) } + if (g.bandTopLeft != null && g.bandSize != null) { + val t = g.bandTopLeft + canvas.drawRect(t.x, t.y, t.x + g.bandSize.width, t.y + g.bandSize.height, fill(color.copy(alpha = ChartInk.NORMAL_BAND_ALPHA))) + } + g.dots.forEach { canvas.drawCircle(it.x, it.y, LineChartLook.LONE_DOT_DP * density, fill(color)) } + g.runs.forEach { pts -> + val line = androidPath(smoothOps(pts)) + val area = Path().apply { addPath(line); lineTo(pts.last().x, g.plotBottom); lineTo(pts.first().x, g.plotBottom); close() } + canvas.drawPath(area, Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = LinearGradient(0f, g.top, 0f, g.plotBottom, color.copy(alpha = LineChartLook.FILL_ALPHA).toArgb(), Color.Transparent.toArgb(), Shader.TileMode.CLAMP) + }) + canvas.drawPath(line, roundStroke(color.copy(alpha = LineChartLook.HALO_ALPHA), LineChartLook.HALO_DP * density)) + canvas.drawPath(line, roundStroke(color, LineChartLook.LINE_DP * density)) + } + if (showAverage) canvas.drawLine(0f, g.averageY, g.width, g.averageY, stroke(ChartInk.Target, hair)) + canvas.drawCircle(g.last.x, g.last.y, LineChartLook.END_HALO_DP * density, fill(color.copy(alpha = LineChartLook.END_HALO_ALPHA))) + canvas.drawCircle(g.last.x, g.last.y, LineChartLook.END_DOT_DP * density, fill(color)) +} + +/** The ghost line (steps or Energy so far): your usual by now in white at 35%, today in [color] ending in a dot. */ +fun drawGhostLine(canvas: Canvas, g: GhostLineGeometry, color: Color, density: Float) { + g.usual?.let { canvas.drawPath(androidPath(polylineOps(it)), roundStroke(Color.White.copy(alpha = GhostLineLook.USUAL_ALPHA), GhostLineLook.USUAL_DP * density)) } + if (g.today.isEmpty()) return + if (g.today.size >= 2) canvas.drawPath(androidPath(polylineOps(g.today)), roundStroke(color, GhostLineLook.LINE_DP * density)) + val end = g.today.last() + canvas.drawCircle(end.x, end.y, g.endDot, fill(color)) +} + +/** + * The hypnogram without its names and times: a hairline per row and the stages as round bands, each in its row's + * colour from [rowColors] (Awake at row 0), or white brighter the deeper, joined by thin lines, when it's null. + */ +fun drawHypnogram(canvas: Canvas, g: HypnogramGeometry, density: Float, rowColors: List? = null) { + val hair = ChartInk.HAIRLINE_DP * density + g.rowY.forEach { y -> canvas.drawLine(g.gridX0, y, g.width, y, stroke(ChartInk.Grid, hair)) } + if (rowColors == null) g.joins.forEach { j -> canvas.drawLine(j.from.x, j.from.y, j.to.x, j.to.y, stroke(Color.White.copy(alpha = HypnogramLook.JOIN_ALPHA), hair)) } + g.bands.forEach { b -> + val color = rowColors?.getOrNull(b.stage.row) ?: Color.White.copy(alpha = b.whiteAlpha) + canvas.drawRoundRect(b.topLeft.x, b.topLeft.y, b.topLeft.x + b.size.width, b.topLeft.y + b.size.height, b.corner, b.corner, fill(color)) + } +} + +/** + * The stage bar (chart kit) across [width] px from ([left], [top]): [totals] (from [stageTotals]) as segments in the + * stage colours, [heightDp] high with 2 dp gaps and 3 dp corners. + */ +fun drawStageBar(canvas: Canvas, totals: List>, left: Float, top: Float, width: Float, density: Float, heightDp: Float = StageBarLook.HEIGHT_DP) { + val h = Math.round(heightDp * density).toFloat() + val corner = StageBarLook.CORNER_DP * density + stageBarSegments(totals, width.toInt(), Math.round(StageBarLook.GAP_DP * density)).forEach { s -> + canvas.drawRoundRect(left + s.left, top, left + s.left + s.width, top + h, corner, corner, fill(stageColor(s.stage))) + } +} + +/** The stage strip ([stageStripSegments]) [height] px thick across the canvas's top, [width] px wide. */ +fun drawStageStrip(canvas: Canvas, spans: List, width: Float, height: Float) { + stageStripSegments(spans, width, height).forEach { s -> + canvas.drawLine(s.x0, height / 2, s.x1, height / 2, roundStroke(stageColor(s.stage), height).apply { strokeJoin = Paint.Join.ROUND }) + } +} + +/** Pill bars: the baseline hairline, then each bar, lit in white or white at 45%. */ +fun drawHourPills(canvas: Canvas, g: HourPillsGeometry, width: Float, density: Float) { + canvas.drawLine(0f, g.baselineY, width, g.baselineY, stroke(ChartInk.Grid, ChartInk.HAIRLINE_DP * density)) + g.bars.forEach { (bar, lit) -> drawPillBar(canvas, bar, if (lit) Color.White else Color.White.copy(alpha = HourPillsLook.DIM_ALPHA)) } +} + +/** A week's bars ([weekBars]): the [shown] day's in [color], the others white at [WeekBarsLook.DIM_ALPHA]. */ +fun drawWeekBars(canvas: Canvas, bars: List, shown: Int, color: Color) { + bars.forEachIndexed { i, bar -> bar?.let { drawPillBar(canvas, it, if (i == shown) color else Color.White.copy(alpha = WeekBarsLook.DIM_ALPHA)) } } +} + +/** One pill bar in [color]. */ +fun drawPillBar(canvas: Canvas, bar: PillBar, color: Color) { + canvas.drawRoundRect(bar.topLeft.x, bar.topLeft.y, bar.topLeft.x + bar.size.width, bar.topLeft.y + bar.size.height, bar.corner, bar.corner, fill(color)) +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Icons */ +/* ------------------------------------------------------------------------------------------------ */ + +/** + * [icon] (the app's own line icons, `AppIcons` and `SportIcons`) tinted [color] as Icon() tints it, [size] px square + * with its top left at [left], [top]: every path stroked and filled as the vector says, its groups' transforms and + * clips applied. + */ +fun drawIcon(canvas: Canvas, icon: ImageVector, color: Color, left: Float, top: Float, size: Float) { + canvas.save() + canvas.translate(left, top) + canvas.scale(size / icon.viewportWidth, size / icon.viewportHeight) + drawVectorGroup(canvas, icon.root, color) + canvas.restore() +} + +private fun drawVectorGroup(canvas: Canvas, group: VectorGroup, color: Color) { + canvas.save() + canvas.translate(group.translationX + group.pivotX, group.translationY + group.pivotY) + canvas.rotate(group.rotation) + canvas.scale(group.scaleX, group.scaleY) + canvas.translate(-group.pivotX, -group.pivotY) + if (group.clipPathData.isNotEmpty()) canvas.clipPath(group.clipPathData.toPath().asAndroidPath()) + for (node in group) when (node) { + is VectorGroup -> drawVectorGroup(canvas, node, color) + is VectorPath -> drawVectorPath(canvas, node, color) + } + canvas.restore() +} + +private fun drawVectorPath(canvas: Canvas, node: VectorPath, color: Color) { + val path = node.pathData.toPath().asAndroidPath() + path.fillType = if (node.pathFillType == PathFillType.EvenOdd) Path.FillType.EVEN_ODD else Path.FillType.WINDING + if (node.fill != null) canvas.drawPath(path, fill(color.copy(alpha = color.alpha * node.fillAlpha))) + if (node.stroke != null && node.strokeLineWidth > 0f) { + canvas.drawPath(path, stroke(color.copy(alpha = color.alpha * node.strokeAlpha), node.strokeLineWidth).apply { + strokeCap = when (node.strokeLineCap) { + StrokeCap.Round -> Paint.Cap.ROUND + StrokeCap.Square -> Paint.Cap.SQUARE + else -> Paint.Cap.BUTT + } + strokeJoin = when (node.strokeLineJoin) { + StrokeJoin.Round -> Paint.Join.ROUND + StrokeJoin.Bevel -> Paint.Join.BEVEL + else -> Paint.Join.MITER + } + strokeMiter = node.strokeLineMiter + }) + } +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Bitmaps */ +/* ------------------------------------------------------------------------------------------------ */ + +/** + * A software ARGB bitmap [width] by [height] px (rounded up, at least 1), drawn by [draw]: what a widget hands to + * RemoteViews. Transparent where [draw] leaves it. + */ +fun renderToBitmap(width: Float, height: Float, draw: (Canvas) -> Unit): Bitmap { + val bmp = Bitmap.createBitmap(ceil(width).toInt().coerceAtLeast(1), ceil(height).toInt().coerceAtLeast(1), Bitmap.Config.ARGB_8888) + draw(Canvas(bmp)) + return bmp +} + +/** A whole glow card's background as a bitmap, [width] by [height] px. */ +fun renderGlow(style: GlowStyle, width: Int, height: Int, density: Float, grainSeed: Int = 0): Bitmap = + renderToBitmap(width.toFloat(), height.toFloat()) { drawGlow(it, style, width.toFloat(), height.toFloat(), density, grainSeed) } + +/** A whole edge card's background as a bitmap. */ +fun renderEdge(paint: EdgePaint): Bitmap = renderToBitmap(paint.width, paint.height) { drawEdge(it, paint) } + +private fun fill(color: Color) = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color.toArgb() } + +private fun stroke(color: Color, width: Float) = Paint(Paint.ANTI_ALIAS_FLAG).apply { + this.color = color.toArgb() + style = Paint.Style.STROKE + strokeWidth = width +} + +private fun roundStroke(color: Color, width: Float) = stroke(color, width).apply { + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ChartSpec.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ChartSpec.kt new file mode 100644 index 00000000..09dc4a63 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ChartSpec.kt @@ -0,0 +1,474 @@ +package org.openstrap.app.ui.glow.draw + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import kotlin.math.roundToInt +import kotlin.math.sign +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.ui.glow.GlowLayout +import org.openstrap.app.ui.glow.GlowStage + +/* + * The charts widgets share with their cards (widgets spec 6.2), as plain geometry: the points of a line and its end + * dot, the bands of a hypnogram, the segments of the stage bar, the pill bars. Nothing here draws or measures text: + * GlowCharts draws these in Compose with its labels, CanvasDraw on an android.graphics.Canvas. Sizes are in px from a + * density in px per dp, worked out the way the charts always did so they land on the same pixels. + */ + +/** Solid chart marks (spec 2.7): grid rows and guides white at 8%, a target or goal at 40%. */ +object ChartInk { + val Grid = Color.White.copy(alpha = GlowLayout.HairlineAlpha) + val Target = Color.White.copy(alpha = 0.40f) + + /** Your normal behind a chart: a band of the ink at 12%. */ + const val NORMAL_BAND_ALPHA = 0.12f + + /** Grid rows, guides and baselines, dp. */ + const val HAIRLINE_DP = 1f +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Paths */ +/* ------------------------------------------------------------------------------------------------ */ + +/** One step of a path, so Compose and Canvas build the same one. */ +sealed interface PathOp { + data class Move(val x: Float, val y: Float) : PathOp + data class Line(val x: Float, val y: Float) : PathOp + data class Cubic(val x1: Float, val y1: Float, val x2: Float, val y2: Float, val x3: Float, val y3: Float) : PathOp +} + +/** Straight lines through [pts]. */ +fun polylineOps(pts: List): List = + pts.mapIndexed { i, p -> if (i == 0) PathOp.Move(p.x, p.y) else PathOp.Line(p.x, p.y) } + +/** + * A smooth path through [pts] using monotone cubic interpolation (Fritsch–Carlson): curves like a spline but never + * overshoots the data, so a smoothed heart rate can't show a peak or dip that didn't happen. + */ +fun smoothOps(pts: List): List { + if (pts.isEmpty()) return emptyList() + val ops = ArrayList(pts.size) + ops += PathOp.Move(pts[0].x, pts[0].y) + if (pts.size == 2) { ops += PathOp.Line(pts[1].x, pts[1].y); return ops } + val n = pts.size + val d = FloatArray(n - 1) { i -> (pts[i + 1].y - pts[i].y) / (pts[i + 1].x - pts[i].x).coerceAtLeast(0.001f) } + val m = FloatArray(n) + m[0] = d[0] + m[n - 1] = d[n - 2] + for (i in 1 until n - 1) m[i] = if (d[i - 1] * d[i] <= 0f) 0f else (d[i - 1] + d[i]) / 2f + for (i in 0 until n - 1) { + if (d[i] == 0f) { m[i] = 0f; m[i + 1] = 0f; continue } + val a = m[i] / d[i] + val b = m[i + 1] / d[i] + val s = a * a + b * b + if (s > 9f) { + val t = 3f / kotlin.math.sqrt(s) + m[i] = t * a * d[i] + m[i + 1] = t * b * d[i] + } + } + for (i in 0 until n - 1) { + val dx = (pts[i + 1].x - pts[i].x) / 3f + ops += PathOp.Cubic(pts[i].x + dx, pts[i].y + m[i] * dx, pts[i + 1].x - dx, pts[i + 1].y - m[i + 1] * dx, pts[i + 1].x, pts[i + 1].y) + } + return ops +} + +/* ------------------------------------------------------------------------------------------------ */ +/* A day's line (heart rate day, GlowLineChart) */ +/* ------------------------------------------------------------------------------------------------ */ + +/** How a [LineChartGeometry] is drawn, in dp: a smooth line with a halo and a soft fill, a dot on the latest. */ +object LineChartLook { + const val LINE_DP = 1.8f + const val HALO_DP = 4f + const val HALO_ALPHA = 0.08f + const val FILL_ALPHA = 0.14f + /** A lone reading between gaps, so a sparse week still shows its days. */ + const val LONE_DOT_DP = 2.6f + const val END_HALO_DP = 6.5f + const val END_HALO_ALPHA = 0.2f + const val END_DOT_DP = 3.5f + /** Room above the line, dp. */ + const val TOP_DP = 10f + /** Room for the axis labels under it when there are any, dp. */ + const val AXIS_DP = 18f +} + +/** + * A day of readings laid out on a chart: three grid rows, your normal's band, the runs of readings between gaps (a + * lone reading a dot), the latest reading, and the average. [plotBottom] is the foot of the plot, over any axis. + */ +@Immutable +class LineChartGeometry( + val width: Float, + val top: Float, + val plotBottom: Float, + /** The three grid rows' y. */ + val grid: List, + /** Your normal: its top left and size, or null. */ + val bandTopLeft: Offset?, + val bandSize: Size?, + /** Lone readings between gaps. */ + val dots: List, + /** Runs of two readings or more, each drawn as one smooth line. */ + val runs: List>, + /** The latest reading. */ + val last: Offset, + /** The average of every reading, and its y. */ + val average: Float, + val averageY: Float, + private val count: Int, + private val inset: Float, + private val lo: Float, + private val hi: Float, +) { + fun xOf(i: Int) = inset + i / (count - 1f).coerceAtLeast(1f) * (width - inset * 2) + fun yOf(v: Float) = top + (plotBottom - top) * (1f - (v - lo) / (hi - lo)) +} + +/** + * [values] (evenly spaced, nulls breaking the line) on a chart [width] by [height] px at [density], the ends [inset] px + * in so a reading at the edge keeps its dot. [axis] leaves room for labels under it. [yRange] fixes the value axis, + * else it fits the data and [band]. Null when there are no readings. + */ +fun lineChartGeometry( + values: List, + width: Float, + height: Float, + density: Float, + inset: Float, + axis: Boolean = false, + yRange: ClosedFloatingPointRange? = null, + band: ClosedFloatingPointRange? = null, +): LineChartGeometry? { + val known = values.filterNotNull() + if (known.isEmpty()) return null + val axisH = if (!axis) 0f else LineChartLook.AXIS_DP * density + val top = LineChartLook.TOP_DP * density + val h = height - axisH + val lo = yRange?.start ?: minOf(known.min(), band?.start ?: Float.MAX_VALUE) + val hi = (yRange?.endInclusive ?: maxOf(known.max(), band?.endInclusive ?: -Float.MAX_VALUE)).let { if (it - lo < 1e-3f) lo + 1f else it } + fun xOf(i: Int) = inset + i / (values.size - 1f).coerceAtLeast(1f) * (width - inset * 2) + fun yOf(v: Float) = top + (h - top) * (1f - (v - lo) / (hi - lo)) + val grid = listOf(0f, 0.5f, 1f).map { top + (h - top) * it } + var bandTopLeft: Offset? = null + var bandSize: Size? = null + band?.let { b -> + val y0 = yOf(b.endInclusive) + val y1 = yOf(b.start) + bandTopLeft = Offset(0f, y0) + bandSize = Size(width, y1 - y0) + } + // Runs of consecutive readings; a gap starts a new run. + val runs = mutableListOf>(mutableListOf()) + values.forEachIndexed { i, v -> + if (v == null) { if (runs.last().isNotEmpty()) runs += mutableListOf() } else runs.last() += Offset(xOf(i), yOf(v)) + } + val avg = known.average().toFloat() + val lastIndex = values.indexOfLast { it != null } + return LineChartGeometry( + width = width, top = top, plotBottom = h, grid = grid, bandTopLeft = bandTopLeft, bandSize = bandSize, + dots = runs.filter { it.size == 1 }.map { it.first() }, + runs = runs.filter { it.size >= 2 }, + last = Offset(xOf(lastIndex), yOf(values[lastIndex]!!)), + average = avg, averageY = yOf(avg), + count = values.size, inset = inset, lo = lo, hi = hi, + ) +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Ghost line (steps so far, Energy so far) */ +/* ------------------------------------------------------------------------------------------------ */ + +/** How a [GhostLineGeometry] is drawn, in dp. */ +object GhostLineLook { + const val LINE_DP = 2.6f + const val USUAL_DP = 1.5f + /** Your usual by now: solid, white at 35% (spec 2.7). */ + const val USUAL_ALPHA = 0.35f + /** The dot the line ends in, its radius. */ + const val END_DOT_DP = 4.5f +} + +/** + * Today so far as a line ending in a dot, your usual by now as the ghost line behind it. [today] is empty when no hour + * has a value yet; [usual] null when there's no usual. + */ +@Immutable +class GhostLineGeometry(val usual: List?, val today: List, val endDot: Float) + +/** + * [today] so far (evenly spaced, nulls the hours to come) and [usual] by then, on a value axis from 0 to [max], in a + * chart [width] by [height] px at [density]. Null when there are fewer than two hours to draw over. + */ +fun ghostLineGeometry(today: List, usual: List?, max: Float, width: Float, height: Float, density: Float): GhostLineGeometry? { + if (today.size < 2) return null + val r = GhostLineLook.END_DOT_DP * density + val top = r + val h = height - r + val hi = max.coerceAtLeast(1f) + fun xOf(i: Int, n: Int) = r + i / (n - 1f) * (width - r * 2) + fun yOf(v: Float) = top + (h - top) * (1f - (v / hi).coerceIn(0f, 1f)) + val ghost = usual?.takeIf { it.size >= 2 }?.let { u -> u.mapIndexed { i, v -> Offset(xOf(i, u.size), yOf(v)) } } + val pts = today.mapIndexedNotNull { i, v -> v?.let { Offset(xOf(i, today.size), yOf(it)) } } + return GhostLineGeometry(ghost, pts, r) +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Hypnogram */ +/* ------------------------------------------------------------------------------------------------ */ + +/** One stage on a hypnogram: its round band's top left and size, and the white it takes without stage colours. */ +@Immutable +class HypnogramBand(val stage: GlowStage, val topLeft: Offset, val size: Size, val corner: Float, val whiteAlpha: Float) + +/** A thin line joining two stages on different rows, for the white hypnogram. */ +@Immutable +class HypnogramJoin(val from: Offset, val to: Offset) + +/** + * A night laid out as a hypnogram: a hairline per row from [gridX0] across, the stages as round bands on them, deepest + * row last, and the joins between rows for the white version. [plotBottom] is the foot of the rows, over any axis. + */ +@Immutable +class HypnogramGeometry( + val width: Float, + val rowY: List, + val gridX0: Float, + val plotBottom: Float, + val bands: List, + val joins: List, + private val nameWidth: Float, + private val plotWidth: Float, +) { + /** The x of [f], a fraction of the night. */ + fun xOf(f: Float) = nameWidth + f * plotWidth + + /** The stages in order of time. */ + val sorted: List get() = bands.map { it.stage } +} + +/** How a hypnogram is drawn. */ +object HypnogramLook { + /** A band's height as a share of its row. */ + const val BAND_SHARE = 0.46f + /** The stage names' column down the left, dp, when there's room for them. */ + const val NAMES_DP = 46f + const val AXIS_DP = 18f + /** The joins of the white hypnogram, white at 30%. */ + const val JOIN_ALPHA = 0.3f +} + +/** + * [stages] on [rows] rows (row 0 at the top, Awake), in a chart [width] by [height] px at [density]. [nameWidth] px + * down the left holds the names, [axis] leaves room for times under it. Null with no stages. + */ +fun hypnogramGeometry(stages: List, rows: Int, width: Float, height: Float, density: Float, nameWidth: Float, axis: Boolean): HypnogramGeometry? { + if (stages.isEmpty()) return null + val axisH = if (!axis) 0f else HypnogramLook.AXIS_DP * density + val h = height - axisH + val w = width - nameWidth + val rowH = h / rows + val pillH = rowH * HypnogramLook.BAND_SHARE + fun yOf(row: Int) = rowH * row + rowH / 2 + fun xOf(f: Float) = nameWidth + f * w + val sorted = stages.sortedBy { it.from } + val joins = (1 until sorted.size).mapNotNull { i -> + val a = sorted[i - 1] + val b = sorted[i] + if (a.row != b.row) HypnogramJoin(Offset(xOf(b.from), yOf(a.row)), Offset(xOf(b.from), yOf(b.row))) else null + } + val bands = sorted.map { s -> + HypnogramBand( + stage = s, + topLeft = Offset(xOf(s.from), yOf(s.row) - pillH / 2), + size = Size((xOf(s.to) - xOf(s.from)).coerceAtLeast(pillH), pillH), + corner = pillH / 2, + // Deeper rows brighter, so deep sleep stands out on the dark core. + whiteAlpha = 0.55f + 0.45f * s.row / (rows - 1).coerceAtLeast(1), + ) + } + return HypnogramGeometry(width, (0 until rows).map(::yOf), nameWidth, h, bands, joins, nameWidth, w) +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Stage bar */ +/* ------------------------------------------------------------------------------------------------ */ + +/** The stages in the order the sleep hero adds them up: deepest first, awake last. */ +val SleepStageOrder = listOf(SleepStage.DEEP, SleepStage.REM, SleepStage.LIGHT, SleepStage.AWAKE) + +/** The stage bar (chart kit): one bar split by stage, 2 dp gaps, 3 dp corners. */ +object StageBarLook { + const val HEIGHT_DP = 8f + const val GAP_DP = 2f + const val CORNER_DP = 3f +} + +/** + * The stage bar's segments from a night's seconds [byStage], in [SleepStageOrder]: a stage under [minSeconds] leaves + * its segment (and legend) out. + */ +fun stageTotals(byStage: Map, minSeconds: Long = 60): List> = + SleepStageOrder.mapNotNull { st -> (byStage[st] ?: 0L).takeIf { it >= minSeconds }?.let { st to it } } + +/** A stage bar segment: its stage and where it sits along the bar, in whole px. */ +@Immutable +data class StageSegment(val stage: Int, val left: Int, val width: Int) + +/** + * The stage bar's [totals] across [width] px with [gap] px between segments, each as wide as its share, laid out in + * whole pixels the way a Row of weighted boxes is: the space left after the gaps split by weight, rounded, the + * rounding remainder handed out one px at a time from the first. + */ +fun stageBarSegments(totals: List>, width: Int, gap: Int): List { + if (totals.isEmpty()) return emptyList() + val weights = totals.map { it.second.toFloat() } + val total = weights.sum() + val space = (width - gap * (totals.size - 1)).coerceAtLeast(0) + val unit = if (total > 0f) space / total else 0f + var remainder = space - weights.sumOf { (unit * it).roundToInt() } + var x = 0 + return totals.mapIndexed { i, (stage, _) -> + val extra = remainder.sign + remainder -= extra + val w = ((unit * weights[i]).roundToInt() + extra).coerceAtLeast(0) + StageSegment(stage, x, w).also { x += w + gap } + } +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Pill bars */ +/* ------------------------------------------------------------------------------------------------ */ + +/** The widest a bar gets (spec 2.7, pill bars), dp: a wide card spaces its bars out but keeps them thin. */ +const val BAR_MAX_WIDTH_DP = 6f + +/** A pill bar: its top left, size and corner. */ +@Immutable +class PillBar(val topLeft: Offset, val size: Size, val corner: Float) + +/** + * One pill bar in a column [pitch] wide starting at [x], standing on [bottom], [height] tall for its value. At most + * [maxWidth] wide, and never shorter than twice its width, so a small value still reads as a bar, not a dot. + */ +fun pillBar(x: Float, pitch: Float, bottom: Float, height: Float, maxWidth: Float): PillBar { + val w = (pitch * 0.56f).coerceAtMost(maxWidth) + val h = height.coerceAtLeast(w * 2) + return PillBar(Offset(x + (pitch - w) / 2, bottom - h), Size(w, h), w / 2) +} + +/** How a week of daily bars is drawn (the Recovery widget's last seven days): solid, the day shown in the ink. */ +object WeekBarsLook { + /** Each day's column at its widest and narrowest, and the gap between columns, dp. Each day's letter sits under its column. */ + const val COLUMN_DP = 30f + const val MIN_COLUMN_DP = 18f + const val GAP_DP = 2f + + /** A full (100) day's bar, dp. */ + const val HEIGHT_DP = 40f + + /** The widest a bar gets, and its corner, dp: a solid bar with soft corners, so a low day still reads lower. */ + const val BAR_DP = 16f + const val CORNER_DP = 4f + + /** The other days, white at this. */ + const val DIM_ALPHA = 0.30f + + /** A day still to come (the Steps widget's week): a stub this high, its corner, and white at this. */ + const val STUB_DP = 4f + const val STUB_CORNER_DP = 2f + const val STUB_ALPHA = 0.12f + + /** The width of [days] columns [column] dp wide, dp. */ + fun width(days: Int, column: Float = COLUMN_DP): Float = days * column + (days - 1).coerceAtLeast(0) * GAP_DP + + /** The widest column, from [MIN_COLUMN_DP] to [COLUMN_DP], that fits [days] into [room] dp. */ + fun column(days: Int, room: Float): Float { + if (days <= 0) return COLUMN_DP + return ((room - (days - 1) * GAP_DP) / days).coerceIn(MIN_COLUMN_DP, COLUMN_DP) + } +} + +/** + * A week of daily [values] (0..1, null for a day without one, which gets no bar) as bars in columns [column] dp wide, + * standing on the bottom of a [WeekBarsLook.width] by [WeekBarsLook.HEIGHT_DP] box at [density], each as high as its + * share of a full day. Sized like a [pillBar] in its column, but with [WeekBarsLook.CORNER_DP] corners and only as + * short as its corners allow: wide bars this short would all look alike if they kept a pill's twice-their-width. + */ +fun weekBars(values: List, density: Float, column: Float = WeekBarsLook.COLUMN_DP): List { + val pitch = column * density + val step = (column + WeekBarsLook.GAP_DP) * density + val height = WeekBarsLook.HEIGHT_DP * density + val corner = WeekBarsLook.CORNER_DP * density + return values.mapIndexed { i, v -> + v?.let { + val pill = pillBar(i * step, pitch, height, 0f, WeekBarsLook.BAR_DP * density) + val h = (height * it.coerceIn(0f, 1f)).coerceAtLeast(corner * 2) + PillBar(Offset(pill.topLeft.x, height - h), Size(pill.size.width, h), corner) + } + } +} + +/** A [WeekBarsLook.STUB_DP] stub where day [index]'s bar will be, placed as [weekBars] places its bars. */ +fun weekStub(index: Int, density: Float, column: Float = WeekBarsLook.COLUMN_DP): PillBar { + val pitch = column * density + val height = WeekBarsLook.HEIGHT_DP * density + val h = WeekBarsLook.STUB_DP * density + val pill = pillBar(index * (column + WeekBarsLook.GAP_DP) * density, pitch, height, 0f, WeekBarsLook.BAR_DP * density) + return PillBar(Offset(pill.topLeft.x, height - h), Size(pill.size.width, h), WeekBarsLook.STUB_CORNER_DP * density) +} + +/** The hour pills' bars, lit or not, and the baseline under them. */ +@Immutable +class HourPillsGeometry(val baselineY: Float, val bars: List>) + +/** How hour pills are drawn: the latest (or picked) hour white, the rest white at 45%. */ +object HourPillsLook { + const val DIM_ALPHA = 0.45f +} + +/** + * A day's steps as 24 pills (spec 2.7, hourly pill bars) in [width] by [height] px at [density]: as high as the hour's + * share of the busiest, the [picked] hour, or else the latest with steps, lit. Hours with none aren't drawn, only the + * hairline baseline under the bars. + */ +fun hourPills(hourly: List, picked: Int?, width: Float, height: Float, density: Float): HourPillsGeometry? { + if (hourly.isEmpty()) return null + val top = hourly.maxOrNull()?.takeIf { it > 0f } ?: 1f + val last = picked ?: hourly.indexOfLast { it > 0f } + val slot = width / hourly.size + val maxW = BAR_MAX_WIDTH_DP * density + val bars = hourly.mapIndexedNotNull { i, v -> + if (v <= 0f) null else pillBar(slot * i, slot, height, height * v / top, maxW) to (i == last) + } + return HourPillsGeometry(height - 0.5f * density, bars) +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Stage strip */ +/* ------------------------------------------------------------------------------------------------ */ + +/** One stretch of a stage strip: from [x0] to [x1] px along its middle, in [stage]'s colour. */ +@Immutable +data class StageStripSegment(val stage: Int, val x0: Float, val x1: Float) + +/** + * A night or nap's stages laid flat ([org.openstrap.app.ui.glow.StageStrip]): one round-capped line [height] px thick + * across [width] px from the first stage's start to the last's end, each stretch its stage's. The ends sit half the + * thickness in, so the round caps stay inside. Empty without stages. + */ +fun stageStripSegments(spans: List, width: Float, height: Float): List { + if (spans.isEmpty()) return emptyList() + val start = spans.minOf { it.start } + val end = spans.maxOf { it.end }.coerceAtLeast(start + 1) + return spans.sortedBy { it.start }.map { s -> + val x0 = height / 2 + (width - height) * (s.start - start) / (end - start) + val x1 = height / 2 + (width - height) * (s.end - start) / (end - start) + StageStripSegment(s.stage, x0, x1.coerceAtLeast(x0 + 0.5f)) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ComposeDraw.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ComposeDraw.kt new file mode 100644 index 00000000..5a7c44e4 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ComposeDraw.kt @@ -0,0 +1,122 @@ +package org.openstrap.app.ui.glow.draw + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke + +/* + * The shared drawing in Compose: the specs in LightSpec, DotSpec and ChartSpec painted on a DrawScope, as the cards + * always drew them. CanvasDraw paints the same specs on an android.graphics.Canvas for the widgets. + */ + +/** The Compose path for [ops]. */ +fun composePath(ops: List): Path = Path().apply { + ops.forEach { op -> + when (op) { + is PathOp.Move -> moveTo(op.x, op.y) + is PathOp.Line -> lineTo(op.x, op.y) + is PathOp.Cubic -> cubicTo(op.x1, op.y1, op.x2, op.y2, op.x3, op.y3) + } + } +} + +/** A gradient spec as a Compose brush. */ +fun LinearGradientSpec.brush(): Brush = Brush.linearGradient(*colorStops(), start = Offset(x0, y0), end = Offset(x1, y1)) + +/** The glow hairline's gloss as a brush across the card or down it. */ +fun GlossSpec.brush(): Brush = + if (across) Brush.horizontalGradient(*stops.toTypedArray()) else Brush.verticalGradient(*stops.toTypedArray()) + +/** An edge card's background: the surface, the light from [glow] (its [EdgePaint.glow] as a brush), the hairline. */ +fun DrawScope.drawEdge(paint: EdgePaint, glow: Brush) { + val radius = CornerRadius(paint.radius) + val stroke = paint.hairlineWidth + drawRoundRect(paint.surface, cornerRadius = radius) + drawRoundRect(glow, cornerRadius = radius) + drawRoundRect( + paint.hairline, + topLeft = Offset(stroke / 2, stroke / 2), + size = Size(size.width - stroke, size.height - stroke), + cornerRadius = CornerRadius(radius.x - stroke / 2), + style = Stroke(stroke), + ) +} + +/** [layout]'s dots in [color], from the top left. */ +fun DrawScope.drawDots(layout: DotLayout, color: Color) { + layout.centers.forEach { drawCircle(color, layout.radius, it) } +} + +/** A solid hairline across at [y], from [x0] to [x1]. */ +fun DrawScope.chartHairline(y: Float, color: Color = ChartInk.Grid, x0: Float = 0f, x1: Float = size.width) = + drawLine(color, Offset(x0, y), Offset(x1, y), strokeWidth = ChartInk.HAIRLINE_DP * density) + +/** + * A day's line, under its average and labels: the grid rows, your normal's band, lone readings as dots, and each run + * as a smooth line with a halo over a soft fill, in [color]. + */ +fun DrawScope.drawLineChartBody(g: LineChartGeometry, color: Color) { + g.grid.forEach { chartHairline(it) } + if (g.bandTopLeft != null && g.bandSize != null) drawRect(color.copy(alpha = ChartInk.NORMAL_BAND_ALPHA), g.bandTopLeft, g.bandSize) + g.dots.forEach { drawCircle(color, LineChartLook.LONE_DOT_DP * density, it) } + g.runs.forEach { pts -> + val line = composePath(smoothOps(pts)) + val fill = Path().apply { addPath(line); lineTo(pts.last().x, g.plotBottom); lineTo(pts.first().x, g.plotBottom); close() } + drawPath(fill, Brush.verticalGradient(listOf(color.copy(alpha = LineChartLook.FILL_ALPHA), Color.Transparent), startY = g.top, endY = g.plotBottom)) + // A faint halo just wider than the line. + drawPath(line, color.copy(alpha = LineChartLook.HALO_ALPHA), style = Stroke(LineChartLook.HALO_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round)) + drawPath(line, color, style = Stroke(LineChartLook.LINE_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round)) + } +} + +/** A day's line's glowing dot on the latest reading. */ +fun DrawScope.drawLineChartEnd(g: LineChartGeometry, color: Color) { + drawCircle(color.copy(alpha = LineChartLook.END_HALO_ALPHA), LineChartLook.END_HALO_DP * density, g.last) + drawCircle(color, LineChartLook.END_DOT_DP * density, g.last) +} + +/** The ghost line: your usual by now in white at 35%, today in [color] ending in a dot. */ +fun DrawScope.drawGhostLine(g: GhostLineGeometry, color: Color) { + g.usual?.let { u -> + drawPath(composePath(polylineOps(u)), Color.White.copy(alpha = GhostLineLook.USUAL_ALPHA), style = Stroke(GhostLineLook.USUAL_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round)) + } + if (g.today.isEmpty()) return + if (g.today.size >= 2) drawPath( + composePath(polylineOps(g.today)), + color, style = Stroke(GhostLineLook.LINE_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) + drawCircle(color, g.endDot, g.today.last()) +} + +/** + * The hypnogram's stages over its rows: each a round band in its row's colour from [rowColors], or white brighter the + * deeper, joined by thin lines, when it's null. + */ +fun DrawScope.drawHypnogramBands(g: HypnogramGeometry, rowColors: List?) { + if (rowColors == null) g.joins.forEach { j -> drawLine(Color.White.copy(alpha = HypnogramLook.JOIN_ALPHA), j.from, j.to, ChartInk.HAIRLINE_DP * density) } + g.bands.forEach { b -> + drawRoundRect( + rowColors?.getOrNull(b.stage.row) ?: Color.White.copy(alpha = b.whiteAlpha), + topLeft = b.topLeft, + size = b.size, + cornerRadius = CornerRadius(b.corner), + ) + } +} + +/** One pill bar in [color]. */ +fun DrawScope.drawPillBar(bar: PillBar, color: Color) = + drawRoundRect(color, topLeft = bar.topLeft, size = bar.size, cornerRadius = CornerRadius(bar.corner)) + +/** Hour pills: the baseline, then each bar, the lit one white, the rest white at 45%. */ +fun DrawScope.drawHourPills(g: HourPillsGeometry) { + chartHairline(g.baselineY) + g.bars.forEach { (bar, lit) -> drawPillBar(bar, if (lit) Color.White else Color.White.copy(alpha = HourPillsLook.DIM_ALPHA)) } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/DotSpec.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/DotSpec.kt new file mode 100644 index 00000000..62b60286 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/DotSpec.kt @@ -0,0 +1,91 @@ +package org.openstrap.app.ui.glow.draw + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Offset +import org.openstrap.app.ui.glow.DotSize + +/* + * The dot-matrix numerals as plain geometry: which dots a string lights and where, for a dot size. DotMatrixText draws + * them in Compose and CanvasDraw on an android.graphics.Canvas, so a widget's number is the card's (widgets spec 6.2). + */ + +/** The 5×7 dot font. Anything not listed draws as a space. */ +object DotFont { + /** Glyphs one string per row, `#` a dot. */ + private val GLYPHS: Map> = mapOf( + '0' to listOf(".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."), + '1' to listOf("..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."), + '2' to listOf(".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"), + '3' to listOf("####.", "....#", "....#", ".###.", "....#", "....#", "####."), + '4' to listOf("...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."), + '5' to listOf("#####", "#....", "####.", "....#", "....#", "#...#", ".###."), + '6' to listOf("..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###."), + '7' to listOf("#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."), + '8' to listOf(".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."), + '9' to listOf(".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##.."), + '.' to listOf(".", ".", ".", ".", ".", ".", "#"), + ',' to listOf(".", ".", ".", ".", ".", "#", "#"), + ':' to listOf(".", ".", "#", ".", ".", "#", "."), + '-' to listOf("...", "...", "...", "###", "...", "...", "..."), + // The real minus sign of signed values (spec 2.3), drawn as the dash is. + '−' to listOf("...", "...", "...", "###", "...", "...", "..."), + '+' to listOf("...", "...", ".#.", "###", ".#.", "...", "..."), + 'k' to listOf("#....", "#....", "#..#.", "#.#..", "##...", "#.#..", "#..#."), + '%' to listOf("##...", "##..#", "...#.", "..#..", ".#...", "#..##", "...##"), + ' ' to listOf("..", "..", "..", "..", "..", "..", ".."), + ) + + /** Rows in every glyph. */ + const val ROWS = 7 + + /** Columns in a digit. */ + const val DIGIT_COLUMNS = 5 + + /** The glyph for [c], a space when the font has none. */ + fun glyph(c: Char): List = GLYPHS[c] ?: GLYPHS.getValue(' ') + + /** Whether [c] has a glyph of its own. */ + fun has(c: Char): Boolean = c in GLYPHS + + /** Dot columns [text] takes, with one empty column between glyphs. */ + fun columns(text: String): Int { + val glyphs = text.map(::glyph) + return glyphs.sumOf { it[0].length } + (glyphs.size - 1).coerceAtLeast(0) + } +} + +/** + * Where [text]'s dots sit: [width] by [height] in dp (what DotMatrixText's box takes), and each dot's centre in px from + * the top left, [dot] px across. + */ +@Immutable +class DotLayout(val width: Float, val height: Float, val dot: Float, val centers: List) { + val radius: Float get() = dot / 2 +} + +/** + * [text] in dots [dot] dp across (a [DotSize]'s, times any shrink), the gap between them [DotSize.DOT_GAP] of the dot, + * at [density] px per dp. Worked out as DotMatrixText always has, so the dots land on the same pixels. + */ +fun dotLayout(text: String, dot: Float, density: Float): DotLayout { + val gap = dot * DotSize.DOT_GAP + val glyphs = text.map(DotFont::glyph) + val columns = glyphs.sumOf { it[0].length } + (glyphs.size - 1).coerceAtLeast(0) + val pitch = dot + gap + val d = dot * density + val step = pitch * density + val centers = ArrayList() + var x0 = 0f + glyphs.forEach { rows -> + rows.forEachIndexed { row, line -> + line.forEachIndexed { col, c -> + if (c == '#') centers += Offset(x0 + col * step + d / 2, row * step + d / 2) + } + } + x0 += (rows[0].length + 1) * step + } + return DotLayout(pitch * columns - gap, pitch * DotFont.ROWS - gap, d, centers) +} + +/** [dotLayout] for one of the six [DotSize]s. */ +fun dotLayout(text: String, size: DotSize, density: Float): DotLayout = dotLayout(text, size.dot.value, density) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/LightSpec.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/LightSpec.kt new file mode 100644 index 00000000..380906d9 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/LightSpec.kt @@ -0,0 +1,319 @@ +package org.openstrap.app.ui.glow.draw + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import kotlin.math.abs +import org.openstrap.app.ui.glow.CoreShape +import org.openstrap.app.ui.glow.Edge +import org.openstrap.app.ui.glow.EdgeLight +import org.openstrap.app.ui.glow.GlowLayout +import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.ui.glow.GlowPalette +import org.openstrap.app.ui.glow.GlowStyle +import org.openstrap.app.ui.glow.LEVEL_STEP +import org.openstrap.app.ui.glow.LevelFade +import org.openstrap.app.ui.glow.SoftGlow +import org.openstrap.app.ui.glow.StrongGlow +import org.openstrap.app.ui.glow.levelEdge +import org.openstrap.app.ui.glow.light +import org.openstrap.app.ui.glow.lightVector + +/* + * The light of the glow and edge cards as plain numbers: where each gradient runs, its colours and stops, the rim + * shadows, the core and the hairline. Nothing here draws. A card in the app and a widget's bitmap both read these, so + * the two can't drift apart (widgets spec 6.2): GlowCard and EdgeCard paint them in Compose, CanvasDraw paints them on + * an android.graphics.Canvas. Sizes are in px, from a [density] in px per dp, worked out the way the cards always did. + */ + +/** A linear gradient from (x0, y0) to (x1, y1), clamped past its ends. Null [stops] spreads the colours evenly. */ +@Immutable +data class LinearGradientSpec( + val x0: Float, + val y0: Float, + val x1: Float, + val y1: Float, + val colors: List, + val stops: List?, +) { + /** The stops as Compose colour stops, for `Brush.linearGradient`. */ + fun colorStops(): Array> = colors.indices.map { i -> (stops?.get(i) ?: 0f) to colors[i] }.toTypedArray() +} + +/** + * The equivalent of CSS `box-shadow: inset [dx] [dy] [blur] [spread] [color]`: everything outside the card's shape + * (shifted by [dx], [dy], shrunk by [spread]), blurred and clipped to the card. [blur] is CSS's, twice the standard + * deviation. + */ +@Immutable +data class InsetShadow(val dx: Float, val dy: Float, val blur: Float, val spread: Float, val color: Color) + +/** The dark core of a glow card, drawn on its own layer that covers the card and [GlowPaint.coreMargin] round it. */ +sealed interface GlowCore { + /** A level card's core: the core colour fading in past the level, a gradient over the whole layer. */ + @Immutable + data class Level(val gradient: LinearGradientSpec) : GlowCore + + /** A blurred [core] in [coreColor], with the second-tone band [tone] round it when there is one. */ + @Immutable + data class Shapes( + val shape: CoreShape, + val core: Rect, + val coreRadius: Float, + val coreColor: Color, + val tone: Rect?, + val toneRadius: Float, + val toneColor: Color, + /** The blur's standard deviation, px. */ + val blur: Float, + ) : GlowCore +} + +/** The hairline's gloss: a lighter line along the lit edge fading out along the sides, across the card or down it. */ +@Immutable +data class GlossSpec(val across: Boolean, val stops: List>) + +/** A glow card's light, everything it's drawn with, for a card [width] by [height] px. */ +@Immutable +data class GlowPaint( + val width: Float, + val height: Float, + /** The card's corner, px. */ + val radius: Float, + /** The base: the light colour at the lit edge melting into the body. */ + val fill: LinearGradientSpec, + /** On a level card the rim lights only the lit part: the rims are clipped to this. Null: the whole card. */ + val rimClip: Rect?, + /** The rim light and the bevel, back to front. */ + val rims: List, + val core: GlowCore, + /** How far past the card the core layer reaches, px, so the core can drift and swell into view. */ + val coreMargin: Float, + val coreOpacity: Float, + val gloss: GlossSpec, + /** The 1 dp hairline round the card, white at [GlowLayout.HairlineAlpha]. */ + val hairline: Color, + val hairlineWidth: Float, + /** Film grain, 0..255 of the grain tile, or 0 for none. */ + val grainAlpha: Int, + /** On hold: only the card's edge in its colour, nothing else (pattern D1). */ + val outline: Color?, + val outlineWidth: Float, +) + +/** An outline card's edge. */ +private const val OUTLINE_WIDTH_DP = 1.5f +private const val OUTLINE_ALPHA = 0.7f + +/** + * How [style] is drawn on a card [w] by [h] px at [density]. The style's mood must already be set, as GlowCard does + * from LocalGlowMood. + */ +fun glowPaint(style: GlowStyle, w: Float, h: Float, density: Float): GlowPaint { + val p = GlowPalette(style) + val light = style.light() + val r = style.radius.value * density + + // Rims: on a level card only over the lit part, clipped where the core is fully dark, so the unlit side keeps just + // its hairline. + var rimClip: Rect? = null + val rims = mutableListOf() + if (light.rim > 0f) { + light.level?.let { level -> + val dark = levelEdge(level) * (if (light.across) w else h) - LEVEL_STEP.value * density + rimClip = when { + light.across -> Rect(0f, 0f, w - dark, h) + light.fromAbove -> Rect(0f, 0f, w, h - dark) + else -> Rect(0f, dark, w, h) + } + } + val k = light.rim + // A rim past full (the workout window's 120%) can't get brighter at the edge, so it reaches further in. + val g = style.glowSize.value * density * k.coerceAtLeast(1f) + // Rim light: a CSS-style inset shadow leaning toward the lit edge, so the rim is full on one side (L4) and gone + // on the other. Light all the way round only when the style has no side. + if (light.angle != null) { + // The hole shifts away from the light, so the lit side shows the most. + val (vx, vy) = lightVector(light.angle) + rims += InsetShadow(-vx * g * 0.6f, -vy * g * 0.6f, blur = g * 1.2f, spread = -g * 0.5f, p.light.copy(alpha = k.coerceAtMost(1f))) + } else { + rims += InsetShadow(0f, 0f, blur = g, spread = g * 0.1f, p.light.copy(alpha = (0.6f * k).coerceAtMost(1f))) + } + // Bevel: a thick band of light all the way round, like the edge of a glossy slab. + if (style.bevel > 0f) { + val b = minOf(w, h) * 0.075f * style.bevel + rims += InsetShadow(0f, 0f, blur = b * 1.6f, spread = b * 0.45f, p.light.copy(alpha = (0.85f * k).coerceAtMost(1f))) + } + } + + // Just enough room round the card for the core to drift and swell into view. + val margin = maxOf(w, h) * 0.08f + val core = light.level?.let { level -> + // A level card: lit at full up to the level, then the core colour fading in over LEVEL_STEP past it (a + // smoothstep), like light rising in a glass. Only the core's alpha changes, so nothing is ever darker than the + // unlit part and there's never a hard edge. + // Measured from the unlit side: the top, the bottom when lit from above, the right when lit from the left. + val edge = levelEdge(level) * (if (light.across) w else h) + val step = LEVEL_STEP.value * density + val (y0, y1) = when { + light.across -> w + margin to -margin + light.fromAbove -> h + margin to -margin + else -> -margin to h + margin + } + val span = abs(y1 - y0) + fun at(fromUnlit: Float) = ((fromUnlit + margin) / span).coerceIn(0f, 1f) + val steps = 10 + val colors = ArrayList(steps + 3) + val stops = ArrayList(steps + 3) + colors += p.core; stops += 0f + for (i in 0..steps) { + val t = i / steps.toFloat() + val fade = t * t * (3f - 2f * t) + colors += p.core.copy(alpha = 1f - fade) + stops += at(edge - step + step * t) + } + colors += Color.Transparent; stops += 1f + GlowCore.Level( + if (light.across) LinearGradientSpec(y0, 0f, y1, 0f, colors, stops) else LinearGradientSpec(0f, y0, 0f, y1, colors, stops), + ) + } ?: run { + // Dark core and the second-tone band around it, blurred. + val cw = w * light.core.width + val ch = h * light.core.height + val left = w * light.core.x - cw / 2 + val top = h * light.core.y - ch / 2 + val box = Rect(left, top, left + cw, top + ch) + val coreRadius = (r - 8f * density).coerceAtLeast(0f) + val band = if (light.tone) style.toneWidth.value * density else 0f + GlowCore.Shapes( + shape = style.coreShape, + core = box, + coreRadius = coreRadius, + coreColor = p.core, + tone = if (band > 0f) Rect(box.left - band, box.top - band, box.right + band, box.bottom + band) else null, + toneRadius = coreRadius + band, + toneColor = p.tone, + blur = style.coreBlur.value * density, + ) + } + + // A glossy edge: a lighter hairline along the top fading out down the sides. On a level card it follows the light + // instead: glossy along the lit edge, plain hairline on the unlit side. + val gloss = if (light.across) { + GlossSpec(true, listOf(0f to Color.White.copy(alpha = 0.35f), 0.25f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.04f))) + } else if (light.level != null && !light.fromAbove) { + GlossSpec(false, listOf(0f to Color.White.copy(alpha = 0.04f), 0.75f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.35f))) + } else { + GlossSpec(false, listOf(0f to Color.White.copy(alpha = 0.35f), 0.25f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.04f))) + } + + return GlowPaint( + width = w, + height = h, + radius = r, + fill = glowFill(light, p, w, h), + rimClip = rimClip, + rims = rims, + core = core, + coreMargin = margin, + coreOpacity = style.coreOpacity, + gloss = gloss, + hairline = Color.White.copy(alpha = GlowLayout.HairlineAlpha), + hairlineWidth = 1f * density, + grainAlpha = if (style.grain > 0f) (style.grain.coerceIn(0f, 1f) * 255).toInt() else 0, + outline = if (style.outline) style.color.copy(alpha = OUTLINE_ALPHA) else null, + outlineWidth = OUTLINE_WIDTH_DP * density, + ) +} + +/** + * The card's base fill: the light colour at the lit edge melting into the mid (body) colour along the light's angle, + * linearly through the level's fill stops (spec 2.1): on L4 the light holds for the first part of the card and reaches + * the body by 45%, on L3 it starts at 70% and is gone by 28%. Light all the way round: the body alone. + */ +private fun glowFill(light: org.openstrap.app.ui.glow.GlowLight, p: GlowPalette, w: Float, h: Float): LinearGradientSpec { + val angle = light.angle ?: return LinearGradientSpec(0f, 0f, 0f, h, listOf(p.mid, p.mid), null) + val points = light.fillStops.map { it.at.coerceIn(0f, 1f) to it.light.coerceIn(0f, 1f) } + val colors = points.map { (_, k) -> lerp(p.mid, p.light, k) } + p.mid + val stops = points.map { it.first } + 1f + // Like a CSS linear-gradient: the line runs through the centre, just long enough to reach both far corners. + val (vx, vy) = lightVector(angle) + val half = (abs(w * vx) + abs(h * vy)) / 2f + return LinearGradientSpec(w / 2 + vx * half, h / 2 + vy * half, w / 2 - vx * half, h / 2 - vy * half, colors, stops) +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Edge light */ +/* ------------------------------------------------------------------------------------------------ */ + +/** The edge card's surface, under its light. */ +val EdgeSurface = Color(0xFF0A0A0B) + +/** A level edge card's lowest light, so an empty one still shows where it fills from. */ +const val EDGE_MIN_LEVEL = 0.04f + +/** How far a level edge card's light reaches for [progress], 0..1, or null for a card that isn't a level. */ +fun edgeLevel(progress: Float?): Float? = progress?.coerceIn(EDGE_MIN_LEVEL, 1f) + +private const val LEVEL_FADE_STEPS = 6 + +/** An edge card's light, everything it's drawn with, for a card [width] by [height] px. */ +@Immutable +data class EdgePaint( + val width: Float, + val height: Float, + val surface: Color, + /** The light coming in from the edge, or filling across to a level. */ + val glow: LinearGradientSpec, + /** The card's corner, px. */ + val radius: Float, + val hairline: Color, + val hairlineWidth: Float, +) + +/** + * How an edge card lit by [light] in [tint] is drawn on a card [w] by [h] px at [density], softened by [mood]. A + * [level] (from [edgeLevel]) fills the light from the left across to it in place of the edge's glow. + */ +fun edgePaint(light: EdgeLight, tint: Color, level: Float?, mood: GlowMood, w: Float, h: Float, density: Float): EdgePaint { + val glow = if (light.strong) StrongGlow else SoftGlow + val gradient = if (level == null) { + val depth = glow.depth.value * density + // From the lit edge inward, the colour falling off like the blurred edge of a CSS inset shadow. + val (from, to) = when (light.edge) { + Edge.Top -> Offset(0f, 0f) to Offset(0f, depth) + Edge.Bottom -> Offset(0f, h) to Offset(0f, h - depth) + Edge.Left -> Offset(0f, 0f) to Offset(depth, 0f) + Edge.Right -> Offset(w, 0f) to Offset(w - depth, 0f) + } + val stops = glow.stops(mood.edge) + LinearGradientSpec(from.x, from.y, to.x, to.y, stops.map { (_, alpha) -> tint.copy(alpha = alpha) }, stops.map { it.first }) + } else { + // The light fills across to the level, 46% of the ink at the left and 30% at the value, then fades out over + // LevelFade past it on a smoothstep, so it never stops in a line (spec 2.1, Levels). + val end = w * level + val fade = LevelFade.value * density + val strength = mood.edge * (if (light.strong) 1.2f else 1f) + val atValue = 0.30f * strength + val past = List(LEVEL_FADE_STEPS) { i -> + val t = (i + 1) / LEVEL_FADE_STEPS.toFloat() + ((end + fade * t) / w).coerceIn(0f, 1f) to tint.copy(alpha = (atValue * (1f - t * t * (3f - 2f * t))).coerceIn(0f, 1f)) + } + val stops = listOf( + 0f to tint.copy(alpha = (0.46f * strength).coerceIn(0f, 1f)), + (end / w).coerceIn(0f, 1f) to tint.copy(alpha = atValue.coerceIn(0f, 1f)), + ) + past + LinearGradientSpec(0f, 0f, w, 0f, stops.map { it.second }, stops.map { it.first }) + } + return EdgePaint( + width = w, + height = h, + surface = EdgeSurface, + glow = gradient, + radius = GlowLayout.CardCorner.value * density, + hairline = Color.White.copy(alpha = GlowLayout.HairlineAlpha), + hairlineWidth = 1f * density, + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/theme/AppIcons.kt b/app/src/main/kotlin/org/openstrap/app/ui/theme/AppIcons.kt index d844bbdf..d0dcd76f 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/theme/AppIcons.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/theme/AppIcons.kt @@ -31,6 +31,8 @@ object AppIcons { val ThermometerSimple: ImageVector by lazy { line("ThermometerSimple", "M13.8801 15.9369L20.6738 8.17267C21.4217 7.31791 21.7956 6.89053 21.9252 6.41326C22.0376 5.99901 22.023 5.56052 21.8833 5.15466C21.7223 4.68707 21.3207 4.28551 20.5176 3.4824C19.7145 2.67928 19.3129 2.27773 18.8453 2.11672C18.4395 1.97696 18.001 1.96236 17.5867 2.07481C17.1095 2.20436 16.6821 2.57832 15.8273 3.32623L8.06312 10.1199C7.10741 10.9562 6.62955 11.3743 6.34846 11.9258C6.06738 12.4774 6.00989 13.1098 5.89492 14.3745L5.87152 14.6319C5.81078 15.3 5.78041 15.6341 5.65293 15.9386C5.52546 16.2431 5.30877 16.4992 4.8754 17.0114L2.27543 20.0841C1.88438 20.5462 1.91286 21.231 2.34094 21.6591C2.76903 22.0871 3.45379 22.1156 3.91594 21.7246L6.98863 19.1246C7.5008 18.6912 7.75689 18.4745 8.06139 18.3471C8.36589 18.2196 8.69998 18.1892 9.36814 18.1285L9.62553 18.1051C10.8902 17.9901 11.5226 17.9326 12.0741 17.6515C12.6257 17.3704 13.0438 16.8926 13.8801 15.9369Z", "M7.78906 9.89453L9.36983 10.843C9.74255 11.0666 9.86341 11.5501 9.63978 11.9228L9.34781 12.4094C8.96198 13.0524 9.06331 13.8755 9.59357 14.4058C10.1238 14.9361 10.9469 15.0374 11.59 14.6516L12.0766 14.3596C12.4493 14.136 12.9328 14.2568 13.1564 14.6296L14.1049 16.2103", "M17.2634 6.7373L14.1055 9.8952") } val Brain: ImageVector by lazy { line("Brain", "M16.998 7.12652C17.3182 7.04393 17.654 7 18 7C20.2091 7 22 8.79086 22 11C22 13.2091 20.2091 15 18 15C17.6451 15 17.3009 14.9538 16.9733 14.867M16.998 7.12652C16.9993 7.08451 17 7.04233 17 7C17 4.79086 15.2091 3 13 3C11.0824 3 9.47994 4.34939 9.09041 6.15043M16.998 7.12652C16.9769 7.80763 16.7854 8.44584 16.4649 9M16.9733 14.867C16.9909 14.7472 17 14.6247 17 14.5C17 13.2905 16.1411 12.2816 15 12.05M16.9733 14.867C16.7957 16.0737 15.756 17 14.5 17H14C11.7909 17 10 18.7909 10 21M9.09041 6.15043C8.74377 6.05243 8.37801 6 8 6C5.79086 6 4 7.79086 4 10C4 10.3886 4.05542 10.7643 4.15878 11.1195M9.09041 6.15043C10.1015 6.43625 10.9498 7.10965 11.4649 8M4.15878 11.1195C2.9114 11.4832 2 12.6352 2 14C2 15.6569 3.34315 17 5 17C6.30622 17 7.41746 16.1652 7.82929 15M4.15878 11.1195C4.24921 11.4303 4.37632 11.7255 4.53513 12", "M11.8361 11.7435C11.3257 12.2353 10.453 12.3202 9.70713 11.9008C8.9612 11.4814 8.58031 10.6917 8.73535 10") } val BatteryCharging: ImageVector by lazy { line("BatteryCharging", "M2 12C2 9.17157 2 7.75736 2.87868 6.87868C3.75736 6 5.17157 6 8 6H13C15.8284 6 17.2426 6 18.1213 6.87868C19 7.75736 19 9.17157 19 12C19 14.8284 19 16.2426 18.1213 17.1213C17.2426 18 15.8284 18 13 18H8C5.17157 18 3.75736 18 2.87868 17.1213C2 16.2426 2 14.8284 2 12Z", "M10.8282 9L9.08572 11.1749C8.89899 11.4079 9.03283 11.7433 9.33733 11.8053L11.1627 12.1773C11.4873 12.2434 11.6111 12.6147 11.3842 12.8413L9.22216 15", "M19 9.5L20.0272 9.6712C20.7085 9.78475 21.0491 9.84152 21.3076 10.0067C21.5618 10.1691 21.7612 10.4044 21.8796 10.6819C22 10.964 22 11.3093 22 12C22 12.6907 22 13.036 21.8796 13.3181C21.7612 13.5956 21.5618 13.8309 21.3076 13.9933C21.0491 14.1585 20.7085 14.2153 20.0272 14.3288L19 14.5") } + /** Battery Low: BatteryCharging's outline and terminal with one short bar at the empty end, for the widgets' low battery. */ + val BatteryLow: ImageVector by lazy { line("BatteryLow", "M2 12C2 9.17157 2 7.75736 2.87868 6.87868C3.75736 6 5.17157 6 8 6H13C15.8284 6 17.2426 6 18.1213 6.87868C19 7.75736 19 9.17157 19 12C19 14.8284 19 16.2426 18.1213 17.1213C17.2426 18 15.8284 18 13 18H8C5.17157 18 3.75736 18 2.87868 17.1213C2 16.2426 2 14.8284 2 12Z", "M6 9.5V14.5", "M19 9.5L20.0272 9.6712C20.7085 9.78475 21.0491 9.84152 21.3076 10.0067C21.5618 10.1691 21.7612 10.4044 21.8796 10.6819C22 10.964 22 11.3093 22 12C22 12.6907 22 13.036 21.8796 13.3181C21.7612 13.5956 21.5618 13.8309 21.3076 13.9933C21.0491 14.1585 20.7085 14.2153 20.0272 14.3288L19 14.5") } val Plant: ImageVector by lazy { line("Plant", "M4 14.0695C5.0145 14.0695 6.43122 13.7685 7.31944 14.4193L9.08188 15.7108C9.73667 16.1906 10.4458 16.0325 11.1765 15.9178C12.1389 15.7667 13 16.5875 13 17.6562C13 17.9482 10.9272 18.6905 10.6276 18.8316C10.0391 19.1088 9.36297 19.0406 8.83021 18.6502L6.84211 17.1934", "M13 17L17.091 15.1096C17.8244 14.854 18.6331 15.0535 19.1797 15.625L19.8505 16.3262C20.0902 16.5768 20.0338 16.9976 19.7375 17.1697L11.8829 21.7315C11.4097 22.0063 10.8514 22.0734 10.3309 21.9179L4 20.0269", "M12.0019 12C12.0019 12 14.1019 9.76142 14.1019 7C14.1019 4.23858 12.0019 2 12.0019 2C12.0019 2 9.90191 4.23858 9.90191 7C9.90191 9.76142 12.0019 12 12.0019 12ZM7.69158 5.43087C6.27136 4.88544 5.00191 4.85714 5.00191 4.85714C5.00191 4.85714 5.06891 7.98674 7.00191 9.95918C8.9349 11.9316 12.0019 12 12.0019 12C12.0019 12 15.0689 11.9316 17.0019 9.95918C18.9349 7.98674 19.0019 4.85714 19.0019 4.85714C19.0019 4.85714 17.7325 4.88544 16.3122 5.43087") } val PlantFill: ImageVector by lazy { bold("PlantFill", "M4 14.0695C5.0145 14.0695 6.43122 13.7685 7.31944 14.4193L9.08188 15.7108C9.73667 16.1906 10.4458 16.0325 11.1765 15.9178C12.1389 15.7667 13 16.5875 13 17.6562C13 17.9482 10.9272 18.6905 10.6276 18.8316C10.0391 19.1088 9.36297 19.0406 8.83021 18.6502L6.84211 17.1934", "M13 17L17.091 15.1096C17.8244 14.854 18.6331 15.0535 19.1797 15.625L19.8505 16.3262C20.0902 16.5768 20.0338 16.9976 19.7375 17.1697L11.8829 21.7315C11.4097 22.0063 10.8514 22.0734 10.3309 21.9179L4 20.0269", "M12.0019 12C12.0019 12 14.1019 9.76142 14.1019 7C14.1019 4.23858 12.0019 2 12.0019 2C12.0019 2 9.90191 4.23858 9.90191 7C9.90191 9.76142 12.0019 12 12.0019 12ZM7.69158 5.43087C6.27136 4.88544 5.00191 4.85714 5.00191 4.85714C5.00191 4.85714 5.06891 7.98674 7.00191 9.95918C8.9349 11.9316 12.0019 12 12.0019 12C12.0019 12 15.0689 11.9316 17.0019 9.95918C18.9349 7.98674 19.0019 4.85714 19.0019 4.85714C19.0019 4.85714 17.7325 4.88544 16.3122 5.43087") } val Footprints: ImageVector by lazy { line("Footprints", "M10.535 10.2187L9.99444 12.4393C9.746 13.4599 9.62179 13.9701 9.24607 14.2823C9.21466 14.3084 9.18215 14.3333 9.14863 14.3568C8.74779 14.6384 8.20467 14.6384 7.11844 14.6384H6.44056C5.71535 14.6384 5.35274 14.6384 5.05823 14.5126C4.7893 14.3978 4.56057 14.2103 4.39963 13.9727C4.22339 13.7125 4.16475 13.3672 4.04748 12.6765L3.5637 9.8272C3.16319 7.46831 4.68869 5.20279 7.08482 4.59797L7.29168 4.54575C8.7774 4.17073 10.2847 5.07654 10.5916 6.52879C10.8492 7.74826 10.8299 9.00715 10.535 10.2187Z", "M5.48863 17.5793H8.32119C8.93314 17.5793 9.42923 18.0581 9.42923 18.6487V19.0641C9.42923 20.4096 8.29905 21.5004 6.90491 21.5004C5.51077 21.5004 4.38059 20.4096 4.38059 19.0641V18.6487C4.38059 18.0581 4.87668 17.5793 5.48863 17.5793Z", "M13.463 8.25779L14.0036 10.4784C14.252 11.4989 14.3763 12.0092 14.752 12.3214C14.7834 12.3475 14.8159 12.3724 14.8494 12.3959C15.2502 12.6775 15.7934 12.6775 16.8796 12.6775H17.5575C18.2827 12.6775 18.6453 12.6775 18.9398 12.5517C19.2087 12.4368 19.4375 12.2493 19.5984 12.0118C19.7747 11.7516 19.8333 11.4063 19.9506 10.7156L20.4343 7.86626C20.8348 5.50737 19.3094 3.24185 16.9132 2.63703L16.7064 2.58482C15.2206 2.20979 13.7133 3.1156 13.4065 4.56785C13.1488 5.78732 13.1681 7.04621 13.463 8.25779Z", "M18.5094 15.6183H15.6769C15.0649 15.6183 14.5688 16.0971 14.5688 16.6877V17.1032C14.5688 18.4487 15.699 19.5394 17.0931 19.5394C18.4873 19.5394 19.6174 18.4487 19.6174 17.1032V16.6877C19.6174 16.0971 19.1214 15.6183 18.5094 15.6183Z") } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModel.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModel.kt index 997b7cce..372141d3 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModel.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModel.kt @@ -103,11 +103,7 @@ class WorkoutsViewModel(private val dao: HealthDao, private val profile: Flow phone to sets.associate { it.workoutStart to it.sets } }, fallbackMaxHr, - ) { ws, effects, last, (phone, sets), maxHr -> - val lastByWorkout = last.associateBy { it.workoutStart } - val strap = ws.map { w -> Workouts.analyse(w, listOfNotNull(lastByWorkout[w.start]), effects, maxHr) } - (strap + phone.map { WorkoutRecorder.analyse(it, emptyList(), maxHr).copy(sets = sets[it.start]) }).sortedByDescending { it.workout.start } - }, + ) { ws, effects, last, (phone, sets), maxHr -> Workouts.listed(ws, effects, last, phone, sets, maxHr) }, listZones, ) { list, zones -> list.map { a -> zones[a.fromPhone to a.workout.start]?.let { a.copy(zoneSeconds = it) } ?: a } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) diff --git a/app/src/main/kotlin/org/openstrap/app/widget/MomentWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/MomentWidget.kt new file mode 100644 index 00000000..f4aca726 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/MomentWidget.kt @@ -0,0 +1,370 @@ +package org.openstrap.app.widget + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.unit.ColorProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import org.openstrap.app.MainActivity +import org.openstrap.app.OpenStrapApp +import org.openstrap.app.data.DayWindow +import org.openstrap.app.data.MomentResolver +import org.openstrap.app.data.SleepHistory +import org.openstrap.app.data.Summaries +import org.openstrap.app.data.LiveSteps +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.WidgetUpdater +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.components.ChartPoint +import org.openstrap.app.ui.dashboard.SLEEP_NIGHTS +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import org.openstrap.app.ui.dashboard.usualSport +import org.openstrap.app.ui.glow.draw.drawGhostLine +import org.openstrap.app.ui.glow.draw.drawStageStrip +import org.openstrap.app.ui.glow.draw.ghostLineGeometry +import org.openstrap.app.ui.glow.draw.renderToBitmap +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.workout.SportIcons +import java.time.Instant +import java.time.ZoneId + +/** + * The Moment widget (widgets spec 3.1): what matters now, following the day as Today's hero does, from the same + * moment, sentences and numbers. A glow card in the moment's light and mood, never leading with a metric whose own + * widget is placed. The whole widget opens Today, or the live screen while a workout records; in the workout window + * its Start opens Ready with your usual sport. It refreshes at each moment's end and, while it counts minutes, every + * five ([WidgetUpdater.scheduleMoment]). + */ +class MomentWidget : LoadingWidget(WidgetKind.Moment) { + override suspend fun loadShown(context: Context): MomentWidgetModel = load(context) + + override fun sample(): MomentWidgetModel = java.time.ZoneId.systemDefault().let { WidgetSamples.moment(java.time.LocalDate.now(it), it) } + + @Composable + override fun Content(data: MomentWidgetModel) = MomentContent(data) + + companion object { + /** + * Everything Today's hero reads, from the database and the app's own sums: the moment's inputs + * ([widgetMomentInputs]), today's Recovery and Energy ([org.openstrap.app.data.HealthRepository.recovery]), + * last night as the Sleep tab reads it, the workouts, the day's heart rate and steps. Then the alarm for the + * next change, from the same inputs. + */ + internal suspend fun load(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): MomentWidgetModel { + val container = (context.applicationContext as OpenStrapApp).container + val today = now.atZone(zone).toLocalDate() + val t = now.epochSecond + val dao = container.database.healthDao() + suspend fun read(what: String, default: T, block: suspend () -> T): T = try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(WIDGET_TAG, "widgets: $what for Moment not read: $e") + default + } + val profile = container.settings.profile.first() + val rec = read("Recovery", null) { container.health.recovery(today, profile) } + val bedtime = rec?.usualBedtime ?: read("bedtime", null) { container.health.usualBedtime(today) } + val inputs = widgetMomentInputs(container, bedtime, now, zone) + val dayStart = DayWindow.of(today, zone).from + val strapScore = read("the strap's score", null) { dao.strapReadinessOnce(dayStart, dayStart).firstOrNull()?.score } + val night = read("last night", null) { + val span = DayWindow(DayWindow.nightEndingOn(today.minusDays(SLEEP_NIGHTS - 1L), zone).from, DayWindow.nightEndingOn(today, zone).to) + val sessions = dao.sleepSessionsOnce(span.from, span.to) + SleepHistory.mainSleep(sessions, DayWindow.nightEndingOn(today, zone))?.let { main -> + SleepWidgetNight.of(today, main, dao.sleepStagesOnce(main.start), SleepHistory.mainSleeps(sessions, zone)) + } + } + // Workouts over the usual workout time's look-back, as Today's usual sport reads them. + val from = t - MomentResolver.USUAL_WORKOUT_DAYS * 86_400L + val strap = read("workouts", emptyList()) { dao.workoutsOnce(from, t) } + val phone = read("phone workouts", emptyList()) { dao.appWorkoutsOnce(from, t) } + val yours = strap.filter { it.subtype != Workouts.SUBTYPE_AUTO_DETECTED && it.sportType != Workouts.GENERIC_ACTIVITY }.map { it.sportType to it.start } + + phone.filter { it.sportType != Workouts.GENERIC_ACTIVITY }.map { it.sportType to it.start } + val all = strap.map { it.sportType to it.start } + phone.map { it.sportType to it.start } + val usual = usualSport(yours) ?: usualSport(all.filter { it.first != Workouts.GENERIC_ACTIVITY }) ?: Workouts.DEFAULT_SPORTS.first() + val ended = strap.map { MomentWorkout(it.sportType, it.end) } + phone.mapNotNull { w -> w.end?.let { MomentWorkout(w.sportType, it) } } + val last = ended.filter { it.end <= t }.maxByOrNull { it.end } + val day = DayWindow.of(today, zone) + val dayWorkouts = (strap.map { it.start to it.sportType } + phone.map { it.start to it.sportType }) + .filter { (start, sport) -> start in day.from..day.to && sport != Workouts.GENERIC_ACTIVITY }.sortedBy { it.first }.map { it.second } + val recording = (container.workout.state.value as? WorkoutState.Recording)?.let { MomentRecordingInput(it.workout.sportType, it.workout.start) } + val minutes = read("steps", emptyList()) { dao.activityOnce(day.from, day.to) } + val hr = read("heart rate", emptyList()) { dao.heartRateOnce(day.from, day.to) } + val series = Summaries.heartRateSeries(hr, minutes) + val heart = Summaries.bucketAverage(series).map { ChartPoint(it.first, it.second.toFloat()) } + val resting = read("resting heart rate", null) { dao.restingHrOnce(day.from, day.to).firstOrNull()?.bpm } + ?: if (series.size >= RESTING_ESTIMATE_MINUTES) Summaries.estimateRestingHr(series) else null + val input = MomentWidgetInput( + inputs = inputs, + placed = WidgetRefresh.placedOverride ?: WidgetUpdater.placed(context), + recovery = rec?.let { TodayCopy.recoveryShown(it.readiness, strapScore, TabCopy.RECOVERY_WAITING) }, + score = rec?.readiness?.score, + alert = rec?.readiness?.strainAlert == true, + noScore = TodayCopy.noScore(rec?.readiness), + firstNights = rec?.charge?.anchored != true, + energy = rec?.charge?.let(MomentEnergy::of), + load = rec?.load, + loadStatus = rec?.let { TodayCopy.loadStatus(it.load, it.chronicLoad, endOfDay = false) }, + night = night, + usualSport = usual, + lastWorkout = last, + recording = recording, + dayWorkouts = dayWorkouts, + heartRate = heart, + restingHr = resting, + steps = LiveSteps().today(Summaries.totalSteps(minutes), container.strap.todaySteps.value), + stepGoal = container.settings.goals.first().steps, + ) + val forced = WidgetRefresh.momentOverride + val model = MomentWidgetModel.from(input, now, zone, forced) + // The debug gallery's forced moment has no end of its own. + if (forced == null) WidgetUpdater.scheduleMoment(context, inputs, model.tick, now, zone) + return WidgetRefresh.moodOverride?.let { model.copy(mood = it) } ?: model + } + + /** Our own resting estimate needs most of a day's minutes, as Today's ([org.openstrap.app.ui.dashboard.DayViewModel]). */ + private const val RESTING_ESTIMATE_MINUTES = 6 * 60 + } +} + +/** The Moment widget's receiver. Its first placed sets the moment's alarm as it renders, its last removed clears it. */ +class MomentWidgetReceiver : PlacedWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = MomentWidget() +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Taps */ +/* ------------------------------------------------------------------------------------------------ */ + +/** Today, as it is now (spec 4): its root, over whatever tab or page was showing. */ +internal fun openToday(context: Context): Intent = + Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_MOMENT, MainActivity.MOMENT_NOW).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + +/* ------------------------------------------------------------------------------------------------ */ +/* Layout */ +/* ------------------------------------------------------------------------------------------------ */ + +/** The moment's name: Sans 500 at 60% (spec 3.1). */ +private val NameStyle = WidgetType.secondary.copy(color = ColorProvider(Color.White.copy(alpha = 0.60f)), fontWeight = FontWeight.Medium) + +/** The sport's name while recording: Body 500 on the Small, the hero's light type on the Wide and Large. */ +private val SportStyle = WidgetType.body.copy(fontWeight = FontWeight.Medium) + +/** Open's word, black on its white pill. */ +private val OpenStyle = WidgetType.body.copy(color = ColorProvider(Color.Black), fontWeight = FontWeight.Medium) + +/** Room for the dots, the chart, play and the tile beside the backgrounds, in the bitmap budget. */ +private const val BITMAP_EXTRA = 1.4 + +/** The moment's glow behind, then the size's content. */ +@Composable +private fun MomentContent(m: MomentWidgetModel) { + val context = LocalContext.current + val open = if (m.opensLive) openLiveWorkout(context) else openToday(context) + // While a workout records, the recording is the news and the live screen shows the strap: no status line. + GlowWidget(m.light.style(m.mood), open, BITMAP_EXTRA, showStatus = m.recording == null) { f -> + when (f.bucket) { + WidgetSizes.Small -> SmallContent(m, f) + WidgetSizes.Wide -> WideContent(m, MomentLayout.wide(m, f.contentWidth, f.contentHeight, f.fontScale, f.status), f) + else -> WideContent(m, MomentLayout.large(m, f.contentWidth, f.contentHeight, f.fontScale, f.status), f) + } + } +} + +/** The half gap above and below at the cell's real height; the gaps between are weights and share the rest. */ +private fun edge(fit: MomentLayout.Fit, f: WidgetFrame): Float = fit.edgeGap(f.drawHeight - 2 * WidgetSizes.PADDING_DP) + +/** Small: the name, the number (with Start beside it in the workout window), its word; or the recording's. */ +@Composable +private fun ColumnScope.SmallContent(m: MomentWidgetModel, f: WidgetFrame) { + val fit = MomentLayout.small(m, f.contentWidth, f.contentHeight, f.fontScale, f.status) + val edge = edge(fit, f) + Spacer(GlanceModifier.height(edge.dp)) + Top(fit, f, edge) + val r = m.recording + if (r != null) { + if (fit.tile > 0f) { Gap(); Tile(r, fit.tile, f) } + fit.sport?.let { Gap(); Text(it.text, style = SportStyle.copy(fontSize = it.sp.sp), maxLines = 1) } + fit.since?.let { Gap(); Since(it, f) } + } else if (fit.dot > 0f) { + Gap() + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { + DotNumber(m.number.orEmpty(), fit.dot, f.density, m.numberDescription) + if (fit.play) { + Spacer(GlanceModifier.defaultWeight()) + Play(m, f) + } + } + fit.word?.let { Gap(); Text(it.text, style = WidgetType.word.copy(fontSize = it.sp.sp), maxLines = 1) } + } else { + fit.waiting?.let { Gap(); Text(it.text, style = WidgetType.secondary.copy(fontSize = it.sp.sp), maxLines = 1) } + if (fit.play) { Gap(); Play(m, f) } + } + Spacer(GlanceModifier.height(edge.dp)) +} + +/** Wide and Large: the name, the sentence, the number row, the Large's chart; or the recording's. */ +@Composable +private fun ColumnScope.WideContent(m: MomentWidgetModel, fit: MomentLayout.Fit, f: WidgetFrame) { + val edge = edge(fit, f) + Spacer(GlanceModifier.height(edge.dp)) + val r = m.recording + if (r != null) { + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Name(fit.name) + Spacer(GlanceModifier.defaultWeight()) + if (fit.open) OpenPill(f) + } + if (fit.tile > 0f) { Gap(); Tile(r, fit.tile, f) } + fit.sport?.let { Gap(); Text(it.text, style = WidgetType.hero.copy(fontSize = it.sp.sp), maxLines = 1) } + fit.since?.let { Gap(); Since(it, f) } + } else { + Top(fit, f, edge) + fit.sentence?.let { Gap(); Text(it.text, style = WidgetType.hero.copy(fontSize = it.sp.sp), maxLines = MomentLayout.SENTENCE_LINES) } + fit.row?.let { Gap(); NumberRow(m, it, f) } + fit.waiting?.let { Gap(); Text(it.text, style = WidgetType.secondary.copy(fontSize = it.sp.sp), maxLines = 1) } + if (fit.chart > 0f) m.chart?.let { Gap(); Chart(it, fit.chart, f) } + } + Spacer(GlanceModifier.height(edge.dp)) +} + +/** A gap between blocks: they all share the spare height alike. */ +@Composable +private fun ColumnScope.Gap() = Spacer(GlanceModifier.defaultWeight()) + +@Composable +private fun Name(name: MomentLayout.Line) = Text(name.text, style = NameStyle.copy(fontSize = name.sp.sp), maxLines = 1) + +/** The top row: the moment's name, with the strap status button at its right when it shows (spec 5), [above] dp under the content's top. */ +@Composable +private fun Top(fit: MomentLayout.Fit, f: WidgetFrame, above: Float) { + val top = fit.top + if (top != null) TopRowView(fit.name.text, null, top, f, above, NameStyle) else Name(fit.name) +} + +/** The dots with the word beside them, the figure or the Start button on the right, bottoms aligned. */ +@Composable +private fun NumberRow(m: MomentWidgetModel, row: MomentLayout.NumberRow, f: WidgetFrame) { + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { + DotNumber(m.number.orEmpty(), row.dot, f.density, m.numberDescription) + row.word?.let { + Spacer(GlanceModifier.width(MomentLayout.WORD_GAP_DP.dp)) + Text(it.text, style = WidgetType.word.copy(fontSize = it.sp.sp), maxLines = 1) + } + Spacer(GlanceModifier.defaultWeight()) + when { + row.play -> Play(m, f) + row.figure != null -> Text(row.figure.text, style = WidgetType.secondary.copy(fontSize = row.figure.sp.sp), maxLines = 1) + } + } +} + +/** The Start button, white with the black play triangle, opening Ready with your usual sport. */ +@Composable +private fun Play(m: MomentWidgetModel, f: WidgetFrame) { + val sport = m.start ?: return + val context = LocalContext.current + Image( + ImageProvider(playBitmap(MomentLayout.PLAY_DP, StartLayout.PLAY_ICON_DP, f.density)), + contentDescription = m.startLabel, + modifier = GlanceModifier.size(MomentLayout.PLAY_DP.dp).clickable(actionStartActivity(openReady(context, sport))), + contentScale = ContentScale.FillBounds, + ) +} + +/** The chart [height] dp high across the content, its times under it as Captions. */ +@Composable +private fun Chart(chart: MomentChart, height: Float, f: WidgetFrame) { + val w = f.drawContentWidth * f.density + val h = height * f.density + val bitmap = renderToBitmap(w, h) { c -> + when (chart.kind) { + MomentChartKind.Stages -> drawStageStrip(c, chart.stages, w, h) + MomentChartKind.Line -> ghostLineGeometry(chart.shares, null, 1f, w, h, f.density)?.let { drawGhostLine(c, it, Color.White, f.density) } + } + } + Column(GlanceModifier.fillMaxWidth()) { + Image( + ImageProvider(bitmap), + contentDescription = chart.description, + modifier = GlanceModifier.fillMaxWidth().height(height.dp), + contentScale = ContentScale.FillBounds, + ) + Spacer(GlanceModifier.height(MomentLayout.CHART_LABEL_GAP_DP.dp)) + Row(GlanceModifier.fillMaxWidth()) { + Text(chart.from, style = WidgetType.caption, maxLines = 1) + Spacer(GlanceModifier.defaultWeight()) + Text(chart.to, style = WidgetType.caption, maxLines = 1) + } + } +} + +/** The sport's tile, [size] dp, as Start's. */ +@Composable +private fun Tile(r: MomentRecording, size: Float, f: WidgetFrame) { + val corner = StartLayout.TILE_CORNER_DP * size / StartLayout.TILE_DP + val icon = (size * StartLayout.ICON_DP / StartLayout.TILE_DP).coerceIn(StartLayout.ICON_DP, 28f) + Image( + ImageProvider(tileBitmap(SportIcons.of(r.sport.shownType), size, corner, icon, f.density)), + contentDescription = null, + modifier = GlanceModifier.size(size.dp), + contentScale = ContentScale.FillBounds, + ) +} + +/** The red recording dot and "Recording since 17:42". */ +@Composable +private fun Since(line: MomentLayout.Line, f: WidgetFrame) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + ImageProvider(dotBitmap(MetricColors.heart, f.density)), + contentDescription = null, + modifier = GlanceModifier.size(MomentLayout.REC_DOT_DP.dp), + contentScale = ContentScale.FillBounds, + ) + Spacer(GlanceModifier.width(MomentLayout.REC_DOT_GAP_DP.dp)) + Text(line.text, style = WidgetType.secondary.copy(fontSize = line.sp.sp), maxLines = 1) + } +} + +/** Open, a white pill as Start's while recording, opening the live screen. */ +@Composable +private fun OpenPill(f: WidgetFrame) { + val context = LocalContext.current + val w = MomentLayout.openWidth(f.fontScale) + Box( + GlanceModifier.size(w.dp, MomentLayout.PLAY_DP.dp) + .background(ImageProvider(pillBitmap(w, MomentLayout.PLAY_DP, f.density)), ContentScale.FillBounds) + .clickable(actionStartActivity(openLiveWorkout(context))), + contentAlignment = Alignment.Center, + ) { Text(MomentLayout.OPEN, style = OpenStyle, maxLines = 1) } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/MomentWidgetModel.kt b/app/src/main/kotlin/org/openstrap/app/widget/MomentWidgetModel.kt new file mode 100644 index 00000000..a6428df8 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/MomentWidgetModel.kt @@ -0,0 +1,875 @@ +package org.openstrap.app.widget + +import androidx.compose.ui.graphics.Color +import org.openstrap.app.data.Moment +import org.openstrap.app.data.MomentInputs +import org.openstrap.app.data.MomentLead +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.momentLead +import org.openstrap.app.ui.components.ChartPoint +import org.openstrap.app.ui.components.StageSpan +import org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import org.openstrap.app.ui.dashboard.WakingLead +import org.openstrap.app.ui.dashboard.clockText +import org.openstrap.app.ui.dashboard.momentMood +import org.openstrap.app.ui.dashboard.sportNoun +import org.openstrap.app.ui.dashboard.todayTitle +import org.openstrap.app.ui.dashboard.wakingLead +import org.openstrap.app.ui.glow.AFTER_RIM +import org.openstrap.app.ui.glow.EVENING_BODY +import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.ui.glow.GlowStyle +import org.openstrap.app.ui.glow.LightAngle +import org.openstrap.app.ui.glow.WINDOW_RIM +import org.openstrap.app.ui.glow.chargeShown +import org.openstrap.app.ui.glow.heroStyle +import org.openstrap.app.ui.theme.MetricColors +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +/* + * The Moment widget's content (widgets spec 3.1): which moment it is, what it leads with once the duplicate rule has + * had its say, and every line, number, figure and chart in it, from the same resolver, copy and sums Today's hero + * uses. Then MomentLayout: what fits each size, one line a text (the hero sentence two at most), the spare height + * shared out. Plain functions, so every moment is tested without a phone. + */ + +/** Energy as the widget reads it, from the day's [Recovery.ChargeDay]. */ +data class MomentEnergy( + /** Energy now, whole, as the Charge detail's headline ([chargeShown]); null before the first night. */ + val now: Int?, + val atWake: Int?, + val recharged: Int, + /** What the day drained, gross: movement, stress and living. */ + val drained: Float, + val anchored: Boolean, + val points: List, +) { + companion object { + fun of(c: Recovery.ChargeDay): MomentEnergy { + val anchored = c.anchored + return MomentEnergy( + now = c.latest?.takeIf { anchored }?.let(::chargeShown), + atWake = c.atWake?.takeIf { anchored }?.toInt(), + recharged = c.recharged.toInt(), + drained = c.drainedExercise + c.drainedStress + c.drainedLiving, + anchored = anchored, + points = c.points, + ) + } + } +} + +/** The last finished workout: its sport and when it ended (epoch seconds). */ +data class MomentWorkout(val sportType: Int, val end: Long) + +/** The workout recording now: its sport and when it started (epoch seconds). */ +data class MomentRecordingInput(val sportType: Int, val start: Long) + +/** + * Everything the Moment widget reads, gathered by [MomentWidget.load] from what the app stores and works out, so the + * model is a plain function of it. + */ +data class MomentWidgetInput( + /** What [org.openstrap.app.data.MomentResolver] reads, as Today gathers it ([widgetMomentInputs]). */ + val inputs: MomentInputs, + /** The widgets on the home screen, for the duplicate rule. */ + val placed: Set, + /** Recovery as Today shows it ([TodayCopy.recoveryShown]); null when it couldn't be worked out. */ + val recovery: TodayCopy.RecoveryShown?, + val score: Int?, + val alert: Boolean, + val noScore: TodayCopy.NoScore?, + /** Before the strap has seen a night. */ + val firstNights: Boolean, + val energy: MomentEnergy?, + /** Load so far today, and against your usual. */ + val load: Float?, + val loadStatus: TodayCopy.LoadStatus?, + /** Today's main sleep, as the Sleep tab's hero reads it; null without one. */ + val night: SleepWidgetNight?, + /** Your usual sport, as Today's Start picks it. */ + val usualSport: Int, + val lastWorkout: MomentWorkout?, + val recording: MomentRecordingInput?, + /** The sports of today's workouts, oldest first, as the evening sentence counts them. */ + val dayWorkouts: List, + /** Today's heart rate, five minute averages (epoch seconds, bpm). */ + val heartRate: List, + val restingHr: Int?, + val steps: Int, + val stepGoal: Int, +) + +/** How the Large's chart is drawn. */ +enum class MomentChartKind { + /** The night's stages laid flat, as the nap strip ([org.openstrap.app.ui.glow.StageStrip]). */ + Stages, + + /** A solid white line ending in its dot, as the Steps widget's ([org.openstrap.app.ui.glow.draw.ghostLineGeometry]). */ + Line, +} + +/** + * The Large's chart: [stages] for [MomentChartKind.Stages], else [values] evenly spaced (nulls the stretch still to + * come) on an axis from [low] to [high], with the times under it from [from] to [to] and what TalkBack reads. + */ +data class MomentChart( + val kind: MomentChartKind, + val stages: List = emptyList(), + val values: List = emptyList(), + val low: Float = 0f, + val high: Float = 100f, + val from: String, + val to: String, + val description: String, +) { + /** [values] as shares of the axis, 0..1, for a line whose top is [high]. */ + val shares: List get() = values.map { v -> v?.let { ((it - low) / (high - low).coerceAtLeast(1f)).coerceIn(0f, 1f) } } +} + +/** + * The hero's light for the moment (v2 spec 4.2, widgets spec 2.2): its [color] from [angle], the rim at [rim], or a + * level from below to [level] for Energy, and the evening's lighter body. + */ +data class MomentLight(val color: Color, val angle: Float?, val rim: Float = 1f, val level: Float? = null, val bodyLightness: Float? = null) { + /** The glow card's style in [mood], as Today's hero draws it ([heroStyle], Energy's level as its Charge hero). */ + fun style(mood: GlowMood): GlowStyle { + val base = if (level != null) GlowStyle.Ember.copy(lightLevel = level) else heroStyle(color, angle, rim) + return (bodyLightness?.let { base.copy(bodyLightness = it) } ?: base).copy(mood = mood) + } +} + +/** During a workout: the sport, its tile and "Recording since 17:42". */ +data class MomentRecording(val sport: StartSport, val since: String, val sinceShort: String) + +/** + * What the Moment widget shows (widgets spec 3.1). Every one-line text comes as options, longest first, and the + * layout takes the first that fits ([MomentLayout]). + */ +data class MomentWidgetModel( + val moment: Moment, + val lead: MomentLead, + /** The moment's name, as Today's header says it ("Midday", "After your run"). */ + val name: String, + /** The number in dots; null when there's none to show. */ + val number: String?, + /** Under the number on the Small: "Energy left". */ + val words: List, + /** Beside the number on the Wide and Large: "left". */ + val shortWords: List, + /** The Secondary figure on the right of the number row: "Drained 60, recharged 38". */ + val figures: List, + /** The hero sentence and shorter wordings of it. */ + val sentences: List, + /** In place of the number when there's none: "After your first night". */ + val waiting: String?, + /** The Start button's sport (the workout window), and what TalkBack reads for it. */ + val start: Int?, + val startLabel: String?, + val recording: MomentRecording?, + val chart: MomentChart?, + val light: MomentLight, + val mood: GlowMood, + /** What TalkBack reads for the dotted number: "62, Energy left". */ + val numberDescription: String?, + /** + * When something shown moves on by itself within the moment (a count of minutes), so the widget refreshes then + * as well as at the moment's end; null when nothing does. + */ + val tick: Instant?, +) { + /** The whole widget opens the live screen while a workout records, else Today. */ + val opensLive: Boolean get() = recording != null + + companion object { + /** How often a count of minutes on the widget moves on: every [TICK_MINUTES] on the clock. */ + const val TICK_MINUTES = 5L + + const val ENERGY_LEFT = "Energy left" + const val LEFT = "left" + + /** + * The widget at [now]: the moment [org.openstrap.app.data.MomentResolver] gives for the [input] (or a + * [forced] one, the debug gallery's), what it leads with ([momentLead], then the moment's next lead where the + * lead has nothing to show), and everything in it. + */ + fun from(input: MomentWidgetInput, now: Instant, zone: ZoneId, forced: Moment? = null): MomentWidgetModel { + val moment = forced ?: input.inputs.resolve(now, zone) + val lead = lead(moment, input) + val noSleep = moment == Moment.Waking && + wakingLead(input.firstNights, input.night?.asleepSeconds, isToday = true) == WakingLead.Charge + val local = now.atZone(zone) + val sport = when (moment) { + Moment.During -> input.recording?.sportType + Moment.After -> input.lastWorkout?.sportType + else -> null + } + val name = todayTitle(moment, local.toLocalDate(), sport, today = local.toLocalDate()) + val mood = momentMood(moment) + val c = Content(input, moment, now, zone) + return when (lead) { + MomentLead.Recovery -> c.recovery(lead, name, mood) + MomentLead.LastNight -> c.lastNight(lead, name, mood) + MomentLead.Energy -> c.energy(lead, name, mood, noSleep) + MomentLead.Start -> c.window(lead, name, mood) + MomentLead.Workout -> c.during(lead, name, mood) + MomentLead.AfterWorkout -> c.after(lead, name, mood) + MomentLead.Sentence -> if (moment == Moment.Night) c.night(lead, name, mood) else c.evening(lead, name, mood) + } + } + + /** + * What the widget leads with: [momentLead]'s, unless that has nothing to show. No sleep last night leads + * with Energy from waking, as Today's hero does (spec 4.3); waking with no Recovery value gives way to last + * night, then Energy; during a workout with nothing recording, or after one with none to tell, it's midday's. + */ + fun lead(moment: Moment, input: MomentWidgetInput): MomentLead { + val lead = momentLead(moment, input.placed) + if (moment == Moment.Waking) { + if (wakingLead(input.firstNights, input.night?.asleepSeconds, isToday = true) == WakingLead.Charge) return MomentLead.Energy + val sleepScore = input.night?.session?.score + var l = lead + if (l == MomentLead.Recovery && input.recovery?.value == null) { + l = if (WidgetKind.Sleep !in input.placed && sleepScore != null) MomentLead.LastNight else MomentLead.Energy + } + if (l == MomentLead.LastNight && sleepScore == null) l = MomentLead.Energy + return l + } + if (lead == MomentLead.Workout && input.recording == null) return MomentLead.Energy + if (lead == MomentLead.AfterWorkout && input.lastWorkout == null) return MomentLead.Energy + return lead + } + + /** The next [TICK_MINUTES] mark on the clock after [now]. */ + fun nextTick(now: Instant): Instant { + val minute = now.truncatedTo(ChronoUnit.MINUTES) + val m = minute.epochSecond / 60 + return minute.plusSeconds((TICK_MINUTES - Math.floorMod(m, TICK_MINUTES)) * 60) + } + + /** Minutes on a clock that starts at noon, so the night runs on past midnight (as [TodayCopy.bedtimeIn]). */ + private fun afterNoon(t: LocalTime): Int = Math.floorMod(t.toSecondOfDay() / 60 - 720, 1440) + + /** "Slept 7 h 40." split into its sentences: the whole, then each alone, longest first. */ + internal fun shorter(sentence: String): List { + val parts = sentence.split(Regex("(?<=\\.) ")).filter { it.isNotBlank() } + return (listOf(sentence) + parts.sortedByDescending { it.length }).distinct() + } + } + + /** Builds each lead's model from the [input] at [now]. */ + private class Content(val input: MomentWidgetInput, val moment: Moment, val now: Instant, val zone: ZoneId) { + val t = now.epochSecond + val energy = input.energy + + fun clock(epoch: Long) = clockText(epoch, zone) + + /** Waking: Recovery's score and word, the waking sentence, Energy at waking, the night's stages. */ + fun recovery(lead: MomentLead, name: String, mood: GlowMood): MomentWidgetModel { + val shown = input.recovery + val word = shown?.word + return MomentWidgetModel( + moment, lead, name, + number = shown?.value?.toString(), + words = listOfNotNull(word), + shortWords = listOfNotNull(word), + figures = listOfNotNull(energy?.atWake?.let { "Energy at waking $it" }), + sentences = shorter(TodayCopy.waking(input.night?.asleepSeconds, input.score, input.alert, input.noScore, input.firstNights, isToday = true)), + waiting = null, start = null, startLabel = null, recording = null, + chart = stagesChart(), + light = MomentLight(MetricColors.readiness, LightAngle.LowerLeft), + mood = mood, + numberDescription = shown?.value?.let { v -> "Recovery $v" + (word?.let { ", $it" } ?: "") }, + tick = null, + ) + } + + /** + * Waking with a Recovery widget placed: last night's score and word, the Sleep tab's sentence, how often you + * woke (the sentence already says how long you slept), Sleep blue lit from above. + */ + fun lastNight(lead: MomentLead, name: String, mood: GlowMood): MomentWidgetModel { + val n = input.night!! + val score = n.session.score!! + val word = TabCopy.sleepScoreWord(score) + val line = TabCopy.sleepScoreLine(score) + return MomentWidgetModel( + moment, lead, name, + number = score.toString(), + words = listOf(line, word), + shortWords = listOf(line, word), + figures = listOfNotNull(TabCopy.woke(n.session.wakeCount)), + // Shorter: the same sentence without the week's comparison, "7 h 40 asleep." + sentences = listOf(TabCopy.night(n.asleepSeconds, n.others), TabCopy.night(n.asleepSeconds, emptyList())).distinct(), + waiting = null, start = null, startLabel = null, recording = null, + chart = stagesChart(), + light = MomentLight(MetricColors.sleepDeep, LightAngle.Above), + mood = mood, + numberDescription = "Sleep score $score, ${word.lowercase()}", + tick = null, + ) + } + + /** + * Midday, and waking when Recovery and Sleep both have their widgets (or there was no sleep): Energy left, + * the Charge hero's sentence, drained and recharged, the line since waking, lit from below to the level. + */ + fun energy(lead: MomentLead, name: String, mood: GlowMood, noSleep: Boolean = false): MomentWidgetModel { + val e = energy + val left = e?.now + val drained = e?.let { TabCopy.drainedAmount(it.drained) } + val sentence = if (noSleep) TodayCopy.NO_SLEEP + else TodayCopy.charge(left, e?.let { TabCopy.drainedAmount(it.drained).toInt() }, e?.anchored == true, now.atZone(zone).hour) + return energyModel( + lead, name, mood, + figures = if (e != null && left != null && drained != null) listOf("Drained $drained, recharged ${e.recharged}", "Drained $drained") else emptyList(), + sentences = shorter(sentence), + ) + } + + /** The workout window: Energy left, "Ready for effort. You usually run around now.", the Start button. */ + fun window(lead: MomentLead, name: String, mood: GlowMood): MomentWidgetModel { + val sport = input.usualSport + val noun = sportNoun(sport) + val title = TodayCopy.windowTitle(input.score) + val usual = TodayCopy.window(noun, null) + val e = energy?.now + return MomentWidgetModel( + moment, lead, name, + number = e?.toString(), + words = listOf(ENERGY_LEFT), + shortWords = listOf(LEFT), + figures = emptyList(), + sentences = listOf("$title. $usual", usual, "$title."), + waiting = if (e == null) title else null, + start = sport, + startLabel = "Start ${noun ?: org.openstrap.app.data.Workouts.sportName(sport).lowercase()}", + recording = null, + chart = energyChart(), + light = MomentLight(MetricColors.heart, LightAngle.UpperRight, rim = WINDOW_RIM), + mood = mood, + numberDescription = e?.let { "Energy $it left" }, + tick = null, + ) + } + + /** During a workout: the sport and since when, no number, nothing live. */ + fun during(lead: MomentLead, name: String, mood: GlowMood): MomentWidgetModel { + val r = input.recording!! + val since = clock(r.start) + return MomentWidgetModel( + moment, lead, name, + number = null, words = emptyList(), shortWords = emptyList(), figures = emptyList(), sentences = emptyList(), + waiting = null, start = null, startLabel = null, + recording = MomentRecording(StartSport.recorded(r.sportType), "Recording since $since", "Since $since"), + chart = null, + light = MomentLight(MetricColors.heart, LightAngle.Below), + mood = mood, + numberDescription = null, + tick = null, + ) + } + + /** After a workout: minutes since it, how the heart is coming down, Load today, the drop since the end. */ + fun after(lead: MomentLead, name: String, mood: GlowMood): MomentWidgetModel { + val w = input.lastWorkout!! + val minutes = ((t - w.end).coerceAtLeast(0) / 60).toInt() + val noun = sportNoun(w.sportType) ?: "workout" + val since = input.heartRate.filter { it.x >= w.end && it.x <= t } + val load = input.load?.let { Math.round(it) } + return MomentWidgetModel( + moment, lead, name, + number = minutes.toString(), + words = listOf("min since your $noun", "min since", "min"), + shortWords = listOf("min since your $noun", "min since", "min"), + figures = listOfNotNull(load?.let { "Load today $it" }, load?.let { "Load $it" }), + sentences = listOf(TodayCopy.after(since.lastOrNull()?.y?.toInt(), input.restingHr)), + waiting = null, start = null, startLabel = null, recording = null, + chart = heartChart(since, w.end, "Heart rate since your $noun"), + light = MomentLight(MetricColors.heart, LightAngle.Below, rim = AFTER_RIM), + mood = mood, + numberDescription = "$minutes minutes since your $noun", + tick = nextTick(now), + ) + } + + /** The evening: Energy left, the day in a sentence, how long to bedtime, the day's heart rate. */ + fun evening(lead: MomentLead, name: String, mood: GlowMood): MomentWidgetModel { + val bed = input.inputs.usualBedtime + val bedIn = TodayCopy.bedtimeIn(now.atZone(zone).toLocalTime(), bed) + return energyModel( + lead, name, mood, + figures = listOfNotNull(bedIn), + sentences = shorter(daySentence()), + light = MomentLight(MetricColors.evening, LightAngle.LowerRight, bodyLightness = EVENING_BODY), + chart = dayHeartChart(), + // "Bedtime in 1 h 10" counts down. + tick = if (bedIn != null) nextTick(now) else null, + ) + } + + /** + * The night: minutes to your usual bedtime until it comes, then Energy left; the day in a sentence, the + * bedtime, the day's heart rate. Sleep blue from below. + */ + fun night(lead: MomentLead, name: String, mood: GlowMood): MomentWidgetModel { + val bed = input.inputs.usualBedtime + val light = MomentLight(MetricColors.sleepDeep, LightAngle.Below) + val bedtime = bed?.let { "Bedtime %02d:%02d".format(it.hour, it.minute) } + val clock = now.atZone(zone).toLocalTime() + val left = bed?.let { afterNoon(it) - afterNoon(clock) }?.takeIf { it > 0 } + if (left == null) { + return energyModel(lead, name, mood, listOfNotNull(bedtime), shorter(daySentence()), light, dayHeartChart(), tick = null) + } + val tick = nextTick(now).let { tk -> minOf(tk, now.truncatedTo(ChronoUnit.MINUTES).plusSeconds(left * 60L)) } + return MomentWidgetModel( + moment, lead, name, + number = left.toString(), + words = listOf("min to bedtime", "min"), + shortWords = listOf("min to bedtime", "min"), + figures = listOfNotNull(bedtime), + sentences = shorter(daySentence()), + waiting = null, start = null, startLabel = null, recording = null, + chart = dayHeartChart(), + light = light, + mood = mood, + numberDescription = "$left minutes to bedtime", + tick = tick, + ) + } + + /** Energy left as the number, with [figures] and [sentences] of the moment's own. */ + fun energyModel( + lead: MomentLead, name: String, mood: GlowMood, figures: List, sentences: List, + light: MomentLight? = null, chart: MomentChart? = energyChart(), tick: Instant? = null, + ): MomentWidgetModel { + val now = energy?.now + return MomentWidgetModel( + moment, lead, name, + number = now?.toString(), + words = listOf(ENERGY_LEFT), + shortWords = listOf(LEFT), + figures = if (now == null) emptyList() else figures, + sentences = sentences, + waiting = if (now == null) AFTER_FIRST_NIGHT else null, + start = null, startLabel = null, recording = null, + chart = if (now == null) null else chart, + // Energy is lit to its level from below (spec 2.5), as Today's Charge hero. + light = light ?: MomentLight(MetricColors.charge, LightAngle.Below, level = (now ?: 0) / 100f), + mood = mood, + numberDescription = now?.let { "Energy $it left" }, + tick = tick, + ) + } + + /** The evening hero's sentence: what kind of day it was, then what made it. */ + fun daySentence(): String { + val nouns = input.dayWorkouts.map(::sportNoun) + return TodayCopy.evening(nouns, input.steps, input.stepGoal, input.loadStatus) + } + + /** The night's stages from bedtime to waking; null without stages. */ + fun stagesChart(): MomentChart? { + val n = input.night ?: return null + if (n.stages.isEmpty()) return null + val from = clock(n.session.start) + val to = clock(n.session.end) + return MomentChart(MomentChartKind.Stages, stages = n.stages, from = from, to = to, description = "Last night's stages, $from to $to") + } + + /** + * Energy's line since waking, in quarter hours up to now, across the day from waking to your usual bedtime + * (or now, when that's later). Null before the first night or with fewer than two readings. + */ + fun energyChart(): MomentChart? { + val e = energy?.takeIf { it.anchored } ?: return null + val today = now.atZone(zone).toLocalDate() + val wake = input.inputs.sleepEnd ?: today.atTime(org.openstrap.app.data.MomentResolver.WAKE_FALLBACK).atZone(zone).toEpochSecond() + if (t <= wake) return null + // A bedtime after midnight is tomorrow's. + val bed = (input.inputs.usualBedtime ?: org.openstrap.app.data.MomentResolver.BEDTIME_FALLBACK).let { b -> + today.atTime(b).atZone(zone).toEpochSecond() + if (b.hour < 12) 86_400 else 0 + } + val end = maxOf(bed, t) + val step = 15 * 60L + val n = ((end - wake) / step).toInt().coerceAtLeast(2) + val sums = FloatArray(n + 1) + val counts = IntArray(n + 1) + e.points.forEach { p -> if (p.t in wake..t) { val i = ((p.t - wake) / step).toInt().coerceAtMost(n); sums[i] += p.value; counts[i]++ } } + val values = List(n + 1) { if (counts[it] == 0) null else sums[it] / counts[it] } + if (values.count { it != null } < 2) return null + val from = clock(wake) + val to = clock(end) + val nowValue = e.now + return MomentChart( + MomentChartKind.Line, values = values, low = 0f, high = 100f, from = from, to = to, + description = "Energy since $from" + (nowValue?.let { ", $it now" } ?: ""), + ) + } + + /** The heart rate in [points] from [start] to now, its axis fitted to the readings and your resting rate. */ + fun heartChart(points: List, start: Long, description: String): MomentChart? { + if (points.size < 2) return null + val step = maxOf(60L, (t - start) / 40) + val n = ((t - start) / step).toInt().coerceAtLeast(2) + val sums = FloatArray(n + 1) + val counts = IntArray(n + 1) + points.forEach { p -> val i = ((p.x - start) / step).toInt(); if (i in 0..n) { sums[i] += p.y; counts[i]++ } } + val values = List(n + 1) { if (counts[it] == 0) null else sums[it] / counts[it] } + val known = values.filterNotNull() + if (known.size < 2) return null + val low = minOf(known.min(), input.restingHr?.toFloat() ?: Float.MAX_VALUE) - 5f + val high = known.max() + 5f + return MomentChart(MomentChartKind.Line, values = values, low = low, high = high, from = clock(start), to = clock(points.last().x), description = description) + } + + /** The day's heart rate since midnight. */ + fun dayHeartChart(): MomentChart? { + val start = now.atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + val points = input.heartRate.filter { it.x in start..t } + return heartChart(points, points.firstOrNull()?.x ?: start, "Your heart rate today") + } + + } +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Layout */ +/* ------------------------------------------------------------------------------------------------ */ + +/** + * What of the Moment widget fits each size, and the spare height shared out (spec 2.1, the lessons of Start in 3.5): + * every text one line (the hero sentence two at most), each at the first wording and size that fits with + * [WidgetText.FIT_MARGIN], else left out. Spare height grows the parts first (the number's dots, the sentence, the + * chart, the recording's tile) until no gap is over [TARGET_GAP_DP], then is shared evenly between the blocks, half a + * gap above the first and below the last, as the padding already frames them. + */ +object MomentLayout { + /** The moment's name: Sans 500 13 at 60% (spec 3.1), Caption size when it won't fit. */ + val NAME_SIZES = listOf(WidgetText.SECONDARY_SP, WidgetText.CAPTION_SP) + + /** The word under or beside the number: Body, then Secondary. */ + val WORD_SIZES = listOf(WidgetText.BODY_SP, WidgetText.SECONDARY_SP) + + /** The figure on the right of the number row, Secondary, then Caption size. */ + val FIGURE_SIZES = listOf(WidgetText.SECONDARY_SP, WidgetText.CAPTION_SP) + + /** The hero sentence (Display 300 20, spec 3.1), a touch smaller before a shorter wording. */ + val SENTENCE_SIZES = listOf(WidgetText.HERO_SP, 18f) + + /** Sizes the sentence grows to on a tall cell, and the most lines it takes. */ + val SENTENCE_GROWTH = listOf(22f, 24f) + const val SENTENCE_LINES = 2 + + /** The number's dots: the Small's from the mockup's 3.6 up, the Wide's and Large's 4.5 (spec 3). */ + const val SMALL_DOT = 3.6f + const val SMALL_MIN_DOT = 3f + val SMALL_DOT_GROWTH = listOf(4.5f, 5f, 5.5f, 6f, 6.5f, 7f) + const val DOT = 4.5f + val DOT_GROWTH = listOf(5f, 5.5f, 6f, 6.5f, 7f) + + /** Between the dots and their word, and the word and the figure. */ + const val WORD_GAP_DP = 8f + const val FIGURE_GAP_DP = 12f + + /** The Start button (44 dp, white) and the room before it. */ + const val PLAY_DP = 44f + + /** The Large's line chart: the mockup's 44 dp, growing on a tall cell, down to 32 when short. */ + const val CHART_DP = 44f + const val CHART_MIN_DP = 32f + val CHART_GROWTH = listOf(56f, 64f, 72f) + + /** The stage strip, as the Sleep widget's bar, and as far as it grows. */ + const val STRIP_DP = 8f + const val STRIP_MIN_DP = 6f + val STRIP_GROWTH = listOf(10f, 12f, 14f, 16f, 18f, 20f) + + /** Between a chart and its times. */ + const val CHART_LABEL_GAP_DP = 6f + + /** During a workout: the sport's tile, and the sport's name in the hero's light type on the Wide and Large. */ + const val TILE_DP = 44f + val TILE_GROWTH = listOf(56f, 64f, 72f) + val SPORT_SIZES = listOf(WidgetText.HERO_SP, 18f) + val SPORT_GROWTH = listOf(24f, 28f) + + /** The red recording dot before "Recording since 17:42", and the room after it, as Start's. */ + const val REC_DOT_DP = 8f + const val REC_DOT_GAP_DP = 6f + + /** Open, as Start's while recording. */ + const val OPEN_PAD_DP = 14f + const val OPEN = "Open" + + /** The least gap between blocks, and the most a tall cell should show. */ + const val MIN_GAP_DP = 4f + const val TARGET_GAP_DP = 32f + + /** A text and the size it fits at. */ + data class Line(val text: String, val sp: Float) { + fun height(fontScale: Float) = WidgetText.line(sp, fontScale) + fun width(fontScale: Float) = WidgetText.fitWidth(text, sp, fontScale) + } + + /** The first of [options] that fits [room] on one line, at the first of [sizes] that does; null when none does. */ + fun line(options: List, sizes: List, room: Float, fontScale: Float): Line? { + for (o in options) for (sp in sizes) if (WidgetText.fits(o, sp, room, fontScale)) return Line(o, sp) + return null + } + + /** The sentence at [sp] and how many lines it wraps to in [width]. */ + data class Sentence(val text: String, val sp: Float, val lines: Int) { + fun height(fontScale: Float) = lines * WidgetText.line(sp, fontScale) + } + + fun sentence(text: String, sp: Float, width: Float, fontScale: Float): Sentence? { + val lines = WidgetText.wrappedLines(text, sp, width, fontScale) + return if (lines <= SENTENCE_LINES) Sentence(text, sp, lines) else null + } + + /** + * The number row on the Wide and Large: the dots at [dot], the first word beside them and the first figure (or + * the Start button) on the right that fit [width]. The button stays before the word, the word before the figure. + */ + data class NumberRow(val dot: Float, val word: Line?, val figure: Line?, val play: Boolean, val height: Float) + + fun numberRow(m: MomentWidgetModel, dot: Float, width: Float, fontScale: Float): NumberRow? { + val number = m.number ?: return null + val dots = WidgetDots.width(number, dot) + if (dots > width) return null + val play = m.start != null && dots + FIGURE_GAP_DP + PLAY_DP <= width + val right = if (play) FIGURE_GAP_DP + PLAY_DP else 0f + val word = line(m.shortWords, WORD_SIZES, width - dots - WORD_GAP_DP - right, fontScale) + val used = dots + (word?.let { WORD_GAP_DP + it.width(fontScale) } ?: 0f) + val figure = if (play) null else line(m.figures, FIGURE_SIZES, width - used - FIGURE_GAP_DP, fontScale) + val h = maxOf(WidgetDots.height(dot), word?.height(fontScale) ?: 0f, figure?.height(fontScale) ?: 0f, if (play) PLAY_DP else 0f) + return NumberRow(dot, word, figure, play, h) + } + + /** + * A size laid out: the [name], the number row or the Small's number and [word], the [sentence], the [chart]'s + * height (0 for none), the [waiting] line, or the recording's parts; [content] is the height of it all without + * gaps and [inner] the gaps between the blocks, which with the half gap above and below share the spare height. + */ + data class Fit( + val name: Line, + val dot: Float = 0f, + val word: Line? = null, + val row: NumberRow? = null, + val play: Boolean = false, + val sentence: Sentence? = null, + val chart: Float = 0f, + val waiting: Line? = null, + val tile: Float = 0f, + val sport: Line? = null, + val since: Line? = null, + val open: Boolean = false, + val content: Float, + val inner: Int, + /** The top row with the strap status button at its right (spec 5), null for the name alone. */ + val top: TopRow? = null, + ) { + /** The gap between blocks in a cell [height] dp high (inside the padding). */ + fun gap(height: Float): Float = if (inner + 1 <= 0) 0f else ((height - content) / (inner + 1)).coerceAtLeast(0f) + + /** The half gap above the first block and below the last. */ + fun edgeGap(height: Float): Float = gap(height) / 2 + + /** Whether it fits [height] with at least [MIN_GAP_DP] between blocks. */ + fun fits(height: Float): Boolean = content + (inner + 1) * (if (inner > 0) MIN_GAP_DP else 0f) <= height + 0.01f + } + + /** + * The first of [ladders] whose first (least grown) fit fits [height], grown along it to the first that brings the + * gap to [TARGET_GAP_DP] or under, else as far as it still fits. The last ladder's first when none fits. + */ + fun pick(ladders: List>, height: Float): Fit { + for (ladder in ladders.filter { it.isNotEmpty() }) { + if (!ladder.first().fits(height)) continue + val fitting = ladder.filter { it.fits(height) } + return fitting.firstOrNull { it.gap(height) <= TARGET_GAP_DP } ?: fitting.last() + } + return ladders.last { it.isNotEmpty() }.first() + } + + private fun nameLine(m: MomentWidgetModel, width: Float, fontScale: Float, beside: Float = 0f): Line = + line(listOf(m.name), NAME_SIZES, width - beside, fontScale) ?: Line(m.name, NAME_SIZES.last()) + + /** The top row's height: the name's line, or the status button's while it shows ([top]). */ + private fun topHeight(name: Line, top: TopRow?, fontScale: Float): Float = top?.height ?: name.height(fontScale) + + /** + * The top row with [status]'s button at its right (and on the Wide and Large, [withWords], its words before it), + * null without a status; the name then at the size the row gives it. + */ + private fun statusRow(m: MomentWidgetModel, status: StatusLine?, width: Float, fontScale: Float, withWords: Boolean): TopRow? = + status?.let { TopRow.of(m.name, null, it, width, fontScale, withWords, titleSizes = NAME_SIZES) } + + /** The name as the top row shows it: at [row]'s size beside the button, else the first of [NAME_SIZES] that fits. */ + private fun nameIn(m: MomentWidgetModel, row: TopRow?, width: Float, fontScale: Float): Line = + row?.let { Line(m.name, it.titleSp) } ?: nameLine(m, width, fontScale) + + /** Open's width at [fontScale]: its word in Body 500 with [OPEN_PAD_DP] each side. */ + fun openWidth(fontScale: Float): Float = WidgetText.fitWidth(OPEN, WidgetText.BODY_SP, fontScale) + 2 * OPEN_PAD_DP + + /* ---------------------------------------------- Small --------------------------------------------- */ + + /** + * The Small in [width] by [height] dp: the name, the number (with the Start button beside it in the workout + * window), its word under it. Three blocks, the dots growing from the mockup's 3.6 until no gap is over the + * target. Without a number, the name and the waiting line; during a workout, [recordingSmall]. + */ + fun small(m: MomentWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Fit { + if (m.recording != null) return recordingSmall(m, m.recording, width, height, fontScale) + val statusTop = statusRow(m, status, width, fontScale, withWords = false) + val name = nameIn(m, statusTop, width, fontScale) + val top = topHeight(name, statusTop, fontScale) + val number = m.number + if (number == null) { + val waiting = m.waiting?.let { line(listOf(it), FIGURE_SIZES, width, fontScale) } + val play = m.start != null + val content = top + (waiting?.height(fontScale) ?: 0f) + (if (play) PLAY_DP else 0f) + return Fit(name, waiting = waiting, play = play, content = content, inner = (if (waiting != null) 1 else 0) + (if (play) 1 else 0), top = statusTop) + } + fun at(dot: Float): Fit? { + val dots = WidgetDots.width(number, dot) + if (dots > width) return null + val play = m.start != null && dots + WORD_GAP_DP + PLAY_DP <= width + val word = line(m.words, WORD_SIZES, width, fontScale) + val row = maxOf(WidgetDots.height(dot), if (play) PLAY_DP else 0f) + val content = top + row + (word?.height(fontScale) ?: 0f) + return Fit(name, dot = dot, word = word, play = play, content = content, inner = if (word != null) 2 else 1, top = statusTop) + } + // Growing never costs the Small its word or its Start button. + val ladder = (listOf(SMALL_DOT) + SMALL_DOT_GROWTH).mapNotNull(::at).let { l -> + l.filter { it.word == l.firstOrNull()?.word && it.play == l.firstOrNull()?.play } + } + return pick(listOf(ladder, listOfNotNull(at(SMALL_MIN_DOT))), height) + } + + /** The Small during a workout: the name, the sport's tile, its name, "Recording since 17:42". */ + fun recordingSmall(m: MomentWidgetModel, r: MomentRecording, width: Float, height: Float, fontScale: Float): Fit { + val name = nameLine(m, width, fontScale) + val sport = line(listOf(r.sport.name, r.sport.shortName), WORD_SIZES, width, fontScale) + val sinceOptions = listOf(listOf(r.since, r.sinceShort), emptyList()) + val ladders = sinceOptions.flatMap { opts -> + val since = line(opts, FIGURE_SIZES, width - REC_DOT_DP - REC_DOT_GAP_DP, fontScale) + fun at(tile: Float): Fit { + val parts = listOfNotNull(name.height(fontScale), tile.takeIf { it > 0f }, sport?.height(fontScale), since?.height(fontScale)) + return Fit(name, tile = tile, sport = sport, since = since, content = parts.sum(), inner = parts.size - 1) + } + listOf((listOf(TILE_DP) + TILE_GROWTH.take(1)).map(::at), listOf(at(0f))) + } + return pick(ladders, height) + } + + /* ---------------------------------------------- Wide ---------------------------------------------- */ + + /** + * The Wide in [width] by [height] dp: the name, the hero sentence (two lines at most), the number row. The + * sentence at 20 sp, then 18, then a shorter wording, then left out; grown to 22 and 24 sp and the dots to 6 dp + * on a tall cell. + */ + fun wide(m: MomentWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Fit = + if (m.recording != null) recordingWide(m, m.recording, width, height, fontScale, large = false) + else pick(ladders(m, width, fontScale, large = false, status), height) + + /** The Large: the Wide plus the moment's chart, with its times under it. */ + fun large(m: MomentWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Fit = + if (m.recording != null) recordingWide(m, m.recording, width, height, fontScale, large = true) + else pick(ladders(m, width, fontScale, large = true, status), height) + + /** A chart's block: the chart [h] dp high and its times under it. */ + fun chartBlock(h: Float, fontScale: Float): Float = if (h <= 0f) 0f else h + CHART_LABEL_GAP_DP + WidgetText.line(WidgetText.CAPTION_SP, fontScale) + + private fun ladders(m: MomentWidgetModel, width: Float, fontScale: Float, large: Boolean, status: StatusLine?): List> { + val statusTop = statusRow(m, status, width, fontScale, withWords = true) + val name = nameIn(m, statusTop, width, fontScale) + val top = topHeight(name, statusTop, fontScale) + val stages = m.chart?.kind == MomentChartKind.Stages + val chartBase = when { + !large || m.chart == null -> emptyList() + stages -> listOf(STRIP_DP, STRIP_MIN_DP) + else -> listOf(CHART_DP, CHART_MIN_DP) + } + val chartGrowth = if (stages) STRIP_GROWTH else CHART_GROWTH + fun fit(sentence: Sentence?, dot: Float, chart: Float): Fit? { + val row = if (m.number != null) numberRow(m, dot, width, fontScale) ?: return null else null + val waiting = if (m.number == null && sentence == null) m.waiting?.let { line(listOf(it), FIGURE_SIZES, width, fontScale) } else null + val blocks = listOfNotNull( + top, sentence?.height(fontScale), row?.height, waiting?.height(fontScale), + chartBlock(chart, fontScale).takeIf { it > 0f }, + ) + return Fit(name, dot = dot, row = row, sentence = sentence, chart = chart, waiting = waiting, content = blocks.sum(), inner = blocks.size - 1, top = statusTop) + } + /** + * The fit with [text] at [sp], the dots at [dot] and the chart [chart] high, then the fits it grows to on a + * tall cell: step by step a taller chart and (from 20 sp) a larger sentence, kept to two lines, then larger + * dots. Shrunk dots or the chart's own step down don't grow: they're already short of room. + */ + fun ladder(text: String?, sp: Float, chart: Float, dot: Float): List { + val s = text?.let { sentence(it, sp, width, fontScale) ?: return emptyList() } + val base = fit(s, dot, chart) ?: return emptyList() + if (dot != DOT || (chart > 0f && chart != chartBase.first())) return listOf(base) + val chartSeq = if (chart > 0f) listOf(chart) + chartGrowth else listOf(0f) + val spSeq = if (text != null && sp == SENTENCE_SIZES.first()) listOf(sp) + SENTENCE_GROWTH else listOf(sp) + val grown = mutableListOf(base) + var shown = s + for (i in 1 until maxOf(chartSeq.size, spSeq.size)) { + shown = text?.let { sentence(it, spSeq[minOf(i, spSeq.lastIndex)], width, fontScale) } ?: shown + fit(shown, DOT, chartSeq[minOf(i, chartSeq.lastIndex)])?.let { grown += it } + } + val last = grown.last() + for (d in DOT_GROWTH) fit(last.sentence, d, last.chart)?.let { grown += it } + // Growing never costs the number row its word, figure or Start: those stay as the base has them. + return grown.filter { it.row?.copy(dot = 0f, height = 0f) == base.row?.copy(dot = 0f, height = 0f) }.distinct() + } + val charts = if (chartBase.isEmpty()) listOf(0f) else chartBase + val options = m.sentences + // The sentence's size and the dots step down before the sentence gives way to a shorter wording. + val steps = listOf(SENTENCE_SIZES[0] to DOT, SENTENCE_SIZES[1] to DOT, SENTENCE_SIZES[0] to SMALL_DOT, SENTENCE_SIZES[1] to SMALL_DOT) + val out = mutableListOf>() + // The whole sentence before a shorter one, the chart (stepping down) before a smaller size; then without + // the chart; then without the sentence. + for (text in options) for ((sp, dot) in steps) for (ch in charts) out += ladder(text, sp, ch, dot) + if (chartBase.isNotEmpty()) for (text in options) for ((sp, dot) in steps) out += ladder(text, sp, 0f, dot) + for (dot in listOf(DOT, SMALL_DOT)) { + for (ch in charts) out += ladder(null, SENTENCE_SIZES.first(), ch, dot) + out += ladder(null, SENTENCE_SIZES.first(), 0f, dot) + } + return out + } + + /** + * The Wide or Large during a workout: the name with Open on the right, the sport's tile, its name in the hero's + * light type, "Recording since 17:42". Nothing live. The tile and the name grow on a tall cell; the line shortens, + * then the tile goes, then the line. + */ + fun recordingWide(m: MomentWidgetModel, r: MomentRecording, width: Float, height: Float, fontScale: Float, large: Boolean): Fit { + val open = openWidth(fontScale) + val name = nameLine(m, width, fontScale, beside = open + FIGURE_GAP_DP) + val top = maxOf(name.height(fontScale), PLAY_DP) + val ladders = mutableListOf>() + for (opts in listOf(listOf(r.since), listOf(r.sinceShort), emptyList())) { + val since = line(opts, FIGURE_SIZES, width - REC_DOT_DP - REC_DOT_GAP_DP, fontScale) + if (opts.isNotEmpty() && since == null) continue + fun at(tile: Float, sp: Float): Fit? { + val sport = line(listOf(r.sport.name, r.sport.shortName), listOf(sp), width, fontScale) + ?: line(listOf(r.sport.name, r.sport.shortName), SPORT_SIZES, width, fontScale) ?: return null + val parts = listOfNotNull(top, tile.takeIf { it > 0f }, sport.height(fontScale), since?.height(fontScale)) + return Fit(name, tile = tile, sport = sport, since = since, open = true, content = parts.sum(), inner = parts.size - 1) + } + val growth = (listOf(TILE_DP to SPORT_SIZES.first()) + TILE_GROWTH.mapIndexed { i, tl -> tl to (SPORT_GROWTH.getOrNull(i - 1) ?: SPORT_SIZES.first()) }) + .let { if (large) it else it.take(2) } + ladders += growth.mapNotNull { (tl, sp) -> at(tl, sp) }.distinct() + ladders += listOfNotNull(at(0f, SPORT_SIZES.first())) + } + return pick(ladders, height) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/RecoveryWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/RecoveryWidget.kt new file mode 100644 index 00000000..8687b968 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/RecoveryWidget.kt @@ -0,0 +1,217 @@ +package org.openstrap.app.widget + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.width +import androidx.glance.text.Text +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import org.openstrap.app.OpenStrapApp +import org.openstrap.app.data.DayWindow +import org.openstrap.app.ui.glow.EdgeKind +import org.openstrap.app.ui.glow.EdgeStatus +import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.ui.glow.draw.WeekBarsLook +import org.openstrap.app.ui.glow.draw.drawWeekBars +import org.openstrap.app.ui.glow.draw.renderToBitmap +import org.openstrap.app.ui.glow.draw.weekBars +import org.openstrap.app.ui.glow.edgeLight +import org.openstrap.app.ui.theme.MetricColors +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * The Recovery widget (widgets spec 3.4): this morning's score and word in the same spot every day, with what's + * behind it on the Wide and the sentence and the last seven days on the Large. An edge card lit from the left, about + * earlier, in Recovery's ink and the moment's mood, as Today's Recovery card after the morning. The whole widget opens + * the Recovery screen. + */ +class RecoveryWidget : LoadingWidget(WidgetKind.Recovery) { + override suspend fun loadShown(context: Context): Shown = load(context) + + override fun sample(): Shown = Shown(WidgetSamples.recovery(LocalDate.now()), GlowMood.Day) + + @Composable + override fun Content(data: Shown) = RecoveryContent(data) + + /** What the widget shows and the light it's lit in. */ + data class Shown(val model: RecoveryWidgetModel, val mood: GlowMood) + + companion object { + /** + * Today's Recovery, worked out as the app does ([org.openstrap.app.data.HealthRepository.recovery]), and + * yesterday's while this morning's isn't in; the mood from the moment now ([widgetMood]). + */ + internal suspend fun load(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): Shown { + val container = (context.applicationContext as OpenStrapApp).container + val profile = container.settings.profile.first() + val dao = container.database.healthDao() + suspend fun day(date: LocalDate) = try { + val r = container.health.recovery(date, profile) + val dayStart = DayWindow.of(date, zone).from + r to RecoveryDay.of(r, dao.strapReadinessOnce(dayStart, dayStart).firstOrNull()?.score) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(WIDGET_TAG, "widgets: Recovery for $date not worked out: $e") + null + } + val today = now.atZone(zone).toLocalDate() + val result = day(today) + val yesterday = if (result?.second?.shown?.value == null) day(today.minusDays(1))?.second else null + val model = RecoveryWidgetModel.from(result?.second, yesterday) + return Shown(model, widgetMood(container, result?.first?.usualBedtime, now, zone)) + } + } +} + +/** The Recovery widget's receiver. The first one placed stops Moment leading with Recovery (spec 3.1). */ +class RecoveryWidgetReceiver : PlacedWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = RecoveryWidget() +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Layout */ +/* ------------------------------------------------------------------------------------------------ */ + +/** Recovery's ink. */ +private val Ink = MetricColors.readiness + +/** + * The widget at the size Glance lays out: the edge light behind, drawn at the cell's real size, then the size's + * content. Any size the launcher gives lands on one of the three ([WidgetSizes.bucket]). + */ +@Composable +private fun RecoveryContent(data: RecoveryWidget.Shown) { + val model = data.model + val open = openMetric(LocalContext.current, RECOVERY_ROUTE, model.date) + EdgeWidget(edgeLight(EdgeStatus.Usual, EdgeKind.Earlier), Ink, data.mood, open, BITMAP_EXTRA) { frame -> + when { + model.score == null -> WaitingContent(model.title, model.waiting, frame) + frame.bucket == WidgetSizes.Small -> SmallContent(model, RecoveryLayout.small(model, frame.contentWidth, frame.contentHeight, frame.fontScale, frame.status), frame) + frame.bucket == WidgetSizes.Wide -> WideContent(model, RecoveryLayout.wide(model, frame.contentWidth, frame.contentHeight, frame.fontScale, frame.status), frame) + else -> LargeContent(model, RecoveryLayout.large(model, frame.contentWidth, frame.contentHeight, frame.fontScale, frame.status), frame) + } + } +} + +/** A gap between blocks: they share the spare height alike, with half a gap above and below ([Edge]). */ +@Composable +private fun ColumnScope.Gap() = Spacer(GlanceModifier.defaultWeight()) + +/** The half gap above the first block or below the last, at the cell's real height. */ +@Composable +private fun Edge(fit: RecoveryLayout.Fit, f: WidgetFrame) = Spacer(GlanceModifier.height(fit.spread.edge(f.drawContentHeight).dp)) + +/** Small: the top row, the score, its word, the dots grown to fill a tall cell. */ +@Composable +private fun ColumnScope.SmallContent(m: RecoveryWidgetModel, fit: RecoveryLayout.Fit, f: WidgetFrame) { + Edge(fit, f) + TopRowView(m.title, null, fit.top, f, fit.spread.edge(f.drawContentHeight)) + Gap() + m.score?.let { DotNumber(it, fit.dot, f.density, m.scoreDescription) } + if (fit.word) m.word?.let { + Gap() + Text(it, style = WidgetType.word.copy(fontSize = fit.wordSp.sp), maxLines = 1) + } + Edge(fit, f) +} + +/** Wide: the top row with "This morning", the score and word, the three figures. */ +@Composable +private fun ColumnScope.WideContent(m: RecoveryWidgetModel, fit: RecoveryLayout.Fit, f: WidgetFrame) { + Edge(fit, f) + TopRowView(m.title, m.note, fit.top, f, fit.spread.edge(f.drawContentHeight)) + Gap() + ScoreRow(m, fit.dot, f.density) + if (fit.figures) { + Gap() + Figures(m.figures) + } + Edge(fit, f) +} + +/** Large: the Wide plus the sentence and the last seven days beside the score. */ +@Composable +private fun ColumnScope.LargeContent(m: RecoveryWidgetModel, fit: RecoveryLayout.Fit, f: WidgetFrame) { + Edge(fit, f) + TopRowView(m.title, m.note, fit.top, f, fit.spread.edge(f.drawContentHeight)) + if (fit.sentenceLines > 0) m.sentence?.let { + Gap() + Text(it, style = WidgetType.hero.copy(fontSize = fit.sentenceSp.sp), maxLines = fit.sentenceLines) + } + Gap() + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { + ScoreRow(m, fit.dot, f.density) + if (fit.week) { + Spacer(GlanceModifier.defaultWeight()) + Week(m.week, fit.weekColumn, f.density) + } + } + if (fit.figures) { + Gap() + Figures(m.figures) + } + Edge(fit, f) +} + +/** The score in dots [dot] dp across with its word beside it, read out as "78, Ready". */ +@Composable +private fun ScoreRow(m: RecoveryWidgetModel, dot: Float, density: Float) { + Row(verticalAlignment = Alignment.Bottom) { + m.score?.let { DotNumber(it, dot, density, m.scoreDescription) } + m.word?.let { + Spacer(GlanceModifier.width(RecoveryLayout.WORD_GAP_DP.dp)) + Text(it, style = WidgetType.word, maxLines = 1) + } + } +} + +/** Resting HR, HRV and Sleep in a row, each a Caption over a Body value. */ +@Composable +private fun Figures(figures: List) { + Row(GlanceModifier.fillMaxWidth()) { + figures.forEachIndexed { i, f -> + if (i > 0) Spacer(GlanceModifier.width(12.dp)) + Column(GlanceModifier.defaultWeight()) { + Text(f.label, style = WidgetType.caption, maxLines = 1) + Spacer(GlanceModifier.height(2.dp)) + Text(f.value, style = WidgetType.body, maxLines = 1) + } + } + } +} + +/** The last seven days as solid bars, the day shown in Recovery's ink, each day's letter under its bar. */ +@Composable +private fun Week(week: List, column: Float, density: Float) { + val width = WeekBarsLook.width(week.size, column) + val bars = weekBars(RecoveryLayout.barValues(week), density, column) + val bitmap = renderToBitmap(width * density, WeekBarsLook.HEIGHT_DP * density) { drawWeekBars(it, bars, week.indexOfFirst { d -> d.shown }, Ink) } + WeekChart(ImageProvider(bitmap), "Recovery each day for the last seven days", week.map { it.letter to it.shown }, column) +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Bitmaps */ +/* ------------------------------------------------------------------------------------------------ */ + +/** The route `open_metric` takes for the Recovery screen ([org.openstrap.app.ui.dashboard.Detail.fromRoute]). */ +internal const val RECOVERY_ROUTE = "Recovery" + +/** Room for the dots and the week beside the backgrounds, in the bitmap budget. */ +private const val BITMAP_EXTRA = 1.25 diff --git a/app/src/main/kotlin/org/openstrap/app/widget/RecoveryWidgetModel.kt b/app/src/main/kotlin/org/openstrap/app/widget/RecoveryWidgetModel.kt new file mode 100644 index 00000000..b9cf4663 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/RecoveryWidgetModel.kt @@ -0,0 +1,270 @@ +package org.openstrap.app.widget + +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.RecoveryResult +import org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT +import org.openstrap.app.ui.dashboard.Metric +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import java.time.LocalDate +import java.time.format.TextStyle +import java.util.Locale + +/** + * One day's Recovery as the widget reads it, taken from the app's own result and copy, so the widget says what the + * Recovery tab and Today's Recovery card say ([of]). + */ +data class RecoveryDay( + val date: LocalDate, + /** As Today shows it everywhere: OpenStrap's score, the strap's estimate, or why there's none. */ + val shown: TodayCopy.RecoveryShown, + /** What the score means for today, as Today's Recovery card says it; null without one. */ + val sentence: String?, + /** Before the strap has seen a night. */ + val firstNights: Boolean, + /** The night Recovery scored: its resting heart rate (bpm), HRV (ms) and sleep score. */ + val restingHr: Float?, + val hrv: Float?, + val sleep: Int?, + /** The 7 days ending on [date], oldest first, null where there was no score. */ + val week: List>, +) { + companion object { + /** + * [r] as the Recovery tab and Today show it, with the strap's own [strapScore] for the day standing in while + * OpenStrap's is withheld. The figures are the night's own values the score was worked out from, the strap's + * where those failed the validity rule, and the Sleep contributor (the strap's sleep score, else OpenStrap's). + */ + fun of(r: RecoveryResult, strapScore: Int?): RecoveryDay { + val ready = r.readiness + val score = ready.score + val night = r.tonight + return RecoveryDay( + date = r.date, + shown = TodayCopy.recoveryShown(ready, strapScore, TabCopy.RECOVERY_WAITING), + sentence = score?.let { TodayCopy.recoveryMeaning(it, ready.strainAlert) }, + firstNights = !r.charge.anchored, + restingHr = night?.validRestingHr ?: ready.fallbackRestingHr, + hrv = night?.hrv?.takeIf { night.hrvLn != null } ?: ready.fallbackHrv, + sleep = ready.contributors.firstOrNull { it.factor == Recovery.Factor.Sleep }?.score ?: night?.strapScore, + week = r.readinessTrend, + ) + } + } +} + +/** A figure under the score: a Caption over a Body value. */ +data class RecoveryFigure(val label: String, val value: String) + +/** A day's bar in the Large's week: its letter, its score (null for none) and whether it's the day shown. */ +data class RecoveryWeekDay(val letter: String, val score: Int?, val shown: Boolean) + +/** + * What the Recovery widget shows (widgets spec 3.4), the same model at every size: the Small shows the title, score + * and word, the Wide adds the note and [figures], the Large adds [sentence] and [week]. Without a score there's only + * the title and [waiting]. + */ +data class RecoveryWidgetModel( + /** The day the score is for, which a tap opens. */ + val date: LocalDate?, + val title: String, + /** "This morning", or "Yesterday" while this morning's score isn't in; null while waiting. */ + val note: String?, + /** The number in dots, and the word beside it. */ + val score: String?, + val word: String?, + /** In place of the score, in Secondary: "After your first night" and the like. */ + val waiting: String?, + /** The Large's sentence. */ + val sentence: String?, + val figures: List, + /** Seven days, the shown one last; empty when fewer than two have a score, as the Recovery tab's trend. */ + val week: List, +) { + /** What TalkBack reads for the dotted number: "78, Ready". */ + val scoreDescription: String? get() = score?.let { s -> word?.let { "$s, $it" } ?: s } + + companion object { + const val TITLE = "Recovery" + const val THIS_MORNING = "This morning" + const val YESTERDAY = "Yesterday" + + /** A figure the night didn't give. */ + const val NO_READING = "No reading" + + /** + * The widget for [today], or [yesterday] with "Yesterday" as the note while this morning's score isn't in. + * Before any: the title and why, "After your first night" before the strap has seen one. Null days are ones + * not worked out (or that failed), which wait like a day with no night yet. + */ + fun from(today: RecoveryDay?, yesterday: RecoveryDay?, locale: Locale = Locale.getDefault()): RecoveryWidgetModel { + today?.takeIf { it.shown.value != null }?.let { return scored(it, THIS_MORNING, locale) } + yesterday?.takeIf { it.shown.value != null }?.let { return scored(it, YESTERDAY, locale) } + val line = when { + today == null -> TabCopy.RECOVERY_WAITING + today.firstNights -> AFTER_FIRST_NIGHT + else -> today.shown.line + } + return RecoveryWidgetModel(null, TITLE, null, null, null, line, null, emptyList(), emptyList()) + } + + private fun scored(d: RecoveryDay, note: String, locale: Locale): RecoveryWidgetModel { + val week = d.week.takeIf { w -> w.count { it.second != null } >= 2 }.orEmpty().mapIndexed { i, (day, score) -> + RecoveryWeekDay(day.dayOfWeek.getDisplayName(TextStyle.NARROW, locale), score, i == d.week.lastIndex) + } + return RecoveryWidgetModel( + date = d.date, + title = TITLE, + note = note, + score = d.shown.value.toString(), + word = d.shown.word, + waiting = null, + // The strap's estimate has no sentence of its own: its line says why OpenStrap's score isn't ready. + sentence = d.sentence ?: d.shown.line, + figures = figures(d), + week = week, + ) + } + + /** Resting HR "52 bpm", HRV "64 ms", Sleep "82". */ + fun figures(d: RecoveryDay): List = listOf( + RecoveryFigure("Resting HR", d.restingHr?.let { "${Metric.RestingHr.format(it)} ${Metric.RestingHr.unit}" } ?: NO_READING), + RecoveryFigure("HRV", d.hrv?.let { "${Metric.Hrv.format(it)} ${Metric.Hrv.unit}" } ?: NO_READING), + RecoveryFigure("Sleep", d.sleep?.toString() ?: NO_READING), + ) + } +} + +/** The sums behind the Large's score row: how wide the week's columns can be beside the score and its word. */ +object RecoveryLayout { + /** Between the dotted score and its word, and between the word and the week, dp. */ + const val WORD_GAP_DP = 8f + const val WEEK_GAP_DP = 12f + + /** The word's size, Body. */ + const val WORD_SP = 15f + + /** A rough width for [text] at [sp] in the system sans, dp ([WidgetText.width]). */ + fun textWidth(text: String?, sp: Float, fontScale: Float = 1f): Float = WidgetText.width(text, sp, fontScale) + + /** + * The week's column width beside a score [dots] dp wide and [word], in [content] dp of width: as wide as + * [WeekBarsLook] allows, narrower when a long word or a large font needs the room. + */ + fun weekColumn(content: Float, dots: Float, word: String?, fontScale: Float = 1f, days: Int = 7): Float = + org.openstrap.app.ui.glow.draw.WeekBarsLook.column(days, content - dots - WORD_GAP_DP - textWidth(word, WORD_SP, fontScale) - WEEK_GAP_DP) + + /** Each day's bar as a share of a full 100, null where the day has no score. */ + fun barValues(week: List): List = week.map { d -> d.score?.let { it.coerceIn(0, 100) / 100f } } + + /* ------------------------------------------- Fitting ------------------------------------------- */ + + /** The least gap between blocks. */ + const val MIN_GAP_DP = 4f + + /** The Small's dots: the mockup's 3 dp (spec 3), growing to 7 on a tall cell before the gaps do. */ + val SMALL_DOTS = listOf(3f, 3.6f, 4.5f, 5f, 5.5f, 6f, 6.5f, 7f) + + /** The Wide's and Large's dots: 3.6, the Small's 3 when short of room, up to 6 on a tall cell. */ + const val WIDE_DOT = 3.6f + const val SHORT_DOT = 3f + val WIDE_GROWTH = listOf(4.5f, 5f, 5.5f, 6f) + + /** The Large's sentence: Display 300 20 on at most three lines, growing to 22 and 24 sp on a tall cell. */ + const val SENTENCE_LINES = 3 + val SENTENCE_SIZES = listOf(WidgetText.HERO_SP, 22f, 24f) + + /** The figures' block: a Caption over a Body value. */ + fun figuresHeight(fontScale: Float): Float = WidgetText.line(WidgetText.CAPTION_SP, fontScale) + 2f + WidgetText.line(WidgetText.BODY_SP, fontScale) + + /** The week beside the score: its bars and the letters under them. */ + fun weekHeight(fontScale: Float): Float = org.openstrap.app.ui.glow.draw.WeekBarsLook.HEIGHT_DP + 4f + WidgetText.line(WidgetText.CAPTION_SP, fontScale) + + /** + * A size laid out: the [top] row, the dots at [dot], whether the [word] shows, the Large's sentence at + * [sentenceSp] on [sentenceLines] lines (0 for none), whether the [figures] and the [week] show, and how the + * blocks share the height ([spread]). + */ + data class Fit( + val top: TopRow, val dot: Float, val word: Boolean, val spread: Spread, + val sentenceLines: Int = 0, val sentenceSp: Float = WidgetText.HERO_SP, val figures: Boolean = false, + val week: Boolean = false, val weekColumn: Float = 0f, val wordSp: Float = WORD_SP, + ) + + /** + * The Small in [width] by [height] dp: the top row, the score, its word, three blocks sharing the height, the dots + * growing from 3 dp until no gap is over [Spread.TARGET_GAP_DP]. Narrow, the dots shrink to the width; short, the + * word goes. + */ + fun small(m: RecoveryWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Fit { + val top = TopRow.of(m.title, null, status, width, fontScale, withWords = false) + val score = m.score.orEmpty() + // Body, then Secondary's size before the word goes. + val wordSp = m.word?.let { w -> listOf(WORD_SP, WidgetText.SECONDARY_SP).firstOrNull { WidgetText.fits(w, it, width, fontScale) } } + fun at(dot: Float, withWord: Boolean): Fit { + val d = WidgetDots.fit(score, width, dot) + val w = withWord && wordSp != null + return Fit(top, d, w, Spread.of(top.height, WidgetDots.height(d), if (w) WidgetText.line(wordSp, fontScale) else null), wordSp = wordSp ?: WORD_SP) + } + val ladder = SMALL_DOTS.map { at(it, true) }.distinct() + if (ladder.first().spread.fits(height, MIN_GAP_DP)) return Spread.grow(ladder, height, MIN_GAP_DP) { it.spread } + return at(SMALL_DOTS.first(), false) + } + + /** The score row's width at [dot]: the dots and the word beside them. */ + private fun rowWidth(m: RecoveryWidgetModel, dot: Float, fontScale: Float): Float = + WidgetDots.width(m.score.orEmpty(), dot) + (m.word?.let { WORD_GAP_DP + textWidth(it, WORD_SP, fontScale) } ?: 0f) + + /** + * The Wide in [width] by [height] dp: the top row, the score with its word, the three figures. The dots at 3.6, + * growing to 6 on a tall cell, down to 3 when short; the figures go last. + */ + fun wide(m: RecoveryWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Fit { + val top = TopRow.of(m.title, m.note, status, width, fontScale, withWords = true) + val score = m.score.orEmpty() + val wordW = m.word?.let { WORD_GAP_DP + textWidth(it, WORD_SP, fontScale) } ?: 0f + fun at(dot: Float, figures: Boolean): Fit { + val d = WidgetDots.fit(score, width - wordW, dot) + val row = maxOf(WidgetDots.height(d), WidgetText.line(WORD_SP, fontScale)) + return Fit(top, d, m.word != null, Spread.of(top.height, row, if (figures) figuresHeight(fontScale) else null), figures = figures) + } + val ladder = (listOf(WIDE_DOT) + WIDE_GROWTH).map { at(it, true) }.distinct() + if (ladder.first().spread.fits(height, MIN_GAP_DP)) return Spread.grow(ladder, height, MIN_GAP_DP) { it.spread } + return listOf(at(SHORT_DOT, true), at(SHORT_DOT, false)).firstOrNull { it.spread.fits(height, MIN_GAP_DP) } ?: at(SHORT_DOT, false) + } + + /** + * The Large in [width] by [height] dp: the Wide plus the sentence under the top row (three lines at most) and the + * last seven days beside the score while they fit beside it. On a tall cell the dots grow, then the sentence; + * short, the sentence goes first, then the dots go to 3, then the figures. + */ + fun large(m: RecoveryWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Fit { + val top = TopRow.of(m.title, m.note, status, width, fontScale, withWords = true) + val score = m.score.orEmpty() + val wordW = m.word?.let { WORD_GAP_DP + textWidth(it, WORD_SP, fontScale) } ?: 0f + fun at(dot: Float, sp: Float, sentence: Boolean, figures: Boolean): Fit? { + val d = WidgetDots.fit(score, width - wordW, dot) + val lines = if (sentence) m.sentence?.let { WidgetText.wrappedLines(it, sp, width, fontScale) }?.takeIf { it <= SENTENCE_LINES } ?: return null else 0 + val column = weekColumn(width, WidgetDots.width(score, d), m.word, fontScale, m.week.size.coerceAtLeast(1)) + val week = m.week.isNotEmpty() && + rowWidth(m, d, fontScale) + WEEK_GAP_DP + org.openstrap.app.ui.glow.draw.WeekBarsLook.width(m.week.size, column) <= width + 0.01f + val row = maxOf(WidgetDots.height(d), WidgetText.line(WORD_SP, fontScale), if (week) weekHeight(fontScale) else 0f) + val spread = Spread.of(top.height, lines.takeIf { it > 0 }?.let { it * WidgetText.line(sp, fontScale) }, row, if (figures) figuresHeight(fontScale) else null) + return Fit(top, d, m.word != null, spread, lines, sp, figures, week, column) + } + val hasSentence = m.sentence != null + val base = at(WIDE_DOT, SENTENCE_SIZES.first(), hasSentence, true) + if (base != null && base.spread.fits(height, MIN_GAP_DP)) { + // Growing never costs the week its place beside the score. + val dots = WIDE_GROWTH.mapNotNull { at(it, SENTENCE_SIZES.first(), hasSentence, true) }.filter { it.week == base.week } + val last = dots.lastOrNull()?.dot ?: WIDE_DOT + val sentences = SENTENCE_SIZES.drop(1).mapNotNull { at(last, it, hasSentence, true) } + return Spread.grow((listOf(base) + dots + sentences).distinct(), height, MIN_GAP_DP) { it.spread } + } + val tries = listOfNotNull( + at(WIDE_DOT, SENTENCE_SIZES.first(), false, true), at(SHORT_DOT, SENTENCE_SIZES.first(), hasSentence, true), + at(SHORT_DOT, SENTENCE_SIZES.first(), false, true), at(SHORT_DOT, SENTENCE_SIZES.first(), false, false), + ) + return tries.firstOrNull { it.spread.fits(height, MIN_GAP_DP) } ?: tries.last() + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/SleepWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/SleepWidget.kt new file mode 100644 index 00000000..24715833 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/SleepWidget.kt @@ -0,0 +1,341 @@ +package org.openstrap.app.widget + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Paint +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.unit.ColorProvider +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import org.openstrap.app.OpenStrapApp +import org.openstrap.app.data.DayWindow +import org.openstrap.app.data.SleepHistory +import org.openstrap.app.ui.dashboard.Detail +import org.openstrap.app.ui.dashboard.SLEEP_NIGHTS +import org.openstrap.app.ui.glow.EdgeKind +import org.openstrap.app.ui.glow.EdgeStatus +import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.ui.glow.draw.drawStageBar +import org.openstrap.app.ui.glow.draw.renderToBitmap +import org.openstrap.app.ui.glow.edgeLight +import org.openstrap.app.ui.glow.stageColor +import org.openstrap.app.ui.theme.MetricColors + +/** + * The Sleep widget (widgets spec 3.2): last night's score, time asleep and stages, as the Sleep tab's hero tells them. + * The Wide adds bedtime to waking and the stage legend, the Large the tab's sentence, how often you woke and the + * time in each stage. An edge card lit from the top like a night sky, in Sleep's ink and always the night mood, + * as the Sleep tab. The whole widget opens the Sleep score's detail for that night. + */ +class SleepWidget : LoadingWidget(WidgetKind.Sleep) { + override suspend fun loadShown(context: Context): SleepWidgetModel = load(context) + + override fun sample(): SleepWidgetModel = ZoneId.systemDefault().let { WidgetSamples.sleep(LocalDate.now(it), it) } + + @Composable + override fun Content(data: SleepWidgetModel) = SleepContent(data) + + companion object { + /** + * This morning's night as the Sleep tab reads it ([SleepHistory.mainSleep] over the night ending today, its + * stages, the week's other nights from [SleepHistory.mainSleeps] over the tab's [SLEEP_NIGHTS]), and + * yesterday's while this morning's isn't synced. "After your first night" until the strap has stored a sleep. + */ + internal suspend fun load(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): SleepWidgetModel { + val container = (context.applicationContext as OpenStrapApp).container + val dao = container.database.healthDao() + val today = now.atZone(zone).toLocalDate() + return try { + val span = DayWindow(DayWindow.nightEndingOn(today.minusDays(SLEEP_NIGHTS - 1L), zone).from, DayWindow.nightEndingOn(today, zone).to) + val sessions = dao.sleepSessionsOnce(span.from, span.to) + val nights = SleepHistory.mainSleeps(sessions, zone) + suspend fun night(date: LocalDate): SleepWidgetNight? = SleepHistory.mainSleep(sessions, DayWindow.nightEndingOn(date, zone))?.let { main -> + SleepWidgetNight.of(date, main, dao.sleepStagesOnce(main.start), nights) + } + val tonight = night(today) + val yesterday = if (tonight == null) night(today.minusDays(1)) else null + val first = tonight == null && yesterday == null && nights.isEmpty() && dao.latestSleepEnd().first() == null + val day = DayWindow.of(today, zone) + val naps = sessions.any { it.nap && it.start in day.from..day.to } + SleepWidgetModel.from(tonight, yesterday, first, naps, zone) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(WIDGET_TAG, "widgets: sleep for $today not read: $e") + SleepWidgetModel.from(null, null, firstNight = false, napsToday = false, zone = zone) + } + } + } +} + +/** The Sleep widget's receiver. The first one placed stops Moment leading with last night (spec 3.1). */ +class SleepWidgetReceiver : PlacedWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = SleepWidget() +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Layout */ +/* ------------------------------------------------------------------------------------------------ */ + +/** Sleep's ink, as the Sleep tab and Today's Last night card. */ +private val Ink = MetricColors.sleepDeep + +/** The legend's labels: Caption size and weight, at Secondary's 72% (the mockup's). */ +private val LegendStyle = WidgetType.caption.copy(color = ColorProvider(Color.White.copy(alpha = 0.72f))) + +/** + * The widget at the size Glance lays out, lit from the top in the night mood whatever the moment ([EdgeKind.Sleep], + * spec 2.2). A night opens its Sleep score; while waiting, the Sleep screen. + */ +@Composable +private fun SleepContent(m: SleepWidgetModel) { + val context = LocalContext.current + val open = if (m.date != null) openMetric(context, Detail.SLEEP_SCORE_ROUTE, m.date) else openMetric(context, SLEEP_ROUTE, null) + EdgeWidget(edgeLight(EdgeStatus.Usual, EdgeKind.Sleep), Ink, GlowMood.Night, open, BITMAP_EXTRA) { frame -> + when { + m.waiting != null -> WaitingContent(m.title, m.waiting, frame) + frame.bucket == WidgetSizes.Small -> SmallContent(m, frame) + frame.bucket == WidgetSizes.Wide -> WideContent(m, frame) + else -> LargeContent(m, frame) + } + } +} + +/** A gap between blocks: they share the spare height alike, with half a gap above and below. */ +@Composable +private fun ColumnScope.Gap() = Spacer(GlanceModifier.defaultWeight()) + +/** The half gap above the first block or below the last, at the cell's real height. */ +@Composable +private fun Edge(spread: Spread, f: WidgetFrame) = Spacer(GlanceModifier.height(spread.edge(f.drawContentHeight).dp)) + +/** Small: the top row, the score and its word, then time asleep and the stage bar. */ +@Composable +private fun ColumnScope.SmallContent(m: SleepWidgetModel, f: WidgetFrame) { + val fit = SleepLayout.small(m, f.contentWidth, f.contentHeight, f.fontScale, f.status) + Edge(fit.spread, f) + TopRowView(m.title, null, fit.top, f, fit.spread.edge(f.drawContentHeight)) + Gap() + ScoreRow(m, fit.row, SleepLayout.SMALL_WORD_GAP_DP, f.density) + if (fit.lines.isNotEmpty() || fit.bar) { + Gap() + // One child of the column, its rows inside: Glance drops a column's children past ten. + Column(GlanceModifier.fillMaxWidth()) { + fit.lines.forEachIndexed { i, line -> + if (i > 0) Spacer(GlanceModifier.height(SleepLayout.SMALL_GAP_DP.dp)) + Text(line.text, style = WidgetType.secondary, maxLines = line.lines) + } + if (fit.bar) { + if (fit.lines.isNotEmpty()) Spacer(GlanceModifier.height(SleepLayout.SMALL_GAP_DP.dp)) + StageBar(m, SleepLayout.SMALL_BAR_DP, f) + } + } + } + Edge(fit.spread, f) +} + +/** Wide: the top row with bedtime to waking, the score with time asleep, the stage bar with its legend. */ +@Composable +private fun ColumnScope.WideContent(m: SleepWidgetModel, f: WidgetFrame) { + val fit = SleepLayout.wide(m, f.contentWidth, f.contentHeight, f.fontScale, f.status) + Edge(fit.spread, f) + TopRowView(m.title, m.note, fit.top, f, fit.spread.edge(f.drawContentHeight)) + Gap() + ScoreRow(m, fit.row, SleepLayout.WORD_GAP_DP, f.density) + if (fit.bar) { + Gap() + Stages(m, fit.legendRows, f) + } + Edge(fit.spread, f) +} + +/** Large: the top row, the tab's sentence, the score with how often you woke, then the time in each stage. */ +@Composable +private fun ColumnScope.LargeContent(m: SleepWidgetModel, f: WidgetFrame) { + val fit = SleepLayout.large(m, f.contentWidth, f.contentHeight, f.fontScale, f.status) + val spread = SleepLayout.largeSpread(fit, m.stages.size, f.fontScale) + Edge(spread, f) + TopRowView(m.title, m.note, fit.top, f, spread.edge(f.drawContentHeight)) + if (fit.sentenceLines > 0 && m.sentence != null) { + Gap() + Text(m.sentence, style = WidgetType.hero, maxLines = fit.sentenceLines) + } + Gap() + ScoreRow(m, fit.row, SleepLayout.WORD_GAP_DP, f.density) + when { + fit.stages -> { Gap(); StageRows(m.stages, f) } + fit.bar -> { Gap(); StageBar(m, SleepLayout.BAR_DP, f) } + } + Edge(spread, f) +} + +/** + * The score in dots with its word beside it and [SleepLayout.ScoreRow.right] on the right; without a score, the + * words saying so in its place. + */ +@Composable +private fun ScoreRow(m: SleepWidgetModel, row: SleepLayout.ScoreRow, wordGap: Float, density: Float) { + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { + val score = m.score + if (row.dot > 0f && score != null) { + DotNumber(score, row.dot, density, m.scoreDescription) + row.word?.let { + Spacer(GlanceModifier.width(wordGap.dp)) + Text(it, style = WidgetType.word, maxLines = 1) + } + Spacer(GlanceModifier.defaultWeight()) + } else row.word?.let { + Text(it, style = WidgetType.word, maxLines = row.wordLines, modifier = GlanceModifier.defaultWeight()) + } + row.right?.let { + Spacer(GlanceModifier.width(SleepLayout.SIDE_GAP_DP.dp)) + Text(it, style = WidgetType.secondary, maxLines = 1) + } + } +} + +/** The stage bar, 8 dp, with its legend under it on [legendRows] rows (none at 0). */ +@Composable +private fun Stages(m: SleepWidgetModel, legendRows: Int, f: WidgetFrame) { + // One child of the widget's column: Glance drops a column's children past ten. + Column(GlanceModifier.fillMaxWidth()) { + StageBar(m, SleepLayout.BAR_DP, f, described = legendRows == 0) + if (legendRows > 0) { + Spacer(GlanceModifier.height(SleepLayout.BAR_GAP_DP.dp)) + Legend(m.legend, legendRows) + } + } +} + +/** + * The legend: one row of every stage spread across the width, or two rows of two, each item in half the width, so a + * label never wraps ([SleepLayout.legendRows] decides). + */ +@Composable +private fun Legend(items: List, rows: Int) { + if (rows == 1) { + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + items.forEachIndexed { i, item -> + if (i > 0) Spacer(GlanceModifier.defaultWeight()) + LegendItem(item) + } + } + return + } + Column(GlanceModifier.fillMaxWidth()) { + items.chunked(2).forEachIndexed { r, pair -> + if (r > 0) Spacer(GlanceModifier.height(SleepLayout.LEGEND_ROW_GAP_DP.dp)) + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + pair.forEach { item -> Box(GlanceModifier.defaultWeight()) { LegendItem(item) } } + if (pair.size == 1) Box(GlanceModifier.defaultWeight()) {} + } + } + } +} + +/** An 8 dp colour mark and the stage's total, "Deep 1 h 32". */ +@Composable +private fun LegendItem(item: SleepLegendItem) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + GlanceModifier.size(SleepLayout.MARK_DP.dp).cornerRadius(2.dp).background(ColorProvider(stageColor(item.stage))), + ) {} + Spacer(GlanceModifier.width(SleepLayout.MARK_GAP_DP.dp)) + Text(item.label, style = LegendStyle, maxLines = 1) + } +} + +/** The stage bar [heightDp] high across the content: the Sleep tab's [drawStageBar]. Read out when there's no legend. */ +@Composable +private fun StageBar(m: SleepWidgetModel, heightDp: Float, f: WidgetFrame, described: Boolean = true) { + val w = f.drawContentWidth * f.density + val bitmap = renderToBitmap(w, heightDp * f.density) { c -> drawStageBar(c, m.totals, 0f, 0f, w, f.density, heightDp) } + Image( + ImageProvider(bitmap), + contentDescription = if (described) m.legend.joinToString(", ") { it.label } else null, + modifier = GlanceModifier.fillMaxWidth().height(heightDp.dp), + contentScale = ContentScale.FillBounds, + ) +} + +/** + * The time in each stage, as the Sleep score detail lists it: the name, a bar for its share of the night in the stage's + * colour on a white 10% track, the time on the right. One child of the column, a row a stage. + */ +@Composable +private fun StageRows(rows: List, f: WidgetFrame) { + val track = f.drawContentWidth - SleepLayout.STAGE_NAME_DP - SleepLayout.STAGE_TIME_DP - 2 * SleepLayout.STAGE_GAP_DP + Column(GlanceModifier.fillMaxWidth()) { + rows.forEachIndexed { i, r -> + if (i > 0) Spacer(GlanceModifier.height(SleepLayout.STAGE_ROW_GAP_DP.dp)) + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(r.name, style = WidgetType.secondary, maxLines = 1, modifier = GlanceModifier.width(SleepLayout.STAGE_NAME_DP.dp)) + Spacer(GlanceModifier.width(SleepLayout.STAGE_GAP_DP.dp)) + Image( + ImageProvider(stageRowBar(r, track, f.density)), + contentDescription = "${r.name} ${r.time}", + modifier = GlanceModifier.defaultWeight().height(SleepLayout.STAGE_BAR_DP.dp), + contentScale = ContentScale.FillBounds, + ) + Spacer(GlanceModifier.width(SleepLayout.STAGE_GAP_DP.dp)) + Text( + r.time, + style = WidgetType.secondaryShown.copy(textAlign = TextAlign.End), + maxLines = 1, + modifier = GlanceModifier.width(SleepLayout.STAGE_TIME_DP.dp), + ) + } + } + } +} + +/** A stage row's bar [widthDp] wide: the white 10% track, the stage's share filled in its colour, both fully round. */ +private fun stageRowBar(r: SleepStageRow, widthDp: Float, density: Float): Bitmap { + val w = (widthDp * density).coerceAtLeast(1f) + val h = SleepLayout.STAGE_BAR_DP * density + return renderToBitmap(w, h) { c -> + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.color = android.graphics.Color.argb(26, 255, 255, 255) + c.drawRoundRect(0f, 0f, w, h, h / 2, h / 2, paint) + paint.color = stageColor(r.stage).toArgb() + val fill = (w * r.share).coerceIn(h, w) + c.drawRoundRect(0f, 0f, fill, h, h / 2, h / 2, paint) + } +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Bitmaps */ +/* ------------------------------------------------------------------------------------------------ */ + +/** The route `open_metric` takes for the Sleep screen, while there's no night to open the score of. */ +internal const val SLEEP_ROUTE = "Sleep" + +/** Room for the dots, the stage bar and the stage rows' bars beside the backgrounds, in the bitmap budget. */ +private const val BITMAP_EXTRA = 1.35 diff --git a/app/src/main/kotlin/org/openstrap/app/widget/SleepWidgetModel.kt b/app/src/main/kotlin/org/openstrap/app/widget/SleepWidgetModel.kt new file mode 100644 index 00000000..7d847ff6 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/SleepWidgetModel.kt @@ -0,0 +1,411 @@ +package org.openstrap.app.widget + +import org.openstrap.app.data.SleepHistory +import org.openstrap.app.data.Summaries +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.ui.components.StageSpan +import org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT +import org.openstrap.app.ui.dashboard.SLEEP_NAMES +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import org.openstrap.app.ui.glow.DotSize +import org.openstrap.app.ui.glow.draw.stageTotals +import java.time.LocalDate +import java.time.ZoneId + +/** + * One night as the Sleep tab's hero reads it ([org.openstrap.app.ui.dashboard.DayUiState]): the night's main sleep, its + * stages and their totals, and the week's other nights for the longest or shortest rule. + */ +data class SleepWidgetNight( + /** The day the night ended on, which a tap opens the Sleep score on. */ + val date: LocalDate, + val session: SleepSession, + val stages: List, + val breakdown: Summaries.SleepBreakdown?, + /** The week's other nights, seconds asleep ([SleepHistory.weekOthers]). */ + val others: List, +) { + /** As the hero has it: the stages' time asleep, else the whole session. */ + val asleepSeconds: Long get() = breakdown?.asleepSeconds ?: (session.end - session.start) + + companion object { + /** The night [main] ended on [date], with its [stages] and the week's [nights] ([SleepHistory.mainSleeps]). */ + fun of(date: LocalDate, main: SleepSession, stages: List, nights: Map): SleepWidgetNight = SleepWidgetNight( + date = date, + session = main, + stages = stages.map { StageSpan(it.start, it.end, it.stage) }, + breakdown = if (stages.isEmpty()) null else Summaries.sleepBreakdown(stages), + others = SleepHistory.weekOthers(nights, date), + ) + } +} + +/** A stage on the stage bar's legend: its colour mark's stage and "Deep 1 h 32". */ +data class SleepLegendItem(val stage: Int, val label: String) + +/** A row of the Large's stages: "Deep", its [share] of the night as a bar, "1 h 32". */ +data class SleepStageRow(val stage: Int, val name: String, val time: String, val share: Float) + +/** + * What the Sleep widget shows (widgets spec 3.2), the same model at every size: the Small shows the title, [score] with + * [word], [asleep] and the stage bar; the Wide adds [note], [scoreLine] in place of [word] and the [legend]; the Large + * adds [sentence], [woke] in place of [asleep] and the time in each stage ([stages]) in place of the bar and + * legend. Without a night there's only the title and [waiting]. + */ +data class SleepWidgetModel( + /** The night's day, which a tap opens the Sleep score's detail on; null while waiting. */ + val date: LocalDate?, + val title: String, + /** Bedtime to waking ("22:58–06:56"), or "Last synced night" while this morning's night isn't in. */ + val note: String?, + /** Yesterday's night, shown while this morning's isn't synced. */ + val stale: Boolean, + /** The strap's sleep score in dots; null when it gave none. */ + val score: String?, + /** The Small's word, "Good". */ + val word: String?, + /** The Wide's and Large's, as the Sleep tab says it: "Sleep score, good", or "No score from the strap". */ + val scoreLine: String?, + /** "7 h 40 asleep". */ + val asleep: String?, + /** "Woke 3 times"; null when the strap didn't say. */ + val woke: String?, + /** The Sleep tab's sentence: "7 h 40, your longest this week." */ + val sentence: String?, + /** The stage bar's stages and seconds ([stageTotals]), deepest first; empty without stages. */ + val totals: List>, + val legend: List, + /** The Large's time in each stage, a row each in [totals]' order. */ + val stages: List, + /** In place of the night, in Secondary: "After your first night" and the like. */ + val waiting: String?, +) { + /** What TalkBack reads for the dotted number: "Sleep score 82, good". */ + val scoreDescription: String? get() = score?.let { s -> word?.let { "Sleep score $s, ${it.lowercase()}" } ?: "Sleep score $s" } + + /** The Large's right of the score row: how often you woke, else time asleep. */ + val largeRight: String? get() = woke ?: asleep + + companion object { + const val TITLE = "Last night" + + /** The title while yesterday's night stands in: it isn't last night. */ + const val STALE_TITLE = "Sleep" + const val LAST_SYNCED = "Last synced night" + + /** + * The widget for [today]'s night, or [yesterday]'s with "Last synced night" as the note while this morning's + * isn't synced. Before any: the title and why, "After your first night" before the strap has seen one + * ([firstNight]), else what the Sleep tab says for a day with no night ([napsToday] as it reads them). + */ + fun from(today: SleepWidgetNight?, yesterday: SleepWidgetNight?, firstNight: Boolean, napsToday: Boolean, zone: ZoneId = ZoneId.systemDefault()): SleepWidgetModel { + today?.let { return night(it, stale = false, zone) } + yesterday?.let { return night(it, stale = true, zone) } + val line = if (firstNight) AFTER_FIRST_NIGHT else TabCopy.noNight(napsToday, isToday = true) + return SleepWidgetModel(null, TITLE, null, false, null, null, null, null, null, null, emptyList(), emptyList(), emptyList(), line) + } + + /** Each stage's name, time and share of the night's stage seconds, as the Sleep score detail lists them. */ + fun stageRows(totals: List>): List { + val all = totals.sumOf { it.second }.coerceAtLeast(1L) + return totals.map { (stage, seconds) -> SleepStageRow(stage, SLEEP_NAMES[stage].orEmpty(), TodayCopy.duration(seconds), seconds.toFloat() / all) } + } + + private fun night(n: SleepWidgetNight, stale: Boolean, zone: ZoneId): SleepWidgetModel { + val s = n.session + val score = s.score + // Stage totals as text, never dots (spec 2.3): a stage under a minute leaves its segment and legend out. + val totals = n.breakdown?.let { stageTotals(it.byStage) }.orEmpty() + return SleepWidgetModel( + date = n.date, + title = if (stale) STALE_TITLE else TITLE, + note = if (stale) LAST_SYNCED else TabCopy.nightTimes(s.start, s.end, zone), + stale = stale, + score = score?.toString(), + word = score?.let(TabCopy::sleepScoreWord), + scoreLine = TabCopy.sleepScoreLine(score), + asleep = TabCopy.asleep(n.asleepSeconds), + woke = TabCopy.woke(s.wakeCount), + sentence = TabCopy.night(n.asleepSeconds, n.others), + totals = totals, + legend = totals.map { (stage, seconds) -> SleepLegendItem(stage, TabCopy.stageTotal(stage, seconds)) }, + stages = stageRows(totals), + waiting = null, + ) + } + } +} + +/** + * The sums that fit the Sleep widget into its smallest cell at any system font size (spec 2.1): what each size keeps + * when it can't hold everything, worked out at the real cell's content size ([WidgetFrame.contentWidth]), text with + * [WidgetText]'s rough sums, dp throughout. + */ +object SleepLayout { + /** The dots on a Small, and on a Wide or Large (spec 3). */ + val SMALL_DOT = DotSize.Edge.dot.value + val WIDE_DOT = DotSize.Second.dot.value + + /** Between the score and its word: the Small's, the Wide's. Before what's on the right of the score row. */ + const val SMALL_WORD_GAP_DP = 6f + const val WORD_GAP_DP = 8f + const val SIDE_GAP_DP = 12f + + /** Between the Small's rows under the title (the mockup's 8). */ + const val SMALL_GAP_DP = 8f + + /** The stage bar's height: the Small's 6, the Wide's and Large's 8. Then the gap to its legend. */ + const val SMALL_BAR_DP = 6f + const val BAR_DP = 8f + const val BAR_GAP_DP = 8f + + /** The legend: Caption size, an 8 dp mark 6 dp before its label, items at least 12 dp apart, rows 6 dp apart. */ + const val LEGEND_SP = WidgetText.CAPTION_SP + const val MARK_DP = 8f + const val MARK_GAP_DP = 6f + const val LEGEND_GAP_DP = 12f + const val LEGEND_ROW_GAP_DP = 6f + + /** + * The Large's stage rows: the name in [STAGE_NAME_DP], the bar between, the time in [STAGE_TIME_DP] on the right, + * [STAGE_GAP_DP] apart, rows [STAGE_ROW_GAP_DP] apart, the bar [STAGE_BAR_DP] high on a white 10% track. + */ + const val STAGE_NAME_DP = 52f + const val STAGE_TIME_DP = 56f + const val STAGE_GAP_DP = 10f + const val STAGE_ROW_GAP_DP = 6f + const val STAGE_BAR_DP = 6f + const val MIN_STAGE_TRACK_DP = 48f + + /** The Large's sentence at most this many lines. */ + const val SENTENCE_LINES = 3 + + /** The least room between the blocks that share out the spare height. */ + const val MIN_GAP_DP = 4f + + /* ---------------------------------------------- Legend ---------------------------------------------- */ + + /** One legend item's width: mark, gap, label. */ + fun legendItem(label: String, fontScale: Float): Float = MARK_DP + MARK_GAP_DP + WidgetText.width(label, LEGEND_SP, fontScale) + + /** + * The legend's rows in [width]: 1 when all of [items] fit on one row, 2 (two a row, each in half the width) when + * not, 0 when even that would wrap a label, so no item ever breaks mid-label. + */ + fun legendRows(items: List, width: Float, fontScale: Float): Int { + if (items.isEmpty()) return 0 + val widths = items.map { legendItem(it.label, fontScale) } + if (widths.sum() + LEGEND_GAP_DP * (items.size - 1) <= width) return 1 + val half = (width - LEGEND_GAP_DP) / 2 + return if (widths.all { it <= half }) 2 else 0 + } + + /** The legend's height at [rows]. */ + fun legendHeight(rows: Int, fontScale: Float): Float = + if (rows <= 0) 0f else rows * legendLine(fontScale) + (rows - 1) * LEGEND_ROW_GAP_DP + + private fun legendLine(fontScale: Float) = maxOf(MARK_DP, WidgetText.line(LEGEND_SP, fontScale)) + + /* --------------------------------------------- Score row --------------------------------------------- */ + + /** + * The score row: the [dot] the score draws at (0 without a score), the [word] beside it, the [right] text and how + * many lines the word takes (only "No score from the strap" ever takes two). + */ + data class ScoreRow(val dot: Float, val word: String?, val right: String?, val wordLines: Int = 1) { + fun height(fontScale: Float): Float = maxOf( + if (dot > 0f) WidgetDots.height(dot) else 0f, + if (word != null) wordLines * WidgetText.line(WidgetText.BODY_SP, fontScale) else 0f, + if (right != null) WidgetText.line(WidgetText.SECONDARY_SP, fontScale) else 0f, + ) + + /** Its width as laid out with [gap] between the dots and the word, for the tests. */ + fun width(score: String?, fontScale: Float, gap: Float = WORD_GAP_DP): Float = + (if (dot > 0f && score != null) WidgetDots.width(score, dot) + (if (word != null) gap else 0f) else 0f) + + (word?.let { WidgetText.width(it, WidgetText.BODY_SP, fontScale) / wordLines } ?: 0f) + + (right?.let { SIDE_GAP_DP + WidgetText.width(it, WidgetText.SECONDARY_SP, fontScale) } ?: 0f) + } + + /** + * The Wide's and Large's score row in [width]: the score at 3.6 dp dots with the tab's "Sleep score, good" and + * [right]; when that doesn't fit, the dots down to the Small's 3 dp, then the short word ("Good") keeping [right], + * then without [right]. Without a score, "No score from the strap" with [right] if both fit, else on its own, + * on up to two lines. + */ + fun scoreRow(m: SleepWidgetModel, right: String?, width: Float, fontScale: Float, maxDot: Float = WIDE_DOT): ScoreRow { + val rightW = right?.let { SIDE_GAP_DP + WidgetText.width(it, WidgetText.SECONDARY_SP, fontScale) } ?: 0f + val score = m.score + if (score == null) { + val line = m.scoreLine ?: TabCopy.NO_STRAP_SCORE + val lineW = WidgetText.width(line, WidgetText.BODY_SP, fontScale) + if (lineW + rightW <= width) return ScoreRow(0f, line, right) + return ScoreRow(0f, line, null, WidgetText.lines(line, WidgetText.BODY_SP, width, fontScale).coerceAtMost(2)) + } + val tries = listOf(m.scoreLine to true, m.word to true, m.scoreLine to false, m.word to false) + for ((word, withRight) in tries) { + if (word == null || (withRight && right == null)) continue + val room = width - (if (withRight) rightW else 0f) - WORD_GAP_DP - WidgetText.width(word, WidgetText.BODY_SP, fontScale) + val dot = WidgetDots.fit(score, room, maxDot) + if (room > 0f && dot >= SMALL_DOT) return ScoreRow(dot, word, if (withRight) right else null) + } + return ScoreRow(WidgetDots.fit(score, width, maxDot), null, null) + } + + /* ----------------------------------------------- Small ----------------------------------------------- */ + + /** A Secondary line under the Small's score, on [lines] lines. */ + data class SmallLine(val text: String, val lines: Int = 1) + + /** + * What the Small keeps: its score [row], the Secondary [lines] under it ("Last synced night" while stale, then + * time asleep) and whether the stage [bar] shows. + */ + data class Small( + val row: ScoreRow, val lines: List, val bar: Boolean, + val top: TopRow = TopRow(null, false, 0f), val spread: Spread = Spread(0f, 0), + ) + + /** The Small's dots: the mockup's 3 dp (spec 3), growing to 7 on a tall cell before the gaps do. */ + val SMALL_DOTS = listOf(3f, 3.6f, 4.5f, 5f, 5.5f, 6f, 6.5f, 7f) + + /** The Wide's dots on a tall cell, from 3.6. */ + val WIDE_GROWTH = listOf(4.5f, 5f, 5.5f, 6f) + + /** + * The Small in [width] by [height]: the title and the score with its word always (the dots smaller for a long + * score, the word dropped before the dots get smaller than 2.4 dp), then while they fit time asleep, the stage bar + * and "Last synced night", dropped in that order. Time asleep only on one line; + * "Last synced night" on two when one can't hold it, since without it yesterday's night would pass for last + * night's. + */ + fun small(m: SleepWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Small { + val top = TopRow.of(m.title, null, status, width, fontScale, withWords = false) + val sec = WidgetText.line(WidgetText.SECONDARY_SP, fontScale) + fun linesOf(text: String) = WidgetText.lines(text, WidgetText.SECONDARY_SP, width, fontScale) + val note = m.note?.takeIf { m.stale }?.let { SmallLine(it, linesOf(it)) }?.takeIf { it.lines <= 2 } + val asleep = m.asleep?.let { SmallLine(it, linesOf(it)) }?.takeIf { it.lines == 1 } + fun at(row: ScoreRow, lines: List, bar: Boolean): Small { + val parts = lines.map { it.lines * sec } + listOfNotNull(SMALL_BAR_DP.takeIf { bar }) + val foot = parts.sum() + (parts.size - 1).coerceAtLeast(0) * SMALL_GAP_DP + return Small(row, lines, bar, top, Spread.of(top.height, row.height(fontScale), foot)) + } + // The top row and the score at least MIN_GAP_DP apart, the score and what's under it SMALL_GAP_DP. + fun fits(f: Small, h: Float) = f.spread.content + MIN_GAP_DP + (if (f.spread.inner > 1) SMALL_GAP_DP else 0f) <= h + 0.01f + val row = smallRow(m, width, fontScale) + val hasBar = m.totals.isNotEmpty() + // Time asleep goes first, then the bar, then the note, which says the night isn't last night's. + val tries = listOf( + listOfNotNull(note, asleep) to hasBar, listOfNotNull(note) to hasBar, listOfNotNull(note) to false, emptyList() to false, + ) + val base = tries.map { (lines, bar) -> at(row, lines, bar) }.firstOrNull { fits(it, height) } ?: return at(row, emptyList(), false) + // A tall cell grows the dots, never at the cost of the word. + val ladder = SMALL_DOTS.map { at(smallRow(m, width, fontScale, it), base.lines, base.bar) }.filter { it.row.word == base.row.word } + val fitting = (listOf(base) + ladder.filter { it.row.dot > base.row.dot }).distinct().filter { fits(it, height) } + return fitting.firstOrNull { it.spread.gap(height) <= Spread.TARGET_GAP_DP } ?: fitting.last() + } + + /** The Small's height as laid out with the least gaps, for the tests. */ + fun smallHeight(f: Small, @Suppress("UNUSED_PARAMETER") fontScale: Float): Float = + f.spread.content + MIN_GAP_DP + (if (f.spread.inner > 1) SMALL_GAP_DP else 0f) + + private fun smallRow(m: SleepWidgetModel, width: Float, fontScale: Float, maxDot: Float = SMALL_DOT): ScoreRow { + val score = m.score ?: return ScoreRow( + 0f, m.scoreLine ?: TabCopy.NO_STRAP_SCORE, null, + WidgetText.lines(m.scoreLine ?: TabCopy.NO_STRAP_SCORE, WidgetText.BODY_SP, width, fontScale).coerceAtMost(2), + ) + val word = m.word + if (word != null) { + val dot = WidgetDots.fit(score, width - SMALL_WORD_GAP_DP - WidgetText.width(word, WidgetText.BODY_SP, fontScale), maxDot) + if (dot >= MIN_SMALL_DOT) return ScoreRow(dot, word, null) + } + return ScoreRow(WidgetDots.fit(score, width, maxDot), null, null) + } + + /** The smallest the Small's dots go to keep its word beside them. */ + private const val MIN_SMALL_DOT = 2.4f + + /* ------------------------------------------------ Wide ----------------------------------------------- */ + + /** What the Wide keeps: the note, the score row, the bar and the legend's rows (0 without it). */ + data class Wide( + val note: Boolean, val row: ScoreRow, val bar: Boolean, val legendRows: Int, + val top: TopRow = TopRow(null, note, 0f), val spread: Spread = Spread(0f, 0), + ) + + /** Whether the title row holds [title] and [note] on one line in [width]. */ + fun noteFits(title: String, note: String?, width: Float, fontScale: Float): Boolean = note != null && + WidgetText.width(title, WidgetText.TITLE_SP, fontScale) + SIDE_GAP_DP + WidgetText.width(note, WidgetText.SECONDARY_SP, fontScale) <= width + + /** + * The Wide in [width] by [height]: the title row, the score row ([scoreRow] with time asleep) and the stage bar + * always, the legend on one row or two while it fits, else none. + */ + fun wide(m: SleepWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Wide { + val top = TopRow.of(m.title, m.note, status, width, fontScale, withWords = true) + val bar = m.totals.isNotEmpty() + fun at(row: ScoreRow): Wide { + val base = top.height + row.height(fontScale) + 2 * MIN_GAP_DP + (if (bar) BAR_DP else 0f) + val rows = legendRows(m.legend, width, fontScale).takeIf { bar } ?: 0 + val legend = rows.takeIf { base + BAR_GAP_DP + legendHeight(it, fontScale) <= height } ?: 0 + val stages = if (bar) BAR_DP + (if (legend > 0) BAR_GAP_DP + legendHeight(legend, fontScale) else 0f) else null + return Wide(top.note, row, bar, legend, top, Spread.of(top.height, row.height(fontScale), stages)) + } + val base = at(scoreRow(m, m.asleep, width, fontScale)) + // A tall cell grows the dots, never at the cost of the word, what's on the right or the legend. + val grown = WIDE_GROWTH.map { at(scoreRow(m, m.asleep, width, fontScale, it)) } + .filter { it.row.word == base.row.word && it.row.right == base.row.right && it.legendRows == base.legendRows && it.row.dot > base.row.dot } + val fitting = (listOf(base) + grown).filter { wideHeight(it, fontScale) <= height + 0.01f } + return fitting.firstOrNull { it.spread.gap(height) <= Spread.TARGET_GAP_DP } ?: fitting.lastOrNull() ?: base + } + + /** The Wide's height as laid out with the least gaps, for the tests. */ + fun wideHeight(w: Wide, @Suppress("UNUSED_PARAMETER") fontScale: Float): Float = w.spread.content + 2 * MIN_GAP_DP + + /* ----------------------------------------------- Large ----------------------------------------------- */ + + /** + * What the Large keeps: the note, the sentence's lines (0 without it), the score row, and the stage rows or, when + * they don't fit, the stage bar. + */ + data class Large( + val note: Boolean, val sentenceLines: Int, val row: ScoreRow, val stages: Boolean, val bar: Boolean, + val top: TopRow = TopRow(null, note, 0f), + ) + + /** The Large's stage rows' height: a Secondary line a stage. */ + fun stagesHeight(count: Int, fontScale: Float): Float = + if (count <= 0) 0f else count * WidgetText.line(WidgetText.SECONDARY_SP, fontScale) + (count - 1) * STAGE_ROW_GAP_DP + + /** + * The Large in [width] by [height]: the Wide's title row, score row (how often you woke on the right, else time + * asleep), then the time in each stage as rows, with the sentence while it fits. The sentence goes first, then + * the rows give way to the stage bar. + */ + fun large(m: SleepWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Large { + val top = TopRow.of(m.title, m.note, status, width, fontScale, withWords = true) + val row = scoreRow(m, m.largeRight, width, fontScale) + val bar = m.totals.isNotEmpty() + val lines = m.sentence?.let { WidgetText.lines(it, WidgetText.HERO_SP, width, fontScale) }?.takeIf { it <= SENTENCE_LINES } ?: 0 + val rowsFit = bar && width - STAGE_NAME_DP - STAGE_TIME_DP - 2 * STAGE_GAP_DP >= MIN_STAGE_TRACK_DP + // Stage rows with the sentence, without it, then the bar with and without it. + val tries = listOf(rowsFit to lines, rowsFit to 0, false to lines, false to 0).distinct() + for ((stages, sentence) in tries) { + val l = Large(top.note, sentence, row, stages, bar, top) + if (largeHeight(l, m.stages.size, fontScale) <= height) return l + } + return Large(top.note, 0, row, false, bar, top) + } + + /** Everything on the Large with the least gaps between its blocks, [stageCount] rows when it shows them. */ + fun largeHeight(l: Large, stageCount: Int, fontScale: Float): Float = largeSpread(l, stageCount, fontScale).content + 3 * MIN_GAP_DP + + /** How the Large's blocks share its height: the top row, the sentence, the score row, the rows or the bar. */ + fun largeSpread(l: Large, stageCount: Int, fontScale: Float): Spread = Spread.of( + l.top.height.takeIf { it > 0f } ?: WidgetText.line(WidgetText.TITLE_SP, fontScale), + l.sentenceLines * WidgetText.line(WidgetText.HERO_SP, fontScale), + l.row.height(fontScale), + if (l.stages) stagesHeight(stageCount, fontScale) else if (l.bar) BAR_DP else 0f, + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt new file mode 100644 index 00000000..48fd4551 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt @@ -0,0 +1,626 @@ +package org.openstrap.app.widget + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Paint +import android.os.SystemClock +import android.util.Log +import android.util.TypedValue +import android.widget.RemoteViews +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.clickable +import androidx.glance.appwidget.AndroidRemoteViews +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.height +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.unit.ColorProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import org.openstrap.app.MainActivity +import org.openstrap.app.OpenStrapApp +import org.openstrap.app.R +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.glow.ControlSurface +import org.openstrap.app.ui.glow.EdgeKind +import org.openstrap.app.ui.glow.EdgeStatus +import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.ui.glow.draw.drawIcon +import org.openstrap.app.ui.glow.draw.renderToBitmap +import org.openstrap.app.ui.glow.edgeLight +import org.openstrap.app.ui.glow.locationAvailable +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.workout.SportIcons +import java.time.Instant +import java.time.ZoneId + +/** + * The Start a workout widget (widgets spec 3.5), the one action widget: home screen to Ready in one tap. The usual + * sport with its play button at every size, the next four as tiles on the Wide, this week and the usual's last on the + * Large; while a workout records, that workout with Open. An edge card lit from the right, toward Start, in heart ink + * and the moment's mood. Play, a tile or the widget opens Ready with that sport, All sports the sport picker. + */ +class StartWidget : LoadingWidget(WidgetKind.Start) { + override suspend fun loadShown(context: Context): Shown = load(context) + + override fun sample(): Shown = Shown(WidgetSamples.start(Instant.now(), ZoneId.systemDefault()), GlowMood.Day) + + @Composable + override fun Content(data: Shown) = StartContent(data) + + /** What the widget shows and the light it's lit in. */ + data class Shown(val model: StartWidgetModel, val mood: GlowMood) + + companion object { + /** + * Every workout as the workout lists read them ([Workouts.listed]), the Outdoor or Indoor side last chosen per + * sport and whether location is on, as Ready reads them, and the workout recording now. The mood from the + * moment now ([widgetMood]). + */ + internal suspend fun load(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): Shown { + val container = (context.applicationContext as OpenStrapApp).container + val recording = (container.workout.state.value as? WorkoutState.Recording)?.let { r -> + val w = r.workout + StartRecordingInput(w.sportType, w.start, WorkoutRecorder.activeSeconds(w, now.epochSecond), r.paused) + } + val model = try { + val dao = container.database.healthDao() + val workouts = Workouts.listed( + dao.workouts().first(), dao.sportEffects().first(), dao.lastWorkoutHeartRates().first(), dao.appWorkouts().first(), + dao.setCounts().first().associate { it.workoutStart to it.sets }, LIST_MAX_HR, + ) + StartWidgetModel.from(workouts, container.settings.workoutSides.first(), locationAvailable(context), recording, now, zone) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(WIDGET_TAG, "widgets: workouts for Start not read: $e") + StartWidgetModel.from(emptyList(), emptyMap(), false, recording, now, zone) + } + val bedtime = try { + container.health.usualBedtime(now.atZone(zone).toLocalDate()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + return Shown(model, widgetMood(container, bedtime, now, zone)) + } + + /** + * The max heart rate the lists' zones are shares of. The widget shows no zones, so it skips the app's 90-day + * read for it ([org.openstrap.app.data.RecoveryEngine.maxHeartRate]); nothing it shows depends on it. + */ + private const val LIST_MAX_HR = 190 + } +} + +/** The Start widget's receiver. */ +class StartWidgetReceiver : PlacedWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = StartWidget() +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Taps */ +/* ------------------------------------------------------------------------------------------------ */ + +/** Ready with [sport] (spec 4), as a launcher shortcut opens it. */ +internal fun openReady(context: Context, sport: Int): Intent = + Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_READY_SPORT, sport).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + +/** Ready on the sport picker: All sports. */ +internal fun openSportPicker(context: Context): Intent = + Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_READY_PICK, true).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + +/** The live screen of the workout recording, as its notification opens it. */ +internal fun openLiveWorkout(context: Context): Intent = + Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_SHOW_WORKOUT, true).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + +/* ------------------------------------------------------------------------------------------------ */ +/* Layout */ +/* ------------------------------------------------------------------------------------------------ */ + +/** Heart ink, as Ready's hero: the light toward Start. */ +private val Ink = MetricColors.heart + +/** The name in the usual row: Body 500. */ +private val NameStyle = WidgetType.body.copy(fontWeight = FontWeight.Medium) + +/** Open's word, black on its white pill. */ +private val OpenStyle = WidgetType.body.copy(color = ColorProvider(Color.Black), fontWeight = FontWeight.Medium) + +/** Back to the card's 20 dp side padding, for every row but the tile captions, which may run into it. */ +private val Inset = GlanceModifier.padding(horizontal = StartLayout.CAPTION_BLEED_DP.dp) + +/** The Large's hairline: white at 8%, at full strength in every mood (spec 2.2). */ +private const val HAIRLINE_ALPHA = 0.08f + +/** + * The widget at the size Glance lays out, lit from the right ([EdgeKind.Live]'s edge). The whole widget opens Ready + * with the usual, or the live screen while a workout records. + */ +@Composable +private fun StartContent(data: StartWidget.Shown) { + val m = data.model + val context = LocalContext.current + val open = if (m.recording != null) openLiveWorkout(context) else openReady(context, m.usual.sportType) + // The side padding is half the card's, so the tile captions can run into it; everything else pads back ([Inset]). + // While a workout records, the recording is the news and the live screen shows the strap: no status line. + EdgeWidget( + edgeLight(EdgeStatus.Usual, EdgeKind.Live), Ink, data.mood, open, BITMAP_EXTRA, + sidePadding = WidgetSizes.PADDING_DP - StartLayout.CAPTION_BLEED_DP, showStatus = m.recording == null, + ) { frame -> + val rec = m.recording + // Start has no title row, so with the strap status (spec 5) the Wide and Large get "Start a workout" above the + // usual row, the status button at its right; the Small gets a title row too ([StatusSmallContent]). + val top = if (rec != null || frame.bucket == WidgetSizes.Small) null + else StartLayout.statusTop(m, frame.status, frame.bucket, frame.contentWidth, frame.contentHeight, frame.fontScale) + when { + rec != null && frame.bucket == WidgetSizes.Small -> Column(GlanceModifier.fillMaxWidth().defaultWeight().then(Inset)) { SmallRecording(m, rec, frame) } + rec != null && frame.bucket == WidgetSizes.Wide -> WideRecording(m, rec, frame) + rec != null -> LargeRecording(m, rec, frame) + frame.bucket == WidgetSizes.Small -> Column(GlanceModifier.fillMaxWidth().defaultWeight().then(Inset)) { SmallContent(m, frame) } + frame.bucket == WidgetSizes.Wide -> WideContent(m, frame, top) + else -> LargeContent(m, frame, top) + } + } +} + +/** + * Small: the usual's tile, then its name and "Your usual" with play at the foot, as the mockup has it. When the name + * won't fit beside play, play moves up beside a bigger tile. One line each, never wrapped. The spare height is shared above, between and below, so nothing leaves a hole. + */ +@Composable +private fun ColumnScope.SmallContent(m: StartWidgetModel, f: WidgetFrame) { + val sport = m.usual + f.status?.let { return StatusSmallContent(m, it, f) } + val fit = StartLayout.small(sport, listOf(StartWidgetModel.USUAL), false, f.contentWidth, f.contentHeight, f.fontScale) + val sz = fit.sizes + val words: @Composable () -> Unit = { + Text(fit.words.name, style = NameStyle, maxLines = 1) + fit.words.line?.let { Text(it, style = WidgetType.secondary, maxLines = 1) } + } + Spacer(GlanceModifier.defaultWeight()) + if (fit.actionTop) { + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (fit.tile) UsualTile(sport, false, sz, f.density) + Spacer(GlanceModifier.defaultWeight()) + Action(m, sz, f) + } + Spacer(GlanceModifier.defaultWeight()) + Column(GlanceModifier.fillMaxWidth()) { words() } + } else { + UsualTile(sport, false, sz, f.density) + Spacer(GlanceModifier.defaultWeight()) + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(GlanceModifier.defaultWeight()) { words() } + Spacer(GlanceModifier.width(StartLayout.SMALL_GAP_DP.dp)) + Action(m, sz, f) + } + } + Spacer(GlanceModifier.defaultWeight()) +} + +/** + * Small with the strap status (spec 5), as the other widgets' Smalls: "Start a workout" (or "Start") with the status + * button at the top right, then the usual's tile with play right beside it, then the name over "Your usual". Half a + * gap above and below, a gap between. + */ +@Composable +private fun ColumnScope.StatusSmallContent(m: StartWidgetModel, line: StatusLine, f: WidgetFrame) { + val fit = StartLayout.statusSmall(m.usual, line, f.contentWidth, f.contentHeight, f.fontScale) + val edge = fit.spread.edge(f.drawContentHeight) + Spacer(GlanceModifier.height(edge.dp)) + TopRowView(fit.title.orEmpty(), null, fit.top, f, edge) + Spacer(GlanceModifier.defaultWeight()) + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + UsualTile(m.usual, false, fit.sizes, f.density) + Spacer(GlanceModifier.width(StartLayout.STATUS_PLAY_GAP_DP.dp)) + Action(m, fit.sizes, f) + } + Spacer(GlanceModifier.defaultWeight()) + Column(GlanceModifier.fillMaxWidth()) { + Text(fit.words.name, style = NameStyle, maxLines = 1) + fit.words.line?.let { Text(it, style = WidgetType.secondary, maxLines = 1) } + } + Spacer(GlanceModifier.height(edge.dp)) +} + +/** + * Wide: the usual row over the tiles, at the largest sizes that keep four sports, the spare height shared above, + * between and below. + */ +@Composable +private fun ColumnScope.WideContent(m: StartWidgetModel, f: WidgetFrame, top: TopRow?) { + val b = StartLayout.wide(m, f.contentWidth, f.contentHeight, f.fontScale, top?.height ?: 0f) + if (top != null) { + // The status title row (spec 5); the gap above it fixed at its share so the card knows where the button is. + val gap = b.gap(f.drawContentHeight) + Spacer(GlanceModifier.height(gap.dp)) + StatusTitle(top, f, gap) + } + Spacer(GlanceModifier.defaultWeight()) + UsualRow(m, b, f) + b.tiles?.let { + Spacer(GlanceModifier.defaultWeight()) + TileRow(it, f) + } + Spacer(GlanceModifier.defaultWeight()) +} + +/** + * Large: the Wide, a hairline, then This week and the usual sport's last, the spare height shared between them all. + */ +@Composable +private fun ColumnScope.LargeContent(m: StartWidgetModel, f: WidgetFrame, top: TopRow?) { + val b = StartLayout.large(m, f.contentWidth, f.contentHeight, f.fontScale, top?.height ?: 0f) + // Half a gap above and below, at the cell's real height: the padding already frames the block. + val edge = b.edgeGap(f.drawHeight - 2 * WidgetSizes.PADDING_DP) + Spacer(GlanceModifier.height(edge.dp)) + if (top != null) { + StatusTitle(top, f, edge) + Spacer(GlanceModifier.defaultWeight()) + } + UsualRow(m, b, f) + Spacer(GlanceModifier.defaultWeight()) + b.tiles?.let { + TileRow(it, f) + Spacer(GlanceModifier.defaultWeight()) + } + Hairline() + Spacer(GlanceModifier.defaultWeight()) + b.figures?.let { Figures(m, it) } + Spacer(GlanceModifier.height(edge.dp)) +} + +/** "Start a workout" with the strap status button and its words at the right (spec 5), [above] dp under the content's top. */ +@Composable +private fun StatusTitle(top: TopRow, f: WidgetFrame, above: Float) { + Column(GlanceModifier.fillMaxWidth().then(Inset)) { TopRowView(StartWidgetModel.TITLE, null, top, f, above) } +} + +/** The usual's tile, its name over "Your usual, GPS on" with the location mark, and play; or the workout recording. */ +@Composable +private fun UsualRow(m: StartWidgetModel, b: StartLayout.Block, f: WidgetFrame) { + val sport = m.usual + val words = b.row.words + Row(GlanceModifier.fillMaxWidth().then(Inset), verticalAlignment = Alignment.CenterVertically) { + UsualTile(sport, false, b.sizes, f.density) + Spacer(GlanceModifier.width(StartLayout.ROW_GAP_DP.dp)) + Column(GlanceModifier.defaultWeight()) { + Text(words.name, style = NameStyle, maxLines = 1) + words.line?.let { line -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text(line, style = WidgetType.secondary, maxLines = 1) + if (words.mark) { + Spacer(GlanceModifier.width(StartLayout.MARK_GAP_DP.dp)) + Image( + ImageProvider(iconBitmap(SportIcons.Location, Color.White.copy(alpha = 0.72f), StartLayout.MARK_DP, f.density)), + contentDescription = null, + modifier = GlanceModifier.size(StartLayout.MARK_DP.dp), + contentScale = ContentScale.FillBounds, + ) + } + } + } + } + Spacer(GlanceModifier.width(StartLayout.ROW_GAP_DP.dp)) + Action(m, b.sizes, f) + } +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Recording */ +/* ------------------------------------------------------------------------------------------------ */ + +/** + * Small while recording: the sport's tile and Open in the top row, then the live timer, the sport's name and, room + * allowing, "Since 11:58" ("Paused" while paused, which always keeps its line). The spare height shared above, between and below. + */ +@Composable +private fun ColumnScope.SmallRecording(m: StartWidgetModel, r: StartRecording, f: WidgetFrame) { + val fit = StartLayout.smallRecording(r, f.contentWidth, f.contentHeight, f.fontScale) + Spacer(GlanceModifier.defaultWeight()) + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (fit.tile >= StartLayout.MIN_TILE_DP) UsualTile(r.sport, true, StartLayout.Sizes(fit.tile, fit.open, 0f), f.density) + Spacer(GlanceModifier.defaultWeight()) + Action(m, fit.sizes, f) + } + Spacer(GlanceModifier.defaultWeight()) + Column(GlanceModifier.fillMaxWidth()) { + Timer(r, fit.timerSp) + Text(fit.name, style = NameStyle, maxLines = 1) + if (fit.since == StartLayout.SincePlace.Under) Text(if (r.paused) r.status else r.since, style = WidgetType.secondary, maxLines = 1) + } + Spacer(GlanceModifier.defaultWeight()) +} + +/** Wide while recording: the sport's row with "Recording" and Open, then the timer as the hero. */ +@Composable +private fun ColumnScope.WideRecording(m: StartWidgetModel, r: StartRecording, f: WidgetFrame) { + val fit = StartLayout.wideRecording(r, f.contentWidth, f.contentHeight, f.fontScale) + Spacer(GlanceModifier.defaultWeight()) + RecordingRow(m, r, fit, f) + Spacer(GlanceModifier.defaultWeight()) + TimerRow(r, fit) + Spacer(GlanceModifier.defaultWeight()) +} + +/** Large while recording: the Wide's, then the hairline and the week's figures, secondary to the timer. */ +@Composable +private fun ColumnScope.LargeRecording(m: StartWidgetModel, r: StartRecording, f: WidgetFrame) { + val fit = StartLayout.largeRecording(r, m, f.contentWidth, f.contentHeight, f.fontScale) + val edge = fit.edgeGap(f.drawHeight - 2 * WidgetSizes.PADDING_DP) + Spacer(GlanceModifier.height(edge.dp)) + RecordingRow(m, r, fit, f) + Spacer(GlanceModifier.defaultWeight()) + TimerRow(r, fit) + Spacer(GlanceModifier.defaultWeight()) + Hairline() + Spacer(GlanceModifier.defaultWeight()) + fit.figures?.let { Figures(m, it) } + Spacer(GlanceModifier.height(edge.dp)) +} + +/** The sport's tile, its name over the red dot and "Recording" (white and "Paused" while paused), and Open. */ +@Composable +private fun RecordingRow(m: StartWidgetModel, r: StartRecording, fit: StartLayout.Recording, f: WidgetFrame) { + Row(GlanceModifier.fillMaxWidth().then(Inset), verticalAlignment = Alignment.CenterVertically) { + UsualTile(r.sport, true, fit.sizes, f.density) + Spacer(GlanceModifier.width(StartLayout.ROW_GAP_DP.dp)) + Column(GlanceModifier.defaultWeight()) { + Text(fit.name, style = NameStyle, maxLines = 1) + if (fit.status) Row(verticalAlignment = Alignment.CenterVertically) { + Image( + ImageProvider(dotBitmap(if (r.paused) Color.White.copy(alpha = 0.5f) else Ink, f.density)), + contentDescription = null, + modifier = GlanceModifier.size(StartLayout.DOT_DP.dp), + contentScale = ContentScale.FillBounds, + ) + Spacer(GlanceModifier.width(StartLayout.DOT_GAP_DP.dp)) + Text(r.status, style = WidgetType.secondary, maxLines = 1) + } + } + Spacer(GlanceModifier.width(StartLayout.ROW_GAP_DP.dp)) + Action(m, fit.sizes, f) + } +} + +/** The timer, with "Since 11:58" beside it (on its baseline side) or under it, where the layout found room. */ +@Composable +private fun TimerRow(r: StartRecording, fit: StartLayout.Recording) { + when (fit.since) { + StartLayout.SincePlace.Beside -> Row(GlanceModifier.fillMaxWidth().then(Inset), verticalAlignment = Alignment.Bottom) { + Timer(r, fit.timerSp) + Spacer(GlanceModifier.width(StartLayout.SINCE_GAP_DP.dp)) + Text(r.since, style = WidgetType.secondary, maxLines = 1) + } + StartLayout.SincePlace.Under -> Column(GlanceModifier.fillMaxWidth().then(Inset)) { + Timer(r, fit.timerSp) + Text(r.since, style = WidgetType.secondary, maxLines = 1) + } + StartLayout.SincePlace.None -> Box(GlanceModifier.fillMaxWidth().then(Inset)) { Timer(r, fit.timerSp) } + } +} + +/** + * The live timer: a RemoteViews Chronometer, which ticks on its own with no widget updates, h:mm:ss or mm:ss, at + * [sp] (so the system font scales it). Its base puts the workout's active time at now; paused, it stands still. + */ +@Composable +private fun Timer(r: StartRecording, sp: Float) { + val context = LocalContext.current + val views = RemoteViews(context.packageName, R.layout.widget_chronometer).apply { + val base = StartRecording.chronometerBase(SystemClock.elapsedRealtime(), System.currentTimeMillis(), r) + setChronometer(R.id.widget_chronometer, base, null, !r.paused) + setTextViewTextSize(R.id.widget_chronometer, TypedValue.COMPLEX_UNIT_SP, sp) + } + AndroidRemoteViews(views) +} + +/** The Large's hairline, padded back to 20 dp outside the line: a background would fill a padding of its own. */ +@Composable +private fun Hairline() { + Box(GlanceModifier.fillMaxWidth().then(Inset)) { + Box(GlanceModifier.fillMaxWidth().height(1.dp).background(ColorProvider(Color.White.copy(alpha = HAIRLINE_ALPHA)))) {} + } +} + +/** Play, white with a black triangle, opening Ready with the usual; or Open while a workout records. */ +@Composable +private fun Action(m: StartWidgetModel, sz: StartLayout.Sizes, f: WidgetFrame) { + val context = LocalContext.current + if (m.recording != null) { + val w = StartLayout.openWidth(f.fontScale) + Box( + GlanceModifier.size(w.dp, sz.play.dp) + .background(ImageProvider(pillBitmap(w, sz.play, f.density)), ContentScale.FillBounds) + .clickable(actionStartActivity(openLiveWorkout(context))), + contentAlignment = Alignment.Center, + ) { Text(StartWidgetModel.OPEN, style = OpenStyle, maxLines = 1) } + return + } + Image( + ImageProvider(playBitmap(sz, f.density)), + contentDescription = m.usual.action, + modifier = GlanceModifier.size(sz.play.dp).clickable(actionStartActivity(openReady(context, m.usual.sportType))), + contentScale = ContentScale.FillBounds, + ) +} + +/** The usual's tile at [sz]. A tap starts it too, or opens the workout recording. */ +@Composable +private fun UsualTile(sport: StartSport, recording: Boolean, sz: StartLayout.Sizes, density: Float) { + val context = LocalContext.current + val tap = if (recording) openLiveWorkout(context) else openReady(context, sport.sportType) + Image( + ImageProvider(tileBitmap(SportIcons.of(sport.shownType), sz.tile, sz.tileCorner, sz.icon, density)), + contentDescription = null, + modifier = GlanceModifier.size(sz.tile.dp).clickable(actionStartActivity(tap)), + contentScale = ContentScale.FillBounds, + ) +} + +/** + * The next sports and All sports: the tiles spread edge to edge with equal room between (the first under the usual's + * tile, the last under play), then their captions on one line, each in a box as wide as its neighbours leave + * ([StartLayout.captionBoxes]), each centred under its tile, the first and last running into the side padding when + * they're wider than their tile. Each row is at most ten children (Glance drops a row's children past ten). A tile + * and its caption open the same. + */ +@Composable +private fun TileRow(tiles: StartLayout.Tiles, f: WidgetFrame) { + val context = LocalContext.current + val opens = tiles.sports.map { openReady(context, it.sportType) } + openSportPicker(context) + val actions = tiles.sports.map { it.action } + StartWidgetModel.ALL_SPORTS_ACTION + val icons = tiles.sports.map { SportIcons.of(it.shownType) } + AppIcons.GridView + val captions = tiles.captions + val n = captions.size + val icon = (tiles.tile * StartLayout.ICON_DP / StartLayout.SMALL_TILE_DP).coerceIn(16f, 24f) + Column(GlanceModifier.fillMaxWidth()) { + Row(GlanceModifier.fillMaxWidth().then(Inset)) { + for (i in 0 until n) { + if (i > 0) Spacer(GlanceModifier.defaultWeight()) + Image( + ImageProvider(tileBitmap(icons[i], tiles.tile, tiles.corner, icon, f.density)), + contentDescription = actions[i], + modifier = GlanceModifier.size(tiles.tile.dp).clickable(actionStartActivity(opens[i])), + contentScale = ContentScale.FillBounds, + ) + } + } + if (!tiles.captioned) return@Column + Spacer(GlanceModifier.height(StartLayout.CAPTION_GAP_DP.dp)) + // Placed at the real width, so the captions sit under tiles spread across it. + val boxes = StartLayout.captionBoxes(captions, tiles.tile, tiles.captionSp, f.drawContentWidth, f.fontScale) + val style = WidgetType.caption.copy(fontSize = tiles.captionSp.sp) + // This row alone spans into the padding: x runs from the bleed's edge. At most ten children: a spacer before + // each of the five captions. + Row(GlanceModifier.fillMaxWidth()) { + var x = -StartLayout.CAPTION_BLEED_DP + boxes.forEachIndexed { i, (left, w) -> + val gap = left - x + if (gap > 0.5f) Spacer(GlanceModifier.width(gap.dp)) + Text( + captions[i], style = style.copy(textAlign = TextAlign.Center), maxLines = 1, + modifier = GlanceModifier.width(w.dp).clickable(actionStartActivity(opens[i])), + ) + x = left + w + } + } + } +} + +/** This week and the usual's last: side by side, Caption over Body, or a row each when a value won't fit half. */ +@Composable +private fun Figures(m: StartWidgetModel, fit: StartLayout.Figures) { + when (fit.layout) { + StartLayout.FigureLayout.Side -> Row(GlanceModifier.fillMaxWidth().then(Inset)) { + Figure(StartWidgetModel.THIS_WEEK, m.weekLine, GlanceModifier.defaultWeight()) + Spacer(GlanceModifier.width(StartLayout.FIGURE_GAP_DP.dp)) + Figure(m.lastLabel, m.lastLine, GlanceModifier.defaultWeight()) + } + StartLayout.FigureLayout.Rows -> Column(GlanceModifier.fillMaxWidth().then(Inset)) { + FigureRow(StartWidgetModel.THIS_WEEK, m.weekLine) + Spacer(GlanceModifier.height(StartLayout.FIGURE_ROW_GAP_DP.dp)) + FigureRow(m.lastLabel, m.lastLine) + } + StartLayout.FigureLayout.Stacked -> Column(GlanceModifier.fillMaxWidth().then(Inset)) { + Figure(StartWidgetModel.THIS_WEEK, m.weekLine, GlanceModifier.fillMaxWidth()) + Spacer(GlanceModifier.height(StartLayout.FIGURE_ROW_GAP_DP.dp)) + Figure(m.lastLabel, m.lastLine, GlanceModifier.fillMaxWidth()) + } + } +} + +@Composable +private fun Figure(label: String, value: String, modifier: GlanceModifier) { + Column(modifier) { + Text(label, style = WidgetType.caption, maxLines = 1) + Spacer(GlanceModifier.height(StartLayout.FIGURE_LINE_GAP_DP.dp)) + Text(value, style = WidgetType.body, maxLines = 1) + } +} + +/** A figure as a row: its Caption on the left, the value on the right. */ +@Composable +private fun FigureRow(label: String, value: String) { + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, style = WidgetType.caption, maxLines = 1) + Spacer(GlanceModifier.defaultWeight()) + Text(value, style = WidgetType.body, maxLines = 1) + } +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Bitmaps */ +/* ------------------------------------------------------------------------------------------------ */ + +/** Room for the tiles, play and the mark beside the backgrounds, in the bitmap budget. */ +private const val BITMAP_EXTRA = 1.3 + +/** A sport's tile [sizeDp] square: the Control surface with [cornerDp] corners, the icon white at [iconDp] in the middle. */ +internal fun tileBitmap(icon: ImageVector, sizeDp: Float, cornerDp: Float, iconDp: Float, density: Float): Bitmap { + val s = sizeDp * density + val r = cornerDp * density + val i = iconDp * density + return renderToBitmap(s, s) { c -> + c.drawRoundRect(0f, 0f, s, s, r, r, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ControlSurface.toArgb() }) + drawIcon(c, icon, Color.White, (s - i) / 2, (s - i) / 2, i) + } +} + +/** The play button at [sz]: a white circle with the black play triangle, as Ready's Start. */ +private fun playBitmap(sz: StartLayout.Sizes, density: Float): Bitmap = playBitmap(sz.play, sz.playIcon, density) + +/** The play button [sizeDp] across with its triangle [iconDp]: a white circle, the black play icon in the middle. */ +internal fun playBitmap(sizeDp: Float, iconDp: Float, density: Float): Bitmap { + val s = sizeDp * density + val i = iconDp * density + return renderToBitmap(s, s) { c -> + c.drawCircle(s / 2, s / 2, s / 2, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.White.toArgb() }) + drawIcon(c, AppIcons.PlayFill, Color.Black, (s - i) / 2, (s - i) / 2, i) + } +} + +/** Open's white pill, [w] by [h] dp, fully round. */ +internal fun pillBitmap(w: Float, h: Float, density: Float): Bitmap { + val pw = w * density + val ph = h * density + return renderToBitmap(pw, ph) { c -> + c.drawRoundRect(0f, 0f, pw, ph, ph / 2, ph / 2, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.White.toArgb() }) + } +} + +/** The recording dot, [color], [StartLayout.DOT_DP] across. */ +internal fun dotBitmap(color: Color, density: Float): Bitmap { + val s = StartLayout.DOT_DP * density + return renderToBitmap(s, s) { c -> c.drawCircle(s / 2, s / 2, s / 2, Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color.toArgb() }) } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StartWidgetModel.kt b/app/src/main/kotlin/org/openstrap/app/widget/StartWidgetModel.kt new file mode 100644 index 00000000..4cda0643 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/StartWidgetModel.kt @@ -0,0 +1,750 @@ +package org.openstrap.app.widget + +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.Workouts +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import org.openstrap.app.ui.dashboard.clockText +import org.openstrap.app.ui.glow.topSports +import org.openstrap.app.ui.workout.WorkoutCopy +import java.time.DayOfWeek +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters +import java.util.Locale + +/** + * A sport on the Start widget. [sportType] is what a tap hands Ready (`EXTRA_READY_SPORT`), which then opens on the + * side you last chose for it; [shownType] is that side's own sport, which the icon and names are of, so the widget + * says what Ready will start ("Treadmill" for a run last done indoors). + */ +data class StartSport( + val sportType: Int, + val shownType: Int, + /** In the usual row, Body 500: "Outdoor run". */ + val name: String, + /** The picker's short name ("Run", "Walk", "Strength"): the name when the full one won't fit a line. */ + val shortName: String, + /** + * Under its tile, one line: the short name, or the full one where the short one would read as the usual or a + * sport before it ("Treadmill" beside your outdoor runs). [StartWidgetModel.from] settles it against the others. + */ + val caption: String, + /** What TalkBack reads for its tile and play: "Start run". */ + val action: String, +) { + companion object { + /** [type] as Ready opens it with the [sides] you last chose. */ + fun of(type: Int, sides: Map): StartSport { + val indoor = Workouts.indoorSide(type, sides) + val shown = Workouts.sidesOf(type)?.type(indoor == true) ?: type + return named(type, shown) + } + + /** A workout's own [type], with no side to choose: the one recording. */ + fun recorded(type: Int): StartSport = named(type, type) + + private fun named(type: Int, shown: Int) = StartSport( + sportType = type, + shownType = shown, + name = Workouts.sportName(shown), + // The picker's search names tiles so: everyday sports by their short name, the rest in full. + shortName = Workouts.shortName(shown), + caption = Workouts.shortName(shown), + action = WorkoutCopy.startLabel(shown), + ) + } +} + +/** + * The workout recording, as the widget reads it from the session: its sport type, its start (epoch seconds), its + * active seconds so far (pauses left out, as the live screen counts) and whether it's paused. + */ +data class StartRecordingInput(val sportType: Int, val start: Long, val activeSeconds: Long, val paused: Boolean) + +/** + * A workout under way, which every size shows in place of the usual: the sport, "Recording" (or "Paused"), "Since + * 11:58" and a live timer of the active time, [activeMillis] at [atMillis] (epoch ms), standing still while [paused]. + */ +data class StartRecording( + val sport: StartSport, + val since: String, + val status: String, + val activeMillis: Long, + val atMillis: Long, + val paused: Boolean, +) { + companion object { + const val RECORDING = "Recording" + const val PAUSED = "Paused" + + /** + * The Chronometer's base on the [elapsedRealtime] clock (ms) for [r] at [nowMillis] (epoch ms): the active + * time so far back from now, so it reads the workout's time and ticks on its own. Paused, the time at the + * pause, and the Chronometer is stopped. + */ + fun chronometerBase(elapsedRealtime: Long, nowMillis: Long, r: StartRecording): Long = + elapsedRealtime - (r.activeMillis + if (r.paused) 0L else (nowMillis - r.atMillis).coerceAtLeast(0L)) + } +} + +/** + * What the Start widget shows (widgets spec 3.5), the same model at every size: the Small shows [usual] with "Your + * usual", the Wide adds [usualLine] and the [next] sports as tiles, the Large adds this week and the usual sport's last. + * While [recording], every size shows that instead, with Open in place of play. + */ +data class StartWidgetModel( + val usual: StartSport, + /** The Wide's and Large's line under the name: "Your usual, GPS on". */ + val usualLine: String, + /** GPS is on for the usual: the line carries the location mark. */ + val gpsOn: Boolean, + /** The next sports by use, more than four: the layout shows the first four whose captions fit ([StartLayout.tiles]). */ + val next: List, + /** "2 workouts, 1 h 35". */ + val weekLine: String, + /** "Last run", and its "Tuesday, 5.2 km". */ + val lastLabel: String, + val lastLine: String, + val recording: StartRecording?, +) { + companion object { + const val USUAL = "Your usual" + const val ALL_SPORTS = "All sports" + const val ALL_SPORTS_ACTION = "Choose a sport" + const val THIS_WEEK = "This week" + const val OPEN = "Open" + /** The Wide's and Large's title row, only while the strap status shows (spec 5): the picker's name. */ + const val TITLE = "Start a workout" + const val NO_WORKOUTS = "No workouts yet" + const val NONE_YET = "None yet" + + /** How many sports by use to offer before the everyday ones pad the list. */ + const val BY_USE = 8 + + /** + * The widget from every listed workout ([Workouts.listed]), the [sides] last chosen per sport on Ready, whether + * the phone's [location] can be used now and the workout [recording], at [now]. + * The usual sport and the next ones are [topSports]' order, then [Workouts.EVERYDAY]'s once those run out + * ([Workouts.DEFAULT_SPORTS] lead it before there's a history), each once as Ready would open it. + */ + fun from( + workouts: List, sides: Map, location: Boolean, recording: StartRecordingInput?, + now: Instant, zone: ZoneId, locale: Locale = Locale.getDefault(), + ): StartWidgetModel { + val sports = (topSports(workouts, BY_USE).map { it.sportType } + Workouts.EVERYDAY) + .map { StartSport.of(it, sides) } + .distinctBy { it.shownType } + val usual = sports.first() + // A caption says which sport it is: the short name unless the usual or a tile before it already has it. + val said = mutableSetOf(usual.shortName) + val next = sports.drop(1).map { sp -> if (said.add(sp.shortName)) sp else sp.copy(caption = sp.name).also { said += sp.name } } + val gpsOffered = Workouts.gpsOffered(usual.sportType, Workouts.indoorSide(usual.sportType, sides)) + val today = now.atZone(zone).toLocalDate() + val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay(zone).toEpochSecond() + // As Today and Your week count them: the strap's own "Activity" records aren't workouts. + val week = workouts.filter { it.workout.sportType != Workouts.GENERIC_ACTIVITY && it.workout.start in monday..now.epochSecond } + val last = workouts.filter { Workouts.tileType(it.workout.sportType) == Workouts.tileType(usual.sportType) && it.workout.start <= now.epochSecond } + .maxByOrNull { it.workout.start } + return StartWidgetModel( + usual = usual, + usualLine = when { + gpsOffered && location -> "$USUAL, GPS on" + gpsOffered -> "$USUAL, GPS off" + else -> USUAL + }, + gpsOn = gpsOffered && location, + next = next, + weekLine = weekLine(week.size, week.sumOf { it.activeSeconds }), + lastLabel = lastLabel(usual.sportType), + lastLine = last?.let { a -> + val day = recentDay(Instant.ofEpochSecond(a.workout.start).atZone(zone).toLocalDate(), today, locale) + "$day, ${fact(a)}" + } ?: NONE_YET, + recording = recording?.let { r -> + StartRecording( + StartSport.recorded(r.sportType), "Since ${clockText(r.start, zone)}", + if (r.paused) StartRecording.PAUSED else StartRecording.RECORDING, + r.activeSeconds * 1000L, now.toEpochMilli(), r.paused, + ) + }, + ) + } + + /** "2 workouts, 1 h 35", "1 workout, 40 min", "No workouts yet". */ + fun weekLine(count: Int, seconds: Long): String = when (count) { + 0 -> NO_WORKOUTS + 1 -> "1 workout, ${TodayCopy.duration(seconds)}" + else -> "$count workouts, ${TodayCopy.duration(seconds)}" + } + + /** "Last run" for a run outdoors or on a treadmill, "Last HIIT", and "Last workout" for Other. */ + fun lastLabel(sportType: Int): String = when (val name = Workouts.shortName(Workouts.tileType(sportType))) { + "Other" -> "Last workout" + else -> "Last " + if (name.any { it.isLowerCase() }) name.lowercase() else name + } + + /** A workout's one figure here: its distance ("5.2 km"), else its sets ("4 sets"), else how long it was. */ + fun fact(a: WorkoutAnalysis): String { + a.distanceMeters?.takeIf { it >= 10f }?.let { return TabCopy.distanceText(it, a.distanceFromSteps) } + a.sets?.takeIf { it > 0 }?.let { return if (it == 1) "1 set" else "$it sets" } + return TodayCopy.duration(a.activeSeconds) + } + } +} + + +/** + * The sums that fit the Start widget into its real cell at any system font size (spec 2.1). Nothing on this widget + * wraps (the user's call, 2026-10-25): every text is one line, and what doesn't fit takes a shorter form (the sport's + * short name, "Since 17:42"), a smaller caption, or gives its place. Widths and heights in dp at the content size + * ([WidgetFrame.contentWidth]), text with [WidgetText]'s rough sums, which err wide. + */ +object StartLayout { + /** The usual's tile and the play button (spec 3.5), and the next sports' tiles. */ + const val TILE_DP = 44f + const val TILE_CORNER_DP = 16f + const val SMALL_TILE_DP = 38f + const val SMALL_TILE_CORNER_DP = 12f + const val PLAY_DP = 44f + const val ICON_DP = 20f + const val PLAY_ICON_DP = 18f + + /** The 12 dp location mark after "GPS on", and the room before it. */ + const val MARK_DP = 12f + const val MARK_GAP_DP = 4f + + /** Open's sides, either side of its word. */ + const val OPEN_PAD_DP = 14f + + /** Between the usual row's tile, words and play (the mockup's), and the Small's words and play. */ + const val ROW_GAP_DP = 12f + const val SMALL_GAP_DP = 8f + + /** Between the tile and play on the Small with a status. */ + const val STATUS_PLAY_GAP_DP = 16f + + /** Between a tile and its caption, and a figure's Caption and its Body. */ + const val CAPTION_GAP_DP = 4f + const val FIGURE_LINE_GAP_DP = 2f + + /** Between the two figures side by side, and between captions in the tile row. */ + const val FIGURE_GAP_DP = 12f + const val CAPTION_SPACE_DP = 4f + + /** Between the figures' rows. */ + const val FIGURE_ROW_GAP_DP = 8f + + /** The least room between the blocks that share out the spare height. */ + const val MIN_GAP_DP = 8f + + /** The tile row at its fullest: four sports and All sports. */ + const val COLUMNS = 5 + + /** Captions at the Caption role's 11 sp, then a touch smaller before a sport gives its place. */ + val CAPTION_SIZES = listOf(WidgetText.CAPTION_SP, 10f) + + + /** Open's width at [fontScale]: its word in Body 500 with [OPEN_PAD_DP] each side. */ + fun openWidth(fontScale: Float): Float = WidgetText.width(StartWidgetModel.OPEN, WidgetText.BODY_SP, fontScale) + 2 * OPEN_PAD_DP + + /** + * The Wide's or Large's title row for the strap status [line] (spec 5): "Start a workout" with the status button + * and its words at the right, a block above the usual row. Null without a line, or when the layout at [size] in + * [width] by [height] dp wouldn't fit with it (the line then drops rather than push the usual sport out). + */ + fun statusTop(m: StartWidgetModel, line: StatusLine?, size: androidx.compose.ui.unit.DpSize, width: Float, height: Float, fontScale: Float): TopRow? { + line ?: return null + val top = TopRow.of(StartWidgetModel.TITLE, null, line, width, fontScale, withWords = true) + val fits = when (size) { + WidgetSizes.Wide -> wide(m, width, height, fontScale, top.height).let { it.content + (it.gaps - 2) * MIN_GAP_DP <= height + 0.01f } + else -> large(m, width, height, fontScale, top.height).let { it.content + (it.gaps - 2) * MIN_GAP_DP <= height + 0.01f } + } + return top.takeIf { fits } + } + + /** Whether [text] fits [room] dp on one line at [sp]. */ + fun fits(text: String, sp: Float, room: Float, fontScale: Float): Boolean = WidgetText.width(text, sp, fontScale) <= room + + private fun body(fontScale: Float) = WidgetText.line(WidgetText.BODY_SP, fontScale) + private fun secondary(fontScale: Float) = WidgetText.line(WidgetText.SECONDARY_SP, fontScale) + private fun caption(fontScale: Float, sp: Float = WidgetText.CAPTION_SP) = WidgetText.line(sp, fontScale) + + /** The words beside or under the usual's tile: the [name] and [line] shown (null for no line), and the mark. */ + data class Words(val name: String, val line: String?, val mark: Boolean) + + /** + * The usual's tile, the play button (Open's height while recording) and the sport tiles, dp. A cell with height to + * spare grows them toward the mockup's (a 48 dp usual tile, play up to 52, sport tiles up to 44) before the rest + * goes to the gaps, so a tall cell reads as one block rather than a hole. + */ + data class Sizes(val tile: Float, val play: Float, val sportTile: Float) { + val tileCorner: Float get() = TILE_CORNER_DP * tile / TILE_DP + val sportCorner: Float get() = SMALL_TILE_CORNER_DP * sportTile / SMALL_TILE_DP + val icon: Float get() = (tile * ICON_DP / TILE_DP).coerceIn(ICON_DP, 24f) + val sportIcon: Float get() = (sportTile * ICON_DP / SMALL_TILE_DP).coerceIn(16f, 24f) + val playIcon: Float get() = play * PLAY_ICON_DP / PLAY_DP + + /** What sits at the end of the usual row: play, or Open [OPEN_PAD_DP] either side of its word. */ + fun action(recording: Boolean, fontScale: Float): Float = if (recording) openWidth(fontScale) else play + } + + /** The Wide's and Large's sizes, largest first; the last only for a short Wide at a large font. */ + val ROW_SIZES = listOf(Sizes(48f, 52f, 44f), Sizes(48f, 48f, 40f), Sizes(TILE_DP, PLAY_DP, SMALL_TILE_DP), Sizes(TILE_DP, PLAY_DP, 32f)) + + /** The Small's tile and play, largest first. */ + val SMALL_SIZES = listOf(Sizes(56f, 52f, 0f), Sizes(48f, 48f, 0f), Sizes(TILE_DP, PLAY_DP, 0f)) + + /** + * The Small: [actionTop] puts play (or Open) in the top row beside the tile instead of at the foot beside the + * words, and [tile] is false when even the top row can't hold the tile beside Open. + */ + data class Small(val actionTop: Boolean, val tile: Boolean, val words: Words, val sizes: Sizes) { + /** How many gaps share the spare height: above, between and below the two blocks. */ + val gaps: Int get() = 3 + } + + /** The Small's two blocks' height: the top (tile, or tile and action) and the foot. */ + fun smallHeight(s: Small, fontScale: Float): Float { + val words = body(fontScale) + (if (s.words.line != null) secondary(fontScale) else 0f) + return if (s.actionTop) maxOf(s.sizes.tile, s.sizes.play) + words else s.sizes.tile + maxOf(s.sizes.play, words) + } + + /** + * The Small in [width] by [height] dp. The mockup's layout when the full name fits beside play at the foot: tile + * at the top, the name over [lines]'s first that fits and play at the foot. Else play goes up beside the tile and + * the words take the whole width. Each at the largest [SMALL_SIZES] that fits, then the same with the short name. + * Every text one line. + */ + fun small(sport: StartSport, lines: List, recording: Boolean, width: Float, height: Float, fontScale: Float): Small { + val words = body(fontScale) + secondary(fontScale) + for (name in listOf(sport.name, sport.shortName).distinct()) { + for (sz in SMALL_SIZES) { + val action = sz.action(recording, fontScale) + val beside = width - action - SMALL_GAP_DP + val line = lines.firstOrNull { fits(it, WidgetText.SECONDARY_SP, beside, fontScale) } ?: continue + if (fits(name, WidgetText.BODY_SP, beside, fontScale) && sz.tile + MIN_GAP_DP + maxOf(sz.play, words) <= height) { + return Small(actionTop = false, tile = true, words = Words(name, line, false), sizes = sz) + } + } + if (!fits(name, WidgetText.BODY_SP, width, fontScale)) continue + val line = lines.firstOrNull { fits(it, WidgetText.SECONDARY_SP, width, fontScale) } + for (sz in SMALL_SIZES) { + val action = sz.action(recording, fontScale) + if (sz.tile + SMALL_GAP_DP + action > width) continue + if (maxOf(sz.tile, sz.play) + MIN_GAP_DP + words <= height) return Small(true, true, Words(name, line, false), sz) + } + val sz = SMALL_SIZES.last() + val tile = sz.tile + SMALL_GAP_DP + sz.action(recording, fontScale) <= width + val fitsLine = maxOf(sz.tile, sz.play) + MIN_GAP_DP + words <= height + return Small(true, tile, Words(name, line?.takeIf { fitsLine }, false), sz) + } + return Small(true, false, Words(sport.shortName, null, false), SMALL_SIZES.last()) + } + + /** + * The Small while the strap status shows (spec 5), laid out as the other widgets' Smalls: the [top] row (the + * [title] at the left, null when it gave way, and the status button at the top right), then the usual's tile with + * play right beside it at [sizes], then the [words]; [spread] shares the spare height, half a gap above and below. + */ + data class StatusSmall(val title: String?, val top: TopRow, val sizes: Sizes, val words: Words, val spread: Spread) + + /** The status Small's title: the picker's name, or "Start" when that won't fit beside the button. */ + const val SHORT_TITLE = "Start" + + /** The status Small's tile and play below [SMALL_SIZES]' last, for the smallest cells at the largest fonts. */ + val STATUS_SHRINK = listOf(Sizes(40f, 40f, 0f), Sizes(36f, 36f, 0f)) + + /** The least gap in the status Small when it's short of room. */ + const val STATUS_MIN_GAP_DP = 4f + + /** + * The Small in [width] by [height] dp with the status [line]: the top row, the tile and play, the name over "Your + * usual". The tile and play grow from 44 dp on a tall cell until no gap passes [Spread.TARGET_GAP_DP]. Short of + * room "Your usual" goes, then the title (the button stays), then the tile and play shrink. The full name, else + * the short one; one line each. + */ + fun statusSmall(sport: StartSport, line: StatusLine, width: Float, height: Float, fontScale: Float): StatusSmall { + val beside = StatusButton.SIZE_DP + StatusButton.TITLE_GAP_DP + val title = listOf(StartWidgetModel.TITLE, SHORT_TITLE).firstOrNull { WidgetText.fitWidth(it, TopRow.TITLE_SIZES.last(), fontScale) + beside <= width } ?: SHORT_TITLE + val name = if (fits(sport.name, WidgetText.BODY_SP, width, fontScale)) sport.name else sport.shortName + val usual = StartWidgetModel.USUAL.takeIf { fits(it, WidgetText.SECONDARY_SP, width, fontScale) } + fun at(sz: Sizes, withLine: Boolean, withTitle: Boolean): StatusSmall { + val shown = title.takeIf { withTitle } + val top = TopRow.of(shown.orEmpty(), null, line, width, fontScale, withWords = false) + val words = Words(name, usual?.takeIf { withLine }, false) + val wordsH = body(fontScale) + (if (words.line != null) secondary(fontScale) else 0f) + return StatusSmall(shown, top, sz, words, Spread.of(top.height, maxOf(sz.tile, sz.play), wordsH)) + } + val wide = { sz: Sizes -> sz.tile + STATUS_PLAY_GAP_DP + sz.play <= width } + val ladder = SMALL_SIZES.reversed().filter(wide) + val drops = listOf(true to true, false to true, false to false) + for ((withLine, withTitle) in drops) { + val grown = ladder.map { at(it, withLine, withTitle) } + if (grown.isNotEmpty() && grown.first().spread.fits(height, MIN_GAP_DP)) return Spread.grow(grown, height, MIN_GAP_DP) { it.spread } + } + val shrink = listOfNotNull(ladder.firstOrNull()) + STATUS_SHRINK + for ((withLine, withTitle) in drops) for (sz in shrink) { + at(sz, withLine, withTitle).takeIf { it.spread.fits(height, STATUS_MIN_GAP_DP) }?.let { return it } + } + return at(STATUS_SHRINK.last(), withLine = false, withTitle = false) + } + + /** The status button's top in the status Small's content [height] dp high, dp: as every Small's top row. */ + fun statusSmallButtonTop(s: StatusSmall, height: Float): Float = s.spread.edge(height) + (s.top.height - StatusButton.SIZE_DP) / 2 + + /** The usual row (or the recording's): its [words] and its height. */ + data class UsualRow(val words: Words, val height: Float) + + /** + * The usual row in [width] dp at [sizes]: the tile, the name over the first of [lines] that fits (with the + * location mark when [mark] and there's room for it), play or Open at the end. The short name when the full one + * won't fit. All on one line each. + */ + fun usualRow(sport: StartSport, lines: List, mark: Boolean, recording: Boolean, sizes: Sizes, width: Float, fontScale: Float): UsualRow { + val room = width - sizes.tile - 2 * ROW_GAP_DP - sizes.action(recording, fontScale) + val name = if (fits(sport.name, WidgetText.BODY_SP, room, fontScale)) sport.name else sport.shortName + val withMark = room - MARK_DP - MARK_GAP_DP + val words = when { + mark && fits(lines.first(), WidgetText.SECONDARY_SP, withMark, fontScale) -> Words(name, lines.first(), true) + else -> Words(name, lines.firstOrNull { fits(it, WidgetText.SECONDARY_SP, room, fontScale) }, false) + } + return UsualRow(words, maxOf(sizes.tile, sizes.play, body(fontScale) + secondary(fontScale))) + } + + /** The lines the usual row tries, longest first: "Your usual, GPS on", then "Your usual". */ + fun usualLines(m: StartWidgetModel): List = listOf(m.usualLine, StartWidgetModel.USUAL).distinct() + + + /** + * The tile row: the [sports] before All sports, [tile] dp tiles spread from the content's left edge to its right, + * evenly spaced, so the first lines up with the usual's tile and All sports with play. Captions at [captionSp], one + * line each, every one centred under its tile. + */ + data class Tiles(val sports: List, val tile: Float, val captionSp: Float, val height: Float) { + /** False for the last resort's bare tiles. */ + val captioned: Boolean get() = captionSp > 0f + + val corner: Float get() = SMALL_TILE_CORNER_DP * tile / SMALL_TILE_DP + val captions: List get() = sports.map { it.caption } + StartWidgetModel.ALL_SPORTS + } + + /** The tile row's height with [tile] dp tiles and captions at [sp]. */ + fun tilesHeight(tile: Float, sp: Float, fontScale: Float): Float = tile + CAPTION_GAP_DP + caption(fontScale, sp) + + /** The centre of tile [i] of [n] spread across [width] dp: the first at the left edge, the last at the right. */ + fun centre(i: Int, n: Int, tile: Float, width: Float): Float = tile / 2 + i * (width - tile) / (n - 1) + + /** + * How far a caption may run past the content's edge into the card's side padding: half of it. The widget's own + * padding there is [CAPTION_BLEED_DP] and every other row pads itself back in ([StartWidget]). + */ + const val CAPTION_BLEED_DP = WidgetSizes.PADDING_DP / 2 + + /** + * Where each caption needs to be, as (left, width) in dp from the content's left edge: centred under its tile, + * the first and last too, so it may start left of 0 or end past [width] (by at most [CAPTION_BLEED_DP] when it + * fits). Widths are [captionWidth]'s: this decides what fits, never the box a caption is drawn in ([captionBoxes]). + */ + fun captionSpans(captions: List, tile: Float, sp: Float, width: Float, fontScale: Float): List> { + val n = captions.size + return captions.mapIndexed { i, c -> + val w = captionWidth(c, sp, fontScale) + (centre(i, n, tile, width) - w / 2) to w + } + } + + /** + * The room a caption needs, dp: its width by letter (a W or an m is wider than an l, which the flat + * [WidgetText.width] misses: "Swim" showed as "Swi..." on the phone) with [CAPTION_MARGIN] on top, so a tight + * caption gives its place to the next sport instead of being cut. + */ + fun captionWidth(text: String, sp: Float, fontScale: Float): Float = WidgetText.fitWidth(text, sp, fontScale) + + /** How much wider than its letters' sum a caption is taken to be ([WidgetText.FIT_MARGIN]). */ + const val CAPTION_MARGIN = WidgetText.FIT_MARGIN + + /** [text]'s width in ems by letter ([WidgetText.letterEms]). */ + fun letterEms(text: String): Float = WidgetText.letterEms(text) + + /** + * True when the captions' spans keep [CAPTION_SPACE_DP] apart and stay within [CAPTION_BLEED_DP] of the content's + * edges. + */ + fun captionsFit(captions: List, tile: Float, sp: Float, width: Float, fontScale: Float): Boolean { + val spans = captionSpans(captions, tile, sp, width, fontScale) + if (spans.any { (l, w) -> l < -CAPTION_BLEED_DP || l + w > width + CAPTION_BLEED_DP }) return false + return spans.zipWithNext().all { (a, b) -> a.first + a.second + CAPTION_SPACE_DP <= b.first } + } + + /** + * The box each caption is drawn in, (left, width) dp from the content's left edge: centred on its tile, and as + * wide as its neighbours and the bleed into the padding leave, not as wide as the sum says, so a caption the sum + * underrates still shows whole. The room between two captions is split down the middle of the free space. + */ + fun captionBoxes(captions: List, tile: Float, sp: Float, width: Float, fontScale: Float): List> { + val n = captions.size + val spans = captionSpans(captions, tile, sp, width, fontScale) + val bounds = listOf(-CAPTION_BLEED_DP) + + (1 until n).map { k -> (spans[k - 1].first + spans[k - 1].second + spans[k].first) / 2 } + + (width + CAPTION_BLEED_DP) + return (0 until n).map { i -> + val c = centre(i, n, tile, width) + val half = minOf(c - bounds[i], bounds[i + 1] - c).coerceAtLeast(0f) + (c - half) to 2 * half + } + } + + /** + * The tile row in [width] by at most [room] dp with [tile] dp tiles and [columns] columns (sports and All + * sports), the caption at 11 sp before 10. With [skip] false only the sports in their order of use, the first ones + * each in its place, so every size that has room shows the same row. With [skip] true one whose caption would run + * into its neighbours gives its place to the next that fits. Null when that can't fill the row. + */ + fun tiles(next: List, width: Float, room: Float, fontScale: Float, tile: Float, columns: Int, skip: Boolean): Tiles? { + if (columns * tile + (columns - 1) * CAPTION_SPACE_DP > width) return null + for (sp in CAPTION_SIZES) { + val height = tilesHeight(tile, sp, fontScale) + if (height > room) continue + val chosen = if (!skip) next.take(columns - 1).takeIf { first -> + first.size == columns - 1 && captionsFit(first.map { it.caption } + StartWidgetModel.ALL_SPORTS, tile, sp, width, fontScale) + } else mutableListOf().apply { + for (s in next) { + if (size == columns - 1) break + val trial = (this + s).map { it.caption } + // The placed ones so far, with All sports at the far end where it will be. + val placed = trial + List(columns - 1 - trial.size) { "" } + StartWidgetModel.ALL_SPORTS + if (captionsFit(placed, tile, sp, width, fontScale)) add(s) + } + } + if (chosen != null && chosen.size == columns - 1) return Tiles(chosen, tile, sp, height) + } + return null + } + + /** + * The tile row at [tile] dp: the sports in order at the most columns that hold them, then one giving its place, then bare + * tiles without captions as the last resort ([bareTiles]). + */ + fun tiles(next: List, width: Float, room: Float, fontScale: Float, tile: Float = SMALL_TILE_DP): Tiles? { + for (skip in listOf(false, true)) for (columns in COLUMNS downTo 2) { + tiles(next, width, room, fontScale, tile, columns, skip)?.let { return it } + } + return bareTiles(next, width, room, tile) + } + + /** + * The last resort, a large font in a short cell: the tiles without captions (each read out by name), so the row + * stays rather than leaving a hole. + */ + fun bareTiles(next: List, width: Float, room: Float, tile: Float): Tiles? = + if (tile <= room && COLUMNS * tile + (COLUMNS - 1) * CAPTION_SPACE_DP <= width) Tiles(next.take(COLUMNS - 1), tile, 0f, tile) else null + + /** How the Large's two figures sit. Every value stays on one line in all three. */ + enum class FigureLayout { + /** Side by side, Caption over Body, as the mockup. */ + Side, + + /** A row each: the Caption on the left, the value on the right. */ + Rows, + + /** One under the other, Caption over Body across the whole width. */ + Stacked, + } + + data class Figures(val layout: FigureLayout, val height: Float) + + /** + * This week and the usual's last in [width] dp, a value never wrapped: side by side when both values fit half the + * width, else a row each with the Caption beside the value, else one under the other. + */ + fun figures(m: StartWidgetModel, width: Float, fontScale: Float): Figures { + val half = (width - FIGURE_GAP_DP) / 2 + val pair = caption(fontScale) + FIGURE_LINE_GAP_DP + body(fontScale) + if (fits(m.weekLine, WidgetText.BODY_SP, half, fontScale) && fits(m.lastLine, WidgetText.BODY_SP, half, fontScale)) { + return Figures(FigureLayout.Side, pair) + } + fun row(label: String, value: String) = + WidgetText.width(label, WidgetText.CAPTION_SP, fontScale) + FIGURE_GAP_DP + WidgetText.width(value, WidgetText.BODY_SP, fontScale) <= width + if (row(StartWidgetModel.THIS_WEEK, m.weekLine) && row(m.lastLabel, m.lastLine)) { + return Figures(FigureLayout.Rows, 2 * body(fontScale) + FIGURE_ROW_GAP_DP) + } + return Figures(FigureLayout.Stacked, 2 * pair + FIGURE_ROW_GAP_DP) + } + + /** + * The Wide or Large laid out: the [sizes] it's drawn at, the usual [row], the [tiles] (none while recording, or + * when not even a bare row fits), the Large's [figures], and the height of it all without gaps, so the spare + * height is what the gaps share ([gaps] of them: above, between each block, below). + */ + data class Block( + val sizes: Sizes, val row: UsualRow, val tiles: Tiles?, val figures: Figures?, val content: Float, val gaps: Int, + /** The status title row's height above the usual row (spec 5), 0 for none; counted in [content] and [gaps]. */ + val top: Float = 0f, + ) { + /** The Wide's gap above, between and below in a cell [height] dp high: all alike. */ + fun gap(height: Float): Float = ((height - content) / gaps).coerceAtLeast(0f) + + /** + * The gap above and below the block in a cell [height] dp high, for the Large: half of each gap between, so + * the card doesn't end in a band under its figures (the padding is under it already). The gaps between take + * the rest. + */ + fun edgeGap(height: Float): Float = ((height - content) / (gaps - 1) / 2).coerceAtLeast(0f) + } + + /** + * The Wide in [width] by [height] dp: the largest [ROW_SIZES] whose tile row still holds four sports and All + * sports, else the one that holds the most. + */ + fun wide(m: StartWidgetModel, width: Float, height: Float, fontScale: Float, top: Float = 0f): Block = block(m, width, height, fontScale, large = false, top) + + /** The Large: as the Wide, with the hairline and the figures under the tiles. */ + fun large(m: StartWidgetModel, width: Float, height: Float, fontScale: Float, top: Float = 0f): Block = block(m, width, height, fontScale, large = true, top) + + /** [top] is the status title row's height above it all ([statusTop]), 0 for none. */ + private fun block(m: StartWidgetModel, width: Float, height: Float, fontScale: Float, large: Boolean, top: Float): Block { + val figures = if (large) figures(m, width, fontScale) else null + val under = (figures?.let { it.height + 1f + 2 * MIN_GAP_DP } ?: 0f) + (if (top > 0f) top + MIN_GAP_DP else 0f) + fun row(sz: Sizes) = usualRow(m.usual, usualLines(m), m.gpsOn, false, sz, width, fontScale) + fun block(sz: Sizes, row: UsualRow, tiles: Tiles?): Block { + val content = top + row.height + (tiles?.height ?: 0f) + (figures?.let { it.height + 1f } ?: 0f) + // Above and below, and between the title row, the row, the tiles, the hairline and the figures. + val gaps = 2 + (if (top > 0f) 1 else 0) + (if (tiles != null) 1 else 0) + (if (figures != null) 2 else 0) + return Block(sz, row, tiles, figures, content, gaps, top) + } + val rows = ROW_SIZES.associateWith { row(it) } + fun room(sz: Sizes) = height - rows.getValue(sz).height - under - MIN_GAP_DP + // The sports in their order first, at the most columns and the largest sizes that hold them, so every size + // shows the same row or the start of it. Only when not even two fit in order does one give its place. + // Captions go last of all. + for (skip in listOf(false, true)) for (columns in COLUMNS downTo 2) for (sz in ROW_SIZES) { + tiles(m.next, width, room(sz), fontScale, sz.sportTile, columns, skip)?.let { return block(sz, rows.getValue(sz), it) } + } + for (sz in ROW_SIZES) bareTiles(m.next, width, room(sz), sz.sportTile)?.let { return block(sz, rows.getValue(sz), it) } + val last = ROW_SIZES.last() + return block(last, rows.getValue(last), null) + } + + /* -------------------------------------------------------------------------------------------- */ + /* Recording: the live timer as the hero */ + /* -------------------------------------------------------------------------------------------- */ + + /** The timer's largest size per layout, sp, and the least it steps down to. */ + const val TIMER_SMALL_SP = 40f + const val TIMER_WIDE_SP = 48f + const val TIMER_LARGE_SP = 56f + const val TIMER_MIN_SP = 20f + const val TIMER_STEP_SP = 2f + + /** Below this the Small recording leaves the sport's tile out rather than squeeze it beside Open. */ + const val MIN_TILE_DP = 32f + + /** The red recording dot before "Recording", and the room after it. */ + const val DOT_DP = 8f + const val DOT_GAP_DP = 6f + + /** Between the timer and "Since 11:58" beside it. */ + const val SINCE_GAP_DP = 12f + + /** The timer's widest text, h:mm:ss past an hour: sized for it from the start so it never outgrows its room. */ + const val TIMER_PATTERN = "0:00:00" + + /** + * The timer's width at [sp], dp: a Chronometer is a TextView, its size in sp, so it scales with the system font + * ([fontScale]) as the sums do. By letter with the captions' margin. + */ + fun timerWidth(sp: Float, fontScale: Float): Float = letterEms(TIMER_PATTERN) * sp * fontScale * CAPTION_MARGIN + + /** One line of the timer at [sp], dp. */ + fun timerLine(sp: Float, fontScale: Float): Float = WidgetText.line(sp, fontScale) + + /** Where "Since 11:58" goes: beside the timer, under it, or nowhere when there's no room. */ + enum class SincePlace { Beside, Under, None } + + /** + * A recording layout: the sport's tile at [tile] dp and Open [open] dp high in the top row, the [name] shown, the + * "Recording" line under it when [status] (the Wide and Large), the timer at [timerSp], "Since 11:58" at [since], + * the Large's [figures], and the height of it all without gaps, which [gaps] share. + */ + data class Recording( + val tile: Float, val open: Float, val name: String, val status: Boolean, val timerSp: Float, val since: SincePlace, + val figures: Figures?, val content: Float, val gaps: Int, + ) { + val sizes: Sizes get() = Sizes(tile, open, 0f) + + /** As [Block.edgeGap]: the Large's gap above and below, half of those between. */ + fun edgeGap(height: Float): Float = ((height - content) / (gaps - 1) / 2).coerceAtLeast(0f) + } + + /** The heights the timer steps through from [top], largest first. */ + private fun timerSizes(top: Float): List = generateSequence(top) { it - TIMER_STEP_SP }.takeWhile { it >= TIMER_MIN_SP }.toList() + + /** + * The Small recording in [width] by [height] dp: the sport's tile top left (smaller when Open needs the room) and + * Open top right, then the timer as large as fits up to [TIMER_SMALL_SP], then the sport's name (the short one when + * the full one won't fit), then "Since 11:58" under it when the height allows. Paused, that line reads "Paused" + * and the timer gives way to keep it. + */ + fun smallRecording(r: StartRecording, width: Float, height: Float, fontScale: Float): Recording { + val open = PLAY_DP + val tile = minOf(56f, width - SMALL_GAP_DP - openWidth(fontScale)).coerceAtLeast(0f) + val top = maxOf(tile, open) + val name = if (fits(r.sport.name, WidgetText.BODY_SP, width, fontScale)) r.sport.name else r.sport.shortName + val words = body(fontScale) + val sinceH = secondary(fontScale) + for (sp in timerSizes(TIMER_SMALL_SP)) { + if (timerWidth(sp, fontScale) > width) continue + val base = top + timerLine(sp, fontScale) + words + if (base + MIN_GAP_DP > height) continue + val roomy = base + sinceH + MIN_GAP_DP <= height + // Paused, the line reads "Paused" and must show: a smaller timer before leaving it out. + if (r.paused && !roomy) continue + val since = roomy && (r.paused || fits(r.since, WidgetText.SECONDARY_SP, width, fontScale)) + return Recording(tile, open, name, false, sp, if (since) SincePlace.Under else SincePlace.None, null, base + if (since) sinceH else 0f, 3) + } + // No room for both lines: paused, "Paused" takes the name's (the tile still shows the sport). + return Recording(tile, open, if (r.paused) r.status else name, false, TIMER_MIN_SP, SincePlace.None, null, top + timerLine(TIMER_MIN_SP, fontScale) + words, 3) + } + + /** The Wide recording: the top row, then the timer with "Since" beside or under it, as large as fits. */ + fun wideRecording(r: StartRecording, width: Float, height: Float, fontScale: Float): Recording = + rowRecording(r, null, TIMER_WIDE_SP, width, height, fontScale) + + /** The Large recording: the Wide's, then the hairline and This week and the usual's last, as idle. */ + fun largeRecording(r: StartRecording, m: StartWidgetModel, width: Float, height: Float, fontScale: Float): Recording = + rowRecording(r, figures(m, width, fontScale), TIMER_LARGE_SP, width, height, fontScale) + + /** + * The Wide's and Large's recording in [width] by [height] dp: the top row (the sport's tile, its name over the red + * dot and "Recording", Open), then the timer as large as fits up to [top] sp, "Since 11:58" beside it when the + * width allows, else under it when the height does, else left out. The biggest timer wins over the biggest tile. + */ + private fun rowRecording(r: StartRecording, figures: Figures?, top: Float, width: Float, height: Float, fontScale: Float): Recording { + val under = figures?.let { it.height + 1f + 2 * MIN_GAP_DP } ?: 0f + val gaps = if (figures != null) 5 else 3 + val sinceW = WidgetText.width(r.since, WidgetText.SECONDARY_SP, fontScale) + for (sp in timerSizes(top)) for (sz in ROW_SIZES) { + val room = width - sz.tile - 2 * ROW_GAP_DP - openWidth(fontScale) + val name = if (fits(r.sport.name, WidgetText.BODY_SP, room, fontScale)) r.sport.name else r.sport.shortName + val status = fits(r.status, WidgetText.SECONDARY_SP, room - DOT_DP - DOT_GAP_DP, fontScale) + val row = maxOf(sz.tile, sz.play, body(fontScale) + if (status) secondary(fontScale) else 0f) + val timerW = timerWidth(sp, fontScale) + if (timerW > width) continue + val timer = timerLine(sp, fontScale) + val beside = timerW + SINCE_GAP_DP + sinceW <= width + val since = when { + beside -> SincePlace.Beside + row + timer + secondary(fontScale) + MIN_GAP_DP + under <= height -> SincePlace.Under + else -> SincePlace.None + } + val block = row + timer + (if (since == SincePlace.Under) secondary(fontScale) else 0f) + if (block + MIN_GAP_DP + under > height) continue + return Recording(sz.tile, sz.play, name, status, sp, since, figures, block + (figures?.let { it.height + 1f } ?: 0f), gaps) + } + val sz = ROW_SIZES.last() + val row = maxOf(sz.tile, sz.play, body(fontScale)) + return Recording(sz.tile, sz.play, r.sport.shortName, false, TIMER_MIN_SP, SincePlace.None, figures, + row + timerLine(TIMER_MIN_SP, fontScale) + (figures?.let { it.height + 1f } ?: 0f), gaps) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StepsWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/StepsWidget.kt new file mode 100644 index 00000000..20c6f2c5 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/StepsWidget.kt @@ -0,0 +1,300 @@ +package org.openstrap.app.widget + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Paint +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.width +import androidx.glance.text.Text +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import org.openstrap.app.OpenStrapApp +import org.openstrap.app.data.DailyMetric +import org.openstrap.app.data.DayWindow +import org.openstrap.app.data.LiveSteps +import org.openstrap.app.data.Summaries +import org.openstrap.app.ui.glow.EdgeKind +import org.openstrap.app.ui.glow.EdgeStatus +import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.ui.glow.draw.WeekBarsLook +import org.openstrap.app.ui.glow.draw.drawGhostLine +import org.openstrap.app.ui.glow.draw.drawPillBar +import org.openstrap.app.ui.glow.draw.ghostLineGeometry +import org.openstrap.app.ui.glow.draw.renderToBitmap +import org.openstrap.app.ui.glow.draw.weekBars +import org.openstrap.app.ui.glow.draw.weekStub +import org.openstrap.app.ui.glow.edgeLight +import org.openstrap.app.ui.theme.MetricColors +import java.time.DayOfWeek +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters +import kotlin.math.roundToInt + +/** + * The Steps widget (widgets spec 3.3): today's steps against the goal, glanceable all day. The Wide adds what's to go + * and the day so far as the Steps card's line, the Large this week day by day. An edge card lit from the bottom, a + * value, in Steps' lime and the moment's mood, as Today's Steps card. The whole widget opens the Steps detail. + */ +class StepsWidget : LoadingWidget(WidgetKind.Steps) { + override suspend fun loadShown(context: Context): Shown = load(context) + + override fun sample(): Shown = Shown(WidgetSamples.steps(java.time.LocalDate.now()), GlowMood.Day) + + @Composable + override fun Content(data: Shown) = StepsContent(data) + + /** What the widget shows and the light it's lit in. */ + data class Shown(val model: StepsWidgetModel, val mood: GlowMood) + + companion object { + /** + * Today's steps as Today's Steps card counts them: the synced minutes with the strap's live total on top + * ([LiveSteps]), the day's line from the minutes hour by hour, the goal from settings, and this week's earlier + * days from the daily totals the app's week shows. The mood from the moment now ([widgetMood]). + */ + internal suspend fun load(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): Shown { + val container = (context.applicationContext as OpenStrapApp).container + val at = now.atZone(zone) + val today = at.toLocalDate() + val goal = container.settings.goals.first().steps + val model = try { + val day = DayWindow.of(today, zone) + val minutes = container.database.healthDao().activityOnce(day.from, day.to) + val steps = LiveSteps().today(Summaries.totalSteps(minutes), container.strap.todaySteps.value) + val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val past = if (monday < today) { + container.health.daily(DailyMetric.Steps, monday, today.minusDays(1), zone).first() + .filter { it.date < today }.associate { it.date to it.value.roundToInt() } + } else emptyMap() + StepsWidgetModel.from(today, steps, Summaries.hourlySteps(minutes, day.from), at.hour, goal, past) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(WIDGET_TAG, "widgets: steps for $today not read: $e") + StepsWidgetModel.from(today, 0, List(24) { 0f }, at.hour, goal, emptyMap()) + } + val bedtime = try { + container.health.usualBedtime(today) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + return Shown(model, widgetMood(container, bedtime, now, zone)) + } + } +} + +/** The Steps widget's receiver. */ +class StepsWidgetReceiver : PlacedWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = StepsWidget() +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Layout */ +/* ------------------------------------------------------------------------------------------------ */ + +/** Steps' lime, as Today's Steps card. */ +private val Ink = MetricColors.steps + +/** The goal bar's track (the mockup's): white at 10%. */ +private const val TRACK_ALPHA = 0.10f + +/** The widget at the size Glance lays out, lit from the bottom as Today's Steps card ([EdgeKind.Progress]). */ +@Composable +private fun StepsContent(data: StepsWidget.Shown) { + val m = data.model + val open = openMetric(LocalContext.current, STEPS_ROUTE, m.date) + EdgeWidget(edgeLight(EdgeStatus.Usual, EdgeKind.Progress), Ink, data.mood, open, BITMAP_EXTRA) { frame -> + when (frame.bucket) { + WidgetSizes.Small -> SmallContent(m, frame) + WidgetSizes.Wide -> WideContent(m, frame) + else -> LargeContent(m, frame) + } + } +} + +/** A gap between blocks: they share the spare height alike, with half a gap above and below. */ +@Composable +private fun ColumnScope.Gap() = Spacer(GlanceModifier.defaultWeight()) + +/** The half gap above the first block or below the last, at the cell's real height. */ +@Composable +private fun Edge(spread: Spread, f: WidgetFrame) = Spacer(GlanceModifier.height(spread.edge(f.drawContentHeight).dp)) + +/** Small: the top row, then the steps with "today", then the goal bar and the share of the goal. */ +@Composable +private fun ColumnScope.SmallContent(m: StepsWidgetModel, f: WidgetFrame) { + val fit = StepsLayout.small(m.stepsText, m.share, f.contentWidth, f.contentHeight, f.fontScale, f.status) + Edge(fit.spread, f) + TopRowView(m.title, null, fit.top, f, fit.spread.edge(f.drawContentHeight)) + Gap() + // Each block one child of the column: Glance drops a column's children past ten. + Column(GlanceModifier.fillMaxWidth()) { + DotNumber(m.stepsText, fit.dot, f.density, m.description) + if (fit.word) { + Spacer(GlanceModifier.height(StepsLayout.SMALL_WORD_GAP_DP.dp)) + Text(m.word, style = WidgetType.word, maxLines = 1) + } + } + Gap() + Column(GlanceModifier.fillMaxWidth()) { + GoalBar(m.progress, f) + if (fit.share) { + Spacer(GlanceModifier.height(StepsLayout.SMALL_BAR_GAP_DP.dp)) + Text(m.share, style = WidgetType.secondary, maxLines = 1) + } + } + Edge(fit.spread, f) +} + +/** Wide: the top row with the goal, the steps with what's to go, the day so far as a line (or the goal bar). */ +@Composable +private fun ColumnScope.WideContent(m: StepsWidgetModel, f: WidgetFrame) { + val fit = StepsLayout.wide(m, f.contentWidth, f.contentHeight, f.fontScale, f.status) + Edge(fit.spread, f) + TopRowView(m.title, m.goalNote, fit.top, f, fit.spread.edge(f.drawContentHeight)) + Gap() + NumberRow(m, fit.row, f) + Gap() + StepsLine(m, fit.line, f) + Edge(fit.spread, f) +} + +/** Large: the Wide plus this week, day by day against the goal. */ +@Composable +private fun ColumnScope.LargeContent(m: StepsWidgetModel, f: WidgetFrame) { + val fit = StepsLayout.large(m, f.contentWidth, f.contentHeight, f.fontScale, f.status) + Edge(fit.spread, f) + TopRowView(m.title, m.goalNote, fit.top, f, fit.spread.edge(f.drawContentHeight)) + Gap() + NumberRow(m, fit.row, f) + Gap() + StepsLine(m, fit.line, f) + Gap() + fit.week?.let { WeekRow(m, it, f.density) } + Edge(fit.spread, f) +} + +/** The steps in dots with "today" beside them when it fits, what's to go (or "Goal met") on the right. */ +@Composable +private fun NumberRow(m: StepsWidgetModel, row: StepsLayout.NumberRow, f: WidgetFrame) { + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { + DotNumber(m.stepsText, row.dot, f.density, m.description) + if (row.word) { + Spacer(GlanceModifier.width(StepsLayout.WORD_GAP_DP.dp)) + Text(m.word, style = WidgetType.word, maxLines = 1) + } + Spacer(GlanceModifier.defaultWeight()) + Text(m.toGo, style = WidgetType.secondary, maxLines = 1) + } +} + +/** + * The day so far as the Steps card's line, solid in lime and ending in its dot, [height] dp high with the goal at the + * top, so it fills as the day reaches the goal. The goal bar instead when there's no room or no step yet. + */ +@Composable +private fun StepsLine(m: StepsWidgetModel, height: Float?, f: WidgetFrame) { + if (!m.showLine || height == null) { + // No line (a large font, or none to draw): the goal bar shows the progress instead. + GoalBar(m.progress, f) + return + } + val w = f.drawContentWidth * f.density + val h = height * f.density + val bitmap = renderToBitmap(w, h) { c -> + ghostLineGeometry(m.line, null, m.lineMax, w, h, f.density)?.let { drawGhostLine(c, it, Ink, f.density) } + } + Image( + ImageProvider(bitmap), + contentDescription = "Steps so far today, ${m.share}", + modifier = GlanceModifier.fillMaxWidth().height(height.dp), + contentScale = ContentScale.FillBounds, + ) +} + +/** The goal bar: 4 dp, lime on white at 10%, as far as the goal, full past it. */ +@Composable +private fun GoalBar(progress: Float, f: WidgetFrame) { + Image( + ImageProvider(goalBar(progress, f.drawContentWidth, f.density)), + contentDescription = null, + modifier = GlanceModifier.fillMaxWidth().height(StepsLayout.BAR_DP.dp), + contentScale = ContentScale.FillBounds, + ) +} + +/** "This week" and how many days met the goal on one line, then a bar a day Monday to Sunday under it. */ +@Composable +private fun WeekRow(m: StepsWidgetModel, week: StepsLayout.Week, density: Float) { + // One child of the Large's column: Glance drops a column's children past ten. + Column(GlanceModifier.fillMaxWidth()) { + Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) { + if (week.title) { + Text(StepsWidgetModel.THIS_WEEK, style = WidgetType.secondaryShown, maxLines = 1) + Spacer(GlanceModifier.defaultWeight()) + } + Text(m.weekLine, style = WidgetType.secondary, maxLines = 1) + } + Spacer(GlanceModifier.height(StepsLayout.WEEK_LINE_GAP_DP.dp)) + WeekChart(ImageProvider(weekBitmap(m, week.column, density)), "Steps each day this week", m.week.map { it.letter to (it.state == StepsDay.Today) }, week.column) + } +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Bitmaps */ +/* ------------------------------------------------------------------------------------------------ */ + +/** The route `open_metric` takes for the Steps detail ([org.openstrap.app.ui.dashboard.Detail.fromRoute]). */ +internal const val STEPS_ROUTE = "Steps" + +/** Room for the dots, the line, the bar and the week beside the backgrounds, in the bitmap budget. */ +private const val BITMAP_EXTRA = 1.35 + +/** The goal bar [width] dp wide at [density]: the track, then the fill as far as [progress]. */ +private fun goalBar(progress: Float, width: Float, density: Float): Bitmap { + val w = width * density + val h = StepsLayout.BAR_DP * density + val r = h / 2 + return renderToBitmap(w, h) { c -> + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + c.drawRoundRect(0f, 0f, w, h, r, r, paint.apply { color = Color.White.copy(alpha = TRACK_ALPHA).toArgb() }) + val fill = w * progress.coerceIn(0f, 1f) + if (fill > 0f) c.drawRoundRect(0f, 0f, fill.coerceAtLeast(h), h, r, r, paint.apply { color = Ink.toArgb() }) + } +} + +/** This week's bars: a met day lime, a missed one white at 30%, today white, a day to come a stub at 12%. */ +private fun weekBitmap(m: StepsWidgetModel, column: Float, density: Float): Bitmap { + val bars = weekBars(StepsWidgetModel.barValues(m.week, m.goal), density, column) + return renderToBitmap(WeekBarsLook.width(m.week.size, column) * density, WeekBarsLook.HEIGHT_DP * density) { c -> + m.week.forEachIndexed { i, d -> + when (d.state) { + StepsDay.ToCome -> drawPillBar(c, weekStub(i, density, column), Color.White.copy(alpha = WeekBarsLook.STUB_ALPHA)) + StepsDay.NoRecord -> Unit + StepsDay.Met -> bars[i]?.let { drawPillBar(c, it, Ink) } + StepsDay.Missed -> bars[i]?.let { drawPillBar(c, it, Color.White.copy(alpha = WeekBarsLook.DIM_ALPHA)) } + StepsDay.Today -> bars[i]?.let { drawPillBar(c, it, Color.White) } + } + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StepsWidgetModel.kt b/app/src/main/kotlin/org/openstrap/app/widget/StepsWidgetModel.kt new file mode 100644 index 00000000..a2d76f9f --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/StepsWidgetModel.kt @@ -0,0 +1,311 @@ +package org.openstrap.app.widget + +import org.openstrap.app.data.Summaries +import org.openstrap.app.ui.dashboard.ActivityCopy +import org.openstrap.app.ui.dashboard.Metric +import org.openstrap.app.ui.glow.DotSize +import org.openstrap.app.ui.glow.draw.WeekBarsLook +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.TextStyle +import java.time.temporal.TemporalAdjusters +import java.util.Locale +import kotlin.math.ceil + +/** How a day of this week stands against the goal, which colours its bar on the Large. */ +enum class StepsDay { + /** At or past the goal: Steps' lime. */ + Met, + + /** Under it: white at 30%. */ + Missed, + + /** Today, met or not yet: white. */ + Today, + + /** Later this week: a 4 dp stub at 12%. */ + ToCome, + + /** A day gone with no steps recorded (the strap not worn): no bar, and it doesn't count. */ + NoRecord, +} + +/** A day's bar in the Large's week: its letter, its steps (null without any) and how it stands. */ +data class StepsWeekDay(val date: LocalDate, val letter: String, val steps: Int?, val state: StepsDay) + +/** + * What the Steps widget shows (widgets spec 3.3), the same model at every size: the Small shows the title, [stepsText] + * with [word], the goal bar and [share]; the Wide adds [goalNote], [toGo] and the day's [line]; the Large adds the + * [week] and [weekLine]. There's no waiting state: before any steps today it shows 0 against the goal, as Today's + * Steps card does. + */ +data class StepsWidgetModel( + /** Today, which a tap opens the Steps detail on. */ + val date: LocalDate, + val title: String, + val goal: Int, + /** "Goal 10,000". */ + val goalNote: String, + val steps: Int, + /** The number in dots, "8,432", and the word under or beside it. */ + val stepsText: String, + val word: String, + /** "1,568 to go", or "Goal met" past the goal. */ + val toGo: String, + /** The Small's line under the bar, as Today's card says it: "84% of your goal", "Goal met". */ + val share: String, + /** The goal bar, 0 to 1: full past the goal. */ + val progress: Float, + /** Today's running total at the end of each hour so far, the Steps card's line ([Summaries.runningSteps]). */ + val line: List, + /** The line's value axis tops out here, as the card's: the goal, or the day when it's past it. */ + val lineMax: Float, + /** Monday to Sunday. */ + val week: List, + /** "Goal met 2 of 6 days". */ + val weekLine: String, +) { + val goalMet: Boolean get() = steps >= goal + + /** The line shows once there's a step to draw, as on Today's card. */ + val showLine: Boolean get() = steps > 0 && line.size >= 2 + + /** What TalkBack reads for the dotted number: "8,432 steps today, 84% of your goal". */ + val description: String get() = "$stepsText steps today, ${share.replaceFirstChar { it.lowercase() }}" + + companion object { + const val TITLE = "Steps" + const val TODAY = "today" + const val GOAL_MET = "Goal met" + const val THIS_WEEK = "This week" + + /** + * The widget for [today] at [hour]: [steps] so far (the live total, as Today's card shows it), the day's + * [hourly] steps from the synced minutes for the line, the [goal] from settings, and [past] days' steps this + * week (days without a record absent). + */ + fun from( + today: LocalDate, steps: Int, hourly: List, hour: Int, goal: Int, past: Map, + locale: Locale = Locale.getDefault(), + ): StepsWidgetModel { + val line = Summaries.runningSteps(hourly, hour) + val week = week(today, steps, goal, past, locale) + val counted = week.filter { it.state != StepsDay.ToCome && it.state != StepsDay.NoRecord } + val met = counted.count { (it.steps ?: 0) >= goal } + return StepsWidgetModel( + date = today, + title = TITLE, + goal = goal, + goalNote = "Goal ${format(goal)}", + steps = steps, + stepsText = format(steps), + word = TODAY, + toGo = toGo(steps, goal), + share = ActivityCopy.goalShare(steps, goal), + progress = if (goal <= 0) 1f else (steps / goal.toFloat()).coerceIn(0f, 1f), + line = line, + // The goal at the top, so the line fills toward it. Past the goal the day's total is the top. + lineMax = maxOf(goal.toFloat(), steps.toFloat(), line.filterNotNull().maxOrNull() ?: 0f), + week = week, + weekLine = weekLine(met, counted.size), + ) + } + + /** "1,568 to go", or "Goal met" from the goal on. */ + fun toGo(steps: Int, goal: Int): String = if (steps >= goal) GOAL_MET else "${format(goal - steps)} to go" + + /** "Goal met 2 of 6 days", "Goal met 0 of 1 day". */ + fun weekLine(met: Int, of: Int): String = "Goal met $met of $of ${if (of == 1) "day" else "days"}" + + /** Monday to Sunday of [today]'s week, each day as it stands against [goal]. */ + fun week(today: LocalDate, steps: Int, goal: Int, past: Map, locale: Locale): List { + val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + return (0L until 7L).map { monday.plusDays(it) }.map { d -> + val letter = d.dayOfWeek.getDisplayName(TextStyle.NARROW, locale) + when { + d > today -> StepsWeekDay(d, letter, null, StepsDay.ToCome) + d == today -> StepsWeekDay(d, letter, steps, StepsDay.Today) + else -> past[d]?.let { s -> StepsWeekDay(d, letter, s, if (s >= goal) StepsDay.Met else StepsDay.Missed) } + ?: StepsWeekDay(d, letter, null, StepsDay.NoRecord) + } + } + } + + /** + * Each day's bar as a share of the week's top, the goal or the most steps in a day if that's more, so a met day + * stands taller than a missed one. Null where there's no bar to draw (days to come get a stub instead). + */ + fun barValues(week: List, goal: Int): List { + val top = maxOf(goal, week.maxOf { it.steps ?: 0 }).coerceAtLeast(1).toFloat() + return week.map { d -> d.steps?.takeIf { d.state != StepsDay.ToCome }?.let { it / top } } + } + + private fun format(n: Int) = Metric.Steps.format(n.toFloat()) + } +} + +/** + * The sums that fit the Steps widget into its smallest cell at any system font size (spec 2.1): what each size keeps + * when it can't hold everything, how small the dots go, how tall the line can be. Widths and heights in dp, worked + * out at the bucket's content size ([WidgetFrame.contentWidth]); text with [WidgetText]'s rough sums. + */ +object StepsLayout { + /** The dots on a Small, and on a Wide or Large (spec 3). */ + val SMALL_DOT = DotSize.Edge.dot.value + val WIDE_DOT = DotSize.Second.dot.value + + /** Small: between the number and "today", and either side of the goal bar. */ + const val SMALL_WORD_GAP_DP = 4f + const val SMALL_BAR_GAP_DP = 6f + + /** Wide: between the number and "today", and before what's to go on the right. */ + const val WORD_GAP_DP = 8f + const val SIDE_GAP_DP = 12f + + /** The goal bar's height: the Small's, and the Wide's and Large's when a large font drops their line. */ + const val BAR_DP = 4f + + /** + * The line at its tallest and shortest worth drawing. It is the Wide's and Large's only progress: its top is the + * goal, so it fills its height as the day reaches it. It takes the mockup's line, gap and goal bar together. + */ + const val LINE_DP = 52f + const val MIN_LINE_DP = 20f + + /** The least room between the rows that share out the spare height. */ + const val MIN_GAP_DP = 4f + + /** Between "This week" and how many days met the goal, on their one line. */ + const val WEEK_GAP_DP = 12f + + /** Between that line and the bars under it. */ + const val WEEK_LINE_GAP_DP = 6f + + /** [text]'s dots at [dot] dp, wide and high. */ + fun dotsWidth(text: String, dot: Float): Float = WidgetDots.width(text, dot) + fun dotsHeight(dot: Float): Float = WidgetDots.height(dot) + + /** The dot that fits [text] into [room] dp, at most [dot]. */ + fun fitDot(text: String, room: Float, dot: Float): Float = WidgetDots.fit(text, room, dot) + + /** + * What the Small keeps: the dot it draws at, whether "today" and the share line fit, its [top] row and how its + * three blocks (the top row, the number with "today", the bar with the share) share the height. + */ + data class Small( + val dot: Float, val word: Boolean, val share: Boolean, + val top: TopRow = TopRow(null, false, 0f), val spread: Spread = Spread(0f, 0), + ) + + /** The Small's dots: the mockup's 3 dp (spec 3), growing to 7 on a tall cell before the gaps do. */ + val SMALL_DOTS = listOf(3f, 3.6f, 4.5f, 5f, 5.5f, 6f, 6.5f, 7f) + + /** + * The Small in [width] by [height] dp: the top row (the title, with the strap [status] button when it shows), the + * number, the bar always, then the share line and "today" while they fit, the share first as it says how far. The + * share only on one line. The blocks share the spare height, the dots growing from 3 dp until no gap is over + * [Spread.TARGET_GAP_DP]; at least [SMALL_BAR_GAP_DP] between them all. + */ + fun small(stepsText: String, share: String, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Small { + val top = TopRow.of(StepsWidgetModel.TITLE, null, status, width, fontScale, withWords = false) + val shareFits = WidgetText.width(share, WidgetText.SECONDARY_SP, fontScale) <= width + fun at(dot: Float, word: Boolean, withShare: Boolean): Small { + val d = fitDot(stepsText, width, dot) + val number = dotsHeight(d) + (if (word) SMALL_WORD_GAP_DP + WidgetText.line(WidgetText.BODY_SP, fontScale) else 0f) + val foot = BAR_DP + (if (withShare) SMALL_BAR_GAP_DP + WidgetText.line(WidgetText.SECONDARY_SP, fontScale) else 0f) + return Small(d, word, withShare, top, Spread.of(top.height, number, foot)) + } + val gap = SMALL_BAR_GAP_DP / 2 + for ((withShare, word) in listOf(true to true, true to false, false to true, false to false)) { + if (withShare && !shareFits) continue + val base = at(SMALL_DOT, word, withShare) + if (!base.spread.fits(height, gap)) continue + val ladder = SMALL_DOTS.map { at(it, word, withShare) }.distinct() + return Spread.grow(ladder, height, gap) { it.spread } + } + return at(SMALL_DOT, word = false, withShare = false) + } + + /** The Wide's and Large's number row: the dot it draws at and whether "today" fits beside it. */ + data class NumberRow(val dot: Float, val word: Boolean) { + fun height(fontScale: Float): Float = + maxOf(dotsHeight(dot), WidgetText.line(WidgetText.BODY_SP, fontScale), WidgetText.line(WidgetText.SECONDARY_SP, fontScale)) + } + + /** + * The number with "today" and [toGo] on the right, in [width] dp: at 3.6 dp dots when all fit, the dots smaller + * down to the Small's 3 dp to keep "today", then without "today" at up to 3.6 dp. + */ + fun numberRow(stepsText: String, toGo: String, width: Float, fontScale: Float): NumberRow { + val right = SIDE_GAP_DP + WidgetText.width(toGo, WidgetText.SECONDARY_SP, fontScale) + val word = WORD_GAP_DP + WidgetText.width(StepsWidgetModel.TODAY, WidgetText.BODY_SP, fontScale) + val withWord = fitDot(stepsText, width - right - word, WIDE_DOT) + if (withWord >= SMALL_DOT) return NumberRow(withWord, word = true) + return NumberRow(fitDot(stepsText, width - right, WIDE_DOT), word = false) + } + + /** + * How tall the Wide's line can be in [height] dp under the title and [row]: up to [LINE_DP], null when less than + * [MIN_LINE_DP] is left, so a large font drops the line before anything clips. + */ + fun wideLine(height: Float, row: NumberRow, fontScale: Float): Float? = + lineIn(height - WidgetText.line(WidgetText.TITLE_SP, fontScale) - row.height(fontScale) - 2 * MIN_GAP_DP) + + /** The Large's week: the bars' column width, whether "This week" fits beside the count, and the height. */ + data class Week(val column: Float, val title: Boolean, val height: Float) + + /** + * This week in [width] dp: one line, "This week" on the left and [weekLine] on the right, never wrapping (a + * large font drops "This week" first), then the bars across the width, columns up to [WeekBarsLook.COLUMN_DP]. + */ + fun week(weekLine: String, width: Float, fontScale: Float, days: Int = 7): Week { + val sp = WidgetText.SECONDARY_SP + val title = WidgetText.width(StepsWidgetModel.THIS_WEEK, sp, fontScale) + WEEK_GAP_DP + WidgetText.width(weekLine, sp, fontScale) <= width + val column = WeekBarsLook.column(days, width) + val bars = WeekBarsLook.HEIGHT_DP + 4f + WidgetText.line(WidgetText.CAPTION_SP, fontScale) + return Week(column, title, WidgetText.line(sp, fontScale) + WEEK_LINE_GAP_DP + bars) + } + + /** How tall the Large's line can be in [height] dp, as [wideLine] with the [week] row under it too. */ + fun largeLine(height: Float, row: NumberRow, week: Week, fontScale: Float): Float? = + lineIn(height - WidgetText.line(WidgetText.TITLE_SP, fontScale) - row.height(fontScale) - week.height - 3 * MIN_GAP_DP) + + private fun lineIn(room: Float): Float? = room.coerceAtMost(LINE_DP).takeIf { it >= MIN_LINE_DP } + + /** As far as the line grows on a tall cell before the gaps do. */ + const val MAX_LINE_DP = 72f + + /** + * The Wide or Large laid out: the [top] row, the number [row], the line's height ([line], null for the goal bar + * instead), the Large's [week], and how the blocks share the height ([spread]). + */ + data class Fit(val top: TopRow, val row: NumberRow, val line: Float?, val week: Week?, val spread: Spread) + + /** + * The Wide in [width] by [height] dp: the top row (the title with the goal, or the strap [status] line in the + * title's place), the number row, then the day's line as tall as fits up to [LINE_DP], growing to [MAX_LINE_DP] + * on a tall cell so no gap passes [Spread.TARGET_GAP_DP]; the goal bar when the line doesn't fit. + */ + fun wide(m: StepsWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Fit = + fit(m, width, height, fontScale, status, large = false) + + /** The Large: as the Wide with this week under the line. */ + fun large(m: StepsWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine? = null): Fit = + fit(m, width, height, fontScale, status, large = true) + + private fun fit(m: StepsWidgetModel, width: Float, height: Float, fontScale: Float, status: StatusLine?, large: Boolean): Fit { + val top = TopRow.of(m.title, m.goalNote, status, width, fontScale, withWords = true) + val row = numberRow(m.stepsText, m.toGo, width, fontScale) + val week = if (large) week(m.weekLine, width, fontScale, m.week.size) else null + val inner = if (large) 3 else 2 + val fixed = top.height + row.height(fontScale) + (week?.height ?: 0f) + val room = height - fixed - inner * MIN_GAP_DP + val line = if (!m.showLine) null else lineIn(room)?.let { l -> + // A tall cell grows the line until the gaps come down to the target, never past the room. + val wanted = height - fixed - (inner + 1) * Spread.TARGET_GAP_DP + maxOf(l, wanted.coerceAtMost(MAX_LINE_DP)).coerceAtMost(room) + } + val spread = Spread.of(top.height, row.height(fontScale), line ?: BAR_DP, week?.height) + return Fit(top, row, line, week, spread) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StrapStatus.kt b/app/src/main/kotlin/org/openstrap/app/widget/StrapStatus.kt new file mode 100644 index 00000000..51a66954 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/StrapStatus.kt @@ -0,0 +1,86 @@ +package org.openstrap.app.widget + +import org.openstrap.app.ui.dashboard.clockText +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +import java.util.Locale + +/** At or under this battery level, while not charging, every widget says so (widgets spec 5). */ +const val STRAP_LOW_PERCENT = 15 + +/** A last sync older than this takes the status line, and a tap syncs. */ +const val SYNC_STALE_SECONDS = 3 * 3600L + +/** The strap status line a widget shows in its top row (widgets spec 5); none while all is well. */ +sealed interface StrapStatus { + /** Opens the Strap page. */ + data object NotPaired : StrapStatus + + /** Opens the Strap page. */ + data class LowBattery(val percent: Int) : StrapStatus + + /** [at] is the last sync's end, epoch seconds. A tap syncs. */ + data class StaleSync(val at: Long) : StrapStatus + + /** The stale line's sync, under way. */ + data object Syncing : StrapStatus +} + +/** + * The status line, first match wins: not [paired], then [battery] at [STRAP_LOW_PERCENT] or less and not [charging], + * then [lastSync] (epoch seconds) more than [SYNC_STALE_SECONDS] before [now]. While [syncing] the stale line reads + * Syncing instead; a sync while all is well shows nothing, so the background syncs don't flash a line. Before the + * first sync is known there's no time to give, so no line. + */ +fun strapStatus(paired: Boolean, battery: Int?, charging: Boolean, lastSync: Long?, now: Long, syncing: Boolean = false): StrapStatus? = when { + !paired -> StrapStatus.NotPaired + battery != null && battery <= STRAP_LOW_PERCENT && !charging -> StrapStatus.LowBattery(battery) + syncStale(lastSync, now) -> if (syncing) StrapStatus.Syncing else StrapStatus.StaleSync(lastSync!!) + else -> null +} + +/** True when the last sync ended more than [SYNC_STALE_SECONDS] before [now]. */ +fun syncStale(lastSync: Long?, now: Long): Boolean = lastSync != null && now - lastSync > SYNC_STALE_SECONDS + +/** True when the line for the strap's state could read otherwise: pairing, the low battery edge or charging changed. */ +fun strapStateChanged(paired: Boolean, battery: Int?, charging: Boolean, wasPaired: Boolean, wasBattery: Int?, wasCharging: Boolean): Boolean { + fun low(b: Int?) = b != null && b <= STRAP_LOW_PERCENT + return paired != wasPaired || charging != wasCharging || low(battery) != low(wasBattery) +} + +/** + * The line's words: "Pair your strap", "Strap at 12%", "Syncing", and "Synced 09:10" today, "Synced yesterday", the + * weekday within the week ("Synced Tuesday") and the date before that ("Synced 21 Oct"). + */ +fun strapStatusLine(status: StrapStatus, now: Long, zone: ZoneId, locale: Locale = Locale.getDefault()): String = when (status) { + StrapStatus.NotPaired -> "Pair your strap" + is StrapStatus.LowBattery -> "Strap at ${status.percent}%" + StrapStatus.Syncing -> "Syncing" + is StrapStatus.StaleSync -> { + val then = Instant.ofEpochSecond(status.at).atZone(zone).toLocalDate() + val days = ChronoUnit.DAYS.between(then, Instant.ofEpochSecond(now).atZone(zone).toLocalDate()) + "Synced " + when { + days <= 0 -> clockText(status.at, zone) + days == 1L -> recentDay(then, then.plusDays(1), locale).lowercase(locale) + else -> recentDay(then, then.plusDays(days), locale) + } + } +} + +/** + * [date] as a widget says when something was, seen from [today]: "Today", "Yesterday", the weekday within the week + * ("Tuesday") and the date before that ("21 Oct"). + */ +fun recentDay(date: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String { + val days = ChronoUnit.DAYS.between(date, today) + return when { + days <= 0 -> "Today" + days == 1L -> "Yesterday" + days < 7 -> date.dayOfWeek.getDisplayName(TextStyle.FULL, locale) + else -> date.format(DateTimeFormatter.ofPattern("d MMM", locale)) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/SyncNowAction.kt b/app/src/main/kotlin/org/openstrap/app/widget/SyncNowAction.kt new file mode 100644 index 00000000..4e0852c4 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/SyncNowAction.kt @@ -0,0 +1,31 @@ +package org.openstrap.app.widget + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import org.openstrap.app.OpenStrapApp +import org.openstrap.app.device.ConnectionState + +/** + * The strap status button's tap on a stale sync (widgets spec 4, 5): syncs in place, no activity. Connected, it's the same + * sync as pulling Today down. Away, it reconnects as the app does in the background (a Disconnect the user tapped + * holds), and connecting syncs on its own. The line reads Syncing while it runs; [org.openstrap.app.device.WidgetUpdater] + * refreshes the widgets as it starts and ends. + */ +class SyncNowAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + try { + val strap = (context.applicationContext as OpenStrapApp).container.strap + when { + strap.connection.value is ConnectionState.Connected -> strap.syncNow() + context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED -> strap.backgroundConnect() + } + } catch (e: Exception) { + Log.w("OpenStrap", "widgets: sync from the status line failed: $e") + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetKind.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetKind.kt new file mode 100644 index 00000000..f4897ce6 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetKind.kt @@ -0,0 +1,4 @@ +package org.openstrap.app.widget + +/** The five home screen widgets (widgets spec 2). */ +enum class WidgetKind { Moment, Sleep, Steps, Recovery, Start } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetMoment.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetMoment.kt new file mode 100644 index 00000000..9d1ef70b --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetMoment.kt @@ -0,0 +1,46 @@ +package org.openstrap.app.widget + +import org.openstrap.app.AppContainer +import org.openstrap.app.data.DayWindow +import org.openstrap.app.data.MomentInputs +import org.openstrap.app.data.MomentResolver +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.WorkoutState +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId + +/** + * What [MomentResolver] reads, from the database rather than Today's screen, so a widget follows the same moment as + * Today (widgets spec 2.2: the mood follows the current moment). The same inputs Today gathers: whether a workout is + * recording, the last workout's end, today's main sleep's end, the usual bedtime (Recovery's [usualBedtime]) and the + * start of every workout you started over [MomentResolver.USUAL_WORKOUT_DAYS]. + */ +suspend fun widgetMomentInputs( + container: AppContainer, + usualBedtime: LocalTime?, + now: Instant = Instant.now(), + zone: ZoneId = ZoneId.systemDefault(), +): MomentInputs { + val dao = container.database.healthDao() + val today = now.atZone(zone).toLocalDate() + val t = now.epochSecond + val from = t - MomentResolver.USUAL_WORKOUT_DAYS * 86_400L + val strap = dao.workoutsOnce(from, t) + val phone = dao.appWorkoutsOnce(from, t) + // You started it: not one the strap noticed or filed as "Activity" by itself (WorkoutAnalysis.startedByYou). + val starts = strap.filter { it.subtype != Workouts.SUBTYPE_AUTO_DETECTED && it.sportType != Workouts.GENERIC_ACTIVITY }.map { it.start } + + phone.filter { it.sportType != Workouts.GENERIC_ACTIVITY }.map { it.start } + val lastEnd = (strap.map { it.end } + phone.mapNotNull { it.end }).filter { it <= t }.maxOrNull() + // Today's main sleep, as Today picks it: the longest non-nap session that ended in the night window. + val night = DayWindow.nightEndingOn(today, zone) + val sleepEnd = dao.sleepSessionsOnce(night.from, night.to).filter { !it.nap }.maxByOrNull { it.end - it.start }?.end + return MomentInputs( + day = today, + recording = container.workout.state.value is WorkoutState.Recording, + lastWorkoutEnd = lastEnd, + sleepEnd = sleepEnd, + usualBedtime = usualBedtime, + workoutStarts = starts.sorted(), + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt new file mode 100644 index 00000000..92d2cef7 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt @@ -0,0 +1,538 @@ +package org.openstrap.app.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Bitmap +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.util.SizeF +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.CircularProgressIndicator +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.LocalAppWidgetOptions +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontFamily +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first +import org.openstrap.app.AppContainer +import org.openstrap.app.MainActivity +import org.openstrap.app.OpenStrapApp +import org.openstrap.app.device.SyncState +import org.openstrap.app.ui.glow.draw.drawIcon +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.glow.ControlSurface +import org.openstrap.app.device.WidgetUpdater +import org.openstrap.app.ui.dashboard.momentMood +import org.openstrap.app.ui.glow.EdgeLight +import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.ui.glow.GlowStyle +import org.openstrap.app.ui.glow.draw.drawGlow +import org.openstrap.app.ui.glow.draw.renderToBitmap +import org.openstrap.app.ui.glow.draw.WeekBarsLook +import org.openstrap.app.ui.glow.draw.dotLayout +import org.openstrap.app.ui.glow.draw.edgePaint +import org.openstrap.app.ui.glow.draw.renderDots +import org.openstrap.app.ui.glow.draw.renderEdge +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId + +/* + * What every widget shares (widgets spec 6): loading and reloading in provideGlance, the receiver's placed callbacks, + * the edge card frame (light, corners, tap), the type roles, the title row and the dotted number. Each widget keeps + * only its own content. + */ + +/** + * A widget that loads its [T] once per Glance session, outside the content Glance composes once per size, and again + * each time its [kind]'s version moves on ([WidgetRefresh]). Every widget has the three sizes ([WidgetSizes]). + */ +abstract class LoadingWidget(private val kind: WidgetKind) : GlanceAppWidget() { + override val sizeMode = SizeMode.Responsive(WidgetSizes.all) + + /** The picker's generated preview (spec 6.4, Android 15 and later) has the three sizes too. */ + override val previewSizeMode = SizeMode.Responsive(WidgetSizes.all) + + /** What the widget shows now. */ + protected abstract suspend fun loadShown(context: Context): T + + /** The mockups' sample ([WidgetSamples]) for the picker's preview, lit by day. */ + protected abstract fun sample(): T + + /** The real layout with [sample]'s values and no status line, so the picker shows the widget as it looks. */ + override suspend fun providePreview(context: Context, widgetCategory: Int) { + val data = sample() + provideContent { CompositionLocalProvider(LocalStatusLine provides null) { Content(data) } } + } + + /** The widget at the size Glance lays out. */ + @Composable + protected abstract fun Content(data: T) + + override suspend fun provideGlance(context: Context, id: GlanceId) = coroutineScope { + val shown = MutableStateFlow(loadShown(context)) + val status = MutableStateFlow(loadStatusLine(context)) + launch { + WidgetRefresh.version(kind).drop(1).collectLatest { + shown.value = loadShown(context) + status.value = loadStatusLine(context) + } + } + provideContent { + val data by shown.collectAsState() + val line by status.collectAsState() + CompositionLocalProvider(LocalStatusLine provides line) { Content(data) } + } + } +} + +/** The strap status line every widget's frame reads ([WidgetFrame.status]), null while all is well. */ +internal val LocalStatusLine = staticCompositionLocalOf { null } + +/** + * The strap status line now (spec 5), from what settings keep of the strap so it reads with the strap away, and + * whether a sync is running; or the debug gallery's ([WidgetRefresh.statusOverride]). Null while all is well. + */ +internal suspend fun loadStatusLine(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): StatusLine? = try { + val t = now.epochSecond + val status = if (WidgetRefresh.statusOverride != null) WidgetRefresh.debugStatus(t) else { + val container = (context.applicationContext as OpenStrapApp).container + val s = container.settings.strapSnapshot.first() + strapStatus(s.paired, s.battery, s.charging, s.lastSyncDone, t, syncing = container.strap.sync.value is SyncState.Running) + } + status?.let { StatusLine.of(it, t, zone) } +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + Log.w(WIDGET_TAG, "widgets: strap status not read: $e") + null +} + +/** + * A widget's receiver, which the launcher knows it by. Placing the first or removing the last of any widget turns + * midnight's alarm on or off, Moment follows what's placed (spec 3.1, the duplicate rule), and removing the last + * Moment turns its alarm off ([WidgetUpdater.placedChanged]). + */ +abstract class PlacedWidgetReceiver : GlanceAppWidgetReceiver() { + override fun onEnabled(context: Context) { + super.onEnabled(context) + placedChanged(context) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + placedChanged(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + placedChanged(context) + } + + private fun placedChanged(context: Context) = WidgetUpdater.placedChanged(context) +} + +/** + * The mood to light a widget in (spec 2.2): the moment's now, from the same inputs Today reads ([widgetMomentInputs]) + * with [usualBedtime] from Recovery, or the debug gallery's. Day when the moment can't be worked out. + */ +internal suspend fun widgetMood(container: AppContainer, usualBedtime: LocalTime?, now: Instant, zone: ZoneId): GlowMood = + WidgetRefresh.moodOverride ?: try { + momentMood(widgetMomentInputs(container, usualBedtime, now, zone).resolve(now, zone)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(WIDGET_TAG, "widgets: moment for the mood not worked out: $e") + GlowMood.Day + } + +/** The Strap page (spec 5): the status line's tap while the strap isn't paired or its battery is low. */ +internal fun openStrap(context: Context): Intent = + Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_STRAP, true).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + +/** Opens the detail at [route] (`open_metric`, [org.openstrap.app.ui.dashboard.Detail.fromRoute]) for [day]. */ +internal fun openMetric(context: Context, route: String, day: LocalDate?): Intent = + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.EXTRA_OPEN_METRIC, route) + .apply { day?.let { putExtra(MainActivity.EXTRA_METRIC_DAY, it.toString()) } } + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + +internal const val WIDGET_TAG = "OpenStrap" + +/* ------------------------------------------------------------------------------------------------ */ +/* The frame */ +/* ------------------------------------------------------------------------------------------------ */ + +/** + * What a widget's content is laid out with: the [bucket] Glance picked (the smallest cell the layout must fit, so + * every fit sum uses it), the cell's real size [drawWidth] by [drawHeight] dp that bitmaps are drawn at so they fill + * it without stretching, [density] for bitmaps (inside the budget) and the system [fontScale]. + */ +class WidgetFrame( + val bucket: DpSize, + val drawWidth: Float, + val drawHeight: Float, + val density: Float, + val fontScale: Float, + /** The real cell to fit the content to ([WidgetSizes.fitSize]), dp. */ + val fitWidth: Float = bucket.width.value, + val fitHeight: Float = bucket.height.value, + /** The strap status line to show in the top row (spec 5), null for none. */ + val status: StatusLine? = null, + /** Where the top row put the status button, for the card's tap target over it ([StatusTapTarget]). */ + val spot: StatusSpot = StatusSpot(), +) { + /** The content's width and height in the real cell, inside the padding, dp: what decides what fits. */ + val contentWidth: Float get() = fitWidth - 2 * WidgetSizes.PADDING_DP + val contentHeight: Float get() = fitHeight - 2 * WidgetSizes.PADDING_DP + + /** The content's real width, dp, for bitmaps that span it. */ + val drawContentWidth: Float get() = drawWidth - 2 * WidgetSizes.PADDING_DP + + /** The content's real height, dp: what the gaps share out ([Spread.edge]). */ + val drawContentHeight: Float get() = drawHeight - 2 * WidgetSizes.PADDING_DP +} + +/** + * The status button's place, set as the content composes and read by the card after it: the circle's [top] from the + * content's top, dp, and the [line] it shows. Null [line] for no button. + */ +class StatusSpot { + var top: Float = 0f + var line: StatusLine? = null + + fun put(top: Float, line: StatusLine) { + this.top = top + this.line = line + } +} + +/** + * An edge card widget at the size Glance lays out ([LocalSize]): [light] in [ink] at [mood] behind, drawn at the + * cell's real size, the whole widget opening [open], the content inside the padding. [bitmapExtra] is the room the + * content's bitmaps take beside the backgrounds in the budget (spec 6.2): every size's bitmaps go to the launcher at + * once and are scaled down together if they'd pass the limit. [sidePadding] narrower than the card's 20 dp lets a row + * reach into the padding (Start's centred captions); the content then pads itself back to 20 dp, and [WidgetFrame]'s + * content sizes stay those inside the full padding. + */ +@Composable +internal fun EdgeWidget( + light: EdgeLight, ink: Color, mood: GlowMood, open: Intent, bitmapExtra: Double, + sidePadding: Float = WidgetSizes.PADDING_DP, + showStatus: Boolean = true, + content: @Composable ColumnScope.(WidgetFrame) -> Unit, +) = CardWidget(open, bitmapExtra, sidePadding, showStatus, { w, h, corner, density -> edgeBackground(light, ink, mood, w, h, corner, density) }, content) + +/** + * A glow card widget (Moment, spec 2.2): [style] behind, its mood already set, drawn at the cell's real size as the + * app's GlowCard draws it ([drawGlow]), the rest as [EdgeWidget]. + */ +@Composable +internal fun GlowWidget(style: GlowStyle, open: Intent, bitmapExtra: Double, showStatus: Boolean = true, content: @Composable ColumnScope.(WidgetFrame) -> Unit) = + CardWidget(open, bitmapExtra, WidgetSizes.PADDING_DP, showStatus, { w, h, corner, density -> glowBackground(style, w, h, corner, density) }, content) + +/** + * A widget card with [background] (dp width, dp height, dp corner, px per dp) behind: what [EdgeWidget] and [GlowWidget] + * share. The frame carries the strap status line ([LocalStatusLine]) unless [showStatus] is false (while a workout + * records, spec 5). + */ +@Composable +private fun CardWidget( + open: Intent, bitmapExtra: Double, sidePadding: Float, showStatus: Boolean, + background: (Float, Float, Float, Float) -> Bitmap, + content: @Composable ColumnScope.(WidgetFrame) -> Unit, +) { + val context = LocalContext.current + val size = LocalSize.current + val bucket = WidgetSizes.bucket(size.width.value, size.height.value) + val metrics = context.resources.displayMetrics + val reported = reportedSizes(LocalAppWidgetOptions.current, context) + val (w, h) = WidgetSizes.drawSize(bucket, reported) + val (fitW, fitH) = WidgetSizes.fitSize(bucket, reported) + val allPixels = WidgetSizes.all.sumOf { (it.width.value * it.height.value).toDouble() } * bitmapExtra * metrics.density * metrics.density + val density = metrics.density * WidgetSizes.bitmapScale(allPixels.toLong(), WidgetSizes.bitmapBudget(metrics.widthPixels, metrics.heightPixels)) + val corner = WidgetSizes.corner(systemCorner(context)) + val status = LocalStatusLine.current.takeIf { showStatus } + val frame = WidgetFrame(bucket, w, h, density, context.resources.configuration.fontScale, fitW, fitH, status) + Box( + GlanceModifier.fillMaxSize() + .appWidgetBackground() + .cornerRadius(corner.dp) + .background(ImageProvider(background(w, h, corner, density)), ContentScale.FillBounds) + .clickable(actionStartActivity(open)), + ) { + Column(GlanceModifier.fillMaxSize().padding(horizontal = sidePadding.dp, vertical = WidgetSizes.PADDING_DP.dp)) { content(frame) } + frame.spot.line?.let { StatusTapTarget(it, frame.spot.top) } + } +} + +/** + * The status button's tap (spec 5), [StatusButton.TOUCH_DP] square over the circle whose top is [top] dp below the + * content's: over the content rather than in it, so the title row counts only the circle. Not paired or a low battery + * opens the Strap page, a stale sync syncs in place ([SyncNowAction]); while it syncs there's no target and a tap is + * the widget's own. + */ +@Composable +private fun StatusTapTarget(line: StatusLine, top: Float) { + val action = statusAction(line.status) ?: return + val reach = (StatusButton.TOUCH_DP - StatusButton.SIZE_DP) / 2 + Box(GlanceModifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) { + Box( + GlanceModifier.padding(top = (WidgetSizes.PADDING_DP + top - reach).coerceAtLeast(0f).dp, end = (WidgetSizes.PADDING_DP - reach).dp), + ) { + Box( + GlanceModifier.size(StatusButton.TOUCH_DP.dp).clickable(action).semantics { contentDescription = line.full }, + ) {} + } + } +} + +/** What tapping the status does: the Strap page, a sync, or nothing while it syncs. */ +@Composable +private fun statusAction(status: StrapStatus): Action? { + val context = LocalContext.current + return when (status) { + StrapStatus.NotPaired, is StrapStatus.LowBattery -> actionStartActivity(openStrap(context)) + is StrapStatus.StaleSync -> actionRunCallback() + StrapStatus.Syncing -> null + } +} + +/** A glow card's light ([style], its mood set) for a widget [w] by [h] dp with [corner] dp corners, at [density] px per dp. */ +internal fun glowBackground(style: GlowStyle, w: Float, h: Float, corner: Float, density: Float): Bitmap { + val pw = w * density + val ph = h * density + return renderToBitmap(pw, ph) { drawGlow(it, style.copy(radius = corner.dp), pw, ph, density) } +} + +/** + * An edge card's light ([light] in [ink] at [mood]) for a widget [w] by [h] dp, with [corner] dp corners, at + * [density] px per dp: the same [edgePaint] the app's edge cards draw. + */ +internal fun edgeBackground(light: EdgeLight, ink: Color, mood: GlowMood, w: Float, h: Float, corner: Float, density: Float): Bitmap { + val paint = edgePaint(light, ink, null, mood, w * density, h * density, density) + return renderEdge(paint.copy(radius = corner * density)) +} + +/** The launcher's widget corner, dp, on Android 12 and later; null before. */ +private fun systemCorner(context: Context): Float? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + runCatching { context.resources.getDimension(android.R.dimen.system_app_widget_background_radius) / context.resources.displayMetrics.density }.getOrNull() +} else null + +/** + * The sizes the launcher says the widget has, dp, the one for the current orientation first: Android 12's list, or + * the portrait (min width, max height) and landscape (max width, min height) pair before it. + */ +private fun reportedSizes(options: Bundle, context: Context): List> { + val portrait = context.resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + @Suppress("DEPRECATION") + val sizes = options.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES) + if (!sizes.isNullOrEmpty()) { + val all = sizes.map { it.width to it.height } + return if (portrait) all.sortedByDescending { it.second / it.first } else all.sortedBy { it.second / it.first } + } + } + val minW = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH).toFloat() + val maxW = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH).toFloat() + val minH = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT).toFloat() + val maxH = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT).toFloat() + val p = minW to maxH + val l = maxW to minH + return if (portrait) listOf(p, l) else listOf(l, p) +} + +/* ------------------------------------------------------------------------------------------------ */ +/* Type, title, number */ +/* ------------------------------------------------------------------------------------------------ */ + +/** The v2 type roles in the system sans (spec 6.2): sizes and weights as the app's, colours as its alphas. */ +internal object WidgetType { + val title = TextStyle(color = ColorProvider(Color.White), fontSize = WidgetText.TITLE_SP.sp, fontWeight = FontWeight.Medium) + val hero = TextStyle(color = ColorProvider(Color.White), fontSize = WidgetText.HERO_SP.sp, fontFamily = FontFamily("sans-serif-light")) + val body = TextStyle(color = ColorProvider(Color.White), fontSize = WidgetText.BODY_SP.sp) + val word = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.72f)), fontSize = WidgetText.BODY_SP.sp) + val secondary = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.72f)), fontSize = WidgetText.SECONDARY_SP.sp) + val secondaryShown = secondary.copy(color = ColorProvider(Color.White)) + val caption = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.50f)), fontSize = WidgetText.CAPTION_SP.sp, fontWeight = FontWeight.Medium) + val captionShown = caption.copy(color = ColorProvider(Color.White)) +} + +/** + * The top row as [top] lays it out (spec 5): [title] in [titleStyle] at the row's size, then on the right the strap + * status button with its words when the strap needs something, else [note] when it fits. [above] is the room over the + * row in the content, dp, so the card can put the button's tap target over it. + */ +@Composable +internal fun TopRowView(title: String, note: String?, top: TopRow, f: WidgetFrame, above: Float, titleStyle: TextStyle = WidgetType.title) { + val line = top.status + if (line != null) f.spot.put(above + (top.height - StatusButton.SIZE_DP) / 2, line) + Row(GlanceModifier.fillMaxWidth().height(top.height.dp), verticalAlignment = Alignment.CenterVertically) { + Text(title, style = titleStyle.copy(fontSize = top.titleSp.sp), maxLines = 1, modifier = GlanceModifier.defaultWeight()) + if (line != null) { + top.words?.let { + Spacer(GlanceModifier.width(StatusButton.TITLE_GAP_DP.dp)) + Text(it, style = StatusWords, maxLines = 1) + Spacer(GlanceModifier.width(StatusButton.WORDS_GAP_DP.dp)) + } + StatusButtonView(line, f) + } else if (note != null && top.note) { + Text(note, style = WidgetType.secondary, maxLines = 1) + } + } +} + +/** The status words: Secondary in amber, the strap and sync colour (v2 spec: amber is for those only). */ +internal val StatusWords = TextStyle(color = ColorProvider(MetricColors.warn), fontSize = StatusButton.WORDS_SP.sp) + +/** + * The status button (spec 5): a [StatusButton.SIZE_DP] circle in the Control surface Start's tiles wear, the state's + * icon in it: the sync arrows in amber for a stale sync, a spinner while it syncs, the strap for pairing, a low + * battery in amber. Its tap, read out as the whole line, is [StatusTapTarget]'s (while it syncs, the spinner reads it). + */ +@Composable +internal fun StatusButtonView(line: StatusLine, f: WidgetFrame) { + val status = line.status + val size = GlanceModifier.size(StatusButton.SIZE_DP.dp) + if (status == StrapStatus.Syncing) { + Box( + size.background(ImageProvider(statusButtonBitmap(null, Color.White, f.density)), ContentScale.FillBounds) + .semantics { contentDescription = line.full }, + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(GlanceModifier.size(StatusButton.ICON_DP.dp), color = ColorProvider(Color.White)) } + return + } + val (icon, color) = when (status) { + is StrapStatus.StaleSync -> AppIcons.ArrowsClockwise to MetricColors.warn + is StrapStatus.LowBattery -> AppIcons.BatteryLow to MetricColors.warn + else -> AppIcons.Watch to Color.White + } + // The tap target over it reads the line out, so the circle doesn't as well. + Image( + ImageProvider(statusButtonBitmap(icon, color, f.density)), + contentDescription = null, + modifier = size, + contentScale = ContentScale.FillBounds, + ) +} + +/** The button's circle in the Control surface with [icon] in [color] in the middle, or none. */ +private fun statusButtonBitmap(icon: ImageVector?, color: Color, density: Float): Bitmap { + val s = StatusButton.SIZE_DP * density + val i = StatusButton.ICON_DP * density + return renderToBitmap(s, s) { c -> + c.drawCircle(s / 2, s / 2, s / 2, android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { this.color = ControlSurface.toArgb() }) + icon?.let { drawIcon(c, it, color, (s - i) / 2, (s - i) / 2, i) } + } +} + +/** [icon] alone in [color], [sizeDp] square. */ +internal fun iconBitmap(icon: ImageVector, color: Color, sizeDp: Float, density: Float): Bitmap { + val s = sizeDp * density + return renderToBitmap(s, s) { c -> drawIcon(c, icon, color, 0f, 0f, s) } +} + +/** + * Before there's anything to show: the top row (with [f]'s status button when it has one, its words on the Wide and + * Large), then why in Secondary ("After your first night"), on as many lines as it needs. + */ +@Composable +internal fun WaitingContent(title: String, line: String?, f: WidgetFrame) { + TopRowView(title, null, TopRow.of(title, null, f.status, f.contentWidth, f.fontScale, withWords = f.bucket != WidgetSizes.Small), f, above = 0f) + Spacer(GlanceModifier.height(6.dp)) + line?.let { Text(it, style = WidgetType.secondary, maxLines = 4) } +} + +/** [text] in white dots [dot] dp across, read out as [description]: the widget's one dotted number (spec 3). */ +@Composable +internal fun DotNumber(text: String, dot: Float, density: Float, description: String?) { + val layout = dotLayout(text, dot, 1f) + Image( + ImageProvider(renderDots(text, dot, density)), + contentDescription = description, + modifier = GlanceModifier.size(layout.width.dp, layout.height.dp), + contentScale = ContentScale.FillBounds, + ) +} + +/** + * A week's bars ([bars], drawn [WeekBarsLook.width] by [WeekBarsLook.HEIGHT_DP] dp for columns [column] dp wide), + * read out as [description], with each day's letter under its column as a Caption, white where it's lit. + */ +@Composable +internal fun WeekChart(bars: ImageProvider, description: String, letters: List>, column: Float) { + val width = WeekBarsLook.width(letters.size, column) + Column { + Image( + bars, + contentDescription = description, + modifier = GlanceModifier.size(width.dp, WeekBarsLook.HEIGHT_DP.dp), + contentScale = ContentScale.FillBounds, + ) + Spacer(GlanceModifier.height(4.dp)) + // A letter a column, the gap as padding rather than a Spacer: Glance drops a row's children past ten. + Row { + letters.forEachIndexed { i, (letter, lit) -> + val gap = if (i < letters.lastIndex) WeekBarsLook.GAP_DP else 0f + Box(GlanceModifier.width((column + gap).dp).padding(end = gap.dp), contentAlignment = Alignment.Center) { + Text(letter, style = (if (lit) WidgetType.captionShown else WidgetType.caption).copy(textAlign = TextAlign.Center), maxLines = 1) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetRefresh.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetRefresh.kt new file mode 100644 index 00000000..dcd353e0 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetRefresh.kt @@ -0,0 +1,68 @@ +package org.openstrap.app.widget + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.openstrap.app.BuildConfig +import org.openstrap.app.ui.glow.GlowMood + +/** + * Tells a running widget its data changed. Glance calls a widget's `provideGlance` once per session and recomposes + * the running session on an update, so a widget loads in `provideGlance` and loads again each time its kind's + * version moves on. [org.openstrap.app.device.WidgetUpdater] bumps it before every update. + */ +object WidgetRefresh { + private val versions = WidgetKind.entries.associateWith { MutableStateFlow(0) } + + fun version(kind: WidgetKind): StateFlow = versions.getValue(kind) + + fun bump(kind: WidgetKind) { + versions.getValue(kind).value++ + } + + /** + * Debug builds: the mood to light widgets in instead of the moment's, for the widget gallery; null follows the + * moment. Release builds never read it. + */ + @Volatile + var debugMood: GlowMood? = null + + /** [debugMood] where it applies. */ + val moodOverride: GlowMood? get() = if (BuildConfig.DEBUG) debugMood else null + + /** Debug builds: the moment the Moment widget shows instead of the clock's, for the gallery; null follows it. */ + @Volatile + var debugMoment: org.openstrap.app.data.Moment? = null + + /** + * Debug builds: the widgets the Moment widget takes as placed for the duplicate rule (spec 3.1), for the gallery; + * null reads the launcher's. + */ + @Volatile + var debugPlaced: Set? = null + + /** + * Debug builds: the strap status line every widget shows instead of the real one, for the gallery: "notpaired", + * "low", "stale" (4 hours ago), "yesterday", "week" (3 days ago), "syncing", or "none" for no line; null reads + * the strap. + */ + @Volatile + var debugStrap: String? = null + + /** [debugStrap] where it applies. */ + val statusOverride: String? get() = if (BuildConfig.DEBUG) debugStrap else null + + /** [statusOverride]'s status at [now] (epoch seconds); null for "none" or a name it doesn't know. */ + fun debugStatus(now: Long): StrapStatus? = when (statusOverride?.lowercase()) { + "notpaired" -> StrapStatus.NotPaired + "low" -> StrapStatus.LowBattery(12) + "stale" -> StrapStatus.StaleSync(now - 4 * 3600) + "yesterday" -> StrapStatus.StaleSync(now - 86_400) + "week" -> StrapStatus.StaleSync(now - 3 * 86_400) + "syncing" -> StrapStatus.Syncing + else -> null + } + + /** [debugMoment] and [debugPlaced] where they apply. */ + val momentOverride: org.openstrap.app.data.Moment? get() = if (BuildConfig.DEBUG) debugMoment else null + val placedOverride: Set? get() = if (BuildConfig.DEBUG) debugPlaced else null +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetSamples.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetSamples.kt new file mode 100644 index 00000000..2f63ff4d --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetSamples.kt @@ -0,0 +1,125 @@ +package org.openstrap.app.widget + +import org.openstrap.app.data.MomentInputs +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.data.db.Workout +import org.openstrap.app.ui.components.ChartPoint +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters +import java.util.Locale + +/** + * The mockups' sample day as each widget's model, made by the same factories the widgets load through, so the + * picker's generated previews (spec 6.4, Android 15 and later) show the real look, and the tests fit the same values: + * Recovery 78 with 52 bpm, 64 ms and a sleep of 82; 8,432 steps of 10,000; a night of 22:58 to 06:56 scored 82; + * outdoor runs as the usual; midday with 62 energy left. + */ +object WidgetSamples { + /** Recovery this morning: 78, Recovered, its week. */ + fun recovery(today: LocalDate, locale: Locale = Locale.getDefault()): RecoveryWidgetModel { + val week = (6 downTo 0).map { today.minusDays(it.toLong()) }.zip(listOf(65, 82, 55, 72, 88, 60, 78)) + val day = RecoveryDay( + date = today, + shown = TodayCopy.recoveryShown(78, false, null, null, TabCopy.RECOVERY_WAITING), + sentence = TodayCopy.recoveryMeaning(78, false), + firstNights = false, + restingHr = 52f, hrv = 64.4f, sleep = 82, + week = week, + ) + return RecoveryWidgetModel.from(day, null, locale) + } + + /** 8,432 steps by 14:00 against 10,000, the week before today with two days met. */ + fun steps(today: LocalDate, locale: Locale = Locale.getDefault()): StepsWidgetModel { + val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val past = listOf(7_800, 11_200, 5_900, 10_400, 6_300, 9_100).mapIndexedNotNull { i, s -> + monday.plusDays(i.toLong()).takeIf { it < today }?.let { it to s } + }.toMap() + val hourly = List(24) { h -> if (h in 7..14) 1_054f else 0f } + return StepsWidgetModel.from(today, 8_432, hourly, 14, 10_000, past, locale) + } + + /** The night ending on [today]: 22:58 to 06:56, score 82, woke 3 times, its stages, three shorter nights before. */ + fun sleepNight(today: LocalDate, zone: ZoneId): SleepWidgetNight { + fun at(date: LocalDate, h: Int, m: Int) = date.atTime(h, m).atZone(zone).toEpochSecond() + val start = at(today.minusDays(1), 22, 58) + val session = SleepSession(start, at(today, 6, 56), score = 82, wakeCount = 3, deepMinutes = 92, remMinutes = 101, lightMinutes = 267, awakeMinutes = 18) + val plan = listOf( + SleepStage.LIGHT to 60, SleepStage.DEEP to 60, SleepStage.LIGHT to 90, SleepStage.REM to 50, SleepStage.AWAKE to 10, + SleepStage.LIGHT to 117, SleepStage.DEEP to 32, SleepStage.REM to 51, SleepStage.AWAKE to 8, + ) + var t = start + val stages = plan.map { (stage, min) -> SleepStage(t, t + min * 60L, start, stage).also { t += min * 60L } } + val week = listOf(1 to 370, 2 to 420, 3 to 440).associate { (ago, min) -> + val d = today.minusDays(ago.toLong()) + val e = at(d, 7, 0) + d to SleepSession(e - min * 60L, e) + } + return SleepWidgetNight.of(today, session, stages, week + (today to session)) + } + + fun sleep(today: LocalDate, zone: ZoneId): SleepWidgetModel = SleepWidgetModel.from(sleepNight(today, zone), null, firstNight = false, napsToday = false, zone = zone) + + /** + * Outdoor runs the most (one of 5.2 km this week), then walks, rides, a treadmill run and strength, at [now]. + */ + fun start(now: Instant, zone: ZoneId, locale: Locale = Locale.getDefault()): StartWidgetModel { + val today = now.atZone(zone).toLocalDate() + fun workout(daysAgo: Int, hour: Int, type: Int, minutes: Int, meters: Float? = null, sets: Int? = null) = run { + val start = today.minusDays(daysAgo.toLong()).atTime(hour, 0).atZone(zone).toEpochSecond() + val w = Workout(start, start + minutes * 60L, type, minutes * 60, null, null, null, null, null, null, null, null, null) + Workouts.analyse(w, emptyList(), emptyList(), 190).copy(distanceMeters = meters, sets = sets) + } + val history = listOf( + workout(4, 18, 1, 60, meters = 5_200f), workout(11, 7, 1, 45), workout(18, 7, 1, 45), + workout(8, 8, 3, 30), workout(12, 8, 3, 30), workout(9, 17, 4, 50), workout(15, 17, 4, 50), + workout(1, 19, 2, 35), workout(6, 10, 52, 40, sets = 12), + ) + return StartWidgetModel.from(history, emptyMap(), true, null, now, zone, locale) + } + + /** Midday on [today]: 62 energy left from 84 at waking, drained 60 and recharged 38, the day's heart so far. */ + fun moment(today: LocalDate, zone: ZoneId): MomentWidgetModel { + val night = sleepNight(today, zone) + val wake = night.session.end + val now = today.atTime(13, 0).atZone(zone).toInstant() + val n = ((now.epochSecond - wake) / 900).toInt().coerceAtLeast(1) + val points = (0..n).map { i -> Recovery.ChargePoint(wake + i * 900L, 84f + (62 - 84f) * i / n, Recovery.State.Activity) } + val midnight = today.atStartOfDay(zone).toEpochSecond() + val heart = (0..((now.epochSecond - midnight) / 300).toInt()).map { i -> + ChartPoint(midnight + i * 300L, 85f + 25f * kotlin.math.sin(i / 12.0).toFloat()) + } + val recovery = TodayCopy.recoveryShown(78, false, null, null, TabCopy.RECOVERY_WAITING) + val input = MomentWidgetInput( + inputs = MomentInputs(today, false, null, wake, LocalTime.of(22, 40), emptyList()), + placed = emptySet(), + recovery = recovery, + score = recovery.value, + alert = false, + noScore = null, + firstNights = false, + energy = MomentEnergy(62, 84, 38, 60f, true, points), + load = 42.3f, + loadStatus = TodayCopy.LoadStatus.SoFar, + night = night, + usualSport = 1, + lastWorkout = null, + recording = null, + dayWorkouts = emptyList(), + heartRate = heart, + restingHr = 52, + steps = 8_432, + stepGoal = 10_000, + ) + return MomentWidgetModel.from(input, now, zone) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetSizes.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetSizes.kt new file mode 100644 index 00000000..0ac31c1f --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetSizes.kt @@ -0,0 +1,183 @@ +package org.openstrap.app.widget + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp + +/** + * The three sizes every widget has (widgets spec 2.1), so a stack that gives all its widgets one size fits each of + * them. Each is the smallest cell its layout needs, not the size it was drawn at (173 × 176, 358 × 176 and + * 358 × 272 dp in the mockups): launchers' cells run smaller (One UI's 2 × 2 can be 167 dp wide, its 4 columns + * 353), and every layout stretches to its cell. Every provider's XML resizes from [MIN_DP] up to [MaxResize] + * (spec 6.1), and Glance picks the largest of these that fits the cell ([bucket]). + */ +object WidgetSizes { + /** 2 × 2: a number and a word, or the one action. */ + val Small = DpSize(150.dp, 150.dp) + + /** 4 × 2: the Small plus one more thing. */ + val Wide = DpSize(300.dp, 160.dp) + + /** 4 × 3: everything the widget has. */ + val Large = DpSize(300.dp, 260.dp) + + /** + * `minWidth`, `minHeight` and their resize limits, dp: two cells by the old formula (70 × 2 − 30), which One UI + * still uses and which ignores `targetCellWidth`. Anything above it, even the [Small]'s 150, places as 3 × 3. + */ + const val MIN_DP = 110 + + /** As far as a widget resizes: wider than four phone columns for an unfolded screen, about four rows high. */ + val MaxResize = DpSize(480.dp, 400.dp) + + /** All three, smallest first: what `SizeMode.Responsive` is given. */ + val all: Set = linkedSetOf(Small, Wide, Large) + + /** Cells a new widget takes (`targetCellWidth`, `targetCellHeight`): a Small. */ + const val TARGET_CELLS = 2 + + /** Every card's corner and padding (spec 2.4), dp. */ + const val CORNER_DP = 28f + const val PADDING_DP = 20f + + /** + * The size Glance lays out for a widget [width] by [height] dp: the largest of [sizes] that fits, or the smallest + * when none does (as `SizeMode.Responsive` picks). + */ + fun bucket(width: Float, height: Float, sizes: Set = all): DpSize { + val fits = sizes.filter { it.width.value <= width + FIT_SLACK && it.height.value <= height + FIT_SLACK } + return fits.maxByOrNull { it.width.value * it.height.value } ?: sizes.minBy { it.width.value * it.height.value } + } + + /** + * The real size to draw [bucket]'s bitmaps at: the first of the widget's [reported] sizes (dp, width to height, + * portrait first) that Glance lays out as [bucket], so the light fills the cell without stretching. [bucket] itself + * when none is reported or none maps to it. + */ + fun drawSize(bucket: DpSize, reported: List>): Pair = + reported.firstOrNull { (w, h) -> w > 0f && h > 0f && bucket(w, h) == bucket } ?: (bucket.width.value to bucket.height.value) + + /** + * The size to fit [bucket]'s content to: the smallest width and height among the widget's [reported] sizes that + * Glance lays out as [bucket] (portrait and landscape can differ), so what fits is decided at the real cell, not + * the bucket's minimum. [bucket] itself when none is reported or none maps to it. + */ + fun fitSize(bucket: DpSize, reported: List>): Pair { + val mine = reported.filter { (w, h) -> w > 0f && h > 0f && bucket(w, h) == bucket } + if (mine.isEmpty()) return bucket.width.value to bucket.height.value + return mine.minOf { it.first } to mine.minOf { it.second } + } + + /** + * The corner a widget draws (spec 6.3): [CORNER_DP], or the launcher's own widget radius ([system], dp, Android 12 + * and later) when that is smaller, so a widget never looks rounder than the launcher's others. + */ + fun corner(system: Float?): Float = system?.takeIf { it > 0f }?.coerceAtMost(CORNER_DP) ?: CORNER_DP + + /** + * RemoteViews' bitmap limit (spec 6.2): screen width × height × 4 × 1.5 bytes for all of a widget's bitmaps at + * once, here for a screen [screenW] by [screenH] px. + */ + fun bitmapBudget(screenW: Int, screenH: Int): Long = (screenW.toLong() * screenH * 4 * 3) / 2 + + /** + * How far to scale the bitmaps' resolution down, at most 1, so [pixels] of ARGB bitmaps (every size's together, + * since a responsive widget sends all three at once) stay inside [budget] bytes with a margin. + */ + fun bitmapScale(pixels: Long, budget: Long): Float { + if (pixels <= 0) return 1f + val room = budget * BUDGET_SHARE / (pixels * 4.0) + return if (room >= 1.0) 1f else kotlin.math.sqrt(room).toFloat() + } + + /** Launchers round cell sizes, so a size this close still fits. */ + private const val FIT_SLACK = 0.5f + + /** The share of the budget the bitmaps may take, leaving room for the rest of the RemoteViews. */ + private const val BUDGET_SHARE = 0.8 +} + +/** + * Rough text sums for fitting a layout at its smallest cell (spec 2.1) in the system sans, which scales with the + * system font: widths and line heights in dp, erring large so a layout that fits here fits on the phone. + */ +object WidgetText { + /** The v2 type roles' sizes, sp (spec 6.2). */ + const val TITLE_SP = 17f + const val HERO_SP = 20f + const val BODY_SP = 15f + const val SECONDARY_SP = 13f + const val CAPTION_SP = 11f + + /** About half an em a character. */ + const val CHAR_EM = 0.52f + + /** A line's height in a TextView with its font padding: Roboto's top to bottom is 1.33 em, rounded up. */ + const val LINE_EM = 1.35f + + /** A rough width for [text] at [sp], dp, scaled with the font. */ + fun width(text: String?, sp: Float, fontScale: Float = 1f): Float = (text?.length ?: 0) * sp * CHAR_EM * fontScale + + /** One line's height at [sp], dp, scaled with the font. */ + fun line(sp: Float, fontScale: Float = 1f): Float = sp * LINE_EM * fontScale + + /** + * How much wider than its letters' sum a text is taken to be when deciding what fits on one line: the rough sums + * have run short on the phone before ("Swim" showed as "Swi..."), so a tight text gives its place instead of being + * cut. + */ + const val FIT_MARGIN = 1.15f + + /** [text]'s width in ems in the system sans at Medium, by letter, rounded up: Roboto's advances. */ + fun letterEms(text: String): Float = text.sumOf { c -> + when { + c == ' ' || c in "iljI.,:!|'" -> 0.27 + c in "ftr" -> 0.37 + c in "mwMW" -> 0.88 + c.isUpperCase() -> 0.68 + c.isDigit() -> 0.58 + else -> 0.57 + } + }.toFloat() + + /** The room [text] needs on one line at [sp], dp: its width by letter with [FIT_MARGIN] on top, scaled with the font. */ + fun fitWidth(text: String?, sp: Float, fontScale: Float = 1f): Float = letterEms(text.orEmpty()) * sp * fontScale * FIT_MARGIN + + /** Whether [text] fits [room] dp on one line at [sp] ([fitWidth]). */ + fun fits(text: String?, sp: Float, room: Float, fontScale: Float = 1f): Boolean = fitWidth(text, sp, fontScale) <= room + + /** + * How many lines [text] wraps to at [sp] in [width] dp, word by word as a TextView breaks it, each word measured + * by [fitWidth]; [Int.MAX_VALUE] when a single word is wider than the line. + */ + fun wrappedLines(text: String, sp: Float, width: Float, fontScale: Float = 1f): Int { + val space = fitWidth(" ", sp, fontScale) + var lines = 1 + var used = 0f + for (word in text.split(' ').filter { it.isNotEmpty() }) { + val w = fitWidth(word, sp, fontScale) + if (w > width) return Int.MAX_VALUE + used = when { + used == 0f -> w + used + space + w <= width -> used + space + w + else -> { lines++; w } + } + } + return lines + } + + /** How many lines [text] takes at [sp] in [width] dp, at least 1. */ + fun lines(text: String?, sp: Float, width: Float, fontScale: Float = 1f): Int = + if (width <= 0f) Int.MAX_VALUE else kotlin.math.ceil(width(text, sp, fontScale) / width).toInt().coerceAtLeast(1) +} + +/** A dotted number's size for fitting (spec 3): the same sums as the dot geometry, in dp. */ +object WidgetDots { + /** [text]'s dots at [dot] dp, wide and high. */ + fun width(text: String, dot: Float): Float = + dot * ((1 + org.openstrap.app.ui.glow.DotSize.DOT_GAP) * org.openstrap.app.ui.glow.draw.DotFont.columns(text) - org.openstrap.app.ui.glow.DotSize.DOT_GAP) + fun height(dot: Float): Float = + dot * ((1 + org.openstrap.app.ui.glow.DotSize.DOT_GAP) * org.openstrap.app.ui.glow.draw.DotFont.ROWS - org.openstrap.app.ui.glow.DotSize.DOT_GAP) + + /** The dot that fits [text] into [room] dp, at most [dot]. */ + fun fit(text: String, room: Float, dot: Float): Float = minOf(dot, room / width(text, 1f)).coerceAtLeast(0.5f) +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetStatusLine.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetStatusLine.kt new file mode 100644 index 00000000..5bc2c6af --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetStatusLine.kt @@ -0,0 +1,148 @@ +package org.openstrap.app.widget + +import org.openstrap.app.ui.dashboard.clockText +import java.time.Instant +import java.time.ZoneId +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +import java.util.Locale + +/** + * The strap status line as a widget shows it (widgets spec 5): the [status] and its wordings, longest first, so a + * narrow top row takes a shorter one before the words drop and the button stands alone. + */ +data class StatusLine(val status: StrapStatus, val options: List) { + /** The whole line, as [strapStatusLine] words it: what TalkBack reads whichever wording shows. */ + val full: String get() = options.first() + + companion object { + /** + * [status]'s wordings at [now] (epoch seconds): the line ("Synced Tuesday"), then shorter ones that keep its + * sense beside the button's icon ("Synced Tue", "Tuesday", "Tue"). Never a dot, semicolon or dash. + */ + fun of(status: StrapStatus, now: Long, zone: ZoneId, locale: Locale = Locale.getDefault()): StatusLine { + val full = strapStatusLine(status, now, zone, locale) + val shorter = when (status) { + StrapStatus.NotPaired -> listOf("Pair strap") + is StrapStatus.LowBattery -> listOf("${status.percent}%") + StrapStatus.Syncing -> emptyList() + is StrapStatus.StaleSync -> { + val then = Instant.ofEpochSecond(status.at).atZone(zone).toLocalDate() + val days = ChronoUnit.DAYS.between(then, Instant.ofEpochSecond(now).atZone(zone).toLocalDate()) + when { + days <= 0 -> listOf(clockText(status.at, zone)) + days == 1L -> listOf("Yesterday") + days < 7 -> { + val day = then.dayOfWeek.getDisplayName(TextStyle.FULL, locale) + val short = then.dayOfWeek.getDisplayName(TextStyle.SHORT, locale) + listOf("Synced $short", day, short) + } + else -> listOf(full.removePrefix("Synced ")) + } + } + } + return StatusLine(status, (listOf(full) + shorter).distinct()) + } + } +} + +/** + * The status button (spec 5, option C): a [SIZE_DP] dark circle at the right end of the title row with the state's + * icon, and on the Wide and Large the state's words just left of it in [WORDS_SP] amber. Its tap reaches past the + * circle to [TOUCH_DP] (an overlay, so the row still counts the circle's height). + */ +object StatusButton { + const val SIZE_DP = 32f + const val TOUCH_DP = 44f + const val ICON_DP = 16f + + /** The words' size: Secondary, one line, never smaller. */ + const val WORDS_SP = WidgetText.SECONDARY_SP + + /** Between the words and the button. */ + const val WORDS_GAP_DP = 8f + + /** At least this between the title and what's right of it. */ + const val TITLE_GAP_DP = 12f + + /** The first of [line]'s wordings that fits [room] dp at [WORDS_SP]; null for none or no line. */ + fun words(line: StatusLine?, room: Float, fontScale: Float): String? = + line?.options?.firstOrNull { WidgetText.fitWidth(it, WORDS_SP, fontScale) <= room } +} + +/** + * A widget's top row (spec 5): the title at [titleSp], always; while the strap needs something its [status] button at + * the right with the [words] that fit beside it (none on a Small, or when none fits); else whether the [note] on the + * right still fits. [height] is what every layout's sums use: the title's line, or the button's when it shows. + */ +data class TopRow( + val status: StatusLine?, val note: Boolean, val height: Float, + val words: String? = null, val titleSp: Float = WidgetText.TITLE_SP, +) { + companion object { + /** The title's sizes while the button shows, largest first, so a long title steps down rather than be cut. */ + val TITLE_SIZES = listOf(WidgetText.TITLE_SP, WidgetText.BODY_SP, WidgetText.SECONDARY_SP) + + /** + * The top row in [width] dp: [title] at [titleSizes]' first with [note] on the right, or with the status + * [line]'s button there and, when [withWords], the first wording that fits between the title and the button. + * The words replace the note while the line shows. While it shows the title takes the first of [titleSizes] + * that fits beside the button, or the next size down when only that leaves room for the words. + */ + fun of( + title: String, note: String?, line: StatusLine?, width: Float, fontScale: Float, withWords: Boolean, + titleSizes: List = TITLE_SIZES, + ): TopRow { + if (line == null) { + val sp = titleSizes.first() + val keep = note != null && + WidgetText.fitWidth(title, sp, fontScale) + StatusButton.TITLE_GAP_DP + WidgetText.fitWidth(note, WidgetText.SECONDARY_SP, fontScale) <= width + return TopRow(null, keep, WidgetText.line(sp, fontScale), titleSp = sp) + } + val beside = StatusButton.SIZE_DP + StatusButton.TITLE_GAP_DP + fun room(sp: Float) = width - WidgetText.fitWidth(title, sp, fontScale) - StatusButton.TITLE_GAP_DP - StatusButton.WORDS_GAP_DP - StatusButton.SIZE_DP + val fitting = titleSizes.firstOrNull { WidgetText.fitWidth(title, it, fontScale) + beside <= width } ?: titleSizes.last() + // The words are the news: the title may step down one size to make room for them, no further. + val sp = if (!withWords) fitting else titleSizes.take(2).filter { it <= fitting } + .firstOrNull { StatusButton.words(line, room(it), fontScale) != null } ?: fitting + val words = if (withWords) StatusButton.words(line, room(sp), fontScale) else null + val h = maxOf(WidgetText.line(sp, fontScale), StatusButton.SIZE_DP, if (words != null) WidgetText.line(StatusButton.WORDS_SP, fontScale) else 0f) + return TopRow(line, false, h, words, sp) + } + } +} + +/** + * How a layout shares its spare height (spec 2.1 and step 5's rule): [content] dp of blocks with [inner] gaps between + * them, half a gap above the first and below the last, all alike. + */ +data class Spread(val content: Float, val inner: Int) { + /** The gap between blocks in [height] dp. */ + fun gap(height: Float): Float = ((height - content) / (inner + 1)).coerceAtLeast(0f) + + /** The half gap above the first block and below the last. */ + fun edge(height: Float): Float = gap(height) / 2 + + /** Whether it fits [height] with at least [minGap] between the blocks. */ + fun fits(height: Float, minGap: Float): Boolean = content + inner * minGap <= height + 0.01f + + companion object { + /** The most a gap should be on a tall cell: the parts grow before a gap passes it. */ + const val TARGET_GAP_DP = 32f + + /** [blocks] laid out: their heights, the gaps between them. */ + fun of(vararg blocks: Float?): Spread { + val shown = blocks.filterNotNull().filter { it > 0f } + return Spread(shown.sum(), (shown.size - 1).coerceAtLeast(0)) + } + + /** + * The first of [ladder] (least grown first) that fits [height] with [minGap] and brings the gap to + * [TARGET_GAP_DP] or under, else the most grown that fits, else the first. + */ + fun grow(ladder: List, height: Float, minGap: Float, spread: (T) -> Spread): T { + val fitting = ladder.filter { spread(it).fits(height, minGap) } + return fitting.firstOrNull { spread(it).gap(height) <= TARGET_GAP_DP } ?: fitting.lastOrNull() ?: ladder.first() + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetWork.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetWork.kt new file mode 100644 index 00000000..26b92792 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetWork.kt @@ -0,0 +1,31 @@ +package org.openstrap.app.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.os.HandlerThread +import androidx.glance.session.SessionWorker +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.android.asCoroutineDispatcher + +/** + * Glance runs every widget session (compose, translate to RemoteViews, hand them to the launcher) on the main + * thread, about 90 ms a widget on the Fold, so a sync's refresh stuttered the app. This gives its [SessionWorker] + * one thread of its own instead: one thread, as on main, so sessions never run side by side. Any other worker is + * left to WorkManager's default factory. + * + * [SessionWorker] and its dispatcher argument are Glance internals (restricted to its library group); a Glance + * update that changes them fails the build here, not at run time. + */ +object WidgetWork : WorkerFactory() { + private val dispatcher: CoroutineDispatcher by lazy { + val thread = HandlerThread("widgets").apply { start() } + android.os.Handler(thread.looper).asCoroutineDispatcher("widgets") + } + + @SuppressLint("RestrictedApi") + override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters): ListenableWorker? = + if (workerClassName == SessionWorker::class.java.name) SessionWorker(appContext, workerParameters, coroutineContext = dispatcher) else null +} diff --git a/app/src/main/res/drawable-nodpi/widget_preview_moment.png b/app/src/main/res/drawable-nodpi/widget_preview_moment.png new file mode 100644 index 00000000..114682fe Binary files /dev/null and b/app/src/main/res/drawable-nodpi/widget_preview_moment.png differ diff --git a/app/src/main/res/drawable-nodpi/widget_preview_recovery.png b/app/src/main/res/drawable-nodpi/widget_preview_recovery.png new file mode 100644 index 00000000..e40941f4 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/widget_preview_recovery.png differ diff --git a/app/src/main/res/drawable-nodpi/widget_preview_sleep.png b/app/src/main/res/drawable-nodpi/widget_preview_sleep.png new file mode 100644 index 00000000..732b30c0 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/widget_preview_sleep.png differ diff --git a/app/src/main/res/drawable-nodpi/widget_preview_start.png b/app/src/main/res/drawable-nodpi/widget_preview_start.png new file mode 100644 index 00000000..ae8944bf Binary files /dev/null and b/app/src/main/res/drawable-nodpi/widget_preview_start.png differ diff --git a/app/src/main/res/drawable-nodpi/widget_preview_steps.png b/app/src/main/res/drawable-nodpi/widget_preview_steps.png new file mode 100644 index 00000000..70a8e80e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/widget_preview_steps.png differ diff --git a/app/src/main/res/drawable/widget_preview_bg_moment.xml b/app/src/main/res/drawable/widget_preview_bg_moment.xml new file mode 100644 index 00000000..66503456 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_bg_moment.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_bg_recovery.xml b/app/src/main/res/drawable/widget_preview_bg_recovery.xml new file mode 100644 index 00000000..483099ef --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_bg_recovery.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_bg_sleep.xml b/app/src/main/res/drawable/widget_preview_bg_sleep.xml new file mode 100644 index 00000000..3fd1070c --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_bg_sleep.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_bg_start.xml b/app/src/main/res/drawable/widget_preview_bg_start.xml new file mode 100644 index 00000000..886db15a --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_bg_start.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_bg_steps.xml b/app/src/main/res/drawable/widget_preview_bg_steps.xml new file mode 100644 index 00000000..98e5d538 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_bg_steps.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_goal.xml b/app/src/main/res/drawable/widget_preview_goal.xml new file mode 100644 index 00000000..375b73b3 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_goal.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_play.xml b/app/src/main/res/drawable/widget_preview_play.xml new file mode 100644 index 00000000..e22f5d26 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_play.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_preview_play_bg.xml b/app/src/main/res/drawable/widget_preview_play_bg.xml new file mode 100644 index 00000000..670ff0a9 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_play_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_preview_run.xml b/app/src/main/res/drawable/widget_preview_run.xml new file mode 100644 index 00000000..a2943217 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_run.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_tile.xml b/app/src/main/res/drawable/widget_preview_tile.xml new file mode 100644 index 00000000..8bf18b89 --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_tile.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/layout/widget_chronometer.xml b/app/src/main/res/layout/widget_chronometer.xml new file mode 100644 index 00000000..bd62a76e --- /dev/null +++ b/app/src/main/res/layout/widget_chronometer.xml @@ -0,0 +1,17 @@ + + + diff --git a/app/src/main/res/layout/widget_preview_moment.xml b/app/src/main/res/layout/widget_preview_moment.xml new file mode 100644 index 00000000..d573d502 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_moment.xml @@ -0,0 +1,45 @@ + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_recovery.xml b/app/src/main/res/layout/widget_preview_recovery.xml new file mode 100644 index 00000000..e64e4011 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_recovery.xml @@ -0,0 +1,45 @@ + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_sleep.xml b/app/src/main/res/layout/widget_preview_sleep.xml new file mode 100644 index 00000000..9b70c055 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_sleep.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_start.xml b/app/src/main/res/layout/widget_preview_start.xml new file mode 100644 index 00000000..72886ced --- /dev/null +++ b/app/src/main/res/layout/widget_preview_start.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_steps.xml b/app/src/main/res/layout/widget_preview_steps.xml new file mode 100644 index 00000000..99aff21e --- /dev/null +++ b/app/src/main/res/layout/widget_preview_steps.xml @@ -0,0 +1,63 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36690a4a..43a743d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,4 +3,15 @@ Start run Start strength Start workout + + Moment + What matters now, changing through your day + Recovery + This morning\'s recovery and what\'s behind it + Steps + Steps today and your goal + Sleep + Last night\'s score, time asleep and stages + Start a workout + Start your usual sport in one tap diff --git a/app/src/main/res/xml/widget_moment.xml b/app/src/main/res/xml/widget_moment.xml new file mode 100644 index 00000000..bfa180e9 --- /dev/null +++ b/app/src/main/res/xml/widget_moment.xml @@ -0,0 +1,23 @@ + + + diff --git a/app/src/main/res/xml/widget_recovery.xml b/app/src/main/res/xml/widget_recovery.xml new file mode 100644 index 00000000..9cac4582 --- /dev/null +++ b/app/src/main/res/xml/widget_recovery.xml @@ -0,0 +1,23 @@ + + + diff --git a/app/src/main/res/xml/widget_sleep.xml b/app/src/main/res/xml/widget_sleep.xml new file mode 100644 index 00000000..d5c6f6d4 --- /dev/null +++ b/app/src/main/res/xml/widget_sleep.xml @@ -0,0 +1,23 @@ + + + diff --git a/app/src/main/res/xml/widget_start.xml b/app/src/main/res/xml/widget_start.xml new file mode 100644 index 00000000..6a7303aa --- /dev/null +++ b/app/src/main/res/xml/widget_start.xml @@ -0,0 +1,23 @@ + + + diff --git a/app/src/main/res/xml/widget_steps.xml b/app/src/main/res/xml/widget_steps.xml new file mode 100644 index 00000000..2c309880 --- /dev/null +++ b/app/src/main/res/xml/widget_steps.xml @@ -0,0 +1,23 @@ + + + diff --git a/app/src/test/kotlin/org/openstrap/app/data/MomentLeadTest.kt b/app/src/test/kotlin/org/openstrap/app/data/MomentLeadTest.kt new file mode 100644 index 00000000..95619218 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/MomentLeadTest.kt @@ -0,0 +1,44 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openstrap.app.widget.WidgetKind + +/** Every row of widgets spec 3.1's duplicate table, and the moments around it. */ +class MomentLeadTest { + private val none = emptySet() + private val all = WidgetKind.entries.toSet() + + @Test fun `waking leads with recovery, and with last night when a Recovery widget is placed`() { + assertEquals(MomentLead.Recovery, momentLead(Moment.Waking, none)) + assertEquals(MomentLead.Recovery, momentLead(Moment.Waking, setOf(WidgetKind.Sleep, WidgetKind.Steps, WidgetKind.Start, WidgetKind.Moment))) + assertEquals(MomentLead.LastNight, momentLead(Moment.Waking, setOf(WidgetKind.Recovery))) + } + + @Test fun `waking with Recovery and Sleep both placed leads with energy`() { + assertEquals(MomentLead.Energy, momentLead(Moment.Waking, setOf(WidgetKind.Recovery, WidgetKind.Sleep))) + } + + @Test fun `midday leads with energy whatever is placed`() { + assertEquals(MomentLead.Energy, momentLead(Moment.Midday, none)) + assertEquals(MomentLead.Energy, momentLead(Moment.Midday, all)) + } + + @Test fun `the workout window leads with Start, even with a Start widget placed`() { + assertEquals(MomentLead.Start, momentLead(Moment.WorkoutWindow, none)) + assertEquals(MomentLead.Start, momentLead(Moment.WorkoutWindow, setOf(WidgetKind.Start))) + assertEquals(MomentLead.Start, momentLead(Moment.WorkoutWindow, all)) + } + + @Test fun `evening and night lead with a sentence whatever is placed`() { + for (m in listOf(Moment.Evening, Moment.Night)) { + assertEquals(MomentLead.Sentence, momentLead(m, none)) + assertEquals(MomentLead.Sentence, momentLead(m, all)) + } + } + + @Test fun `during and after a workout lead with the workout`() { + assertEquals(MomentLead.Workout, momentLead(Moment.During, all)) + assertEquals(MomentLead.AfterWorkout, momentLead(Moment.After, all)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/MomentNextChangeTest.kt b/app/src/test/kotlin/org/openstrap/app/data/MomentNextChangeTest.kt new file mode 100644 index 00000000..43416347 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/MomentNextChangeTest.kt @@ -0,0 +1,185 @@ +package org.openstrap.app.data + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [MomentResolver.nextChange]: every boundary of widgets spec 7 and v2 4.1, overlaps and past midnight, and that it + * agrees with [MomentResolver.resolve]: the moment just before is the one now, at it another, and a minute by minute + * scan finds no change earlier. + */ +class MomentNextChangeTest { + private val utc: ZoneId = ZoneOffset.UTC + private val today = LocalDate.of(2026, 10, 23) // a Friday + + private fun at(h: Int, m: Int, date: LocalDate = today, zone: ZoneId = utc): Instant = date.atTime(h, m).atZone(zone).toInstant() + private fun epoch(h: Int, m: Int, date: LocalDate = today): Long = at(h, m, date).epochSecond + + /** Three Fridays before [today] at [h]:[m]: enough for a weekday usual. */ + private fun fridays(h: Int, m: Int) = (1..3).map { epoch(h, m, today.minusWeeks(it.toLong())) } + + private data class In( + val day: LocalDate? = null, + val recording: Boolean = false, + val lastWorkoutEnd: Long? = null, + val sleepEnd: Long? = null, + val bedtime: LocalTime? = LocalTime.of(23, 0), + val starts: List = emptyList(), + val zone: ZoneId = ZoneOffset.UTC, + ) + + private fun next(now: Instant, i: In = In()): Instant? = MomentResolver.nextChange( + now, i.zone, i.day ?: now.atZone(i.zone).toLocalDate(), i.recording, i.lastWorkoutEnd, i.sleepEnd, i.bedtime, i.starts, + ) + + /** The moment at [t] read as [MomentResolver.nextChange] reads it: past midnight the new day, without today's sleep. */ + private fun momentAt(t: Instant, day: LocalDate, i: In): Moment { + val date = t.atZone(i.zone).toLocalDate() + val later = date > day + return MomentResolver.resolve(t, i.zone, if (later) date else day, i.recording, i.lastWorkoutEnd, if (later) null else i.sleepEnd, i.bedtime, i.starts) + } + + /** + * Checks [next] at [now] against [MomentResolver.resolve] and a minute by minute scan, and returns it with the + * moment it changes to. + */ + private fun checked(now: Instant, i: In = In()): Pair? { + val day = i.day ?: now.atZone(i.zone).toLocalDate() + val current = momentAt(now, day, i) + val r = next(now, i) + val scan = generateSequence(now.plusSeconds(60)) { it.plusSeconds(60) } + .takeWhile { it <= now.plusSeconds(MomentResolver.NEXT_CHANGE_HORIZON_SECONDS) } + .firstOrNull { momentAt(it, day, i) != current } + if (r == null) { + assertNull("no change from $now, but the scan found one at $scan", scan) + return null + } + assertEquals("just before $r", current, momentAt(r.minusSeconds(1), day, i)) + assertNotEquals("at $r", current, momentAt(r, day, i)) + assertTrue("the scan from $now found a change at $scan, before $r", scan != null && r <= scan && scan.epochSecond - r.epochSecond < 60) + return r to momentAt(r, day, i) + } + + // Each boundary + + @Test fun `after ends two hours after the workout`() { + assertEquals(at(14, 0) to Moment.Midday, checked(at(12, 30), In(lastWorkoutEnd = epoch(12, 0)))) + } + + @Test fun `the workout window starts and ends 45 minutes either side`() { + val i = In(starts = fridays(18, 0)) + assertEquals(at(17, 15) to Moment.WorkoutWindow, checked(at(16, 0), i)) + assertEquals(at(18, 45) to Moment.Midday, checked(at(17, 15), i)) + } + + @Test fun `waking ends three hours after the main sleep`() { + assertEquals(at(9, 30) to Moment.Midday, checked(at(7, 0), In(sleepEnd = epoch(6, 30)))) + } + + @Test fun `without a main sleep the morning starts at 05_00 and ends at 08_00`() { + assertEquals(at(5, 0) to Moment.Waking, checked(at(4, 0))) + assertEquals(at(8, 0) to Moment.Midday, checked(at(5, 0))) + } + + @Test fun `evening and night start before bedtime`() { + assertEquals(at(20, 0) to Moment.Evening, checked(at(12, 0), In(sleepEnd = epoch(7, 0)))) + assertEquals(at(22, 15) to Moment.Night, checked(at(20, 0), In(sleepEnd = epoch(7, 0)))) + assertEquals(at(19, 0) to Moment.Evening, checked(at(12, 0), In(bedtime = null))) + } + + // Past midnight + + @Test fun `night runs on past midnight to 04_00`() { + assertEquals(at(4, 0, today.plusDays(1)) to Moment.Midday, checked(at(22, 15), In(sleepEnd = epoch(7, 0)))) + } + + @Test fun `after midnight the morning is the new day's, not today's sleep`() { + // Up at 22:00 after a day sleep: waking would run to 01:00, but at midnight that sleep is yesterday's. + assertEquals(at(0, 0, today.plusDays(1)) to Moment.Night, checked(at(22, 30), In(sleepEnd = epoch(22, 0)))) + } + + @Test fun `after a late workout crosses midnight into night`() { + assertEquals(at(1, 30, today.plusDays(1)) to Moment.Night, checked(at(23, 40), In(lastWorkoutEnd = epoch(23, 30)))) + } + + @Test fun `a bedtime after midnight starts night after midnight`() { + assertEquals(at(0, 45, today.plusDays(1)) to Moment.Night, checked(at(23, 0), In(sleepEnd = epoch(7, 0), bedtime = LocalTime.of(1, 30)))) + } + + @Test fun `the workout window crosses midnight`() { + val starts = listOf(19, 20, 21).map { epoch(23, 50, LocalDate.of(2026, 10, it)) } + assertEquals(at(0, 35, today.plusDays(1)) to Moment.Night, checked(at(23, 30), In(sleepEnd = epoch(7, 0), starts = starts))) + } + + // Overlaps + + @Test fun `after over the window ends at after's end, then the window carries on`() { + val i = In(lastWorkoutEnd = epoch(17, 0), starts = fridays(18, 0)) + assertEquals(at(19, 0) to Moment.Midday, checked(at(17, 30), i)) + val j = In(lastWorkoutEnd = epoch(16, 30), starts = fridays(18, 0)) + assertEquals(at(18, 30) to Moment.WorkoutWindow, checked(at(17, 30), j)) + } + + @Test fun `waking over evening ends at waking's end, into night`() { + assertEquals(at(23, 0) to Moment.Night, checked(at(21, 0), In(sleepEnd = epoch(20, 0)))) + } + + @Test fun `a midnight with nothing changing is not a change`() { + val r = checked(at(23, 0), In(sleepEnd = epoch(7, 0))) + assertEquals(at(4, 0, today.plusDays(1)), r?.first) + } + + @Test fun `a workout leaving the usual time's look-back ends the window`() { + // Three workouts in 28 days, the oldest started 28 days ago at 17:40: once it's out there's no usual time. + val oldest = epoch(17, 40, today.minusDays(28)) + val starts = listOf(oldest, epoch(18, 0, today.minusDays(10)), epoch(18, 0, today.minusDays(5))) + assertEquals(Instant.ofEpochSecond(oldest + 28 * 86_400L + 1) to Moment.Midday, checked(at(17, 30), In(sleepEnd = epoch(7, 0), starts = starts))) + } + + // None + + @Test fun `a recording has no known end`() { + assertNull(next(at(17, 0), In(recording = true, lastWorkoutEnd = epoch(12, 0), starts = fridays(18, 0)))) + } + + @Test fun `a past day is always waking`() { + assertNull(next(at(17, 0), In(day = today.minusDays(1)))) + } + + // The property, swept + + @Test fun `it agrees with resolve from every point of a day`() { + val scenarios = listOf( + In(), + In(sleepEnd = epoch(6, 40)), + In(sleepEnd = epoch(6, 40), lastWorkoutEnd = epoch(18, 50), starts = fridays(18, 0)), + In(bedtime = null, starts = listOf(19, 20, 21).map { epoch(23, 50, LocalDate.of(2026, 10, it)) }), + In(sleepEnd = epoch(11, 0), bedtime = LocalTime.of(2, 0), lastWorkoutEnd = epoch(13, 7)), + ) + for (i in scenarios) { + var now = at(0, 0) + while (now < at(0, 0, today.plusDays(1))) { checked(now, i); now = now.plusSeconds(37 * 60) } + } + } + + @Test fun `it agrees with resolve across the clocks going back and forward`() { + val london = ZoneId.of("Europe/London") + // 2026-10-25 passes 01:00 to 02:00 twice, 2026-03-29 skips it. The window's end (01:15) and night's start + // (01:45) sit inside it: going back, the window and evening come round again. + listOf(LocalDate.of(2026, 10, 24), LocalDate.of(2026, 3, 28)).forEach { eve -> + val starts = (1..3).map { eve.minusDays(it.toLong()).atTime(0, 30).atZone(london).toEpochSecond() } + val i = In(bedtime = LocalTime.of(2, 30), starts = starts, zone = london) + var now = eve.atTime(12, 0).atZone(london).toInstant() + val end = now.plusSeconds(86_400) + while (now < end) { checked(now, i); now = now.plusSeconds(23 * 60) } + } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/device/UpdateThrottleTest.kt b/app/src/test/kotlin/org/openstrap/app/device/UpdateThrottleTest.kt new file mode 100644 index 00000000..c7a05611 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/device/UpdateThrottleTest.kt @@ -0,0 +1,32 @@ +package org.openstrap.app.device + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** The Steps widget's throttle: at most one update every 15 minutes, and the last change never lost. */ +class UpdateThrottleTest { + private val every = 15 * 60_000L + + @Test fun `the first request runs now`() { + assertEquals(0L, UpdateThrottle(every).request(1_000)) + } + + @Test fun `a request inside the gap waits for its end, and others join it`() { + val t = UpdateThrottle(every) + t.request(0) + assertEquals(every - 60_000, t.request(60_000)) + assertNull(t.request(120_000)) + assertNull(t.request(every - 1)) + t.ran(every) + // The waiting one ran: the next gap counts from it. + assertEquals(every - 1, t.request(every + 1)) + } + + @Test fun `a request after the gap runs now`() { + val t = UpdateThrottle(every) + t.request(0) + assertEquals(0L, t.request(every)) + assertEquals(0L, t.request(3 * every)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/CopyPunctuationTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/CopyPunctuationTest.kt index e962ed9e..4d92d4be 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/CopyPunctuationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/CopyPunctuationTest.kt @@ -21,13 +21,13 @@ class CopyPunctuationTest { private val root: File = listOf(File("src/main"), File("app/src/main")).first { it.isDirectory } private val kotlinRoot = File(root, "kotlin/org/openstrap/app") - /** Everything under ui/, every *Copy*.kt, the notification text in StrapService and data the UI shows as is. */ + /** Everything under ui/ and widget/, every *Copy*.kt, the notification text in StrapService and data the UI shows as is. */ private fun sources(): List { val all = kotlinRoot.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() val named = setOf("device/StrapService.kt", "data/Recovery.kt", "data/settings/Goals.kt") return all.filter { f -> val path = f.relativeTo(kotlinRoot).invariantSeparatorsPath - path.startsWith("ui/") || "Copy" in f.name || path in named + path.startsWith("ui/") || path.startsWith("widget/") || "Copy" in f.name || path in named }.sortedBy { it.path } } @@ -47,6 +47,14 @@ class CopyPunctuationTest { File(root, "res/values/strings.xml").takeIf { it.isFile }?.readLines()?.forEachIndexed { i, line -> if (line.any { it in banned }) found += "res/values/strings.xml:${i + 1} ${line.trim()}" } + // The widgets' provider XML (widgets spec 8). + File(root, "res/xml").listFiles().orEmpty().filter { it.name.startsWith("widget_") && it.extension == "xml" }.sortedBy { it.name }.forEach { f -> + f.readLines().forEachIndexed { i, line -> if (line.any { it in banned }) found += "res/xml/${f.name}:${i + 1} ${line.trim()}" } + } + // The picker previews' sample copy (widgets spec 6.4). + File(root, "res/layout").listFiles().orEmpty().filter { it.name.startsWith("widget_preview_") }.sortedBy { it.name }.forEach { f -> + f.readLines().forEachIndexed { i, line -> if (line.any { it in banned }) found += "res/layout/${f.name}:${i + 1} ${line.trim()}" } + } if (found.isNotEmpty()) fail("${found.size} user-facing strings use ·, ; or —:\n" + found.joinToString("\n")) val stale = allowed.keys - used assertTrue("allowlist entries that no longer match: $stale", stale.isEmpty()) diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/ActivityCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/ActivityCopyTest.kt index 45c9606c..b12561db 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/ActivityCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/ActivityCopyTest.kt @@ -66,7 +66,7 @@ class ActivityCopyTest { @Test fun `active minutes count toward 150 a week`() { assertEquals("54 to go for 150.", ActivityCopy.activeWeek(96)) assertEquals("10 past the 150 to aim for.", ActivityCopy.activeWeek(160)) - assertEquals("Goal reached", ActivityCopy.activeWord(Activity.MVPA_WEEKLY_TARGET)) + assertEquals("Goal met", ActivityCopy.activeWord(Activity.MVPA_WEEKLY_TARGET)) assertEquals("So far today, 20 minutes brisk and 4 minutes hard.", ActivityCopy.activeDay(Activity.Mvpa(20, 4, 0), true)) } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/FrameTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/FrameTest.kt index fb2571f2..570f78ad 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/FrameTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/FrameTest.kt @@ -166,6 +166,7 @@ class FrameTest { assertEquals(Detail.OfMetric(Metric.Hrv), Detail.fromRoute("Hrv")) assertEquals(Detail.Recovery, Detail.fromRoute("Recovery")) assertEquals(Detail.Sleep, Detail.fromRoute("Sleep")) + assertEquals(Detail.OfScore(Score.Sleep), Detail.fromRoute(Detail.SLEEP_SCORE_ROUTE)) assertNull(Detail.fromRoute("Readiness")) } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/ChartSpecTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/ChartSpecTest.kt new file mode 100644 index 00000000..b04fa6fd --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/ChartSpecTest.kt @@ -0,0 +1,156 @@ +package org.openstrap.app.ui.glow.draw + +import androidx.compose.ui.geometry.Offset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.ui.glow.GlowStage + +/** The charts' shared geometry (widgets spec 6.2): what the cards and a widget's chart bitmaps both draw. */ +class ChartSpecTest { + private val density = 2.75f + + @Test fun `hypnogram bands are as long as their minutes`() { + // A 400 minute night: 100 deep, 200 light, 60 REM, 40 awake. + val stages = listOf( + GlowStage(0f, 0.25f, 3), GlowStage(0.25f, 0.75f, 2), GlowStage(0.75f, 0.9f, 1), GlowStage(0.9f, 1f, 0), + ) + val g = hypnogramGeometry(stages.shuffled(java.util.Random(3)), rows = 4, width = 318f * density, height = 96f * density, density = density, nameWidth = 0f, axis = false)!! + val total = 318f * density + listOf(100f, 200f, 60f, 40f).zip(g.bands).forEach { (minutes, band) -> assertEquals(minutes / 400f * total, band.size.width, 1e-2f) } + // In order of time, each on its row's hairline, Awake at the top. + assertEquals(stages, g.sorted) + g.bands.forEach { b -> assertEquals(g.rowY[b.stage.row], b.topLeft.y + b.size.height / 2, 1e-3f) } + assertTrue(g.rowY.zipWithNext().all { (a, b) -> a < b }) + assertEquals(4, g.rowY.size) + // Round: the corner is half the band. + g.bands.forEach { assertEquals(it.size.height / 2, it.corner, 1e-6f) } + // Three changes of row, three joins for the white version. + assertEquals(3, g.joins.size) + assertNull(hypnogramGeometry(emptyList(), 4, 100f, 100f, density, 0f, false)) + } + + @Test fun `a stage too short to see is still as wide as it is high`() { + val g = hypnogramGeometry(listOf(GlowStage(0f, 0.001f, 0)), 4, 300f, 100f, 1f, 0f, false)!! + assertEquals(g.bands[0].size.height, g.bands[0].size.width, 1e-6f) + } + + @Test fun `names and times take room off the plot`() { + val g = hypnogramGeometry(listOf(GlowStage(0f, 1f, 0)), 4, 300f * density, 130f * density, density, 46f * density, axis = true)!! + assertEquals(46f * density, g.gridX0, 0f) + assertEquals(46f * density, g.bands[0].topLeft.x, 1e-3f) + assertEquals((130f - 18f) * density, g.plotBottom, 1e-3f) + } + + @Test fun `the ghost line ends in a dot on the latest hour`() { + val today = listOf(0f, 100f, 900f, 2400f, null, null) + val g = ghostLineGeometry(today, usual = listOf(0f, 200f, 800f, 2000f, 4000f, 6000f), max = 10_000f, width = 300f, height = 56f, density = 1f)!! + assertEquals(4, g.today.size) + assertEquals(4.5f, g.endDot, 0f) + // Evenly spaced over the whole span, the ends in by the dot. + assertEquals(4.5f, g.today.first().x, 1e-4f) + assertEquals(4.5f + 3 * (300f - 9f) / 5, g.today.last().x, 1e-3f) + // Zero sits on the floor, the axis tops out at max. + assertEquals(56f - 4.5f, g.today.first().y, 1e-4f) + assertEquals(6, g.usual!!.size) + assertNull(ghostLineGeometry(listOf(1f), null, 10f, 300f, 56f, 1f)) + assertNull(ghostLineGeometry(listOf(1f, 2f), listOf(1f), 10f, 300f, 56f, 1f)!!.usual) + } + + @Test fun `the ghost line is the one GhostLine always drew`() { + // GhostLine's inline maths before it was shared, kept here as the reference. + val today = listOf(10f, 40f, null, 70f) + val (w, hgt, max) = Triple(173f * density, 52f * density, 100f) + val r = 4.5f * density + val h = hgt - r + fun xOf(i: Int, n: Int) = r + i / (n - 1f) * (w - r * 2) + fun yOf(v: Float) = r + (h - r) * (1f - (v / max).coerceIn(0f, 1f)) + val expected = today.mapIndexedNotNull { i, v -> v?.let { Offset(xOf(i, today.size), yOf(it)) } } + assertEquals(expected, ghostLineGeometry(today, null, max, w, hgt, density)!!.today) + } + + @Test fun `a gap breaks the line, a lone reading is a dot`() { + val values = listOf(60f, 62f, 64f, null, 70f, null, null, 58f, 57f) + val g = lineChartGeometry(values, 358f * density, 72f * density, density, inset = 7f * density, axis = true, band = 55f..65f)!! + assertEquals(2, g.runs.size) + assertEquals(listOf(3, 2), g.runs.map { it.size }) + assertEquals(1, g.dots.size) + assertEquals(g.xOf(4), g.dots[0].x, 1e-4f) + assertEquals(Offset(g.xOf(8), g.yOf(57f)), g.last) + assertEquals(3, g.grid.size) + assertEquals(10f * density, g.grid.first(), 1e-4f) + assertEquals((72f - 18f) * density, g.plotBottom, 1e-3f) + // The band covers your normal, from its top to its foot. + assertEquals(g.yOf(65f), g.bandTopLeft!!.y, 1e-4f) + assertEquals(g.yOf(55f) - g.yOf(65f), g.bandSize!!.height, 1e-4f) + assertEquals(values.filterNotNull().average().toFloat(), g.average, 1e-4f) + assertNull(lineChartGeometry(listOf(null, null), 100f, 100f, 1f, 0f)) + } + + @Test fun `a fixed axis holds whatever the data`() { + val g = lineChartGeometry(listOf(40f, 50f), 200f, 64f, 1f, inset = 7f, yRange = 0f..100f)!! + assertEquals(10f + 54f * 0.5f, g.yOf(50f), 1e-4f) + assertEquals(64f, g.yOf(0f), 1e-4f) + } + + @Test fun `the smooth line passes through every reading and never overshoots`() { + val pts = listOf(Offset(0f, 50f), Offset(10f, 20f), Offset(20f, 20f), Offset(30f, 80f), Offset(40f, 70f)) + val ops = smoothOps(pts) + assertEquals(PathOp.Move(0f, 50f), ops.first()) + val cubics = ops.drop(1).map { it as PathOp.Cubic } + assertEquals(pts.drop(1), cubics.map { Offset(it.x3, it.y3) }) + cubics.forEachIndexed { i, c -> + val lo = minOf(pts[i].y, pts[i + 1].y) + val hi = maxOf(pts[i].y, pts[i + 1].y) + assertTrue("segment $i", c.y1 in lo..hi && c.y2 in lo..hi) + } + assertEquals(listOf(PathOp.Move(0f, 0f), PathOp.Line(1f, 1f)), smoothOps(listOf(Offset(0f, 0f), Offset(1f, 1f)))) + assertTrue(smoothOps(emptyList()).isEmpty()) + } + + @Test fun `stage totals go deepest first and drop a stage under a minute`() { + val totals = stageTotals(mapOf(SleepStage.AWAKE to 600L, SleepStage.LIGHT to 16_000L, SleepStage.DEEP to 5_500L, SleepStage.REM to 59L)) + assertEquals(listOf(SleepStage.DEEP to 5_500L, SleepStage.LIGHT to 16_000L, SleepStage.AWAKE to 600L), totals) + } + + @Test fun `stage bar segments fill the bar in whole pixels, split by time`() { + val totals = listOf(SleepStage.DEEP to 5_520L, SleepStage.REM to 6_060L, SleepStage.LIGHT to 16_020L, SleepStage.AWAKE to 1_080L) + val width = 873 + val gap = 6 + val s = stageBarSegments(totals, width, gap) + assertEquals(width, s.last().left + s.last().width) + assertEquals(width - gap * 3, s.sumOf { it.width }) + s.zipWithNext().forEach { (a, b) -> assertEquals(a.left + a.width + gap, b.left) } + val space = (width - gap * 3).toFloat() + val total = totals.sumOf { it.second }.toFloat() + s.forEachIndexed { i, seg -> assertEquals(space * totals[i].second / total, seg.width.toFloat(), 1f) } + assertTrue(stageBarSegments(emptyList(), 100, 2).isEmpty()) + } + + @Test fun `pill bars stay thin and never shrink to a dot`() { + val wide = pillBar(0f, pitch = 40f, bottom = 100f, height = 50f, maxWidth = 6f * density) + assertEquals(6f * density, wide.size.width, 1e-4f) + assertEquals((40f - 6f * density) / 2, wide.topLeft.x, 1e-4f) + assertEquals(50f, wide.size.height, 0f) + assertEquals(wide.size.width / 2, wide.corner, 0f) + val short = pillBar(0f, pitch = 10f, bottom = 100f, height = 1f, maxWidth = 100f) + assertEquals(short.size.width * 2, short.size.height, 1e-4f) + assertEquals(100f, short.topLeft.y + short.size.height, 1e-4f) + } + + @Test fun `hour pills light the latest hour with steps, or the picked one`() { + val hourly = List(24) { h -> if (h in 7..15 && h != 12) (h * 100).toFloat() else 0f } + val g = hourPills(hourly, picked = null, width = 318f * density, height = 56f * density, density = density)!! + assertEquals(8, g.bars.size) + assertEquals(listOf(false, false, false, false, false, false, false, true), g.bars.map { it.second }) + // The busiest hour reaches the top. + assertEquals(0f, g.bars.minOf { it.first.topLeft.y }, 1e-3f) + assertEquals(56f * density - 0.5f * density, g.baselineY, 1e-4f) + val picked = hourPills(hourly, picked = 9, width = 318f * density, height = 56f * density, density = density)!! + assertEquals(1, picked.bars.count { it.second }) + assertTrue(picked.bars.first { it.second }.first.topLeft.x < picked.bars.last().first.topLeft.x) + assertNull(hourPills(emptyList(), null, 100f, 100f, 1f)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/DotSpecTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/DotSpecTest.kt new file mode 100644 index 00000000..a2d37b60 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/DotSpecTest.kt @@ -0,0 +1,66 @@ +package org.openstrap.app.ui.glow.draw + +import androidx.compose.ui.geometry.Offset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.ui.glow.DotSize + +/** The dot numerals' shared geometry (widgets spec 6.2): what DotMatrixText and a widget's bitmap both draw. */ +class DotSpecTest { + @Test fun `8,432 in the second glow's dots`() { + val l = dotLayout("8,432", DotSize.Second, density = 1f) + // Four 5-wide digits, the 1-wide comma and four empty columns between them. + assertEquals(25, DotFont.columns("8,432")) + // 8 has 17 dots, 4 has 14, 3 has 15, 2 has 14, the comma 2. + assertEquals(62, l.centers.size) + val pitch = 3.6f + 3.6f * DotSize.DOT_GAP + assertEquals(pitch * 25 - 3.6f * DotSize.DOT_GAP, l.width, 1e-4f) + assertEquals(pitch * 7 - 3.6f * DotSize.DOT_GAP, l.height, 1e-4f) + assertEquals(1.8f, l.radius, 1e-6f) + } + + @Test fun `every dot sits inside the box at any density`() { + listOf(1f, 2.625f, 3.5f).forEach { density -> + DotSize.entries.forEach { size -> + val l = dotLayout("1,568 −94%", size, density) + val w = l.width * density + val h = l.height * density + l.centers.forEach { c -> + assertTrue("$size $density $c", c.x - l.radius >= -1e-3f && c.x + l.radius <= w + 1e-3f) + assertTrue("$size $density $c", c.y - l.radius >= -1e-3f && c.y + l.radius <= h + 1e-3f) + } + } + } + } + + @Test fun `the layout is the one DotMatrixText always drew`() { + // The inline drawing DotMatrixText had before the geometry was shared, kept here as the reference. + val text = "62:07" + val density = 2.75f + val dot = 4.5f + val gap = dot * DotSize.DOT_GAP + val d = dot * density + val step = (dot + gap) * density + val expected = mutableListOf() + var x0 = 0f + text.map(DotFont::glyph).forEach { rows -> + rows.forEachIndexed { row, line -> + line.forEachIndexed { col, c -> if (c == '#') expected += Offset(x0 + col * step + d / 2, row * step + d / 2) } + } + x0 += (rows[0].length + 1) * step + } + assertEquals(expected, dotLayout(text, DotSize.Hero, density).centers) + } + + @Test fun `a character the font lacks is a space`() { + assertTrue(!DotFont.has('x')) + assertEquals(dotLayout("1 2", DotSize.Edge, 1f).width, dotLayout("1x2", DotSize.Edge, 1f).width, 0f) + assertEquals(dotLayout("12", DotSize.Edge, 1f).centers.size, dotLayout("1x2", DotSize.Edge, 1f).centers.size) + } + + @Test fun `a number's width matches its widest digits`() { + val four = dotLayout("8888", DotSize.Detail, 1f) + assertEquals(four.width, org.openstrap.app.ui.glow.dotNumberWidth(4, DotSize.Detail).value, 1e-4f) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/LightSpecTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/LightSpecTest.kt new file mode 100644 index 00000000..7959d10f --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/LightSpecTest.kt @@ -0,0 +1,151 @@ +package org.openstrap.app.ui.glow.draw + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.ui.glow.Edge +import org.openstrap.app.ui.glow.EdgeKind +import org.openstrap.app.ui.glow.EdgeStatus +import org.openstrap.app.ui.glow.GlowLayout +import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.ui.glow.GlowPalette +import org.openstrap.app.ui.glow.GlowStyle +import org.openstrap.app.ui.glow.LightAngle +import org.openstrap.app.ui.glow.SoftGlow +import org.openstrap.app.ui.glow.StrongGlow +import org.openstrap.app.ui.glow.edgeLight +import org.openstrap.app.ui.glow.level +import org.openstrap.app.ui.glow.levelEdge +import org.openstrap.app.ui.theme.MetricColors + +/** The glow and edge light's shared spec (widgets spec 6.2): what the cards and a widget's background are drawn with. */ +class LightSpecTest { + private val density = 2.75f + private val w = 358f * density + private val h = 176f * density + + @Test fun `a hero lit from below fades from the light to the body by 45%`() { + val style = GlowStyle(MetricColors.readiness, lightAngle = LightAngle.Below) + val p = glowPaint(style, w, h, density) + val palette = GlowPalette(style) + assertEquals(listOf(0f, 0.16f, 0.45f, 1f), p.fill.stops) + assertEquals(palette.light, p.fill.colors.first()) + assertEquals(palette.mid, p.fill.colors[2]) + assertEquals(palette.mid, p.fill.colors.last()) + // From the bottom edge up the middle. + assertEquals(w / 2, p.fill.x0, 1e-3f) + assertEquals(w / 2, p.fill.x1, 1e-3f) + assertEquals(h, p.fill.y0, 1e-3f) + assertEquals(0f, p.fill.y1, 1e-3f) + // One rim at full, leaning to the light: the hole moves up, away from it. + assertEquals(1, p.rims.size) + assertEquals(1f, p.rims[0].color.alpha, 1f / 255) + assertTrue(p.rims[0].dy < 0f) + assertNull(p.rimClip) + assertTrue(p.core is GlowCore.Shapes) + assertEquals(GlowLayout.CardCorner.value * density, p.radius, 1e-3f) + } + + @Test fun `the second glow starts at 70% and reaches the body by 28%`() { + val p = glowPaint(GlowStyle(MetricColors.readiness, lightAngle = LightAngle.Below, second = true), w, h, density) + assertEquals(listOf(0f, 0.28f, 1f), p.fill.stops) + assertEquals(0.7f, p.rims[0].color.alpha, 1f / 255) + } + + @Test fun `a level's rim and core stop at the level`() { + val style = GlowStyle.level(MetricColors.steps, 0.3f) + val p = glowPaint(style, w, h, density) + val clip = p.rimClip + assertNotNull(clip) + clip!! + assertEquals(levelEdge(0.3f) * h - 22f * density, clip.top, 1e-3f) + assertEquals(h, clip.bottom, 1e-3f) + val core = p.core as GlowCore.Level + // Dark from above the card, transparent below it, across the layer's margin. + assertEquals(-p.coreMargin, core.gradient.y0, 1e-3f) + assertEquals(h + p.coreMargin, core.gradient.y1, 1e-3f) + assertEquals(1f, core.gradient.colors.first().alpha, 1f / 255) + assertEquals(0f, core.gradient.colors.last().alpha, 1f / 255) + // Glossy along the lit (bottom) edge. + assertEquals(0.35f, p.gloss.stops.last().second.alpha, 1f / 255) + } + + @Test fun `a level from the left fills across`() { + val p = glowPaint(GlowStyle.Ember.copy(lightLevel = 0.6f, lightAngle = LightAngle.Left, second = true), w, h, density) + val core = p.core as GlowCore.Level + assertEquals(0f, core.gradient.y0, 0f) + assertEquals(w + p.coreMargin, core.gradient.x0, 1e-3f) + assertTrue(p.gloss.across) + // A level ignores second: always at full. + assertEquals(1f, p.rims[0].color.alpha, 1f / 255) + assertEquals(listOf(0f, 0.35f * 0.6f, 0.85f * 0.6f, 1f), p.fill.stops) + } + + @Test fun `the night mood lights from below and dims`() { + val day = glowPaint(GlowStyle(MetricColors.readiness, lightAngle = LightAngle.UpperRight), w, h, density) + val night = glowPaint(GlowStyle(MetricColors.readiness, lightAngle = LightAngle.UpperRight, mood = GlowMood.Night), w, h, density) + assertTrue(day.fill.y0 < h / 2) + assertEquals(h, night.fill.y0, 1e-3f) + assertTrue(night.fill.colors.first().red + night.fill.colors.first().green < day.fill.colors.first().red + day.fill.colors.first().green) + } + + @Test fun `on hold only the outline`() { + val p = glowPaint(GlowStyle(MetricColors.heart, outline = true), w, h, density) + assertEquals(MetricColors.heart.copy(alpha = 0.7f), p.outline) + assertEquals(1.5f * density, p.outlineWidth, 1e-4f) + assertNull(glowPaint(GlowStyle(MetricColors.heart), w, h, density).outline) + } + + @Test fun `grain and hairline`() { + val p = glowPaint(GlowStyle(MetricColors.heart), w, h, density) + assertEquals((0.06f * 255).toInt(), p.grainAlpha) + assertEquals(Color.White.copy(alpha = GlowLayout.HairlineAlpha), p.hairline) + assertEquals(0, glowPaint(GlowStyle(MetricColors.heart, grain = 0f), w, h, density).grainAlpha) + } + + @Test fun `a usual edge card is lit softly from the bottom`() { + val e = edgePaint(edgeLight(EdgeStatus.Usual), MetricColors.steps, null, GlowMood.Day, w, h, density) + assertEquals(h, e.glow.y0, 0f) + assertEquals(h - SoftGlow.depth.value * density, e.glow.y1, 1e-3f) + assertEquals(0.70f, e.glow.colors.first().alpha, 1f / 255) + assertEquals(0f, e.glow.colors.last().alpha, 1f / 255) + assertEquals(EdgeSurface, e.surface) + assertEquals(1f * density, e.hairlineWidth, 0f) + } + + @Test fun `off your normal glows stronger, and the mood softens it`() { + val strong = edgePaint(edgeLight(EdgeStatus.Above), MetricColors.heart, null, GlowMood.Day, w, h, density) + assertEquals(0f, strong.glow.y0, 0f) + assertEquals(StrongGlow.depth.value * density, strong.glow.y1, 1e-3f) + assertEquals(0.90f, strong.glow.colors.first().alpha, 1f / 255) + val night = edgePaint(edgeLight(EdgeStatus.Above), MetricColors.heart, null, GlowMood.Night, w, h, density) + assertEquals(0.90f * 0.8f, night.glow.colors.first().alpha, 1f / 255) + } + + @Test fun `each edge runs from its side`() { + val right = edgePaint(edgeLight(EdgeStatus.Usual, EdgeKind.Live), MetricColors.heart, null, GlowMood.Day, w, h, density) + assertEquals(w, right.glow.x0, 0f) + assertTrue(right.glow.x1 < w) + val left = edgePaint(edgeLight(EdgeStatus.Usual, EdgeKind.Earlier), MetricColors.readiness, null, GlowMood.Day, w, h, density) + assertEquals(Edge.Left, edgeLight(EdgeStatus.Usual, EdgeKind.Earlier).edge) + assertEquals(0f, left.glow.x0, 0f) + assertTrue(left.glow.x1 > 0f) + } + + @Test fun `a level edge card fills across to its value`() { + val level = edgeLevel(0.5f)!! + val e = edgePaint(edgeLight(EdgeStatus.Usual, EdgeKind.Level), MetricColors.load, level, GlowMood.Day, w, h, density) + assertEquals(0f, e.glow.x0, 0f) + assertEquals(w, e.glow.x1, 0f) + assertEquals(0.5f, e.glow.stops!![1], 1e-6f) + assertEquals(0.46f, e.glow.colors[0].alpha, 1f / 255) + assertEquals(0.30f, e.glow.colors[1].alpha, 1f / 255) + assertEquals(0f, e.glow.colors.last().alpha, 1f / 255) + // An empty level still shows where it fills from. + assertEquals(EDGE_MIN_LEVEL, edgeLevel(0f)) + assertNull(edgeLevel(null)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/widget/MomentWidgetModelTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/MomentWidgetModelTest.kt new file mode 100644 index 00000000..3f31a19d --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/widget/MomentWidgetModelTest.kt @@ -0,0 +1,614 @@ +package org.openstrap.app.widget + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.Moment +import org.openstrap.app.data.MomentInputs +import org.openstrap.app.data.MomentLead +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.device.WidgetUpdater +import org.openstrap.app.ui.components.ChartPoint +import org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import org.openstrap.app.ui.glow.AFTER_RIM +import org.openstrap.app.ui.glow.EVENING_BODY +import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.ui.glow.LightAngle +import org.openstrap.app.ui.glow.WINDOW_RIM +import org.openstrap.app.ui.theme.MetricColors + +/** + * Widgets spec 3.1: what the Moment widget shows at each moment, from Today's own resolver, copy and sums; the + * duplicate rule; the refresh at each moment's end and while it counts minutes; and what fits each size, one line a + * text, no gap over 32 dp on One UI's cells. + */ +class MomentWidgetModelTest { + private val zone: ZoneId = ZoneOffset.UTC + + /** A Saturday. */ + private val today = LocalDate.of(2026, 10, 24) + + private fun at(h: Int, m: Int, date: LocalDate = today): Instant = date.atTime(h, m).atZone(zone).toInstant() + private fun epoch(h: Int, m: Int, date: LocalDate = today) = at(h, m, date).epochSecond + + /* ---------------------------------------------- Sample day ---------------------------------------------- */ + + /** The Sleep widget's night: 22:58 to 06:56, score 82, woke 3 times, 7 h 40 asleep. */ + private val sleepStart = epoch(22, 58, today.minusDays(1)) + private val sleepEnd = epoch(6, 56) + private val session = SleepSession(sleepStart, sleepEnd, score = 82, wakeCount = 3, deepMinutes = 92, remMinutes = 101, lightMinutes = 267, awakeMinutes = 18) + + private fun stages(): List { + val plan = listOf( + SleepStage.LIGHT to 60, SleepStage.DEEP to 60, SleepStage.LIGHT to 90, SleepStage.REM to 50, SleepStage.AWAKE to 10, + SleepStage.LIGHT to 117, SleepStage.DEEP to 32, SleepStage.REM to 51, SleepStage.AWAKE to 8, + ) + var t = sleepStart + return plan.map { (stage, min) -> SleepStage(t, t + min * 60L, sleepStart, stage).also { t += min * 60L } } + } + + /** Earlier nights this week, shorter: 6 h 10, 7 h, 7 h 20. */ + private val week: Map = listOf(1 to 370, 2 to 420, 3 to 440).associate { (ago, min) -> + val d = today.minusDays(ago.toLong()) + val e = d.atTime(7, 0).atZone(zone).toEpochSecond() + d to SleepSession(e - min * 60L, e) + } + + private val night = SleepWidgetNight.of(today, session, stages(), week + (today to session)) + + /** Energy from 84 at waking down to 62 at [now], a reading every 15 minutes. */ + private fun energy(now: Instant, value: Int = 62): MomentEnergy { + val n = ((now.epochSecond - sleepEnd) / 900).toInt().coerceAtLeast(1) + val points = (0..n).map { i -> Recovery.ChargePoint(sleepEnd + i * 900L, 84f + (value - 84f) * i / n, Recovery.State.Activity) } + return MomentEnergy(value, 84, 38, 60f, true, points) + } + + /** Heart rate every five minutes since midnight, 60 to 110 and back. */ + private fun heart(now: Instant): List { + val start = epoch(0, 0) + return (0..((now.epochSecond - start) / 300).toInt()).map { i -> ChartPoint(start + i * 300L, 85f + 25f * kotlin.math.sin(i / 12.0).toFloat()) } + } + + private val bedtime = LocalTime.of(22, 40) + + /** Three runs at 17:30 on Saturdays: the workout window runs 16:45 to 18:15. */ + private val runs = (1..3).map { epoch(17, 30, today.minusWeeks(it.toLong())) } + + private val recovery = TodayCopy.recoveryShown(78, false, null, null, TabCopy.RECOVERY_WAITING) + + private fun input( + now: Instant, + placed: Set = emptySet(), + recording: MomentRecordingInput? = null, + last: MomentWorkout? = null, + starts: List = emptyList(), + night: SleepWidgetNight? = this.night, + recovery: TodayCopy.RecoveryShown? = this.recovery, + energy: MomentEnergy? = energy(now), + bed: LocalTime? = bedtime, + firstNights: Boolean = false, + day: LocalDate = today, + ) = MomentWidgetInput( + inputs = MomentInputs(day, recording != null, last?.end, night?.session?.end, bed, starts), + placed = placed, + recovery = recovery, + score = recovery?.value, + alert = false, + noScore = null, + firstNights = firstNights, + energy = energy, + load = 42.3f, + loadStatus = TodayCopy.LoadStatus.SoFar, + night = night, + usualSport = 1, + lastWorkout = last, + recording = recording, + dayWorkouts = listOfNotNull(last?.sportType), + heartRate = heart(now), + restingHr = 52, + steps = 8432, + stepGoal = 10_000, + ) + + private fun model(now: Instant, input: MomentWidgetInput = input(now)) = MomentWidgetModel.from(input, now, zone) + + // The moments, as the resolver gives them for the sample day. + private val waking = at(8, 0) + private val midday = at(13, 0) + private val window = at(17, 20) + private val during = at(18, 0) + private val after = at(18, 20) + private val evening = at(20, 30) + private val nightBefore = at(22, 10) + private val nightAfter = at(23, 30) + private val run = MomentWorkout(1, epoch(17, 42)) + private val recording = MomentRecordingInput(1, epoch(17, 42)) + + private fun wakingModel(placed: Set = emptySet()) = model(waking, input(waking, placed)) + private fun middayModel() = model(midday) + private fun windowModel() = model(window, input(window, starts = runs)) + private fun duringModel() = model(during, input(during, recording = recording, starts = runs)) + private fun afterModel() = model(after, input(after, last = run, starts = runs)) + private fun eveningModel() = model(evening, input(evening, last = run)) + private fun nightBeforeModel() = model(nightBefore, input(nightBefore, last = run)) + private fun nightAfterModel() = model(nightAfter, input(nightAfter, last = run)) + + /* ---------------------------------------------- Each moment ---------------------------------------------- */ + + @Test fun `waking up leads with Recovery, the waking sentence, Energy at waking and the night's stages`() { + val m = wakingModel() + assertEquals(Moment.Waking, m.moment) + assertEquals(MomentLead.Recovery, m.lead) + assertEquals("Waking up", m.name) + assertEquals("78", m.number) + assertEquals(listOf("Ready"), m.words) + assertEquals(listOf("Energy at waking 84"), m.figures) + assertEquals(TodayCopy.waking(night.asleepSeconds, 78, false, null, false, isToday = true), m.sentences.first()) + assertEquals("Slept 7 h 40. Recovered enough for your usual training.", m.sentences.first()) + assertTrue("a shorter wording keeps what Recovery says", "Recovered enough for your usual training." in m.sentences) + val chart = m.chart!! + assertEquals(MomentChartKind.Stages, chart.kind) + assertEquals(night.stages, chart.stages) + assertEquals("22:58" to "06:56", chart.from to chart.to) + assertEquals(MomentLight(MetricColors.readiness, LightAngle.LowerLeft), m.light) + assertEquals(GlowMood.Day, m.mood) + assertEquals("Recovery 78, Ready", m.numberDescription) + assertFalse(m.opensLive) + assertNull(m.tick) + } + + @Test fun `midday leads with Energy, the Charge hero's sentence, drained and recharged, the line since waking`() { + val m = middayModel() + assertEquals(Moment.Midday, m.moment) + assertEquals(MomentLead.Energy, m.lead) + assertEquals("Midday", m.name) + assertEquals("62", m.number) + assertEquals(listOf("Energy left"), m.words) + assertEquals(listOf("left"), m.shortWords) + assertEquals("Drained 60, recharged 38", m.figures.first()) + assertEquals(TodayCopy.charge(62, 60, true, 13), m.sentences.first()) + assertEquals("The day so far drained 60. Plenty left for this evening.", m.sentences.first()) + // Lit from below up to the value, as Today's Charge hero. + assertEquals(0.62f, m.light.level!!, 1e-4f) + val chart = m.chart!! + assertEquals(MomentChartKind.Line, chart.kind) + assertEquals("06:56" to "22:40", chart.from to chart.to) + // The line runs from waking to now and stops there: the rest of the day is still to come. + val lastIndex = chart.values.indexOfLast { it != null } + assertEquals(((epoch(13, 0) - sleepEnd) / 900).toInt(), lastIndex) + assertTrue(chart.values.drop(lastIndex + 1).all { it == null }) + assertTrue(chart.shares.filterNotNull().all { it in 0f..1f }) + assertEquals(0.62f, chart.shares[lastIndex]!!, 0.01f) + assertEquals("Energy 62 left", m.numberDescription) + assertNull(m.tick) + } + + @Test fun `the workout window leads with Start, the window's sentence and Energy left`() { + val m = windowModel() + assertEquals(Moment.WorkoutWindow, m.moment) + assertEquals(MomentLead.Start, m.lead) + assertEquals("Workout time", m.name) + assertEquals("62", m.number) + assertEquals("Ready for effort. You usually run around now.", m.sentences.first()) + assertEquals(1, m.start) + assertEquals("Start run", m.startLabel) + assertTrue("no figure: the Start button stands there", m.figures.isEmpty()) + assertEquals(MomentLight(MetricColors.heart, LightAngle.UpperRight, rim = WINDOW_RIM), m.light) + assertEquals(MomentChartKind.Line, m.chart?.kind) + } + + @Test fun `during a workout, the sport and since when, nothing live, opening the live screen`() { + val m = duringModel() + assertEquals(Moment.During, m.moment) + assertEquals(MomentLead.Workout, m.lead) + assertEquals("Running", m.name) + assertNull(m.number) + assertTrue(m.sentences.isEmpty() && m.figures.isEmpty()) + assertNull(m.chart) + val r = m.recording!! + assertEquals("Outdoor run", r.sport.name) + assertEquals("Recording since 17:42", r.since) + assertEquals("Since 17:42", r.sinceShort) + assertTrue(m.opensLive) + assertNull("the recording's end refreshes it", m.tick) + } + + @Test fun `after a workout, minutes since it, the heart coming down, Load today, the drop since the end`() { + val m = afterModel() + assertEquals(Moment.After, m.moment) + assertEquals(MomentLead.AfterWorkout, m.lead) + assertEquals("After your run", m.name) + assertEquals("38", m.number) + assertEquals("min since your run", m.words.first()) + assertEquals("Load today 42", m.figures.first()) + val lastHr = heart(after).last { it.x <= after.epochSecond }.y.toInt() + assertEquals(TodayCopy.after(lastHr, 52), m.sentences.single()) + assertEquals(MomentLight(MetricColors.heart, LightAngle.Below, rim = AFTER_RIM), m.light) + val chart = m.chart!! + assertEquals("17:42", chart.from) + // The count moves on: the next five minute mark. + assertEquals(at(18, 25), m.tick) + } + + @Test fun `the evening leads with Energy left, the day in a sentence and how long to bedtime`() { + val m = eveningModel() + assertEquals(Moment.Evening, m.moment) + assertEquals(MomentLead.Sentence, m.lead) + assertEquals("Evening", m.name) + assertEquals("62", m.number) + assertEquals(listOf("Bedtime in 2 h 10"), m.figures) + assertEquals(TodayCopy.evening(listOf("run"), 8432, 10_000, TodayCopy.LoadStatus.SoFar), m.sentences.first()) + assertEquals(GlowMood.Evening, m.mood) + assertEquals(MomentLight(MetricColors.evening, LightAngle.LowerRight, bodyLightness = EVENING_BODY), m.light) + assertEquals("the day's heart rate", MomentChartKind.Line, m.chart?.kind) + assertEquals(at(20, 35), m.tick) + } + + @Test fun `the night counts the minutes to bedtime, then shows Energy left once it has passed`() { + val m = nightBeforeModel() + assertEquals(Moment.Night, m.moment) + assertEquals("Night", m.name) + assertEquals("30", m.number) + assertEquals(listOf("min to bedtime", "min"), m.words) + assertEquals(listOf("Bedtime 22:40"), m.figures) + assertEquals(GlowMood.Night, m.mood) + assertEquals(MomentLight(MetricColors.sleepDeep, LightAngle.Below), m.light) + assertEquals(at(22, 15), m.tick) + // The last count before bedtime stops at it, not at the five minute mark past it. + val late = model(at(22, 36), input(at(22, 36), bed = LocalTime.of(22, 38))) + assertEquals("2", late.number) + assertEquals(at(22, 38), late.tick) + + val past = nightAfterModel() + assertEquals(Moment.Night, past.moment) + assertEquals("62", past.number) + assertEquals(listOf("Energy left"), past.words) + assertEquals(listOf("Bedtime 22:40"), past.figures) + assertNull(past.tick) + } + + @Test fun `without a known bedtime the night guesses none`() { + val m = model(at(22, 30), input(at(22, 30), bed = null)) + assertEquals(Moment.Night, m.moment) + assertEquals("62", m.number) + assertTrue(m.figures.isEmpty()) + assertNull(m.tick) + } + + @Test fun `before the first night, Energy waits with After your first night`() { + val noEnergy = MomentEnergy(null, null, 0, 0f, false, emptyList()) + val m = model(midday, input(midday, energy = noEnergy, recovery = null, night = null, firstNights = true)) + assertEquals(MomentLead.Energy, m.lead) + assertNull(m.number) + assertEquals(AFTER_FIRST_NIGHT, m.waiting) + assertEquals("Energy starts after your first night. Sleep is what fills it.", m.sentences.first()) + assertNull(m.chart) + assertTrue(m.figures.isEmpty()) + } + + /* ---------------------------------------------- The duplicate rule ---------------------------------------------- */ + + @Test fun `waking with a Recovery widget placed leads with last night, lit from above in Sleep blue`() { + val m = wakingModel(setOf(WidgetKind.Recovery)) + assertEquals(MomentLead.LastNight, m.lead) + assertEquals("82", m.number) + assertEquals(listOf("Sleep score, good", "Good"), m.words) + // The sentence already says how long you slept, so the figure says how often you woke. + assertEquals(TabCopy.night(night.asleepSeconds, night.others), m.sentences.first()) + assertEquals(listOf("7 h 40, your longest this week.", "7 h 40 asleep."), m.sentences) + assertEquals(listOf("Woke 3 times"), m.figures) + assertEquals(MomentLight(MetricColors.sleepDeep, LightAngle.Above), m.light) + assertEquals(MomentChartKind.Stages, m.chart?.kind) + assertEquals("Sleep score 82, good", m.numberDescription) + } + + @Test fun `waking with Recovery and Sleep both placed leads with Energy, as at midday`() { + val m = wakingModel(setOf(WidgetKind.Recovery, WidgetKind.Sleep)) + assertEquals(Moment.Waking, m.moment) + assertEquals(MomentLead.Energy, m.lead) + assertEquals("Waking up", m.name) + assertEquals("62", m.number) + assertEquals("Drained 60, recharged 38", m.figures.first()) + assertEquals(TodayCopy.charge(62, 60, true, 8), m.sentences.first()) + assertNotNull(m.light.level) + } + + @Test fun `a Sleep widget alone changes nothing at waking, nor do the others`() { + for (placed in listOf(setOf(WidgetKind.Sleep), setOf(WidgetKind.Steps, WidgetKind.Start, WidgetKind.Moment))) { + assertEquals(placed.toString(), MomentLead.Recovery, wakingModel(placed).lead) + } + } + + @Test fun `the other moments lead the same whatever is placed`() { + val all = WidgetKind.entries.toSet() + for (placed in listOf(emptySet(), all)) { + assertEquals(MomentLead.Energy, model(midday, input(midday, placed)).lead) + assertEquals(MomentLead.Start, model(window, input(window, placed, starts = runs)).lead) + assertEquals(MomentLead.Workout, model(during, input(during, placed, recording = recording)).lead) + assertEquals(MomentLead.AfterWorkout, model(after, input(after, placed, last = run)).lead) + assertEquals(MomentLead.Sentence, model(evening, input(evening, placed)).lead) + assertEquals(MomentLead.Sentence, model(nightBefore, input(nightBefore, placed)).lead) + } + } + + @Test fun `no sleep last night leads with Energy from waking, saying so, as Today does`() { + // Without a night, the morning starts at 05:00 and lasts three hours. + val m = model(at(7, 30), input(at(7, 30), night = null)) + assertEquals(Moment.Waking, m.moment) + assertEquals(MomentLead.Energy, m.lead) + assertEquals(TodayCopy.NO_SLEEP, m.sentences.first()) + assertEquals("62", m.number) + } + + @Test fun `waking without a Recovery value gives way to last night, then to Energy`() { + val none = TodayCopy.recoveryShown(null, false, TodayCopy.NoScore.Learning(5, 7), null, TabCopy.RECOVERY_WAITING) + assertEquals(MomentLead.LastNight, model(waking, input(waking, recovery = none)).lead) + assertEquals(MomentLead.Energy, model(waking, input(waking, setOf(WidgetKind.Sleep), recovery = none)).lead) + // The strap's estimate stands in, labelled as its own. + val estimate = TodayCopy.recoveryShown(null, false, TodayCopy.NoScore.Learning(5, 7), 71, TabCopy.RECOVERY_WAITING) + val m = model(waking, input(waking, recovery = estimate)) + assertEquals(MomentLead.Recovery, m.lead) + assertEquals("71", m.number) + assertEquals(listOf("Strap's estimate"), m.words) + } + + @Test fun `after with no workout to tell, or during with none recording, it's midday's`() { + val r = MomentWidgetModel.lead(Moment.During, input(during)) + assertEquals(MomentLead.Energy, r) + assertEquals(MomentLead.Energy, MomentWidgetModel.lead(Moment.After, input(after))) + } + + /* ---------------------------------------------- Refresh ---------------------------------------------- */ + + @Test fun `the alarm is the moment's end or the next count, whichever comes first, only while placed`() { + val a = at(19, 40) + val b = at(18, 25) + assertNull(WidgetUpdater.momentAlarm(false, a, b)) + assertEquals(a, WidgetUpdater.momentAlarm(true, a, null)) + assertEquals(b, WidgetUpdater.momentAlarm(true, a, b)) + assertEquals(b, WidgetUpdater.momentAlarm(true, null, b)) + assertNull(WidgetUpdater.momentAlarm(true, null, null)) + // Only the moment's change wakes the phone; a tick before it waits for the screen. + assertTrue(WidgetUpdater.momentWakes(a, null)) + assertFalse(WidgetUpdater.momentWakes(a, b)) + assertFalse(WidgetUpdater.momentWakes(null, b)) + assertTrue(WidgetUpdater.momentWakes(b, a)) + } + + @Test fun `midday's alarm is the evening's start, from the inputs the widget rendered with`() { + val i = input(midday) + val m = model(midday, i) + val next = i.inputs.nextChange(midday, zone) + assertEquals(at(19, 40), next) + assertEquals(next, WidgetUpdater.momentAlarm(true, next, m.tick)) + assertEquals(Moment.Evening, i.inputs.resolve(next!!, zone)) + } + + @Test fun `five minute marks on the clock`() { + assertEquals(at(18, 25), MomentWidgetModel.nextTick(at(18, 20))) + assertEquals(at(18, 25), MomentWidgetModel.nextTick(at(18, 21).plusSeconds(30))) + assertEquals(at(0, 0, today.plusDays(1)), MomentWidgetModel.nextTick(at(23, 58))) + } + + /** + * A whole day on its alarms alone, 06:00 to 04:00: each alarm lands where the moment changes or the count moves + * on, the counts never wait more than five minutes, and every moment of the day is reached. + */ + @Test fun `a day on the widget's alarms reaches every moment and keeps its counts fresh`() { + val last = run + val started = epoch(17, 0) + // A run recorded from 17:00 to 17:42: its start and end refresh the widget on their own (spec 7). + val events = listOf(started, last.end).map(Instant::ofEpochSecond) + fun inputAt(now: Instant): MomentWidgetInput { + val t = now.epochSecond + val rec = if (t in started until last.end) MomentRecordingInput(1, started) else null + // Past midnight the widget reads the new day, before its night has synced. + val date = now.atZone(zone).toLocalDate() + val later = date > today + return input(now, recording = rec, last = last.takeIf { t >= it.end }, starts = runs, night = if (later) null else night, day = date) + } + var now = at(6, 0) + val end = at(4, 0, today.plusDays(1)) + val seen = mutableListOf() + var steps = 0 + while (now < end && steps < 500) { + val i = inputAt(now) + val m = model(now, i) + if (seen.lastOrNull() != m.moment) seen += m.moment + // A recording has no known end: its end refreshes the widget. Step to it as the recorder would. + // A recording has no known end: its end refreshes the widget. + val alarm = WidgetUpdater.momentAlarm(true, i.inputs.nextChange(now, zone), m.tick) + if (i.inputs.recording) assertNull(i.inputs.nextChange(now, zone)) + val event = events.firstOrNull { it > now } + val next = listOfNotNull(alarm, event).minOrNull() ?: break + assertTrue("$now: the alarm $next is after now", next > now) + if (m.tick != null) assertTrue("$now: a count waits at most five minutes", next.epochSecond - now.epochSecond <= 300) + // Just before the alarm the moment is still the one shown. + val before = next.minusSeconds(1) + assertEquals("$now: moment before $next", m.moment, MomentWidgetModel.from(inputAt(before), before, zone).moment) + now = next + steps++ + } + assertTrue("stopped early at $now", now >= end) + assertEquals( + listOf(Moment.Midday, Moment.Waking, Moment.Midday, Moment.WorkoutWindow, Moment.During, Moment.After, Moment.Evening, Moment.Night), + seen.dropLastWhile { it != Moment.Night }, + ) + } + + /* ---------------------------------------------- Layout ---------------------------------------------- */ + + private val scales = listOf(1f, 1.08f, 1.3f) + + /** One UI's cells inside the padding (spec 2.1), and the three sizes' minimums. */ + private val oneUi = Triple(140f to 180f, 313f to 180f, 313f to 290f) + private val minimum = Triple(110f to 110f, 260f to 120f, 260f to 220f) + + private fun everyMoment(): List> = listOf( + "waking" to wakingModel(), + "last night" to wakingModel(setOf(WidgetKind.Recovery)), + "waking energy" to wakingModel(setOf(WidgetKind.Recovery, WidgetKind.Sleep)), + "midday" to middayModel(), + "window" to windowModel(), + "during" to duringModel(), + "after" to afterModel(), + "evening" to eveningModel(), + "night" to nightBeforeModel(), + "night past bedtime" to nightAfterModel(), + ) + + /** Every text in [fit] on one line within [width], the sentence in two lines at most, the number row within it. */ + private fun assertOneLine(what: String, fit: MomentLayout.Fit, width: Float, fontScale: Float) { + listOfNotNull(fit.name, fit.word, fit.waiting, fit.sport).forEach { l -> + assertTrue("$what: \"${l.text}\" at ${l.sp} is ${l.width(fontScale)} in $width", l.width(fontScale) <= width + 0.01f) + } + fit.since?.let { l -> + val w = MomentLayout.REC_DOT_DP + MomentLayout.REC_DOT_GAP_DP + l.width(fontScale) + assertTrue("$what: \"${l.text}\" is $w in $width", w <= width + 0.01f) + } + fit.sentence?.let { s -> + assertTrue("$what: \"${s.text}\" takes ${s.lines} lines", s.lines <= MomentLayout.SENTENCE_LINES) + assertEquals(s.lines, WidgetText.wrappedLines(s.text, s.sp, width, fontScale)) + } + fit.row?.let { r -> + val dots = WidgetDots.width(dotsText(fit), r.dot) + val w = dots + (r.word?.let { MomentLayout.WORD_GAP_DP + it.width(fontScale) } ?: 0f) + + (if (r.play) MomentLayout.FIGURE_GAP_DP + MomentLayout.PLAY_DP else 0f) + + (r.figure?.let { MomentLayout.FIGURE_GAP_DP + it.width(fontScale) } ?: 0f) + assertTrue("$what: the number row is $w in $width", w <= width + 0.01f) + } + } + + private var current: MomentWidgetModel? = null + private fun dotsText(@Suppress("UNUSED_PARAMETER") fit: MomentLayout.Fit) = current?.number.orEmpty() + + private fun fits(m: MomentWidgetModel, cells: Triple, Pair, Pair>, s: Float) = listOf( + "Small" to cells.first to MomentLayout.small(m, cells.first.first, cells.first.second, s), + "Wide" to cells.second to MomentLayout.wide(m, cells.second.first, cells.second.second, s), + "Large" to cells.third to MomentLayout.large(m, cells.third.first, cells.third.second, s), + ) + + @Test fun `on One UI's cells no gap is over 32 dp, at every moment and font`() { + for ((what, m) in everyMoment()) for (s in scales) for ((key, fit) in fits(m, oneUi, s)) { + val (size, cell) = key + val gap = fit.gap(cell.second) + assertTrue("$what $size at $s: gap $gap (content ${fit.content}, ${fit.inner} between)", gap <= MomentLayout.TARGET_GAP_DP) + assertTrue("$what $size at $s: doesn't fit ${cell.second}", fit.fits(cell.second)) + } + } + + @Test fun `every moment fits every size at its minimum and on One UI, one line a text`() { + for ((what, m) in everyMoment()) for (s in scales) for (cells in listOf(minimum, oneUi)) for ((key, fit) in fits(m, cells, s)) { + val (size, cell) = key + current = m + assertTrue("$what $size ${cell} at $s: content ${fit.content} + gaps", fit.fits(cell.second)) + assertOneLine("$what $size $cell at $s", fit, cell.first, s) + } + } + + @Test fun `the essentials stay at the minimums at the fonts phones use`() { + for ((what, m) in everyMoment()) for (s in listOf(1f, 1.08f)) { + val small = MomentLayout.small(m, 110f, 110f, s) + val wide = MomentLayout.wide(m, 260f, 120f, s) + val large = MomentLayout.large(m, 260f, 220f, s) + if (m.number != null) { + assertTrue("$what Small at $s keeps the number", small.dot > 0f) + assertNotNull("$what Small at $s keeps the word", small.word) + assertNotNull("$what Wide at $s keeps the number row", wide.row) + assertNotNull("$what Large at $s keeps the number row", large.row) + } + if (m.sentences.isNotEmpty()) { + assertNotNull("$what Wide at $s keeps a sentence", wide.sentence) + assertNotNull("$what Large at $s keeps a sentence", large.sentence) + } + if (m.chart != null) assertTrue("$what Large at $s keeps the chart", large.chart > 0f) + m.recording?.let { assertNotNull("$what Small at $s keeps the sport", small.sport) } + } + } + + @Test fun `the workout window keeps its Start button at every size on One UI and at the minimum`() { + val m = windowModel() + for (s in listOf(1f, 1.08f)) { + assertTrue(MomentLayout.small(m, 110f, 110f, s).play) + assertTrue(MomentLayout.wide(m, 260f, 120f, s).row!!.play) + assertTrue(MomentLayout.large(m, 313f, 290f, s).row!!.play) + } + } + + @Test fun `on One UI the Wide shows the mockup's whole number row and sentence at midday`() { + val fit = MomentLayout.wide(middayModel(), 313f, 180f, 1f) + assertEquals("left", fit.row!!.word!!.text) + assertEquals("Drained 60, recharged 38", fit.row.figure!!.text) + assertEquals("The day so far drained 60. Plenty left for this evening.", fit.sentence!!.text) + } + + @Test fun `a tall cell grows the parts before the gaps, a short one shrinks them`() { + val m = middayModel() + val tall = MomentLayout.large(m, 313f, 290f, 1f) + val short = MomentLayout.large(m, 260f, 220f, 1.3f) + assertTrue("grown ${tall.chart} over ${MomentLayout.CHART_DP}", tall.chart >= MomentLayout.CHART_DP) + assertTrue(short.chart <= MomentLayout.CHART_DP) + val small = MomentLayout.small(m, 140f, 180f, 1f) + assertTrue("the Small's dots grow past the mockup's on One UI", small.dot > MomentLayout.SMALL_DOT) + } + + @Test fun `a sentence too long for two lines gives way to a shorter wording, never a third line`() { + val long = middayModel().copy(sentences = MomentWidgetModel.shorter("A very long sentence that goes on and on about the day and what it drained and what it left. Short one.")) + for (s in scales) { + val fit = MomentLayout.wide(long, 260f, 120f, s) + fit.sentence?.let { assertTrue(it.lines <= 2) } + } + assertEquals(listOf("A b. C d e.", "C d e.", "A b."), MomentWidgetModel.shorter("A b. C d e.")) + } + + @Test fun `one dotted number at every size`() { + for ((what, m) in everyMoment()) for ((key, fit) in fits(m, oneUi, 1f)) { + val numbers = listOf(fit.dot > 0f && fit.row == null, fit.row != null).count { it } + assertTrue("$what ${key.first}: $numbers", numbers <= 1) + if (m.number == null) assertEquals(0, numbers) + } + } + + @Test fun `every string keeps to the copy rules`() { + for ((what, m) in everyMoment()) { + val texts = listOfNotNull(m.name, m.number, m.waiting, m.startLabel, m.numberDescription, m.chart?.description, m.chart?.from, m.chart?.to, + m.recording?.since, m.recording?.sinceShort) + m.words + m.shortWords + m.figures + m.sentences + texts.forEach { assertFalse("$what: $it", it.any { c -> c in "·;—" }) } + } + } + + @Test fun `the hero's light is Today's, at the moment's mood`() { + val style = middayModel().light.style(GlowMood.Day) + assertEquals(0.62f, style.lightLevel!!, 1e-4f) + val ev = eveningModel() + val evStyle = ev.light.style(ev.mood) + assertEquals(GlowMood.Evening, evStyle.mood) + assertEquals(EVENING_BODY, evStyle.bodyLightness, 1e-4f) + assertEquals(LightAngle.LowerRight, evStyle.lightAngle) + assertEquals(WINDOW_RIM, windowModel().light.style(GlowMood.Day).glowStrength, 1e-4f) + } + + @Test fun `with the strap status the name stays and the row counts the button`() { + val m = WidgetSamples.moment(LocalDate.of(2026, 10, 24), zone) + val l = StatusLine(StrapStatus.StaleSync(0), listOf("Synced Wednesday", "Synced Wed", "Wednesday", "Wed")) + val wide = MomentLayout.wide(m, 313f, 180f, 1f, l) + assertEquals(m.name, wide.name.text) + assertEquals(StatusButton.SIZE_DP, wide.top!!.height, 0f) + assertNotNull(wide.top!!.words) + assertTrue(wide.fits(180f)) + assertNull(MomentLayout.small(m, 140f, 180f, 1f, l).top!!.words) + assertNull(MomentLayout.wide(m, 313f, 180f, 1f).top) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/widget/RecoveryWidgetModelTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/RecoveryWidgetModelTest.kt new file mode 100644 index 00000000..bed35bec --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/widget/RecoveryWidgetModelTest.kt @@ -0,0 +1,179 @@ +package org.openstrap.app.widget + +import java.time.LocalDate +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.RecoveryResult +import org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import org.openstrap.app.ui.glow.draw.WeekBarsLook +import org.openstrap.app.ui.glow.draw.weekBars + +/** Widgets spec 3.4: what the Recovery widget shows in each state, from the app's own result and copy. */ +class RecoveryWidgetModelTest { + private val today = LocalDate.of(2026, 10, 24) // a Saturday + private val week = (6 downTo 0).map { today.minusDays(it.toLong()) }.zip(listOf(65, 82, 55, 72, 88, 60, 78)) + + private fun day( + score: Int? = 78, date: LocalDate = today, firstNights: Boolean = false, alert: Boolean = false, + days: List> = week, strap: Int? = null, + ): RecoveryDay { + val ready = Recovery.Readiness(score, emptyList(), 30, alert, emptyList(), false) + return RecoveryDay( + date = date, + shown = TodayCopy.recoveryShown(score, alert, null, strap, TabCopy.RECOVERY_WAITING), + sentence = score?.let { TabCopy.recovery(it, alert, ready.contributors) }, + firstNights = firstNights, + restingHr = 52f, hrv = 64.4f, sleep = 82, + week = days, + ) + } + + @Test fun `this morning's score`() { + val m = RecoveryWidgetModel.from(day(), null, Locale.UK) + assertEquals("Recovery", m.title) + assertEquals("This morning", m.note) + assertEquals("78", m.score) + assertEquals("Ready", m.word) + assertEquals("78, Ready", m.scoreDescription) + assertEquals("Recovered enough for your usual training.", m.sentence) + assertNull(m.waiting) + assertEquals(today, m.date) + assertEquals( + listOf(RecoveryFigure("Resting HR", "52 bpm"), RecoveryFigure("HRV", "64 ms"), RecoveryFigure("Sleep", "82")), + m.figures, + ) + } + + @Test fun `the week, oldest first, the day shown last and lit`() { + val m = RecoveryWidgetModel.from(day(), null, Locale.UK) + assertEquals(listOf("S", "M", "T", "W", "T", "F", "S"), m.week.map { it.letter }) + assertEquals(listOf(65, 82, 55, 72, 88, 60, 78), m.week.map { it.score }) + assertEquals(listOf(false, false, false, false, false, false, true), m.week.map { it.shown }) + } + + @Test fun `fewer than two scored days leaves the week out, as the tab's trend`() { + val lone = week.map { (d, _) -> d to null } .dropLast(1) + (today to 78) + assertTrue(RecoveryWidgetModel.from(day(days = lone), null).week.isEmpty()) + } + + @Test fun `before this morning's score, yesterday's with Yesterday as the note`() { + val y = day(score = 64, date = today.minusDays(1)) + val m = RecoveryWidgetModel.from(day(score = null), y) + assertEquals("Yesterday", m.note) + assertEquals("64", m.score) + assertEquals("Moderate", m.word) + assertEquals(today.minusDays(1), m.date) + } + + @Test fun `the strap's estimate stands in as Today shows it`() { + val m = RecoveryWidgetModel.from(day(score = null, strap = 91), null) + assertEquals("91", m.score) + assertEquals("Strap's estimate", m.word) + assertEquals("This morning", m.note) + assertEquals("OpenStrap's own score isn't ready yet.", m.sentence) + } + + @Test fun `before the first night, the title and After your first night`() { + val m = RecoveryWidgetModel.from(day(score = null, firstNights = true), null) + assertEquals("Recovery", m.title) + assertNull(m.score) + assertNull(m.note) + assertEquals(AFTER_FIRST_NIGHT, m.waiting) + assertTrue(m.figures.isEmpty() && m.week.isEmpty()) + } + + @Test fun `no score today or yesterday after the first night says why`() { + assertEquals(TabCopy.RECOVERY_WAITING, RecoveryWidgetModel.from(day(score = null), day(score = null)).waiting) + assertEquals(TabCopy.RECOVERY_WAITING, RecoveryWidgetModel.from(null, null).waiting) + } + + @Test fun `a figure the night didn't give says so`() { + val m = RecoveryWidgetModel.from(day().copy(hrv = null, sleep = null), null) + assertEquals(listOf("52 bpm", "No reading", "No reading"), m.figures.map { it.value }) + } + + @Test fun `from the app's result, the night's own values and the Sleep contributor`() { + val night = Recovery.Night( + day = 0, start = 0, end = 8 * 3600, asleepMinutes = 450, strapScore = 80, hrv = 61.6f, restingHr = 51.6f, + hrvSamples = 40, hrMinutes = 400, + ) + val ready = Recovery.Readiness( + 78, listOf(Recovery.Contributor(Recovery.Factor.Sleep, 82, "7 h 30 asleep")), 30, false, emptyList(), false, + ) + val r = RecoveryResult( + today, ready, Recovery.ChargeDay(emptyList(), null, null, 0f, 0f, 0f, 0f, null, null, anchored = true), week, + 0f, null, 52f, 190f, true, Recovery.StressThreshold(60, null, 0), tonight = night, + ) + val d = RecoveryDay.of(r, strapScore = 91) + assertEquals("Ready", d.shown.word) + assertEquals(78, d.shown.value) + assertEquals(51.6f, d.restingHr) + assertEquals(61.6f, d.hrv) + assertEquals(82, d.sleep) + assertEquals(false, d.firstNights) + assertEquals(listOf("52 bpm", "62 ms", "82"), RecoveryWidgetModel.figures(d).map { it.value }) + } + + @Test fun `the strap's values stand in where the night's failed the validity rule`() { + val night = Recovery.Night(day = 0, start = 0, end = 3600, asleepMinutes = 50, hrv = 30f, restingHr = 70f, strapScore = 40) + val ready = Recovery.Readiness(null, emptyList(), 30, false, emptyList(), false, fallbackHrv = 45f, fallbackRestingHr = 55f) + val r = RecoveryResult( + today, ready, Recovery.ChargeDay(emptyList(), null, null, 0f, 0f, 0f, 0f, null, null, anchored = false), week, + 0f, null, 52f, 190f, true, Recovery.StressThreshold(60, null, 0), tonight = night, + ) + val d = RecoveryDay.of(r, null) + assertEquals(55f, d.restingHr) + assertEquals(45f, d.hrv) + assertEquals(40, d.sleep) + assertTrue(d.firstNights) + } + + @Test fun `week bars stand as high as the day's share of 100, none for a day without a score`() { + val values = RecoveryLayout.barValues(listOf(RecoveryWeekDay("M", 100, false), RecoveryWeekDay("T", 50, false), RecoveryWeekDay("W", null, true))) + assertEquals(listOf(1f, 0.5f, null), values) + val bars = weekBars(values, density = 2f) + assertEquals(WeekBarsLook.HEIGHT_DP * 2f, bars[0]!!.size.height, 0.01f) + assertEquals(WeekBarsLook.HEIGHT_DP, bars[1]!!.size.height, 0.01f) + assertNull(bars[2]) + // Solid pills at most 16 dp wide, standing on the box's bottom, one per 30 dp column with 2 dp gaps. + assertEquals(WeekBarsLook.BAR_DP * 2f, bars[0]!!.size.width, 0.01f) + bars.filterNotNull().forEach { assertEquals(WeekBarsLook.HEIGHT_DP * 2f, it.topLeft.y + it.size.height, 0.01f) } + assertEquals((30f + 2f) * 2f + (30f - 16f), bars[1]!!.topLeft.x, 0.01f) + assertEquals(7 * 30f + 6 * 2f, WeekBarsLook.width(7), 0.01f) + } + + @Test fun `a low day's bar is still a bar, with soft corners`() { + val bar = weekBars(listOf(0.05f), density = 1f).single()!! + assertEquals(WeekBarsLook.CORNER_DP * 2, bar.size.height, 0.01f) + assertEquals(WeekBarsLook.CORNER_DP, bar.corner, 0.01f) + } + + @Test fun `the week narrows beside a long word, never below its least`() { + assertEquals(WeekBarsLook.COLUMN_DP, RecoveryLayout.weekColumn(358f, 55f, "Ready"), 0.01f) + assertTrue(RecoveryLayout.weekColumn(318f, 55f, "Ready") > 26f) + val long = RecoveryLayout.weekColumn(318f, 55f, "Ready for more") + assertTrue(long < WeekBarsLook.COLUMN_DP && long >= WeekBarsLook.MIN_COLUMN_DP) + assertEquals(WeekBarsLook.MIN_COLUMN_DP, RecoveryLayout.weekColumn(318f, 55f, "Ready for more", fontScale = 2f), 0.01f) + } + + @Test fun `with the strap status the title row counts the button and the words replace the note`() { + val m = WidgetSamples.recovery(today, java.util.Locale.UK) + val l = StatusLine(StrapStatus.StaleSync(0), listOf("Synced Wednesday", "Synced Wed", "Wednesday", "Wed")) + val plain = RecoveryLayout.wide(m, 313f, 180f, 1f) + val shown = RecoveryLayout.wide(m, 313f, 180f, 1f, l) + assertTrue(plain.top.note) + assertEquals(StatusButton.SIZE_DP, shown.top.height, 0f) + assertEquals("Synced Wednesday", shown.top.words) + assertTrue(!shown.top.note) + assertTrue(shown.spread.fits(180f, RecoveryLayout.MIN_GAP_DP)) + // The Small: the button alone. + assertNull(RecoveryLayout.small(m, 140f, 180f, 1f, l).top.words) + assertEquals(StatusButton.SIZE_DP, RecoveryLayout.small(m, 140f, 180f, 1f, l).top.height, 0f) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/widget/SleepWidgetModelTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/SleepWidgetModelTest.kt new file mode 100644 index 00000000..f1c733d4 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/widget/SleepWidgetModelTest.kt @@ -0,0 +1,287 @@ +package org.openstrap.app.widget + +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.DayWindow +import org.openstrap.app.data.SleepHistory +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import org.openstrap.app.ui.glow.draw.SleepStageOrder +import org.openstrap.app.ui.glow.draw.stageBarSegments + +/** Widgets spec 3.2: what the Sleep widget shows in each state, from the Sleep tab's own sums and copy, and how it fits. */ +class SleepWidgetModelTest { + private val zone: ZoneId = ZoneOffset.UTC + private val today = LocalDate.of(2026, 10, 24) + + private fun at(date: LocalDate, h: Int, m: Int) = date.atTime(h, m).atZone(zone).toEpochSecond() + + /** The mockup's night: 22:58 to 06:56, Deep 1 h 32, REM 1 h 41, Light 4 h 27, Awake 18 min, woke 3 times, 82. */ + private val start = at(today.minusDays(1), 22, 58) + private val end = at(today, 6, 56) + private val session = SleepSession(start, end, score = 82, wakeCount = 3, deepMinutes = 92, remMinutes = 101, lightMinutes = 267, awakeMinutes = 18) + + /** The night's stages in chunks that add up to the mockup's totals: Light, Deep, Light, REM, Awake, Light, Deep, REM, Awake. */ + private fun stages(s: SleepSession = session): List { + val plan = listOf( + SleepStage.LIGHT to 60, SleepStage.DEEP to 60, SleepStage.LIGHT to 90, SleepStage.REM to 50, SleepStage.AWAKE to 10, + SleepStage.LIGHT to 117, SleepStage.DEEP to 32, SleepStage.REM to 51, SleepStage.AWAKE to 8, + ) + var t = s.start + return plan.map { (stage, min) -> SleepStage(t, t + min * 60L, s.start, stage).also { t += min * 60L } } + } + + /** Earlier nights this week, shorter than this one: 6 h 10, 7 h, 7 h 20. */ + private val week: Map = listOf(1 to 370, 2 to 420, 3 to 440).associate { (ago, min) -> + val d = today.minusDays(ago.toLong()) + val e = at(d, 7, 0) + d to SleepSession(e - min * 60L, e) + } + + private fun night(s: SleepSession = session, st: List = stages(s), date: LocalDate = today) = + SleepWidgetNight.of(date, s, st, week + (date to s)) + + private fun model() = SleepWidgetModel.from(night(), null, firstNight = false, napsToday = false, zone = zone) + + @Test fun `last night as the Sleep tab's hero tells it`() { + val m = model() + assertEquals("Last night", m.title) + assertEquals("22:58–06:56", m.note) + assertFalse(m.stale) + assertEquals(today, m.date) + assertEquals("82", m.score) + assertEquals("Good", m.word) + assertEquals("Sleep score, good", m.scoreLine) + assertEquals("7 h 40 asleep", m.asleep) + assertEquals("Woke 3 times", m.woke) + assertEquals("Woke 3 times", m.largeRight) + assertEquals("7 h 40, your longest this week.", m.sentence) + assertEquals("Sleep score 82, good", m.scoreDescription) + assertNull(m.waiting) + // The hero's own sums and copy. + assertEquals(TabCopy.night(27_600, SleepHistory.weekOthers(week, today)), m.sentence) + assertEquals(TabCopy.sleepScoreLine(82), m.scoreLine) + } + + @Test fun `the legend is the stage totals, deepest first, and adds up to time asleep`() { + val m = model() + assertEquals(listOf("Deep 1 h 32", "REM 1 h 41", "Light 4 h 27", "Awake 18 min"), m.legend.map { it.label }) + assertEquals(SleepStageOrder, m.totals.map { it.first }) + assertEquals(SleepStageOrder, m.legend.map { it.stage }) + val asleep = m.totals.filter { it.first != SleepStage.AWAKE }.sumOf { it.second } + assertEquals(night().asleepSeconds, asleep) + assertEquals("7 h 40", TodayCopy.duration(asleep)) + assertEquals(18 * 60L, m.totals.single { it.first == SleepStage.AWAKE }.second) + } + + @Test fun `a stage under a minute leaves the bar and the legend`() { + val st = stages().filter { it.stage != SleepStage.AWAKE } + SleepStage(end, end + 30, start, SleepStage.AWAKE) + val m = SleepWidgetModel.from(night(st = st), null, false, false, zone) + assertEquals(listOf("Deep", "REM", "Light"), m.legend.map { it.label.substringBefore(' ') }) + } + + @Test fun `the stage bar and the stage rows get the tab's inputs`() { + val m = model() + // The bar: segments in stage order, filling the width less the gaps. + val segments = stageBarSegments(m.totals, 636, 4) + assertEquals(SleepStageOrder, segments.map { it.stage }) + assertEquals(636 - 3 * 4, segments.sumOf { it.width }) + // The rows: a stage each in the same order, named and timed as the legend, shares adding up to the night. + assertEquals(m.totals.map { it.first }, m.stages.map { it.stage }) + assertEquals(m.legend.map { it.label }, m.stages.map { "${it.name} ${it.time}" }) + assertEquals(1f, m.stages.sumOf { it.share.toDouble() }.toFloat(), 1e-4f) + } + + @Test fun `before the first night, the title and After your first night`() { + val m = SleepWidgetModel.from(null, null, firstNight = true, napsToday = false, zone = zone) + assertEquals("Last night", m.title) + assertEquals(AFTER_FIRST_NIGHT, m.waiting) + assertNull(m.date) + assertNull(m.score) + assertNull(m.note) + } + + @Test fun `with no night at all after the first, the Sleep tab's no-night line`() { + assertEquals("No sleep recorded last night. Was the strap on?", SleepWidgetModel.from(null, null, false, false, zone).waiting) + assertEquals("No night sleep recorded, only naps.", SleepWidgetModel.from(null, null, false, true, zone).waiting) + } + + @Test fun `before this morning's night is synced, yesterday's with Last synced night`() { + val y = today.minusDays(1) + val ys = SleepSession(at(y.minusDays(1), 23, 10), at(y, 6, 40), score = 74, wakeCount = 1) + val m = SleepWidgetModel.from(null, night(ys, stages(ys), y), firstNight = false, napsToday = false, zone = zone) + assertTrue(m.stale) + assertEquals("Sleep", m.title) + assertEquals("Last synced night", m.note) + assertEquals(y, m.date) + assertEquals("74", m.score) + assertEquals("Fair", m.word) + assertEquals("Woke once", m.woke) + // This morning's wins once it's in. + assertEquals(today, SleepWidgetModel.from(night(), night(ys, stages(ys), y), false, false, zone).date) + } + + @Test fun `a night without a score or stages says so and keeps time asleep`() { + val bare = SleepSession(start, end) + val m = SleepWidgetModel.from(night(bare, emptyList()), null, false, false, zone) + assertNull(m.score) + assertNull(m.word) + assertEquals("No score from the strap", m.scoreLine) + assertNull(m.scoreDescription) + // No stages: the whole session, as the hero. + assertEquals("7 h 58 asleep", m.asleep) + assertTrue(m.totals.isEmpty()) + assertTrue(m.legend.isEmpty()) + assertTrue(m.stages.isEmpty()) + assertNull(m.woke) + assertEquals("7 h 58 asleep", m.largeRight) + } + + @Test fun `the main sleep and the week are the hero's`() { + val nap = SleepSession(at(today, 14, 0), at(today, 14, 40), nap = true) + val short = SleepSession(at(today, 3, 0), at(today, 4, 0)) + val main = SleepHistory.mainSleep(listOf(nap, short, session), DayWindow.nightEndingOn(today, zone)) + assertEquals(session, main) + assertNull(SleepHistory.mainSleep(listOf(nap), DayWindow.nightEndingOn(today, zone))) + // The six nights before, with a sleep: not this one. + assertEquals(listOf(26_400L, 25_200L, 22_200L).sorted(), SleepHistory.weekOthers(week + (today to session), today).sorted()) + } + + @Test fun `every string keeps to the copy rules`() { + val y = SleepWidgetModel.from(null, night(), false, false, zone) + val all = listOf(model(), y, SleepWidgetModel.from(null, null, true, false, zone), SleepWidgetModel.from(null, null, false, true, zone)) + all.flatMap { m -> listOfNotNull(m.title, m.note, m.word, m.scoreLine, m.asleep, m.woke, m.sentence, m.waiting, m.scoreDescription) + m.legend.map { it.label } } + .forEach { s -> assertFalse(s, s.any { it in "·;—" }) } + } + + /* --------------------------------------------- The legend --------------------------------------------- */ + + @Test fun `the legend is one row of four when it fits, else two of two, never a wrapped label`() { + val items = model().legend + val oneRow = items.sumOf { SleepLayout.legendItem(it.label, 1f).toDouble() }.toFloat() + 3 * SleepLayout.LEGEND_GAP_DP + assertEquals(1, SleepLayout.legendRows(items, oneRow, 1f)) + assertEquals(1, SleepLayout.legendRows(items, 420f, 1f)) + assertEquals(2, SleepLayout.legendRows(items, oneRow - 1f, 1f)) + assertEquals(2, SleepLayout.legendRows(items, wide.first, 1f)) + assertEquals(2, SleepLayout.legendRows(items, wide.first, 1.3f)) + // So narrow that half the width can't hold "Light 4 h 27": no legend rather than a broken one. + assertEquals(0, SleepLayout.legendRows(items, 150f, 1f)) + assertEquals(0, SleepLayout.legendRows(emptyList(), 400f, 1f)) + // Two rows: each label fits its half. + val half = (wide.first - SleepLayout.LEGEND_GAP_DP) / 2 + assertTrue(items.all { SleepLayout.legendItem(it.label, 1.3f) <= half }) + assertEquals(2 * WidgetText.line(SleepLayout.LEGEND_SP) + SleepLayout.LEGEND_ROW_GAP_DP, SleepLayout.legendHeight(2, 1f), 1e-4f) + } + + /* ------------------------------------------- Fit at the minimum ------------------------------------------- */ + + private val small = WidgetSizes.Small.width.value - 2 * WidgetSizes.PADDING_DP to WidgetSizes.Small.height.value - 2 * WidgetSizes.PADDING_DP + private val wide = WidgetSizes.Wide.width.value - 2 * WidgetSizes.PADDING_DP to WidgetSizes.Wide.height.value - 2 * WidgetSizes.PADDING_DP + private val large = WidgetSizes.Large.width.value - 2 * WidgetSizes.PADDING_DP to WidgetSizes.Large.height.value - 2 * WidgetSizes.PADDING_DP + + /** The default font, the large one, and One UI's default (about 1.08). */ + private val scales = listOf(1f, 1.08f, 1.3f) + + private fun smallHeight(f: SleepLayout.Small, s: Float) = SleepLayout.smallHeight(f, s) + + @Test fun `the Small keeps everything at the default font and fits at every size`() { + val m = model() + val normal = SleepLayout.small(m, small.first, small.second, 1f) + assertEquals(SleepLayout.SMALL_DOT, normal.row.dot, 0f) + assertEquals("Good", normal.row.word) + assertEquals(listOf(SleepLayout.SmallLine("7 h 40 asleep")), normal.lines) + assertTrue(normal.bar) + for (s in scales) { + for (mm in listOf(m, SleepWidgetModel.from(null, night(), false, false, zone), SleepWidgetModel.from(night(SleepSession(start, end), emptyList()), null, false, false, zone))) { + val f = SleepLayout.small(mm, small.first, small.second, s) + assertTrue("${mm.title} at $s: ${smallHeight(f, s)}", smallHeight(f, s) <= small.second + 1e-3f) + assertTrue(f.row.width(mm.score, s, SleepLayout.SMALL_WORD_GAP_DP) <= small.first + 1e-3f || f.row.wordLines > 1) + f.lines.forEach { assertTrue(WidgetText.width(it.text, WidgetText.SECONDARY_SP, s) <= small.first * it.lines) } + } + } + // Yesterday's: "Last synced night" stays (on two lines at the minimum's width), time asleep and then the bar go. + val stale = SleepLayout.small(SleepWidgetModel.from(null, night(), false, false, zone), small.first, small.second, 1f) + assertEquals(listOf(SleepLayout.SmallLine("Last synced night", 2)), stale.lines) + // On One UI's real 2 x 2 (167 by 201 dp), one line, then time asleep and the bar. + val real = SleepLayout.small(SleepWidgetModel.from(null, night(), false, false, zone), 167.6f - 40f, 201.1f - 40f, 1f) + assertTrue(real.bar) + assertEquals(listOf(SleepLayout.SmallLine("Last synced night"), SleepLayout.SmallLine("7 h 40 asleep")), real.lines) + // A score of 100 shrinks the dots to keep the word, and at 1.3 drops the word before the dots get too small. + val hundred = SleepLayout.small(m.copy(score = "100", word = "Great"), small.first, small.second, 1f) + assertEquals("Great", hundred.row.word) + assertTrue(hundred.row.dot < SleepLayout.SMALL_DOT) + assertTrue(hundred.row.width("100", 1f, SleepLayout.SMALL_WORD_GAP_DP) <= small.first + 1e-3f) + val big = SleepLayout.small(m.copy(score = "100", word = "Great"), small.first, small.second, 1.3f) + assertNull(big.row.word) + assertEquals(SleepLayout.SMALL_DOT, big.row.dot, 0f) + } + + @Test fun `the Wide fits its width and height at every size, dropping the legend before clipping`() { + val m = model() + for (s in scales) { + val f = SleepLayout.wide(m, wide.first, wide.second, s) + assertTrue("width at $s: ${f.row.width(m.score, s)}", f.row.width(m.score, s) <= wide.first + 1e-3f) + assertTrue("height at $s: ${SleepLayout.wideHeight(f, s)}", SleepLayout.wideHeight(f, s) <= wide.second + 1e-3f) + assertTrue(f.row.dot >= SleepLayout.SMALL_DOT) + assertEquals("7 h 40 asleep", f.row.right) + assertTrue(f.note) + assertTrue(f.bar) + } + // The default font at the minimum: the short word keeps time asleep, the legend on two rows. + val normal = SleepLayout.wide(m, wide.first, wide.second, 1f) + assertEquals("Good", normal.row.word) + assertEquals(2, normal.legendRows) + // A real 4 x 2 (One UI's, 353 dp) has room for the tab's words. + assertEquals("Sleep score, good", SleepLayout.wide(m, 353f - 40f, 161f, 1.08f).row.word) + assertEquals(SleepLayout.WIDE_DOT, SleepLayout.wide(m, 353f - 40f, 161f, 1f).row.dot, 0f) + // At 1.3 on the minimum, no room for the legend: it goes, the bar stays. + assertEquals(0, SleepLayout.wide(m, wide.first, wide.second, 1.3f).legendRows) + } + + @Test fun `the Large fits its width and height at every size, the sentence going first, then the rows`() { + val m = model() + for (s in scales) { + val f = SleepLayout.large(m, large.first, large.second, s) + assertTrue("width at $s", f.row.width(m.score, s) <= large.first + 1e-3f) + assertTrue("height at $s: ${SleepLayout.largeHeight(f, m.stages.size, s)}", SleepLayout.largeHeight(f, m.stages.size, s) <= large.second + 1e-3f) + assertEquals("Woke 3 times", f.row.right) + assertTrue("rows at $s", f.stages) + } + // At the default font the minimum Large keeps the sentence beside the rows. + assertTrue(SleepLayout.large(m, large.first, large.second, 1f).sentenceLines > 0) + // One UI's 4 x 3 (353 x 375, content 313 x 335) keeps it all at its own font size. + val oneUi = SleepLayout.large(m, 313f, 335f, 1.08f) + assertTrue(oneUi.stages && oneUi.sentenceLines > 0) + // Too short for the rows: the stage bar. + val short = SleepLayout.large(m, large.first, 140f, 1f) + assertFalse(short.stages) + assertTrue(short.bar) + // Without stages: no rows or bar, and the rest fits. + val bare = SleepWidgetModel.from(night(SleepSession(start, end), emptyList()), null, false, false, zone) + val b = SleepLayout.large(bare, large.first, large.second, 1.3f) + assertFalse(b.stages) + assertFalse(b.bar) + assertTrue(SleepLayout.largeHeight(b, 0, 1.3f) <= large.second) + } + + @Test fun `with the strap status the title row counts the button and the words replace the times`() { + val m = WidgetSamples.sleep(today, zone) + val l = StatusLine(StrapStatus.StaleSync(0), listOf("Synced Wednesday", "Synced Wed", "Wednesday", "Wed")) + val shown = SleepLayout.large(m, 313f, 290f, 1f, l) + assertEquals(StatusButton.SIZE_DP, shown.top.height, 0f) + assertNotNull(shown.top.words) + assertFalse(shown.top.note) + assertTrue(SleepLayout.largeHeight(shown, m.stages.size, 1f) <= 290.01f) + assertNull(SleepLayout.small(m, 140f, 180f, 1f, l).top.words) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/widget/StartWidgetModelTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/StartWidgetModelTest.kt new file mode 100644 index 00000000..10c97eba --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/widget/StartWidgetModelTest.kt @@ -0,0 +1,453 @@ +package org.openstrap.app.widget + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.db.Workout + +/** Widgets spec 3.5: what the Start widget offers from your workouts, this week's line, recording, and how it fits. */ +class StartWidgetModelTest { + private val zone: ZoneId = ZoneOffset.UTC + /** Saturday 24 October 2026, midday. */ + private val now: Instant = at(24, 12, 0) + + private fun at(day: Int, hour: Int, minute: Int): Instant = LocalDateTime.of(2026, 10, day, hour, minute).toInstant(ZoneOffset.UTC) + + private fun workout(day: Int, hour: Int, type: Int, minutes: Int, meters: Float? = null, sets: Int? = null): WorkoutAnalysis { + val start = at(day, hour, 0).epochSecond + val w = Workout(start, start + minutes * 60L, type, minutes * 60, null, null, null, null, null, null, null, null, null) + return Workouts.analyse(w, emptyList(), emptyList(), 190).copy(distanceMeters = meters, sets = sets) + } + + /** + * Runs three times (Tuesday's 5.2 km this week), walks and rides twice, a treadmill run on Friday, strength once, + * and the strap's own "Activity" this week, which isn't a workout. + */ + private val history = listOf( + workout(5, 7, 1, 45), + workout(1, 7, 1, 45), + workout(20, 18, 1, 60, meters = 5_200f), + workout(16, 8, 3, 30), + workout(12, 8, 3, 30), + workout(15, 17, 4, 50), + workout(9, 17, 4, 50), + workout(23, 19, 2, 35), + workout(18, 10, 52, 40, sets = 12), + workout(22, 13, Workouts.GENERIC_ACTIVITY, 20), + ) + + private fun model( + workouts: List = history, sides: Map = emptyMap(), location: Boolean = true, + recording: StartRecordingInput? = null, + ) = StartWidgetModel.from(workouts, sides, location, recording, now, zone, Locale.UK) + + @Test fun `the usual is the sport done most, the next four by use`() { + val m = model() + assertEquals(1, m.usual.sportType) + assertEquals("Outdoor run", m.usual.name) + assertEquals("Start run", m.usual.action) + // Walk and cycling twice, the walk more recently; treadmill and strength once, the treadmill more recently. + assertEquals(listOf("Walk", "Cycling", "Treadmill", "Strength"), m.next.take(4).map { it.caption }) + assertEquals(listOf(3, 4, 2, 52), m.next.take(4).map { it.sportType }) + // Then the everyday sports, each once. + assertEquals("Hike", m.next[4].caption) + assertEquals(m.next.size, m.next.distinctBy { it.shownType }.size) + assertTrue(m.next.none { it.shownType == Workouts.GENERIC_ACTIVITY }) + assertNull(m.recording) + } + + @Test fun `before any workout, the default sports, then the everyday ones`() { + val m = model(workouts = emptyList()) + assertEquals(Workouts.DEFAULT_SPORTS.first(), m.usual.sportType) + assertEquals(listOf("Walk", "Strength", "Hike", "Cycling"), m.next.take(4).map { it.caption }) + assertEquals("No workouts yet", m.weekLine) + assertEquals("Last run", m.lastLabel) + assertEquals("None yet", m.lastLine) + } + + @Test fun `GPS on or off as Ready would start the usual`() { + val on = model() + assertEquals("Your usual, GPS on", on.usualLine) + assertTrue(on.gpsOn) + val off = model(location = false) + assertEquals("Your usual, GPS off", off.usualLine) + assertFalse(off.gpsOn) + // Strength has no GPS to speak of. + val indoor = model(workouts = listOf(workout(20, 8, 52, 40), workout(21, 8, 52, 40))) + assertEquals("Strength training", indoor.usual.name) + assertEquals("Your usual", indoor.usualLine) + assertFalse(indoor.gpsOn) + } + + @Test fun `a sport with two sides shows the side Ready opens on, once`() { + // Your runs, last done on the treadmill side: Ready opens on Treadmill, so that's what the widget says. + val m = model(sides = mapOf(1 to true)) + assertEquals(1, m.usual.sportType) + assertEquals(2, m.usual.shownType) + assertEquals("Treadmill", m.usual.name) + assertEquals("Your usual", m.usualLine) + assertFalse(m.gpsOn) + assertTrue(m.next.none { it.shownType == 2 }) + } + + @Test fun `this week counts workouts since Monday, not the strap's Activity`() { + val m = model() + // Tuesday's hour and Friday's 35 minutes. + assertEquals("2 workouts, 1 h 35", m.weekLine) + assertEquals("1 workout, 40 min", StartWidgetModel.weekLine(1, 40 * 60L)) + assertEquals("3 workouts, 2 h", StartWidgetModel.weekLine(3, 7_200L)) + assertEquals("No workouts yet", StartWidgetModel.weekLine(0, 0L)) + } + + @Test fun `the usual sport's last, by its tile, with its distance, sets or length`() { + // The treadmill on Friday is a run too. + assertEquals("Last run", model().lastLabel) + assertEquals("Yesterday, 35 min", model().lastLine) + val outdoors = model(workouts = history.filter { it.workout.sportType != 2 }) + assertEquals("Tuesday, 5.2 km", outdoors.lastLine) + val strength = model(workouts = listOf(workout(18, 10, 52, 40, sets = 12), workout(10, 10, 52, 40))) + assertEquals("Last strength", strength.lastLabel) + assertEquals("Sunday, 12 sets", strength.lastLine) + assertEquals("Last HIIT", StartWidgetModel.lastLabel(49)) + assertEquals("Last workout", StartWidgetModel.lastLabel(5)) + assertEquals("Last walk", StartWidgetModel.lastLabel(40)) + assertEquals("Today, 35 min", "${recentDay(now.atZone(zone).toLocalDate(), now.atZone(zone).toLocalDate())}, 35 min") + assertEquals("10 Oct", recentDay(java.time.LocalDate.of(2026, 10, 10), java.time.LocalDate.of(2026, 10, 24), Locale.UK)) + } + + @Test fun `while recording, the sport, since when and the active time`() { + val m = model(recording = StartRecordingInput(4, at(24, 11, 58).epochSecond, 1_500L, paused = false)) + val rec = assertNotNullAnd(m.recording) + assertEquals("Outdoor cycling", rec.sport.name) + assertEquals("Since 11:58", rec.since) + assertEquals("Recording", rec.status) + assertEquals(4, rec.sport.shownType) + assertEquals(1_500_000L, rec.activeMillis) + assertEquals(now.toEpochMilli(), rec.atMillis) + assertEquals("Paused", model(recording = StartRecordingInput(4, 0L, 60L, paused = true)).recording!!.status) + } + + @Test fun `the timer's base puts the active time at now, and stands still paused`() { + val r = model(recording = StartRecordingInput(4, at(24, 11, 58).epochSecond, 1_500L, paused = false)).recording!! + val elapsed = 9_000_000L + // Loaded now: 25 minutes back from the elapsed clock. + assertEquals(elapsed - 1_500_000L, StartRecording.chronometerBase(elapsed, r.atMillis, r)) + // Drawn 10 s after loading: 10 s more have passed. + assertEquals(elapsed - 1_510_000L, StartRecording.chronometerBase(elapsed, r.atMillis + 10_000L, r)) + // Paused, the time at the pause whenever it's drawn. + val p = r.copy(paused = true) + assertEquals(elapsed - 1_500_000L, StartRecording.chronometerBase(elapsed, r.atMillis + 60_000L, p)) + } + + @Test fun `the recording layouts fit every cell and font, one line each, the timer whole`() { + val r = model(recording = StartRecordingInput(60, at(24, 11, 58).epochSecond, 1_500L, paused = false)).recording!! + val walk = r.copy(sport = StartSport.recorded(40)) + val m = model().copy(weekLine = "5 workouts, 10 min", lastLine = "Yesterday, 1 min") + for (s in scales) for (rec in listOf(r, walk)) { + for ((w, h) in smallCells) { + val fit = StartLayout.smallRecording(rec, w, h, s) + val where = "Small $w x $h at $s" + assertTrue("$where: timer too wide", StartLayout.timerWidth(fit.timerSp, s) <= w) + assertTrue("$where: name wraps", one(fit.name, WidgetText.BODY_SP, w, s) || fit.name == rec.sport.shortName) + assertTrue("$where: too tall ${fit.content}", fit.content <= h) + if (fit.tile >= StartLayout.MIN_TILE_DP) assertTrue("$where: top row", fit.tile + StartLayout.SMALL_GAP_DP + StartLayout.openWidth(s) <= w) + } + for ((w, h) in wideCells) { + val fit = StartLayout.wideRecording(rec, w, h, s) + val where = "Wide $w x $h at $s" + assertTrue("$where: timer too wide", StartLayout.timerWidth(fit.timerSp, s) <= w) + if (fit.since == StartLayout.SincePlace.Beside) { + assertTrue(where, StartLayout.timerWidth(fit.timerSp, s) + StartLayout.SINCE_GAP_DP + WidgetText.width(rec.since, WidgetText.SECONDARY_SP, s) <= w) + } + assertTrue("$where: too tall ${fit.content}", fit.content + StartLayout.MIN_GAP_DP <= h) + } + for ((w, h) in largeCells) { + val fit = StartLayout.largeRecording(rec, m, w, h, s) + assertTrue("Large $w x $h at $s: too tall ${fit.content}", fit.content + 3 * StartLayout.MIN_GAP_DP <= h) + } + } + // At the minimum Small the tile stays beside Open at the fonts phones use. + for (s in listOf(1f, 1.08f)) assertTrue(StartLayout.smallRecording(walk, 110f, 110f, s).tile >= StartLayout.MIN_TILE_DP) + // The timer is the hero: as large as the spec's sizes on One UI's 4 x 2 and 4 x 3. + assertEquals(StartLayout.TIMER_WIDE_SP, StartLayout.wideRecording(r, 313f, 180f, 1f).timerSp, 0f) + assertTrue(StartLayout.largeRecording(r, m, 313f, 290f, 1.08f).timerSp >= 48f) + } + + @Test fun `the Small says Paused at every cell and the fonts phones use`() { + val r = model(recording = StartRecordingInput(40, at(24, 11, 58).epochSecond, 1_500L, paused = true)).recording!! + for (s in listOf(1f, 1.08f)) for ((w, h) in smallCells + listOf(110f to 110f)) { + val fit = StartLayout.smallRecording(r, w, h, s) + assertTrue("Small $w x $h at $s", fit.since == StartLayout.SincePlace.Under || fit.name == "Paused") + assertTrue("Small $w x $h at $s: too tall ${fit.content}", fit.content <= h) + } + } + + @Test fun `recording on One UI's cells leaves no gap over 32 dp`() { + val r = model(recording = StartRecordingInput(40, at(24, 11, 58).epochSecond, 1_500L, paused = false)).recording!! + val m = model().copy(weekLine = "5 workouts, 10 min", lastLine = "Yesterday, 1 min") + for (s in listOf(1f, 1.08f, 1.3f)) { + val small = StartLayout.smallRecording(r, 140f, 180f, s) + assertTrue("Small at $s: ${(180f - small.content) / small.gaps}", (180f - small.content) / small.gaps <= 32f) + val wide = StartLayout.wideRecording(r, 313f, 180f, s) + assertTrue("Wide at $s: ${(180f - wide.content) / wide.gaps}", (180f - wide.content) / wide.gaps <= 32f) + val large = StartLayout.largeRecording(r, m, 313f, 290f, s) + val edge = large.edgeGap(290f) + val inner = (290f - large.content - 2 * edge) / (large.gaps - 2) + assertTrue("Large at $s: $inner", inner <= 32f) + } + } + + private fun assertNotNullAnd(v: T?): T { + assertNotNull(v) + return v!! + } + + @Test fun `captions are short names, the full one where the short one is already said`() { + // Indoor walk alone reads "Walk", a treadmill beside your outdoor runs "Treadmill". + val m = model(workouts = listOf(workout(20, 8, 1, 30), workout(21, 8, 1, 30), workout(22, 8, 40, 30), workout(23, 8, 2, 30))) + assertEquals(listOf("Treadmill", "Walk"), m.next.take(2).map { it.caption }) + assertEquals("Indoor walk", m.next[1].name) + } + + /* ------------------------------------------------------------------------------------------ */ + /* Fitting: nothing wraps, nothing cut, no holes */ + /* ------------------------------------------------------------------------------------------ */ + + /** Content sizes: the buckets' minimums and One UI's cells (spec 2.1), width to height. */ + private val smallCells = listOf(110f to 110f, 127f to 161f, 140f to 180f) + private val wideCells = listOf(260f to 120f, 313f to 161f, 313f to 180f) + private val largeCells = listOf(260f to 220f, 313f to 290f, 313f to 335f) + private val scales = listOf(1f, 1.08f, 1.3f) + + private fun one(text: String, sp: Float, room: Float, s: Float) = WidgetText.width(text, sp, s) <= room + + @Test fun `the Small's words are one line each, in every cell and font`() { + val sports = listOf(1, 52, 7, 23, 222, 2).map { StartSport.of(it, emptyMap()) } + for ((w, h) in smallCells) for (s in scales) for (sp in sports) for (rec in listOf(false)) { + val lines = listOf(StartWidgetModel.USUAL) + val fit = StartLayout.small(sp, lines, rec, w, h, s) + val action = fit.sizes.action(rec, s) + val room = if (fit.actionTop) w else w - action - StartLayout.SMALL_GAP_DP + val where = "${sp.name} at $w x $h, font $s, recording $rec" + assertTrue("$where: name wraps", one(fit.words.name, WidgetText.BODY_SP, room, s) || fit.words.name == sp.shortName) + fit.words.line?.let { assertTrue("$where: line wraps", one(it, WidgetText.SECONDARY_SP, room, s)) } + if (fit.actionTop && fit.tile) assertTrue("$where: top row too wide", fit.sizes.tile + StartLayout.SMALL_GAP_DP + action <= w) + } + // On One UI's 2 x 2 the usual keeps its full name and "Your usual". + val fit = StartLayout.small(StartSport.of(1, emptyMap()), listOf(StartWidgetModel.USUAL), false, 127f, 161f, 1.08f) + assertEquals("Outdoor run", fit.words.name) + assertEquals("Your usual", fit.words.line) + } + + @Test fun `the usual row is one line each, the mark and GPS giving way first`() { + val m = model() + for ((w, h) in wideCells + largeCells) for (s in scales) { + val b = StartLayout.wide(m, w, h, s) + val words = b.row.words + val room = w - b.sizes.tile - 2 * StartLayout.ROW_GAP_DP - b.sizes.play - if (words.mark) StartLayout.MARK_DP + StartLayout.MARK_GAP_DP else 0f + words.line?.let { assertTrue("$w at $s: line wraps", one(it, WidgetText.SECONDARY_SP, room, s)) } + } + // At One UI's 4 columns the whole line and its mark fit; at the minimum the mark gives way first. + assertEquals(StartLayout.Words("Outdoor run", "Your usual, GPS on", true), StartLayout.wide(m, 313f, 161f, 1.08f).row.words) + assertEquals("Your usual, GPS on", StartLayout.wide(m, 260f, 120f, 1.08f).row.words.line) + } + + /** Every caption shown fits its box on one line: Glance shows it whole, no "W...". */ + private fun assertCaptionsWhole(t: StartLayout.Tiles, w: Float, s: Float, where: String) { + if (!t.captioned) return + assertTrue("$where: captions run together or out", StartLayout.captionsFit(t.captions, t.tile, t.captionSp, w, s)) + val boxes = StartLayout.captionBoxes(t.captions, t.tile, t.captionSp, w, s) + t.captions.zip(boxes).forEach { (c, box) -> + // With the safety margin: the rough sum underrates short words ("Swi..." on the phone). + val need = StartLayout.captionWidth(c, t.captionSp, s) + assertTrue("$where: $c needs $need dp in a ${box.second} dp box", need <= box.second + 0.01f) + } + // Each box centred on its own tile, the first and last too, in order, within half the side padding. + val n = t.captions.size + boxes.forEachIndexed { i, (left, bw) -> assertEquals("$where: box $i off centre", StartLayout.centre(i, n, t.tile, w), left + bw / 2, 0.01f) } + boxes.zipWithNext().forEach { (a, b) -> assertTrue("$where: boxes overlap", a.first + a.second <= b.first + 0.01f) } + val bleed = StartLayout.CAPTION_BLEED_DP + assertTrue("$where: past the bleed", boxes.first().first >= -bleed - 0.01f && boxes.last().first + boxes.last().second <= w + bleed + 0.01f) + } + + @Test fun `short captions get their column's room, not their rough width`() { + val captions = listOf("Walk", "Strength", "Hike", "Swim", "All sports") + // One UI's 4 x 2, where the phone showed "W..." and "S...". + run { + val w = 313f + val boxes = StartLayout.captionBoxes(captions, 38f, 10f, w, 1.08f) + // "Walk" and "Swim" get far more than their 25 dp, so a sum that underrates them can't cut them. + assertTrue(boxes[0].second >= 1.2f * StartLayout.captionWidth("Walk", 10f, 1.08f)) + assertTrue(boxes[3].second >= 1.2f * StartLayout.captionWidth("Swim", 10f, 1.08f)) + } + } + + @Test fun `the Wide keeps the tile row, four sports on One UI, at least three at the minimum`() { + val m = model() + for (s in listOf(1f, 1.08f)) { + assertEquals("One UI 4 x 2 at $s", 4, StartLayout.wide(m, 313f, 180f, s).tiles?.sports?.size) + val b = StartLayout.wide(m, 260f, 120f, s) + val tiles = b.tiles + assertNotNull("Wide 300 x 160 at $s lost its tiles", tiles) + // With the caption margin a 300 wide cell can't always give four captions room: a tight one gives way. + assertTrue(tiles!!.sports.size >= 3) + assertCaptionsWhole(tiles, 260f, s, "Wide at $s") + assertTrue(b.content + (b.gaps - 2) * StartLayout.MIN_GAP_DP <= 120f) + } + } + + @Test fun `the Large holds four sports on One UI and at least three at 300 wide, captions on one line`() { + val m = model(workouts = history + listOf(workout(24, 8, 40, 30), workout(17, 8, 40, 30), workout(10, 8, 40, 30))) + for (s in listOf(1f, 1.08f)) { + assertEquals("One UI 4 x 3 at $s", 4, StartLayout.large(m, 313f, 290f, s).tiles?.sports?.size) + val b = StartLayout.large(m, 260f, 220f, s) + assertNotNull("Large 300 x 260 at $s lost its tiles", b.tiles) + assertTrue(b.tiles!!.sports.size >= 3) + assertCaptionsWhole(b.tiles!!, 260f, s, "Large at $s") + assertTrue(b.content + (b.gaps - 2) * StartLayout.MIN_GAP_DP <= 220f) + } + } + + @Test fun `tiles never cut or run captions together, at any cell or font`() { + val long = listOf(14, 6, 8, 17, 18, 21).map { StartSport.of(it, emptyMap()) } + model().next + val m = model().copy(next = long) + for ((w, h) in wideCells) for (s in scales) { + val b = StartLayout.wide(m, w, h, s) + val t = b.tiles ?: continue + assertCaptionsWhole(t, w, s, "Wide $w x $h at $s") + assertTrue("Wide $w x $h at $s too tall", b.content + (b.gaps - 2) * StartLayout.MIN_GAP_DP <= h) + } + for ((w, h) in largeCells) for (s in scales) { + val b = StartLayout.large(m, w, h, s) + val t = b.tiles ?: continue + assertCaptionsWhole(t, w, s, "Large $w x $h at $s") + assertTrue("Large $w x $h at $s too tall", b.content + (b.gaps - 2) * StartLayout.MIN_GAP_DP <= h) + } + // At the largest font a short Wide still has its row, its captions the last resort to go. + assertNotNull(StartLayout.wide(model(), 260f, 120f, 1.3f).tiles) + assertTrue(StartLayout.wide(model(), 313f, 180f, 1.08f).tiles!!.captioned) + } + + @Test fun `on One UI's cells the spare height is used, no gap bigger than 32 dp`() { + val m = model().copy(weekLine = "5 workouts, 10 min", lastLine = "Yesterday, 1 min") + for (s in listOf(1f, 1.08f)) { + // 4 x 2 at 353 x 220 and 4 x 3 at 353 x 330, less the padding. + val wide = StartLayout.wide(m, 313f, 180f, s) + val large = StartLayout.large(m, 313f, 290f, s) + assertEquals("Wide grows to the mockup's sizes", 48f, wide.sizes.tile, 0f) + assertTrue("Wide at $s: gap ${(180f - wide.content) / wide.gaps}", (180f - wide.content) / wide.gaps <= 32f) + assertTrue("Large at $s: gap ${(290f - large.content) / large.gaps}", (290f - large.content) / large.gaps <= 32f) + // 2 x 2 at 180 x 220. + val small = StartLayout.small(m.usual, listOf(StartWidgetModel.USUAL), false, 140f, 180f, s) + val gap = (180f - StartLayout.smallHeight(small, s)) / small.gaps + assertTrue("Small at $s: gap $gap", gap <= 32f) + } + } + + @Test fun `the Large's spare height goes between the blocks, half a gap above and below`() { + val m = model().copy(weekLine = "5 workouts, 10 min", lastLine = "Yesterday, 1 min") + for (s in listOf(1f, 1.08f)) { + val b = StartLayout.large(m, 313f, 290f, s) + val edge = b.edgeGap(290f) + val inner = (290f - b.content - 2 * edge) / (b.gaps - 2) + assertEquals("Large at $s", inner / 2, edge, 0.01f) + // Under the figures: the padding and half a gap, no band. + assertTrue("Large at $s: ${edge + WidgetSizes.PADDING_DP} dp under Last run", edge + WidgetSizes.PADDING_DP <= 40f) + } + } + + @Test fun `Swim's caption needs more than its rough width, so a tight one gives way`() { + // By letter: the W and m make Swim wider than four average letters, then the margin on top. + assertTrue(StartLayout.letterEms("Swim") > StartLayout.letterEms("Hike")) + assertEquals(StartLayout.letterEms("Swim") * 11f * 1.08f * 1.15f, StartLayout.captionWidth("Swim", 11f, 1.08f), 1e-3f) + assertTrue(StartLayout.captionWidth("Swim", 11f, 1.08f) > 1.15f * WidgetText.width("Swim", 11f, 1.08f)) + } + + @Test fun `the tile row is the same sports in the same order at every size`() { + // The phone's history: walks, strength, hikes, rides, then swims. + val sports = listOf(3, 52, 15, 4, 7, 49, 60).map { StartSport.of(it, emptyMap()) } + val m = model().copy(next = sports) + val rows = mutableMapOf>() + for (s in listOf(1f, 1.08f)) { + rows["Wide min $s"] = StartLayout.wide(m, 260f, 120f, s).tiles!!.captions + rows["Wide One UI $s"] = StartLayout.wide(m, 313f, 180f, s).tiles!!.captions + rows["Large min $s"] = StartLayout.large(m, 260f, 220f, s).tiles!!.captions + rows["Large One UI $s"] = StartLayout.large(m, 313f, 290f, s).tiles!!.captions + } + val order = listOf("Walk", "Strength", "Hike", "Cycling", "All sports") + rows.forEach { (where, captions) -> assertEquals(where, order, captions) } + // Every size's row is the order's start, whatever its room. + for ((w, h) in wideCells) for (s in scales) { + val t = StartLayout.wide(m, w, h, s).tiles ?: continue + assertEquals("Wide $w x $h at $s", sports.take(t.sports.size), t.sports) + } + } + + @Test fun `the Wide at its minimum keeps its captions at font 1 and 1_08`() { + for (s in listOf(1f, 1.08f)) assertTrue("Wide 300 x 160 at $s", StartLayout.wide(model(), 260f, 120f, s).tiles!!.captioned) + } + + @Test fun `the figures never wrap a value`() { + val m = model().copy(weekLine = "5 workouts, 10 min", lastLine = "Yesterday, 1 min") + for ((w, _) in largeCells) for (s in scales) { + val f = StartLayout.figures(m, w, s) + val room = when (f.layout) { + StartLayout.FigureLayout.Side -> (w - StartLayout.FIGURE_GAP_DP) / 2 + StartLayout.FigureLayout.Rows -> w - StartLayout.FIGURE_GAP_DP - WidgetText.width(m.lastLabel, WidgetText.CAPTION_SP, s) + StartLayout.FigureLayout.Stacked -> w + } + assertTrue("$w at $s: ${f.layout}", one(m.lastLine, WidgetText.BODY_SP, room, s)) + } + // The phone's case: at 300 wide and 1.08 the two don't fit half each, so they take a row each. + assertEquals(StartLayout.FigureLayout.Rows, StartLayout.figures(m, 260f, 1.08f).layout) + assertEquals(StartLayout.FigureLayout.Side, StartLayout.figures(model(), 313f, 1f).layout) + } + + @Test fun `tiles spread edge to edge, evenly`() { + assertEquals(19f, StartLayout.centre(0, 5, 38f, 260f), 1e-3f) + assertEquals(241f, StartLayout.centre(4, 5, 38f, 260f), 1e-3f) + assertEquals(StartLayout.centre(2, 5, 38f, 260f) - StartLayout.centre(1, 5, 38f, 260f), StartLayout.centre(1, 5, 38f, 260f) - StartLayout.centre(0, 5, 38f, 260f), 1e-3f) + val boxes = StartLayout.captionBoxes(listOf("Walk", "Run", "Yoga", "Swim", "All sports"), 38f, 11f, 260f, 1f) + // Every caption centred under its tile, Walk and All sports too. + boxes.forEachIndexed { i, (l, w) -> assertEquals(StartLayout.centre(i, 5, 38f, 260f), l + w / 2, 1e-3f) } + // All sports, wider than its tile, runs into the side padding by at most half of it. + assertTrue(boxes.last().first + boxes.last().second > 260f) + assertTrue(boxes.last().first + boxes.last().second <= 260f + StartLayout.CAPTION_BLEED_DP + 1e-3f) + } + + @Test fun `with the strap status the Wide and the Small get a title row with the button`() { + val m = WidgetSamples.start(now, zone, Locale.UK) + val l = StatusLine(StrapStatus.StaleSync(0), listOf("Synced Wednesday", "Synced Wed", "Wednesday", "Wed")) + val top = StartLayout.statusTop(m, l, WidgetSizes.Wide, 313f, 180f, 1f)!! + assertEquals(StatusButton.SIZE_DP, top.height, 0f) + assertNotNull(top.words) + val b = StartLayout.wide(m, 313f, 180f, 1f, top.height) + assertEquals(StartLayout.wide(m, 313f, 180f, 1f).gaps + 1, b.gaps) + assertNotNull(b.tiles) + assertNull(StartLayout.statusTop(m, null, WidgetSizes.Wide, 313f, 180f, 1f)) + // The Small: a title row with the button, the tile with play beside it, the name over "Your usual". + val small = StartLayout.statusSmall(m.usual, l, 140f, 180f, 1f) + // "Start a workout" won't fit beside the button on a 2 x 2 even at 13 sp, so "Start". + assertEquals(StartLayout.SHORT_TITLE, small.title) + assertEquals(StartWidgetModel.TITLE, StartLayout.statusSmall(m.usual, l, 200f, 180f, 1f).title) + assertEquals(l, small.top.status) + assertNull(small.top.words) + assertEquals(StartWidgetModel.USUAL, small.words.line) + assertTrue(small.sizes.tile + StartLayout.STATUS_PLAY_GAP_DP + small.sizes.play <= 140f) + assertTrue(small.spread.gap(180f) <= Spread.TARGET_GAP_DP) + // Short of room "Your usual" goes, then the title; the tile and play stay. + val tight = StartLayout.statusSmall(m.usual, l, 110f, 110f, 1.3f) + assertNull(tight.words.line) + assertTrue(tight.spread.fits(110f, StartLayout.STATUS_MIN_GAP_DP)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/widget/StepsWidgetModelTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/StepsWidgetModelTest.kt new file mode 100644 index 00000000..116c3b77 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/widget/StepsWidgetModelTest.kt @@ -0,0 +1,221 @@ +package org.openstrap.app.widget + +import java.time.LocalDate +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.Summaries +import org.openstrap.app.ui.glow.draw.WeekBarsLook +import org.openstrap.app.ui.glow.draw.weekBars +import org.openstrap.app.ui.glow.draw.weekStub + +/** Widgets spec 3.3: what the Steps widget shows in each state, from the app's own sums and copy, and how it fits. */ +class StepsWidgetModelTest { + private val today = LocalDate.of(2026, 10, 24) // a Saturday + private val monday = LocalDate.of(2026, 10, 19) + + /** Mon 7,800, Tue 11,200, Wed 5,900, Thu 10,400, Fri 6,300: the mockup's week, two met of five. */ + private val past = listOf(7_800, 11_200, 5_900, 10_400, 6_300).mapIndexed { i, s -> monday.plusDays(i.toLong()) to s }.toMap() + + /** 8,432 steps by 14:xx, spread over the day's hours. */ + private val hourly = List(24) { h -> if (h in 7..14) 1_054f else 0f } + + private fun model(steps: Int = 8_432, goal: Int = 10_000, days: Map = past, hour: Int = 14, h: List = hourly) = + StepsWidgetModel.from(today, steps, h, hour, goal, days, Locale.UK) + + @Test fun `today's steps against the goal`() { + val m = model() + assertEquals("Steps", m.title) + assertEquals("Goal 10,000", m.goalNote) + assertEquals("8,432", m.stepsText) + assertEquals("today", m.word) + assertEquals("1,568 to go", m.toGo) + assertEquals("84% of your goal", m.share) + assertEquals(0.8432f, m.progress, 1e-4f) + assertFalse(m.goalMet) + assertEquals("8,432 steps today, 84% of your goal", m.description) + assertEquals(today, m.date) + } + + @Test fun `past the goal, Goal met replaces what's to go and the bar stays full`() { + val m = model(steps = 12_345) + assertTrue(m.goalMet) + assertEquals("Goal met", m.toGo) + assertEquals("Goal met", m.share) + assertEquals(1f, m.progress, 0f) + assertEquals("12,345 steps today, goal met", m.description) + assertEquals("Goal met", StepsWidgetModel.toGo(10_000, 10_000)) + assertEquals("1 to go", StepsWidgetModel.toGo(9_999, 10_000)) + } + + @Test fun `before any steps today, 0 against the goal and no line`() { + val m = model(steps = 0, h = List(24) { 0f }, days = emptyMap(), hour = 7) + assertEquals("0", m.stepsText) + assertEquals("10,000 to go", m.toGo) + assertEquals("0% of your goal", m.share) + assertEquals(0f, m.progress, 0f) + assertFalse(m.showLine) + assertEquals("Goal met 0 of 1 day", m.weekLine) + } + + @Test fun `the line is the Steps card's, the running total up to the hour now`() { + val m = model() + assertTrue(m.showLine) + assertEquals(Summaries.runningSteps(hourly, 14), m.line) + assertEquals(24, m.line.size) + assertNull(m.line[15]) + assertEquals(8_432f, m.line[14]!!, 0f) + assertEquals(10_000f, m.lineMax, 0f) + assertEquals(12_345f, model(steps = 12_345).lineMax, 0f) + } + + @Test fun `the week, Monday to Sunday, met, missed, today and to come`() { + val m = model() + assertEquals(listOf("M", "T", "W", "T", "F", "S", "S"), m.week.map { it.letter }) + assertEquals( + listOf(StepsDay.Missed, StepsDay.Met, StepsDay.Missed, StepsDay.Met, StepsDay.Missed, StepsDay.Today, StepsDay.ToCome), + m.week.map { it.state }, + ) + assertEquals(listOf(7_800, 11_200, 5_900, 10_400, 6_300, 8_432, null), m.week.map { it.steps }) + assertEquals("Goal met 2 of 6 days", m.weekLine) + } + + @Test fun `today counts once it meets the goal, and a day without a record doesn't count`() { + val days = past - monday.plusDays(2) + val m = model(steps = 10_500, days = days) + assertEquals(StepsDay.NoRecord, m.week[2].state) + assertEquals(StepsDay.Today, m.week[5].state) + assertEquals("Goal met 3 of 5 days", m.weekLine) + } + + @Test fun `on a Monday the week is today and six to come`() { + val m = StepsWidgetModel.from(monday, 3_000, hourly, 9, 10_000, emptyMap(), Locale.UK) + assertEquals(StepsDay.Today, m.week.first().state) + assertTrue(m.week.drop(1).all { it.state == StepsDay.ToCome }) + assertEquals("Goal met 0 of 1 day", m.weekLine) + } + + @Test fun `bars are each day's share of the week's top, none for days to come or not recorded`() { + val m = model(days = past - monday.plusDays(2)) + val values = StepsWidgetModel.barValues(m.week, m.goal) + assertEquals(1f, values[1]!!, 1e-4f) // Tuesday's 11,200 is the top, over the goal + assertEquals(7_800f / 11_200f, values[0]!!, 1e-4f) + assertEquals(8_432f / 11_200f, values[5]!!, 1e-4f) + assertNull(values[2]) + assertNull(values[6]) + // Under the goal all week, the goal is the top. + val quiet = model(steps = 5_000, days = mapOf(monday to 4_000)) + assertEquals(0.4f, StepsWidgetModel.barValues(quiet.week, quiet.goal)[0]!!, 1e-4f) + + val density = 2f + val bars = weekBars(values, density) + val full = WeekBarsLook.HEIGHT_DP * density + assertEquals(full, bars[1]!!.size.height, 1e-3f) + assertTrue(bars[0]!!.size.height < bars[1]!!.size.height) + val stub = weekStub(6, density) + assertEquals(WeekBarsLook.STUB_DP * density, stub.size.height, 1e-3f) + assertEquals(full - stub.size.height, stub.topLeft.y, 1e-3f) + assertEquals(bars[1]!!.size.width, stub.size.width, 1e-3f) + assertEquals(bars[0]!!.topLeft.x + 6 * (WeekBarsLook.COLUMN_DP + WeekBarsLook.GAP_DP) * density, stub.topLeft.x, 1e-3f) + } + + @Test fun `every string keeps to the copy rules`() { + val m = model() + (listOf(m.title, m.goalNote, m.word, m.toGo, m.share, m.weekLine, m.description)).forEach { s -> + assertFalse(s, s.any { it in "·;—" }) + } + } + + /* ------------------------------------------- Fit at the minimum ------------------------------------------- */ + + private val small = WidgetSizes.Small.width.value - 2 * WidgetSizes.PADDING_DP to WidgetSizes.Small.height.value - 2 * WidgetSizes.PADDING_DP + private val wide = WidgetSizes.Wide.width.value - 2 * WidgetSizes.PADDING_DP to WidgetSizes.Wide.height.value - 2 * WidgetSizes.PADDING_DP + private val large = WidgetSizes.Large.width.value - 2 * WidgetSizes.PADDING_DP to WidgetSizes.Large.height.value - 2 * WidgetSizes.PADDING_DP + + /** The Small's column, as SmallContent stacks it. */ + private fun smallHeight(fit: StepsLayout.Small, fontScale: Float): Float = + WidgetText.line(WidgetText.TITLE_SP, fontScale) + StepsLayout.dotsHeight(fit.dot) + + (if (fit.word) StepsLayout.SMALL_WORD_GAP_DP + WidgetText.line(WidgetText.BODY_SP, fontScale) else 0f) + + StepsLayout.SMALL_BAR_GAP_DP + StepsLayout.BAR_DP + + (if (fit.share) StepsLayout.SMALL_BAR_GAP_DP + WidgetText.line(WidgetText.SECONDARY_SP, fontScale) else 0f) + + @Test fun `the Small keeps everything at the default font and fits at 1_3`() { + val m = model() + val normal = StepsLayout.small(m.stepsText, m.share, small.first, small.second, 1f) + assertEquals(Triple(StepsLayout.SMALL_DOT, true, true), Triple(normal.dot, normal.word, normal.share)) + assertTrue(smallHeight(normal, 1f) <= small.second) + val big = StepsLayout.small(m.stepsText, m.share, small.first, small.second, 1.3f) + assertTrue(smallHeight(big, 1.3f) <= small.second) + assertTrue(big.word || big.share) + // Five figures shrink the dots to the width rather than clip. + val many = StepsLayout.small("12,345", "Goal met", small.first, small.second, 1f) + assertTrue(many.dot < StepsLayout.SMALL_DOT) + assertTrue(StepsLayout.dotsWidth("12,345", many.dot) <= small.first + 1e-3f) + } + + @Test fun `the Wide's number row fits its width at both font sizes`() { + for (scale in listOf(1f, 1.3f)) for ((steps, toGo) in listOf("8,432" to "1,568 to go", "12,345" to "2,655 to go", "12,345" to "Goal met")) { + val row = StepsLayout.numberRow(steps, toGo, wide.first, scale) + val need = StepsLayout.dotsWidth(steps, row.dot) + + (if (row.word) StepsLayout.WORD_GAP_DP + WidgetText.width("today", WidgetText.BODY_SP, scale) else 0f) + + StepsLayout.SIDE_GAP_DP + WidgetText.width(toGo, WidgetText.SECONDARY_SP, scale) + assertTrue("$steps $toGo at $scale: $need", need <= wide.first + 1e-3f) + assertTrue(row.dot >= StepsLayout.SMALL_DOT || !row.word) + } + assertEquals(StepsLayout.NumberRow(StepsLayout.WIDE_DOT, word = true), StepsLayout.numberRow("8,432", "1,568 to go", wide.first, 1f)) + } + + @Test fun `the Wide's line fits its height at both font sizes`() { + for (scale in listOf(1f, 1.3f)) { + val row = StepsLayout.numberRow("8,432", "1,568 to go", wide.first, scale) + val line = assertNotNullLine(StepsLayout.wideLine(wide.second, row, scale)) + val total = WidgetText.line(WidgetText.TITLE_SP, scale) + row.height(scale) + line + 2 * StepsLayout.MIN_GAP_DP + assertTrue("at $scale: $total", total <= wide.second + 1e-3f) + } + assertEquals(StepsLayout.LINE_DP, StepsLayout.wideLine(wide.second, StepsLayout.numberRow("8,432", "1,568 to go", wide.first, 1f), 1f)!!, 0f) + // A font too large for the line drops it rather than clip. + assertNull(StepsLayout.wideLine(wide.second, StepsLayout.numberRow("8,432", "1,568 to go", wide.first, 2.4f), 2.4f)) + } + + @Test fun `the Large fits its height and the week its width at both font sizes`() { + for (scale in listOf(1f, 1.3f)) { + val row = StepsLayout.numberRow("8,432", "1,568 to go", large.first, scale) + val week = StepsLayout.week("Goal met 2 of 6 days", large.first, scale) + val line = assertNotNullLine(StepsLayout.largeLine(large.second, row, week, scale)) + val total = WidgetText.line(WidgetText.TITLE_SP, scale) + row.height(scale) + line + + week.height + 3 * StepsLayout.MIN_GAP_DP + assertTrue("at $scale: $total", total <= large.second + 1e-3f) + assertTrue(week.column >= WeekBarsLook.MIN_COLUMN_DP) + assertTrue(WeekBarsLook.width(7, week.column) <= large.first) + // "This week" beside the count at the minimum Large at the default font. At 1.3 the estimate leaves no + // room, so "This week" drops and the count keeps its one line. + if (scale == 1f) assertTrue(week.title) + } + // With room, the week keeps the mockup's columns. + assertEquals(WeekBarsLook.COLUMN_DP, StepsLayout.week("Goal met 2 of 6 days", 420f, 1f).column, 0f) + // A font too large for both drops "This week", never wraps the count. + assertFalse(StepsLayout.week("Goal met 2 of 6 days", large.first, 2f).title) + } + + private fun assertNotNullLine(v: Float?): Float { + assertNotNull(v) + return v!! + } + + @Test fun `with the strap status the title row counts the button and the words replace the goal`() { + val m = WidgetSamples.steps(today, Locale.UK) + val l = StatusLine(StrapStatus.StaleSync(0), listOf("Synced Wednesday", "Synced Wed", "Wednesday", "Wed")) + val shown = StepsLayout.wide(m, 313f, 180f, 1f, l) + assertEquals(StatusButton.SIZE_DP, shown.top.height, 0f) + assertNotNull(shown.top.words) + assertFalse(shown.top.note) + assertTrue(StepsLayout.wide(m, 313f, 180f, 1f).top.note) + val small = StepsLayout.small(m.stepsText, m.share, 140f, 180f, 1f, l) + assertNull(small.top.words) + assertEquals(StatusButton.SIZE_DP, small.top.height, 0f) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/widget/StrapStatusTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/StrapStatusTest.kt new file mode 100644 index 00000000..dac8ce60 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/widget/StrapStatusTest.kt @@ -0,0 +1,74 @@ +package org.openstrap.app.widget + +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Widgets spec 5: each state, the order they win in, the 15% and 3 hour edges, and the words. */ +class StrapStatusTest { + private val zone: ZoneId = ZoneOffset.UTC + private val now = LocalDate.of(2026, 10, 23).atTime(12, 0).atZone(zone).toEpochSecond() + private val fresh = now - 600 + private val old = now - 4 * 3600 + + @Test fun `all well shows nothing`() { + assertNull(strapStatus(paired = true, battery = 80, charging = false, lastSync = fresh, now = now)) + } + + @Test fun `each state`() { + assertEquals(StrapStatus.NotPaired, strapStatus(false, 80, false, fresh, now)) + assertEquals(StrapStatus.LowBattery(12), strapStatus(true, 12, false, fresh, now)) + assertEquals(StrapStatus.StaleSync(old), strapStatus(true, 80, false, old, now)) + assertEquals(StrapStatus.Syncing, strapStatus(true, 80, false, old, now, syncing = true)) + } + + @Test fun `not paired wins over a low battery, which wins over an old sync`() { + assertEquals(StrapStatus.NotPaired, strapStatus(false, 5, false, old, now, syncing = true)) + assertEquals(StrapStatus.LowBattery(5), strapStatus(true, 5, false, old, now)) + assertEquals(StrapStatus.LowBattery(5), strapStatus(true, 5, false, old, now, syncing = true)) + } + + @Test fun `the battery line holds at 15 percent and under, not charging`() { + assertEquals(StrapStatus.LowBattery(15), strapStatus(true, 15, false, fresh, now)) + assertNull(strapStatus(true, 16, false, fresh, now)) + assertNull(strapStatus(true, 15, true, fresh, now)) + assertNull(strapStatus(true, null, false, fresh, now)) + } + + @Test fun `the sync line holds past 3 hours, not at them`() { + assertNull(strapStatus(true, 80, false, now - 3 * 3600, now)) + assertEquals(StrapStatus.StaleSync(now - 3 * 3600 - 1), strapStatus(true, 80, false, now - 3 * 3600 - 1, now)) + assertNull(strapStatus(true, 80, false, null, now)) + } + + @Test fun `a sync while all is well shows nothing`() { + assertNull(strapStatus(true, 80, false, fresh, now, syncing = true)) + } + + @Test fun `the words`() { + fun line(s: StrapStatus) = strapStatusLine(s, now, zone, Locale.UK) + assertEquals("Pair your strap", line(StrapStatus.NotPaired)) + assertEquals("Strap at 12%", line(StrapStatus.LowBattery(12))) + assertEquals("Syncing", line(StrapStatus.Syncing)) + assertEquals("Synced 09:10", line(StrapStatus.StaleSync(now - 2 * 3600 - 50 * 60))) + assertEquals("Synced yesterday", line(StrapStatus.StaleSync(now - 86_400))) + assertEquals("Synced Tuesday", line(StrapStatus.StaleSync(now - 3 * 86_400))) + assertEquals("Synced 13 Oct", line(StrapStatus.StaleSync(now - 10 * 86_400))) + } + + @Test fun `the widgets hear of pairing, the low edge and charging, not every percent`() { + assertFalse(strapStateChanged(true, 79, false, true, 80, false)) + assertFalse(strapStateChanged(true, 14, false, true, 15, false)) + assertTrue(strapStateChanged(true, 15, false, true, 16, false)) + assertTrue(strapStateChanged(true, 16, false, true, 15, false)) + assertTrue(strapStateChanged(true, 80, true, true, 80, false)) + assertTrue(strapStateChanged(false, 80, false, true, 80, false)) + assertTrue(strapStateChanged(true, 10, false, true, null, false)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/widget/WidgetSizesTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/WidgetSizesTest.kt new file mode 100644 index 00000000..95363f55 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/widget/WidgetSizesTest.kt @@ -0,0 +1,133 @@ +package org.openstrap.app.widget + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.w3c.dom.Element + +/** + * Widgets spec 2.1 and 6.1: every widget has the same three sizes and resizes the same way. The provider check reads + * every `appwidget-provider` in res/xml, so each widget step 4 on adds is held to it too, and a failure names it. + */ +class WidgetSizesTest { + private val root: File = listOf(File("src/main"), File("app/src/main")).first { it.isDirectory } + private val android = "http://schemas.android.com/apk/res/android" + + @Test fun `the three sizes are the spec's`() { + assertEquals(listOf(150f to 150f, 300f to 160f, 300f to 260f), WidgetSizes.all.map { it.width.value to it.height.value }) + assertEquals(WidgetSizes.Small, WidgetSizes.all.first()) + assertEquals(WidgetSizes.Large, WidgetSizes.all.last()) + } + + /** The provider XML files, by name: those whose root is `appwidget-provider`. */ + private fun providers(): Map { + val factory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } + return File(root, "res/xml").listFiles().orEmpty().filter { it.extension == "xml" }.mapNotNull { f -> + val doc = factory.newDocumentBuilder().parse(f).documentElement + if (doc.tagName == "appwidget-provider") f.name to doc else null + }.toMap() + } + + @Test fun `every provider declares the same sizes and resize limits`() { + val all = providers() + assertTrue("no widget providers in res/xml", all.containsKey("widget_recovery.xml")) + // Each widget built so far, so a provider dropped by mistake fails here too. + listOf("widget_moment.xml", "widget_recovery.xml", "widget_steps.xml", "widget_sleep.xml", "widget_start.xml").forEach { + assertTrue("$it missing from res/xml", all.containsKey(it)) + } + val wrong = mutableListOf() + val max = WidgetSizes.MaxResize + val expected = mapOf( + "targetCellWidth" to "${WidgetSizes.TARGET_CELLS}", + "targetCellHeight" to "${WidgetSizes.TARGET_CELLS}", + "minWidth" to "${WidgetSizes.MIN_DP}dp", + "minHeight" to "${WidgetSizes.MIN_DP}dp", + "minResizeWidth" to "${WidgetSizes.MIN_DP}dp", + "minResizeHeight" to "${WidgetSizes.MIN_DP}dp", + "maxResizeWidth" to "${max.width.value.toInt()}dp", + "maxResizeHeight" to "${max.height.value.toInt()}dp", + "resizeMode" to "horizontal|vertical", + "updatePeriodMillis" to "0", + "widgetCategory" to "home_screen", + ) + all.forEach { (name, e) -> + expected.forEach { (attr, value) -> + val got = e.getAttributeNS(android, attr) + if (got != value) wrong += "$name: $attr is \"$got\", the spec's is \"$value\"" + } + if (!e.getAttributeNS(android, "description").startsWith("@string/")) wrong += "$name: no description string" + // The picker's preview (spec 6.4): a layout on Android 12 and later, an image before, both real files. + val layout = e.getAttributeNS(android, "previewLayout") + if (!layout.startsWith("@layout/")) wrong += "$name: no previewLayout" + else if (!File(root, "res/layout/${layout.removePrefix("@layout/")}.xml").isFile) wrong += "$name: $layout has no file" + val image = e.getAttributeNS(android, "previewImage") + if (!image.startsWith("@drawable/")) wrong += "$name: no previewImage" + else if (root.resolve("res").listFiles().orEmpty().filter { it.name.startsWith("drawable") } + .none { d -> d.listFiles().orEmpty().any { it.nameWithoutExtension == image.removePrefix("@drawable/") } }) wrong += "$name: $image has no file" + } + if (wrong.isNotEmpty()) fail(wrong.joinToString("\n")) + } + + @Test fun `every provider's description and name are strings in the copy rules`() { + val strings = File(root, "res/values/strings.xml").readText() + providers().forEach { (name, e) -> + val key = e.getAttributeNS(android, "description").removePrefix("@string/") + val text = Regex("([^<]*)").find(strings)?.groupValues?.get(1) + assertTrue("$name: $key missing from strings.xml", text != null) + assertTrue("$name: \"$text\" breaks the copy rules", text!!.none { it in "·;—" } && !text.endsWith(".")) + } + // Each widget's receiver has its picker name. + val manifest = File(root, "AndroidManifest.xml").readText() + listOf("moment", "recovery", "steps", "sleep", "start").forEach { + assertTrue("widget_${it}_name not the receiver's label", "@string/widget_${it}_name" in manifest) + assertTrue("widget_${it}_name missing", "name=\"widget_${it}_name\"" in strings) + } + } + + @Test fun `the layout picked is the largest that fits, else the Small`() { + assertEquals(WidgetSizes.Small, WidgetSizes.bucket(173f, 176f)) + assertEquals(WidgetSizes.Wide, WidgetSizes.bucket(300f, 250f)) + // One UI on the Fold's cover screen: 2 × 2 is 167 × 201, 4 × 2 is 353 × 201, 4 × 3 is 353 × 375. + assertEquals(WidgetSizes.Small, WidgetSizes.bucket(167.6f, 201.1f)) + assertEquals(WidgetSizes.Wide, WidgetSizes.bucket(353.5f, 201.1f)) + assertEquals(WidgetSizes.Large, WidgetSizes.bucket(353.5f, 375.8f)) + assertEquals(WidgetSizes.Wide, WidgetSizes.bucket(400f, 200f)) + assertEquals(WidgetSizes.Large, WidgetSizes.bucket(358f, 272f)) + assertEquals(WidgetSizes.Large, WidgetSizes.bucket(357.8f, 271.6f)) + assertEquals(WidgetSizes.Small, WidgetSizes.bucket(120f, 120f)) + } + + @Test fun `bitmaps are drawn at the cell's real size when the launcher reports it`() { + assertEquals(390f to 190f, WidgetSizes.drawSize(WidgetSizes.Wide, listOf(180f to 400f, 390f to 190f))) + assertEquals(300f to 160f, WidgetSizes.drawSize(WidgetSizes.Wide, emptyList())) + assertEquals(300f to 160f, WidgetSizes.drawSize(WidgetSizes.Wide, listOf(0f to 0f))) + } + + @Test fun `content fits the smallest real cell of its layout`() { + // One UI's 2 x 2 in portrait and landscape: both are the Small, fit to the smaller of each. + assertEquals(155.4f to 158.5f, WidgetSizes.fitSize(WidgetSizes.Small, listOf(167.6f to 201.1f, 155.4f to 158.5f))) + // A size that maps to another layout doesn't count. + assertEquals(167.6f to 201.1f, WidgetSizes.fitSize(WidgetSizes.Small, listOf(167.6f to 201.1f, 353.5f to 201.1f))) + assertEquals(150f to 150f, WidgetSizes.fitSize(WidgetSizes.Small, emptyList())) + } + + @Test fun `the corner is 28 dp, or the launcher's when smaller`() { + assertEquals(28f, WidgetSizes.corner(null), 0f) + assertEquals(16f, WidgetSizes.corner(16f), 0f) + assertEquals(28f, WidgetSizes.corner(32f), 0f) + assertEquals(28f, WidgetSizes.corner(0f), 0f) + } + + @Test fun `bitmaps stay inside the RemoteViews limit`() { + val budget = WidgetSizes.bitmapBudget(1080, 2400) + assertEquals(1080L * 2400 * 6, budget) + assertEquals(1f, WidgetSizes.bitmapScale(1_000_000, budget), 0f) + val big = 10_000_000L + val s = WidgetSizes.bitmapScale(big, budget) + assertTrue(s < 1f) + assertTrue(big * s * s * 4 <= budget) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/widget/WidgetStatusLineTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/WidgetStatusLineTest.kt new file mode 100644 index 00000000..b5d91fb6 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/widget/WidgetStatusLineTest.kt @@ -0,0 +1,273 @@ +package org.openstrap.app.widget + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.device.WidgetUpdater + +/** + * Widgets spec 5: the strap status line's wordings, its button and words in the title row, and that every widget + * still fits with it showing, the row counted at the button's 32 dp, at the three sizes' minimums and on One UI's + * cells, at the fonts phones use and the largest, no gap over 32 dp on One UI. + */ +class WidgetStatusLineTest { + private val zone: ZoneId = ZoneOffset.UTC + private val today = LocalDate.of(2026, 10, 24) // a Saturday + private val now: Long = today.atTime(12, 0).atZone(zone).toEpochSecond() + + private fun line(s: StrapStatus) = StatusLine.of(s, now, zone, Locale.UK) + + /** Every state, the longest wording of each: "Synced Wednesday" is the widest the line gets. */ + private val lines = listOf( + line(StrapStatus.NotPaired), + line(StrapStatus.LowBattery(12)), + line(StrapStatus.StaleSync(now - 3 * 3600 - 60)), + line(StrapStatus.StaleSync(now - 86_400)), + line(StrapStatus.StaleSync(now - 3 * 86_400)), + line(StrapStatus.StaleSync(now - 11 * 86_400)), + line(StrapStatus.Syncing), + ) + + private val scales = listOf(1f, 1.08f, 1.3f) + + /** Content sizes inside the padding: the three minimums and One UI's cells (spec 2.1). */ + private val minimum = Triple(110f to 110f, 260f to 120f, 260f to 220f) + private val oneUi = Triple(140f to 180f, 313f to 180f, 313f to 290f) + private val target = Spread.TARGET_GAP_DP + 0.01f + + /** + * How far Start's Small button may sit from Steps' at the same cell, dp. Both rows sit half a gap from the top, + * and each widget's gap is its own spare height shared out, so they differ by a few dp: 5 at most, on a One UI + * 2 x 2 at font 1.3, under 2 elsewhere. + */ + private val BUTTON_TOP_SLACK = 6f + + /* --------------------------------------------- The line itself --------------------------------------------- */ + + @Test fun `each state's wordings, longest first, the line's own copy first`() { + assertEquals(listOf("Pair your strap", "Pair strap"), line(StrapStatus.NotPaired).options) + assertEquals(listOf("Strap at 12%", "12%"), line(StrapStatus.LowBattery(12)).options) + assertEquals(listOf("Syncing"), line(StrapStatus.Syncing).options) + assertEquals(listOf("Synced 08:59", "08:59"), line(StrapStatus.StaleSync(now - 3 * 3600 - 60)).options) + assertEquals(listOf("Synced yesterday", "Yesterday"), line(StrapStatus.StaleSync(now - 86_400)).options) + assertEquals(listOf("Synced Wednesday", "Synced Wed", "Wednesday", "Wed"), line(StrapStatus.StaleSync(now - 3 * 86_400)).options) + assertEquals(listOf("Synced 13 Oct", "13 Oct"), line(StrapStatus.StaleSync(now - 11 * 86_400)).options) + lines.forEach { l -> + assertEquals(strapStatusLine(l.status, now, zone, Locale.UK), l.full) + l.options.forEach { assertFalse(it, it.any { c -> c in "·;—–" }) } + } + } + + @Test fun `the words take a shorter wording before they drop, the button stays`() { + val l = line(StrapStatus.StaleSync(now - 3 * 86_400)) + val full = WidgetText.fitWidth("Synced Wednesday", StatusButton.WORDS_SP, 1f) + assertEquals("Synced Wednesday", StatusButton.words(l, full, 1f)) + assertEquals("Synced Wed", StatusButton.words(l, full - 1f, 1f)) + val tiny = WidgetText.fitWidth("Wed", StatusButton.WORDS_SP, 1f) + assertEquals("Wed", StatusButton.words(l, tiny, 1f)) + assertNull(StatusButton.words(l, tiny - 1f, 1f)) + assertNull(StatusButton.words(null, 400f, 1f)) + // A Wide too narrow for any wording keeps the title and the button alone. + val narrow = TopRow.of("Recovery", "This morning", l, 140f, 1f, withWords = true) + assertEquals(l, narrow.status) + assertNull(narrow.words) + assertFalse(narrow.note) + } + + @Test fun `the title stays, the button sits at the right and the words replace the note`() { + val l = line(StrapStatus.StaleSync(now - 86_400)) + val wide = TopRow.of("Steps", "Goal 10,000", l, 313f, 1f, withWords = true) + assertEquals(l, wide.status) + assertEquals("Synced yesterday", wide.words) + assertFalse("the words replace the note", wide.note) + assertEquals(WidgetText.TITLE_SP, wide.titleSp, 0f) + // The row counts the button's 32 dp, or the title's line when that's taller. + assertEquals(StatusButton.SIZE_DP, wide.height, 0f) + val big = TopRow.of("Steps", null, l, 313f, 2f, withWords = true) + assertEquals(WidgetText.line(WidgetText.TITLE_SP, 2f), big.height, 1e-4f) + // The words fit between the title and the button. + val used = WidgetText.fitWidth("Steps", wide.titleSp, 1f) + StatusButton.TITLE_GAP_DP + + WidgetText.fitWidth(wide.words, StatusButton.WORDS_SP, 1f) + StatusButton.WORDS_GAP_DP + StatusButton.SIZE_DP + assertTrue(used <= 313f) + // A Small has the button only. + val small = TopRow.of("Steps", null, l, 110f, 1f, withWords = false) + assertEquals(l, small.status) + assertNull(small.words) + // A long title steps down to fit beside the button rather than be cut. + val tight = TopRow.of("Last night", null, l, 140f, 1.3f, withWords = false) + assertTrue(tight.titleSp < WidgetText.TITLE_SP) + assertTrue(WidgetText.fitWidth("Last night", tight.titleSp, 1.3f) + StatusButton.TITLE_GAP_DP + StatusButton.SIZE_DP <= 140f) + // And one size down, no further, to make room for the words. + val start = TopRow.of(StartWidgetModel.TITLE, null, line(StrapStatus.NotPaired), 313f, 1.3f, withWords = true) + assertEquals(WidgetText.BODY_SP, start.titleSp, 0f) + assertNotNull(start.words) + // No status: the title's line, the note while it fits. + val none = TopRow.of("Steps", "Goal 10,000", null, 313f, 1f, withWords = true) + assertNull(none.status) + assertTrue(none.note) + assertEquals(WidgetText.line(WidgetText.TITLE_SP, 1f), none.height, 0f) + // The tap reaches 44 dp, past the 32 dp circle on every side. + assertTrue(StatusButton.TOUCH_DP >= 44f) + assertTrue(StatusButton.TOUCH_DP > StatusButton.SIZE_DP) + } + + @Test fun `a stale line has an alarm of its own, only while widgets are placed`() { + assertEquals(now + 1, WidgetUpdater.staleAt(true, now - SYNC_STALE_SECONDS, now - 10)) + assertNull(WidgetUpdater.staleAt(false, now, now)) + assertNull(WidgetUpdater.staleAt(true, null, now)) + // Already stale: the line shows now, no alarm needed. + assertNull(WidgetUpdater.staleAt(true, now - SYNC_STALE_SECONDS - 5, now)) + } + + @Test fun `the gaps share the spare height, half above and below`() { + val s = Spread.of(20f, null, 0f, 30f, 10f) + assertEquals(Spread(60f, 2), s) + assertEquals(40f / 3, s.gap(100f), 1e-4f) + assertEquals(20f / 3, s.edge(100f), 1e-4f) + assertTrue(s.fits(68f, 4f)) + assertFalse(s.fits(67f, 4f)) + } + + /* ---------------------------------------------- Each widget ---------------------------------------------- */ + + /** The title row's height at least the button's whenever the status shows: what every layout's sums now count. */ + private fun checkTop(where: String, top: TopRow, i: Int, w: Float, s: Float, l: StatusLine, real: Boolean) { + assertEquals("$where: the button", l, top.status) + assertTrue("$where: row ${top.height}", top.height >= StatusButton.SIZE_DP) + if (i == 0) assertNull("$where: words on a Small", top.words) + top.words?.let { assertTrue("$where: \"$it\" isn't one of the line's", it in l.options) } + if (real && i > 0) assertNotNull("$where: no words", top.words) + assertFalse("$where: the note stays", top.note) + } + + /** Checks [fits] and, on One UI, [gap] at every line, font and size, and the top row ([checkTop]). */ + private fun every( + what: String, + check: (size: Int, w: Float, h: Float, s: Float, l: StatusLine) -> Triple, + ) { + for (l in lines) for (s in scales) for ((cells, real) in listOf(minimum to false, oneUi to true)) { + listOf(cells.first, cells.second, cells.third).forEachIndexed { i, (w, h) -> + val (fits, gap, top) = check(i, w, h, s, l) + val where = "$what ${listOf("Small", "Wide", "Large")[i]} $w x $h at $s with \"${l.full}\"" + assertTrue("$where: doesn't fit", fits) + checkTop(where, top, i, w, s, l, real) + if (real) assertTrue("$where: gap $gap", gap <= target) + } + } + } + + @Test fun `Recovery fits with the status line`() { + val m = WidgetSamples.recovery(today, Locale.UK) + every("Recovery") { i, w, h, s, l -> + val f = when (i) { + 0 -> RecoveryLayout.small(m, w, h, s, l) + 1 -> RecoveryLayout.wide(m, w, h, s, l) + else -> RecoveryLayout.large(m, w, h, s, l) + } + assertTrue(WidgetDots.width(m.score!!, f.dot) <= w) + Triple(f.spread.fits(h, RecoveryLayout.MIN_GAP_DP), f.spread.gap(h), f.top) + } + } + + @Test fun `Steps fits with the status line`() { + val m = WidgetSamples.steps(today, Locale.UK) + every("Steps") { i, w, h, s, l -> + if (i == 0) { + val f = StepsLayout.small(m.stepsText, m.share, w, h, s, l) + Triple(f.spread.fits(h, StepsLayout.SMALL_BAR_GAP_DP / 2), f.spread.gap(h), f.top) + } else { + val f = if (i == 1) StepsLayout.wide(m, w, h, s, l) else StepsLayout.large(m, w, h, s, l) + Triple(f.spread.fits(h, StepsLayout.MIN_GAP_DP), f.spread.gap(h), f.top) + } + } + } + + @Test fun `Sleep fits with the status line`() { + val m = WidgetSamples.sleep(today, zone) + every("Sleep") { i, w, h, s, l -> + when (i) { + 0 -> SleepLayout.small(m, w, h, s, l).let { Triple(SleepLayout.smallHeight(it, s) <= h + 0.01f, it.spread.gap(h), it.top) } + 1 -> SleepLayout.wide(m, w, h, s, l).let { Triple(SleepLayout.wideHeight(it, s) <= h + 0.01f, it.spread.gap(h), it.top) } + else -> SleepLayout.large(m, w, h, s, l).let { + Triple(SleepLayout.largeHeight(it, m.stages.size, s) <= h + 0.01f, SleepLayout.largeSpread(it, m.stages.size, s).gap(h), it.top) + } + } + } + } + + private val stepsModel = WidgetSamples.steps(today, Locale.UK) + + @Test fun `Start fits with the status line`() { + val m = WidgetSamples.start(Instant.ofEpochSecond(now), zone, Locale.UK) + val sizes = listOf(WidgetSizes.Small, WidgetSizes.Wide, WidgetSizes.Large) + for (l in lines) for (s in scales) for ((cells, real) in listOf(minimum to false, oneUi to true)) { + listOf(cells.first, cells.second, cells.third).forEachIndexed { i, (w, h) -> + val where = "Start ${sizes[i]} $w x $h at $s with \"${l.full}\"" + if (i == 0) { + // As the other Smalls: the title row with the button, the tile with play beside it, the name. + val f = StartLayout.statusSmall(m.usual, l, w, h, s) + assertTrue("$where: ${f.spread.content} in $h", f.spread.fits(h, StartLayout.STATUS_MIN_GAP_DP)) + checkTop(where, f.top, 0, w, s, l, real) + assertTrue("$where: play beside the tile", f.sizes.tile + StartLayout.STATUS_PLAY_GAP_DP + f.sizes.play <= w + 0.01f) + assertTrue("$where: the name", WidgetText.width(f.words.name, WidgetText.BODY_SP, s) <= w) + // The button where Steps' Small has it: the same right edge (both rows span the content) and top. + val steps = StepsLayout.small(stepsModel.stepsText, stepsModel.share, w, h, s, l) + val stepsTop = steps.spread.edge(h) + (steps.top.height - StatusButton.SIZE_DP) / 2 + val startTop = StartLayout.statusSmallButtonTop(f, h) + assertEquals("$where: the button's top against Steps'", stepsTop, startTop, BUTTON_TOP_SLACK) + if (real) { + assertNotNull("$where: the title", f.title) + assertTrue("$where: gap ${f.spread.gap(h)}", f.spread.gap(h) <= target) + assertTrue("$where: shrunk", f.sizes.tile >= StartLayout.TILE_DP) + } + return@forEachIndexed + } + val top = StartLayout.statusTop(m, l, sizes[i], w, h, s) + val b = if (i == 1) StartLayout.wide(m, w, h, s, top?.height ?: 0f) else StartLayout.large(m, w, h, s, top?.height ?: 0f) + val least = (b.gaps - 2) * StartLayout.MIN_GAP_DP + assertTrue("$where: ${b.content + least} in $h", b.content + least <= h + 0.01f) + if (real) { + assertNotNull("$where: the line dropped", top) + checkTop(where, top!!, i, w, s, l, real) + assertTrue("$where: gap ${b.gap(h)}", b.gap(h) <= target) + assertNotNull("$where: the tiles", b.tiles) + } + } + } + } + + @Test fun `Moment fits with the status line`() { + val m = WidgetSamples.moment(today, zone) + every("Moment") { i, w, h, s, l -> + val f = when (i) { + 0 -> MomentLayout.small(m, w, h, s, l) + 1 -> MomentLayout.wide(m, w, h, s, l) + else -> MomentLayout.large(m, w, h, s, l) + } + assertNotNull(f.top) + assertEquals(f.top!!.titleSp, f.name.sp, 0f) + Triple(f.fits(h), f.gap(h), f.top!!) + } + } + + @Test fun `the samples are each widget's real look, in the copy rules`() { + assertEquals("78", WidgetSamples.recovery(today).score) + assertEquals("8,432", WidgetSamples.steps(today).stepsText) + assertEquals("82", WidgetSamples.sleep(today, zone).score) + assertEquals("Outdoor run", WidgetSamples.start(Instant.ofEpochSecond(now), zone, Locale.UK).usual.name) + val moment = WidgetSamples.moment(today, zone) + assertEquals("62", moment.number) + val texts = listOfNotNull(moment.name, moment.number) + moment.sentences + moment.figures + moment.words + + WidgetSamples.recovery(today).let { listOfNotNull(it.title, it.word, it.sentence, it.note) } + + WidgetSamples.steps(today).let { listOf(it.title, it.word, it.toGo, it.share, it.weekLine) } + texts.forEach { assertFalse(it, it.any { c -> c in "·;—" }) } + } +} diff --git a/docs/design/widgets/spec.md b/docs/design/widgets/spec.md new file mode 100644 index 00000000..c6ca554b --- /dev/null +++ b/docs/design/widgets/spec.md @@ -0,0 +1,446 @@ +# Widgets: spec + +Status: draft, 2026-10-24. Branch `widgets`. The mockups are the canvas +[OpenStrap widgets, final set](https://claude.ai/artifact/HfG4KPf8jM5nRHpBapK8zu); the explorations before it are in +[OpenStrap widgets](https://claude.ai/artifact/RqHGxrzzdh53FmydVGuVsM). Everything here follows the v2 design system +([../v2/spec.md](../v2/spec.md) section 2) unless it says otherwise. + +## 1. Goal and limits + +Home screen widgets that answer the questions people open the app for, without opening it: how did I sleep, am I +recovered, how far am I on steps, and start my workout. + +- **New feature, after 2.0.** The v2 brief ruled out new features for 2.0. Widgets ship in a later release. +- **No new data.** Widgets read what the app already stores and computes. No algorithm or strap protocol changes. +- **No live numbers.** Nothing on a widget updates faster than the refresh rules in 7. Live heart rate stays in the + workout notification. +- **Same look as the app.** Black, glow and edge light, dots for numbers, the v2 copy rules. + +## 2. The set + +Five widgets. Energy and Strap were explored and dropped (2.3). + +| Widget | Answers | Why it earns a place | +|---|---|---| +| Moment | What matters right now | Follows the day like Today's hero. On launchers without stacks it is the stack. | +| Sleep | How did I sleep | The first thing people check in the morning, and the richest Large | +| Steps | How far am I today | Glanceable all day, the goal bar gives a reason to look again | +| Recovery | Should I train | The same answer in the same spot every day, which Moment can't promise | +| Start a workout | Start my usual | The one action widget: home screen to recording in one tap | + +### 2.1 Every widget has the same three sizes + +Stacks (Samsung One UI, iOS Smart Stack, and any launcher that adds them) give every widget in a stack the stack's +size. So each widget has a layout for each of the same three sizes, and they resize the same way. + +| Size | Cells | Picked from | Drawn at in the mockups | Holds | +|---|---|---|---|---| +| Small | 2 × 2 | Any cell (the smallest) | 173 × 176 dp | A number and a word, or the one action | +| Wide | 4 × 2 | 300 × 160 dp | 358 × 176 dp | The Small plus one more thing: figures, a chart or a row of choices | +| Large | 4 × 3 | 300 × 260 dp | 358 × 272 dp | Everything the widget has | + +- No 4 × 1. One row of text loses too much on Sleep and Start, and stacks of strips are rare. +- "Picked from" is the smallest cell each layout needs (`WidgetSizes`): Glance shows the largest that fits. Launcher + cells run smaller than the mockups (found on the phone, 2026-10-25: One UI's 2 × 2 is 167 to 201 dp wide, its 4 + columns 353 dp), so every layout stretches to its cell and must fit at its minimum. +- Corners 28 dp, padding 20 dp on every side, as every card (spec 2.4). The system's widget corner radius is not used + where the launcher allows it (6.3). + +### 2.2 Tiers and light + +Each widget keeps one look at every size, so a stack that rotates never changes a widget's tier. No two widgets share +a tier and edge side, so two placed side by side never repeat (the rule from v2: never two consecutive cards with the +same design). The launcher decides placement, so this is the only way the rule can hold. + +| Widget | Tier | Light | Mood | +|---|---|---|---| +| Moment | Glow | The moment's light (spec 4.2) | The moment's mood | +| Sleep | Edge | Top, like a night sky | Night, always (as the Sleep tab) | +| Steps | Edge | Bottom, a value | The moment's mood | +| Recovery | Edge | Left, about earlier (this morning) | The moment's mood | +| Start a workout | Edge | Right, toward Start | The moment's mood | + +Moods are spec 2.1: Day 100%, Evening 80%, Night 65% for glows and 80% for edge light. Hairlines stay at full +strength at night. + +### 2.3 Dropped + +- **Energy.** Moment leads with it at midday, in the workout window and in the evening. On its own it is a niche. +- **Strap.** Battery and sync matter only when something is wrong. Every widget carries the strap status button + instead (5). +- **4 × 1 strips** of any widget (2.1). + +## 3. Each widget + +One dotted number per widget at every size (spec 2.3). Dot sizes: 3 dp on a Small, 3.6 dp on a Wide or Large, 4.5 dp +for Moment's number on a Wide or Large. Example values are the mockups'. + +### 3.1 Moment + +Uses `MomentResolver` exactly as Today does. The hero's colour, light angle and strength are spec 4.2's table. + +| Size | Content | +|---|---| +| Small | The moment's name ("Midday") in Sans 500 13 at 60%. Its number and word: "62", "Energy left" | +| Wide | The name, the hero sentence (Display 300 20, two lines at most), the number and word, one figure as Secondary on the right ("Drained 60, recharged 38") | +| Large | The Wide plus the moment's chart, solid, with times as Captions under it | + +What each moment shows: + +| Moment | Number and word | Sentence (Wide, Large) | Figure | Large chart | +|---|---|---|---|---| +| Waking up | Recovery, status word | Today's waking sentence | "Energy at waking 84" | The night's stage strip | +| Midday | Energy, "Energy left" | Today's midday sentence | "Drained 60, recharged 38" | Energy's line since waking | +| Workout window | Energy, "left" | "Ready for effort. You usually run around now." | None. Start button (44 dp, white) instead | Energy's line since waking | +| During a workout | None. The sport, "Recording since 17:42" | None | None | None | +| After a workout | Minutes since, "since your run" | Today's after sentence | Load today | The heart rate drop since | +| Evening | Energy, "left" | Today's evening sentence | "Bedtime in 1 h 10" | The day's heart rate | +| Night | Minutes to bedtime, "min to bedtime" | Today's night sentence | "Bedtime 22:40" | The day's heart rate | + +- **Sentences** come from the same copy functions Today uses (`TodayCopy` and friends), never a second copy path. +- **During a workout** the widget doesn't try to be live. Tapping it opens the live screen. +- **The duplicate rule.** Moment never leads with a metric that has its own widget placed. It falls back to the + moment's next lead: + + | Moment | Leads with | When that widget is placed | + |---|---|---| + | Waking up | Recovery | Last night: sleep score and word, "Slept 7 h 40", Sleep colour lit from above. With a Sleep widget placed too: Energy, as at midday | + | Midday | Energy | No Energy widget exists, no change | + | Workout window | Start | No change, the window's point is Start | + | Evening, Night | A sentence | No change | + + "Placed" means `AppWidgetManager.getAppWidgetIds` returns at least one id for that provider. It can't tell which + home screen page, so anywhere counts. A pure function `momentLead(moment, placed: Set)` decides it, + next to `MomentResolver`. +- Decided in step 5 (2026-10-25): + - A lead with nothing to show gives way to the moment's next one: no sleep last night leads with Energy from + waking with Today's no-sleep sentence (as Today's hero, spec 4.3); waking with no Recovery value (still learning, + no heart data) leads with last night, then Energy; during a workout with nothing recording, or after one with + none to tell, it's midday's. Before the first night Energy waits: the name, then "After your first night". + - Last night's sentence is the Sleep tab's ("7 h 40, your longest this week."), which already says how long you + slept, so its figure is "Woke 3 times" (as the Sleep widget's Large), not "Slept 7 h 40". + - The window's sentence is `windowTitle` and `window` without the Energy part ("Ready for effort. You usually run + around now."), the number being Energy. Its Start (44 dp, white play) sits right of the number on the Wide and + Large and beside it on the Small, and opens Ready with Today's usual sport (started by you over 28 days). + - Evening and night have the evening hero's sentence (Today's night shows the same). Past your usual bedtime the + night shows Energy left with "Bedtime 22:40"; with no usual bedtime known it shows no bedtime at all, never the + 22:00 fallback the resolver uses. + - Counts move on by themselves ("38 min since your run", "30 min to bedtime", "Bedtime in 1 h 10"), so while one + shows, the moment alarm also fires at the next five minute mark on the clock (and at bedtime), whichever comes + first with the moment's end. That's at most 24 refreshes after a workout and about 40 in an evening and night. + These count alarms never wake the phone (`RTC`, not `RTC_WAKEUP`): a count is only worth drawing while the screen + is on, so a tick that falls while it's off waits until it comes on. Only the moment's end wakes it (decided + 2026-10-25). + - During a workout, nothing live as the spec says, but the Wide and Large keep Start's Open pill in the top row, + the sport's tile under it (44 to 72 dp) and its name in the hero's light type (20 to 28 sp), so a tall cell has + no hole. The Small has the name, the tile, the sport and "Since 17:42" with the red dot. + - Charts: Energy's line runs from waking to your usual bedtime (or now, when later), quarter hours, the times + under it waking and bedtime. The heart rate drop runs from the workout's end to the last reading, the day's heart + rate from its first reading; both on an axis fitted to the readings and resting. The stage strip is the Sleep + tab's nap strip (shared geometry, `stageStripSegments`), bedtime to waking under it. All solid white lines ending + in their dot, as the Steps widget's. + - Nothing wraps but the sentence, which takes two lines at most (spec's own rule): 20 sp, then 18, then the dots + at 3.6, then a shorter wording (one of its sentences, last night's without the week's comparison), then left out. + Other texts have options and sizes: "min since your run", "min since", "min"; "Drained 60, recharged 38", then + "Drained 60"; 13 sp then 11 for the name and figure, 15 then 13 for the word. + - Spare height grows the parts first: the Small's dots from the mockup's 3.6 to 7 dp, the Wide's and Large's + sentence to 22 and 24 sp, the line chart from 44 to 72 dp, the stage strip from 8 to 20 dp, the dots from 4.5 to + 7 dp, never at the cost of the number row's word, figure or Start. The rest is shared evenly between the blocks, + half a gap above and below; the least gap is 4 dp so the minimum Wide keeps its sentence at 1.08. + - The tap opens Today at its root on today: `EXTRA_MOMENT` with `"now"` (`MainActivity.MOMENT_NOW`), which + release builds act on too. Any other value is still the debug build's forced moment. + +### 3.2 Sleep + +Last night's main sleep. Night mood always. + +| Size | Content | +|---|---| +| Small | "Last night". Score in dots with its word ("82", "Good"). "7 h 40 asleep". The stage bar, 6 dp | +| Wide | Title row with bedtime to wake as its note ("22:58–06:56"). Score with "Sleep score, good", "7 h 40 asleep" on the right. Stage bar 8 dp with its legend ("Deep 1 h 32", "REM 1 h 41", "Light 4 h 27", "Awake 18 min", 8 dp marks) | +| Large | Title row with bedtime to wake. The Sleep tab's sentence ("7 h 40, your longest this week."). Score with "Sleep score, good" and "Woke 3 times" on the right. Then the time in each stage as rows, as the Sleep score detail lists them: the name, a bar for its share of the night in the stage's colour on a white 10% track, the time on the right ("Deep 1 h 32"). No hypnogram (the user's call, 2026-10-25). The rows replace the stage bar and legend; when they don't fit, the sentence goes first, then the rows give way to the stage bar | + +- Same copy and numbers as the Sleep tab hero (spec 5.2): `SleepCopy`, the stage totals, the longest or shortest + rule. +- Before the first night: the title, then "After your first night" in Secondary, the icon at 30% (spec 2.5 waiting + cards). Before the night is synced in the morning: yesterday's night with "Last synced night" as the note. + +### 3.3 Steps + +| Size | Content | +|---|---| +| Small | "Steps". Steps today in dots, "today". The goal bar, 4 dp. "84% of your goal" | +| Wide | Title row with "Goal 10,000". Steps with "today", "1,568 to go" on the right. Today's steps so far as one solid line from waking to now, ending in the Steps card's end dot. Its top is the goal, so the line fills its height as the day reaches the goal. No goal bar: the line is the progress | +| Large | The Wide plus This week: "This week" and "Goal met 2 of 6 days" on one line (a large font drops "This week", the count never wraps), then a bar per day Monday to Sunday across the width. A met day is Steps lime, a missed day white at 30%, today white, days to come a 4 dp stub at 12% | + +- Steps format with the thousands comma (`Metric.format`). Goal from settings. +- Past the goal: "Goal met" replaces "to go", the line reaches the top. +- When a large font leaves no room for the line, the goal bar shows instead. + +### 3.4 Recovery + +| Size | Content | +|---|---| +| Small | "Recovery". Score in dots, status word ("78", "Recovered") | +| Wide | Title row with "This morning". Score and word. Three figures in a row, Caption over Body: Resting HR "52 bpm", HRV "64 ms", Sleep "82" | +| Large | The Wide plus what the score means, as Today's Recovery card says it (`TodayCopy.recoveryMeaning`: "Recovered enough for your usual training.") and the last seven days as bars beside the score, today in Recovery's ink, the others white at 30% | + +- Score, word and sentence from the Recovery tab (`RecoveryEngine`, its copy). Figures use `Metric.format`. +- Before the morning's score: yesterday's with "Yesterday" as the note. Before the first score ever: "After your + first night", as Sleep. + +### 3.5 Start a workout + +| Size | Content | +|---|---| +| Small | The usual sport's tile (44 dp, 16 dp corners, Control), its name in Body 500 ("Outdoor run"), "Your usual" in Secondary, the white 44 dp play button | +| Wide | A row: tile, name, "Your usual, GPS on" with the 12 dp location mark, play button. Under it the next four sports by use as 38 dp tiles with Captions under them, then All sports | +| Large | The Wide plus a hairline and two figures, Caption over Body: This week "2 workouts, 1 h 35", Last run "Tuesday, 5.2 km" (the usual sport's last) | + +- The usual sport and the next four come from `topSports` (ui/glow/GlowWorkoutMode.kt), with + `Workouts.DEFAULT_SPORTS` before there are enough workouts. GPS on or off is the per sport setting from v2. +- While recording (decided 2026-10-25, replacing a row that left the card empty): the workout's active time is the + hero, a live timer that ticks on its own (a RemoteViews `Chronometer`, h:mm:ss or mm:ss, light system sans in white, + up to 40 sp on the Small, 48 on the Wide, 56 on the Large, stepping down to fit, sized for h:mm:ss from the start). + Its base is the active time at load back from now, pauses left out as the live screen counts them. Paused, it + stands still and the line reads "Paused". Open replaces play, and it and the whole widget open the live screen. + - Small: the sport's tile and Open in the top row, then the timer, the sport's name (short name if needed), then + "Since 11:58" when there's room. + - Wide: the sport's tile, its name over a red dot and "Recording", and Open, then the timer with "Since 11:58" + beside it, else under it, else left out. + - Large: the Wide's, then the hairline and This week and the usual's last, secondary to the timer. + - Start refreshes on a pause and a resume as well as on the start and end. +- No dotted number: this widget has none. +- Decided in step 4 (2026-10-25): + - `Workouts.DEFAULT_SPORTS` is three sports and the Wide needs five, so `Workouts.EVERYDAY` pads the list after it. + - A sport with two sides shows the side Ready will open on (your runs, last done on the treadmill, show + "Treadmill"), each sport once. + - Nothing on this widget wraps (the user's call, 2026-10-25): every text is one line. Captions are the picker's + short names ("Walk" for an indoor walk), the full name only where the short one is already said ("Treadmill" + beside your outdoor runs). The row is always the sports in their order of use, so every size shows the same row + or its start: a caption that won't fit goes to 10 sp, the tiles get smaller, then the row has fewer columns. Only + when not even two fit in order does a sport give its place to the next. A short Wide shrinks the tiles to 32 dp before losing a column, and always keeps the row. + - The tile row spans the content edge to edge: the first tile under the usual's tile, All sports under play, even + room between. Every caption is centred under its own tile, the first and last too, and may run past its tile + into the gap and up to half the side padding, never into a neighbour's caption. At a large font in a short + Wide the captions are the last thing to go, the tiles stay. + - A name too long for its line becomes the short name, and the location mark, then ", GPS on", give way in the + usual row. + - Spare height grows the parts toward the mockup's sizes first (usual tile 48, play up to 52, sport tiles up to + 44, the Small's tile up to 56), then is shared evenly above, between and below the blocks, so a tall cell has no + hole. On the Large the gap above and below is half of those between, as the padding already frames it. Each + caption is drawn in a box as wide as its neighbours leave, never its estimated width. What fits is decided by its + letters' widths plus 15%, so a tight one gives its place rather than being cut. + - The Large's figures sit side by side when both values fit half the width, else a row each (Caption left, value + right), else one under the other. + - The GPS line reads "Your usual, GPS on" with the mark, "Your usual, GPS off" when location is off, and "Your + usual" for a sport without GPS. + - On a Small too narrow for the name beside play, play moves up beside the tile. + - "Last run" is the usual sport's tile ("Last strength", "Last HIIT"), runs and treadmill runs together. Its value + is the day and the distance, else the sets, else how long ("Yesterday, 35 min"), and "None yet" before any. + - This week counts Monday to now as Your week does, without the strap's "Activity" records ("No workouts yet"). + +## 4. Taps + +Every tap opens `MainActivity` with an existing extra. The only new ones are the sync action, opening the sport +picker and opening the Strap page (`EXTRA_OPEN_STRAP`, step 6). + +| Where | Opens | Extra | +|---|---|---| +| Moment, anywhere | Today | `EXTRA_MOMENT` | +| Moment's Start (workout window) | Ready with the usual sport | `EXTRA_READY_SPORT` | +| Moment or Start while recording | The live screen | `EXTRA_SHOW_WORKOUT` | +| Sleep | The Sleep score detail for that night | `EXTRA_OPEN_METRIC`, `EXTRA_METRIC_DAY` | +| Steps | The Steps detail | `EXTRA_OPEN_METRIC` | +| Recovery | The Recovery detail | `EXTRA_OPEN_METRIC` | +| Start's play or a sport tile | Ready with that sport | `EXTRA_READY_SPORT` | +| Start's All sports | Choose a sport | New: an extra that opens Ready with the picker open (`readyPicking`). `EXTRA_SHOW_WORKOUT` opens Ready with the last sport, not the picker | +| The strap status button | Syncs in place, or the Strap page (5) | A Glance `actionRunCallback`, no activity, or `EXTRA_OPEN_STRAP` | + +No carets on widgets: the whole widget is the target. Touch targets stay 44 dp or more. + +## 5. The strap status line + +Any widget, any size, shows the strap's status in its top row when one of these holds, first match wins: + +| State | Words | Icon | Tap | +|---|---|---|---| +| Strap not paired | "Pair your strap" | The strap (Watch), white | Opens the Strap page (`EXTRA_OPEN_STRAP`) | +| Battery 15% or less, not charging | "Strap at 12%" | A low battery, amber | Opens the Strap page | +| Last sync more than 3 hours ago | "Synced 09:10", "Synced yesterday", "Synced Tuesday" within a week, then "Synced 13 Oct" | The sync arrows, amber | Syncs now, the app stays shut | +| That sync running | "Syncing" | An indeterminate spinner | None: a tap is the widget's own | + +- Option C (decided 2026-10-25, replacing step 6's pill, which read long and thin in the title's place): + - The title always stays ("Recovery", "Steps", "Last night", the moment's name). A round 32 dp button sits at + the right end of the title row, its right edge on the content's: a circle in the Control surface Start's tiles + wear, the state's 16 dp icon in it. + - The tap reaches 44 dp, 6 dp past the circle each way. It's a transparent target laid over the card where the + circle is, so the row counts only the circle's 32 dp. + - Wide and Large: the state's words sit just left of the button, Secondary (13 sp) in amber (`MetricColors.warn`, + amber being for the strap and sync only), 8 dp from it. They replace the row's note ("This morning", "Goal + 8,000", "22:58–06:56") while the status shows. + - Small: the button alone. TalkBack reads the whole line on every size. +- One line, never wrapped. The words try shorter wordings in order, all at 13 sp, and if not even the shortest fits + the button stands alone: + - "Pair your strap", "Pair strap" + - "Strap at 12%", "12%" + - "Synced Wednesday", "Synced Wed", "Wednesday", "Wed" + - "Synced yesterday", "Yesterday" + - "Synced 09:10", "09:10" + - "Synced 13 Oct", "13 Oct" +- Beside the button the title takes the first of its sizes that fits (17, 15, 13 sp, and Moment's name 13, 11), and + may step down one size more only to make room for the words. +- Every layout counts the title row at the larger of the title's line and 32 dp while the status shows. +- Start a workout has no title row: + - Wide and Large: while the status shows they get one, "Start a workout" with the button and words at the + right, a block above the usual row in the spare height's share. Where the layout won't fit under it (the + smallest cells at the largest font) the status drops. + - Small (revised on the phone, 2026-10-25): laid out as the other Smalls. The top row has "Start a workout" at + the left, or "Start" when that won't fit beside the button even at 13 sp (a 2 × 2), and the button at the top + right. Then the usual's tile with play right beside it, then the name over "Your usual". The tile and play grow + from 44 dp on a tall cell. Short of room "Your usual" goes, then the title (the button stays), then the tile and + play shrink to 40 and 36 dp. The tile and play never go. Without a status the Small is as 3.5 has it. +- Last sync time is `SettingsStore.lastSync`. Battery and charging are `StrapManager`'s connection state, kept in + settings so a widget can read them with the strap away. When a sync ends the widgets refresh (7). +- Decided in step 6 (2026-10-25), still holding: + - `EXTRA_STRAP_STATE` is the debug strap preview, so the status opens the Strap page with a new + `EXTRA_OPEN_STRAP`. + - While a workout records, Start and Moment show no status: the recording is the news, and the live screen and + its notification show the strap. + - A stale sync needs no event to show: `WidgetUpdater` sets an alarm at the last sync plus 3 hours while a widget + is placed. + - Debug gallery: `--es strap notpaired|low|stale|yesterday|week|syncing|none|auto`. + +## 6. Rendering + +### 6.1 Glance + +- Jetpack Glance (`androidx.glance:glance-appwidget`), one `GlanceAppWidget` and receiver per widget. +- `SizeMode.Responsive(setOf(Small, Wide, Large))` with the three layout sizes in 2.1. The system picks the largest + that fits, so any cell size gets one of the three. +- Provider XML: `targetCellWidth="2" targetCellHeight="2"`, `minWidth`, `minHeight`, `minResizeWidth` and + `minResizeHeight` 110 dp (two cells by the old formula, 70 × 2 − 30, which One UI still uses and which ignores + `targetCell*`: anything larger places as 3 × 3), `maxResizeWidth` 480 and `maxResizeHeight` 400 dp, + `resizeMode="horizontal|vertical"`, the same on all five. +- `widgetCategory="home_screen"`. Lock screen and keyguard are out of scope. + +### 6.2 What Glance can't draw + +RemoteViews has no blur, gradients beyond simple ones, shaders or custom canvases. So: + +- **Light:** each widget's background (glow or edge light, at its mood) is a bitmap drawn with the same light code + the app uses. Extract the pure parts of `GlowStyle` and `EdgeLight` (angle, level, strength, colours) into a + function both Compose and an Android `Canvas` call, so a widget and its card can't drift apart. +- **Dots:** dotted numbers are bitmaps drawn by the same dot geometry as `DotMatrixText` (ui/glow/GlowParts.kt), + shared the same way. Each bitmap carries a content description with the number and word. +- **Charts:** the Energy and Steps lines, the hypnogram, stage bars, week bars: bitmaps from the same code as their + cards, sized to the layout. +- **Text:** Glance `Text` in the system sans, so it scales with the system font size and reads to TalkBack. Funnel + is not used on widgets (decided 2026-10-24, 9.1). Sizes and weights still follow the v2 roles: card title 17 + Medium, hero sentence 20 Light, Body 15, Secondary 13 at 72%, Caption 11 Medium at 50%. +- Glance keeps at most ten children in a Row or Column and drops the rest with only a log line. Group + children (a Column per block) and use padding rather than Spacers in repeated rows. +- Bitmaps are drawn at the widget's actual size and density from `LocalSize`, kept under the RemoteViews bitmap + memory limit (screen width × height × 4 × 1.5 bytes for all of a widget's bitmaps together). + +### 6.3 Corners + +On Android 12 and later, use `system_app_widget_background_radius` only when it is smaller than 28 dp, so the widget +never looks rounder than the launcher's other widgets. Below Android 12 the background bitmap is clipped to 28 dp. + +### 6.4 Previews + +A `previewLayout` per widget for Android 12 and later, and generated previews (`setWidgetPreview`) on Android 15 and +later from the real layout with sample values, so the picker shows the real look. `description` per widget, one line, +in the copy rules. + +Decided in step 6 (2026-10-25): the generated previews are Glance 1.2's `providePreview` with `WidgetSamples` (the +mockups' values through the widgets' own factories), sent by `WidgetUpdater.publishPreviews` once per app version +(the system rate-limits it, so a limited call is tried again on the next start). `previewLayout` is a Small in plain +views (the card and its light as a drawable, the number as light text, Steps' bar a `ProgressBar`, Sleep's stages +weighted views). `previewImage`, for launchers before Android 12, is a PNG of each Small rendered off the device +(dots included, Helvetica Neue standing in for Roboto). + +### 6.5 Spare height (decided in step 6) + +Every layout shares its spare height as Moment's and Start's do: half a gap above and below, a gap between blocks, +and on a tall cell the parts grow first so no gap passes 32 dp on One UI's cells. Recovery's Small has three blocks +(top row, score, word) and its dots grow from 3 to 7 dp; its Wide's and Large's dots grow from 3.6 to 6 and the +Large's sentence to 22 and 24 sp; the Large's week shows only while it fits beside the score. Steps' Small dots grow +from 3 to 7 dp and its line from 52 to 72 dp. Sleep's Small dots grow from 3 to 7 dp and its Wide's from 3.6 to 6. With the strap status showing (5) the title row counts +at the larger of its line and the button's 32 dp, and every size still fits at the minimums and One UI's cells at +font 1.0, 1.08 and 1.3, no gap over 32 dp on One UI. + +## 7. Refresh + +Glance updates are cheap when the app asks for them. The app asks when something on a widget changed, never on a +timer it doesn't need. + +| Trigger | Updates | +|---|---| +| A history sync ends (`HistorySync`) | All five | +| A workout starts or ends (`WorkoutRecorder`, strap auto-detect) | Moment, Start | +| The moment changes | Moment. An exact alarm at the next boundary: add `MomentResolver.nextChange(...)` returning the time the current moment ends | +| Midnight | All five (new day, new goal bar) | +| Steps (`LiveSteps`) | Steps, at most every 15 minutes while the strap is connected. Moment shows no steps figure | +| A widget is added or removed | Moment (the duplicate rule) | +| Strap state changes (paired, battery crosses 15%, charging) | All five, for the status line | +| The last sync turns 3 hours old | All five, for the status line (an alarm, step 6) | +| Mood or settings change (goal, sport GPS) | The widgets that show them | + +- `updatePeriodMillis="0"`. No periodic updates from the system. +- Exact alarms need `SCHEDULE_EXACT_ALARM`, which Android 14 and later deny by default, and `USE_EXACT_ALARM` is + not meant for this. So moment boundaries use `setExactAndAllowWhileIdle` when `canScheduleExactAlarms()` is true + and `setWindow` with a 5 minute window when not. A moment a few minutes late on a widget is fine. The app declares `SCHEDULE_EXACT_ALARM` for this (decided 2026-10-25), which adds an "Alarms and + reminders" entry to the app's system info page. +- One `WidgetUpdater` in `device/` owns all of this. Everything else calls `WidgetUpdater.changed(kinds)`. + +## 8. Copy + +All of v2 section 7: no `·`, `;` or `—`, a real minus sign, `Metric.format` for every number, en dashes only in time +ranges. Widget picker names: "Moment", "Sleep", "Steps", "Recovery", "Start a workout". Descriptions: + +- Moment: "What matters now, changing through your day" +- Sleep: "Last night's score, time asleep and stages" +- Steps: "Steps today and your goal" +- Recovery: "This morning's recovery and what's behind it" +- Start a workout: "Start your usual sport in one tap" + +The punctuation test (v2 8.2) also scans `widget/` and the widget XML. + +## 9. Open questions + +1. **Fonts. Decided 2026-10-24: system sans.** It keeps font scaling and TalkBack, which bitmapped Funnel would + lose. Dotted numbers are bitmaps and unaffected. Revisit only if it looks wrong on the phone in step 7. +2. **Moment's chart at Large in the workout window. Decided 2026-10-25 (step 5): Energy's line**, as 3.1's table + has it. Revisit on the phone. +3. **Pixel Launcher stacks**: whether it has them on the user's phone changes nothing in this spec, but the stack + check in 10 needs a launcher that has them (Samsung One UI, or a test launcher). +4. **Two Recovery widgets or two Moments**: allowed, both show the same. No rule needed unless the phone shows a + problem. + +## 10. Build + +Each step is one or a few commits on `widgets`, with tests, installed over adb and checked on the phone with +screenshots, never during a running workout. + +1. **Shared drawing:** extract the light, dot and chart drawing into functions Compose and `Canvas` both call. Today + and the tabs must look the same before and after (screenshots). +2. **Plumbing:** Glance dependency, `WidgetUpdater`, `MomentResolver.nextChange`, strap state in settings, the sync + callback. No widgets yet. +3. **Recovery widget**, all three sizes, first because it is the simplest. Proves sizes, light, dots and taps. +4. **Steps**, **Sleep**, **Start a workout**, in that order. +5. **Moment**, every moment at every size, and the duplicate rule. +6. **Status line**, previews, descriptions. +7. **Phone pass:** each widget at each size, each mood, in a stack where the launcher has them, side by side, with + the system font at its largest, TalkBack on, strap away, no data yet. + +### 10.1 Tests + +- `momentLead`: every row of 3.1's duplicate table. +- `MomentResolver.nextChange`: every boundary in v2 4.1, overlaps, past midnight. +- Status line: each state, the order they win in, the 15% and 3 hour edges. +- Sizes: every provider declares the same three sizes and the same resize limits. Fails naming the provider. +- One dotted number: every widget layout at every size has at most one dot bitmap. +- Copy: the punctuation test over widget strings. +- Snapshot of each layout at each size with sample data, so changes to shared drawing show up. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5beb5cc..3de7410c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,9 @@ coreKtx = "1.18.0" haze = "2.0.1" window = "1.5.0" maplibre = "13.7.1" +glance = "1.2.0" +# What Glance 1.2.0 brings; named so the app can set WorkManager up itself (widget/WidgetWork.kt). +work = "2.7.1" [libraries] compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -36,6 +39,8 @@ room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } health-connect = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" } +glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } +work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } maplibre = { group = "org.maplibre.gl", name = "android-sdk", version.ref = "maplibre" } coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }