From bddd7f06dc3c1e29f400581e2b701244e12a7a80 Mon Sep 17 00:00:00 2001 From: Sayed Ameer Date: Wed, 28 Oct 2026 21:43:58 +0400 Subject: [PATCH 1/2] Fewer dotted numbers, and the evening hero says each figure once List rows show their value as plain Sans 20 instead of dots (GlowType.rowValue), the Workouts rows on Activity too. Recharged and Drained on Recovery are plain Display 34, since they break down Energy beside them. Recovery and Sleep go from 10 and 8 dotted numbers to 2 each. The spec and design system say list rows never use dots. The evening and night hero says the steps against the goal ("Well short of your step goal") instead of repeating the count shown in dots. Load is only in the sentence, and Energy is left to its card under the hero, unless that card is hidden in Edit Today. Scrubbing the heart rate and breathing lines in the Sleep score detail puts the reading in the line's title row, since the headline has scrolled away by then. Co-Authored-By: Claude Opus 5.5 --- .../openstrap/app/ui/dashboard/TodayCopy.kt | 17 ++++++++++--- .../app/ui/glow/GlowActivityScreen.kt | 2 +- .../org/openstrap/app/ui/glow/GlowList.kt | 2 +- .../app/ui/glow/GlowRecoveryScreen.kt | 2 +- .../openstrap/app/ui/glow/GlowTodayScreen.kt | 24 +++++++++++++------ .../org/openstrap/app/ui/glow/GlowType.kt | 11 ++++++++- .../org/openstrap/app/ui/glow/SleepDetail.kt | 19 +++++++++------ .../org/openstrap/app/ui/glow/TodayCards.kt | 8 ++++++- .../app/ui/dashboard/TodayCopyTest.kt | 11 +++++++++ docs/design/v2/design-system.html | 4 ++-- docs/design/v2/spec.md | 10 ++++---- 11 files changed, 82 insertions(+), 28 deletions(-) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayCopy.kt index 33b1d6fd..0140849c 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayCopy.kt @@ -199,9 +199,12 @@ object TodayCopy { /** * The evening hero: what kind of day it was, then what made it. [workouts] are the day's workouts as short nouns - * (null where none reads well). + * (null where none reads well). Without [stepCount] the steps are said against the goal ([stepsAgainstGoal]), for + * the app's hero, which shows the count in dots under the sentence; the widget keeps the count. */ - fun evening(workouts: List, steps: Int, stepGoal: Int, load: LoadStatus?, locale: Locale = Locale.getDefault()): String { + fun evening( + workouts: List, steps: Int, stepGoal: Int, load: LoadStatus?, locale: Locale = Locale.getDefault(), stepCount: Boolean = true, + ): String { val head = when { workouts.isNotEmpty() && (steps >= stepGoal || load == LoadStatus.Above) -> "A full day." workouts.isEmpty() && steps < stepGoal / 2 -> "A quiet day." @@ -213,7 +216,7 @@ object TodayCopy { 1 -> "a ${workouts[0] ?: "workout"}" else -> "${workouts.size} workouts" }, - "%,d steps".format(locale, steps), + if (stepCount || stepGoal <= 0) "%,d steps".format(locale, steps) else stepsAgainstGoal(steps, stepGoal), when (load) { LoadStatus.Above -> "a Load above your usual" LoadStatus.Below -> "a Load below your usual" @@ -225,6 +228,14 @@ object TodayCopy { return "$head ${list.replaceFirstChar { it.uppercase() }}." } + /** The day's [steps] against the [goal] in words: under half is well short, from 80% close, the goal itself past. */ + fun stepsAgainstGoal(steps: Int, goal: Int): String = when { + steps >= goal -> "past your step goal" + steps >= goal * 0.8 -> "close to your step goal" + steps >= goal / 2 -> "short of your step goal" + else -> "well short of your step goal" + } + /** "Bedtime in 1 h 10" until your usual [bedtime], read on a clock that starts at noon; null once it's passed. */ fun bedtimeIn(now: LocalTime, bedtime: LocalTime?): String? { bedtime ?: return null 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 ea28ad46..5def6aaa 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 @@ -466,7 +466,7 @@ internal fun WorkoutRow(a: WorkoutAnalysis, usual: Float?, divider: Boolean, onC ) } Spacer(Modifier.width(12.dp)) - DotMatrixText(workoutClock(a.activeSeconds), dot = DotSize.Row, color = Color.White) + Text(workoutClock(a.activeSeconds), color = Color.White, style = GlowType.rowValue, maxLines = 1) Spacer(Modifier.width(RowCaretGap)) TapCaret(ROW_CARET_ALPHA) } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowList.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowList.kt index 7ad1940d..6fc338b2 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowList.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowList.kt @@ -184,7 +184,7 @@ fun ListRow( } Spacer(Modifier.width(12.dp)) if (value != null) CompositionLocalProvider(LocalCardNumbers provides opening?.numbers) { - DotMatrixText(value, Modifier.cardNumber(value), dot = DotSize.Row, color = accent) + Text(value, Modifier.cardNumber(value), color = accent, style = GlowType.rowValue, maxLines = 1) } else if (dash) WaitingDash() if (onClick != null) { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowRecoveryScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowRecoveryScreen.kt index 12ccd78e..aa60fc22 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowRecoveryScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowRecoveryScreen.kt @@ -300,7 +300,7 @@ private fun ChargeSmall(c: Recovery.ChargeDay?, recharged: Boolean, modifier: Mo recharged -> TabCopy.RECHARGED else -> TabCopy.drainedMostly(c!!.drainedExercise, c.drainedStress, c.drainedLiving) } - SmallCard(MetricColors.charge, if (recharged) "Recharged" else "Drained", value, null, status, modifier) + SmallCard(MetricColors.charge, if (recharged) "Recharged" else "Drained", value, null, status, modifier, plain = true) } /** Averages charge samples into evenly spaced buckets across [range]; empty buckets are gaps. */ 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 eaab440c..aae8b5b1 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 @@ -241,6 +241,7 @@ fun GlowTodayScreen( running, onOpenRunning, onResumeRunning, onStartSport, onOtherSport, onPair, onSyncNow, onOpenRecovery, onOpenWorkout, onRetryRecovery, onOpenScore = { onOpen(org.openstrap.app.ui.dashboard.Detail.OfScore(it)) }, + energyHidden = slots.none { it.card == TodayCard.Charge }, ) TodayCard.EditToday -> EditTodayButton({ editing = true }) else -> TodayRow(slot, ctx, healthspan, history, checkIn, onCheckIn, onOpenMetric, @@ -369,6 +370,8 @@ private fun Hero( * heroes, as the Charge battery on Recovery does. */ onOpenScore: (org.openstrap.app.ui.dashboard.Score) -> Unit, + /** The Energy card is hidden in Edit Today, so the evening hero says Energy itself. */ + energyHidden: Boolean = false, ) { val onOpenCharge = { onOpenScore(org.openstrap.app.ui.dashboard.Score.Charge) } // Every hero is as high as its content with the card padding around it. The floor is only a Wide's height, under @@ -405,7 +408,7 @@ private fun Hero( HeroKind.NoSleep -> ChargeHero(ctx, onOpenCharge, onRetryRecovery, m, noSleep = true) HeroKind.Window -> WindowHero(ctx, lightAngle, onStartSport, onOtherSport, m) HeroKind.After -> AfterHero(ctx, lightAngle, onOpenWorkout, m) - HeroKind.Evening -> EveningHero(ctx, lightAngle, bedtime, onOpenRecovery, m) + HeroKind.Evening -> EveningHero(ctx, lightAngle, bedtime, energyHidden, onOpenRecovery, m) HeroKind.During -> (running ?: lastRunning.value)?.let { DuringHero(ctx, it, onOpenRunning, onResumeRunning, m) } } } @@ -594,12 +597,13 @@ private fun AfterHero(ctx: TodayContext, lightAngle: Float?, onOpenWorkout: (Lon } /** - * Evening and night: the day in a sentence, the steps in dots with Load and Charge left as text beside them, and how + * Evening and night: the day in a sentence (Load in it), the steps in dots, Energy left under them only when + * [energyHidden] (its card, normally right under the hero, is hidden in Edit Today), and how * long until your usual bedtime. Evening violet from the lower right in the evening, Sleep blue from below at night * (spec 4.2), each in its mood. */ @Composable -private fun EveningHero(ctx: TodayContext, lightAngle: Float?, bedtime: LocalTime?, onOpen: () -> Unit, modifier: Modifier) { +private fun EveningHero(ctx: TodayContext, lightAngle: Float?, bedtime: LocalTime?, energyHidden: Boolean, onOpen: () -> Unit, modifier: Modifier) { val goals = LocalGoals.current val nouns = ctx.dayWorkouts.map { sportNoun(it.workout.sportType) } val night = ctx.moment == Moment.Night @@ -608,11 +612,17 @@ private fun EveningHero(ctx: TodayContext, lightAngle: Float?, bedtime: LocalTim GlowCard(if (night) style else style.copy(bodyLightness = EVENING_BODY), modifier, onClick = onOpen) { Column { GlowHeader("Today", trailing = TodayCopy.bedtimeIn(LocalTime.now(), bedtime), marker = ctx.upTo(ctx.newest), tag = ctx.partial) - HeroSentence(TodayCopy.evening(nouns, ctx.state.steps, goals.steps, ctx.loadStatus)) + // The count is in dots below, so the sentence says the steps against the goal instead. + HeroSentence(TodayCopy.evening(nouns, ctx.state.steps, goals.steps, ctx.loadStatus, stepCount = false)) Spacer(Modifier.height(24.dp)) - // Figures it doesn't have yet are left out, never a lone dash in the hero. - val rest = listOfNotNull(ctx.rec?.load?.let { "Load ${Math.round(it)}" }, ctx.charge?.let { "Energy $it left" }) - HeroNumber("%,d".format(ctx.state.steps), listOfNotNull("steps", rest.joinToString(", ").ifEmpty { null })) + HeroNumber("%,d".format(ctx.state.steps), listOf("steps")) + // Load is said in the sentence, and Energy has its own card right under the hero. Only with that card + // hidden in Edit Today does Energy come back here, last and full width, never a lone dash. + val energy = ctx.charge?.takeIf { energyHidden } + if (energy != null) Text( + "Energy $energy left", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, + maxLines = 1, modifier = Modifier.padding(top = 10.dp), + ) } } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowType.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowType.kt index 5528a32f..77ed1289 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowType.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowType.kt @@ -54,6 +54,15 @@ object GlowType { /** White for [caption] text. */ const val CAPTION_ALPHA = 0.5f + /** A list row's value: Sans 500, 20 on 24, tabular so a column of values lines up. Plain text, never dots. */ + val rowValue = TextStyle(fontFamily = FunnelSans, fontWeight = FontWeight.Medium, fontSize = 20.sp, lineHeight = 24.sp, fontFeatureSettings = "tnum") + + /** + * A Small that breaks down the card beside it (Recharged, Drained): Display 300, 34 on 36, tabular. Plain, since + * the dotted answer is the other card's. + */ + val smallValue = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Light, fontSize = 34.sp, letterSpacing = (-0.02).em, lineHeight = 36.sp, fontFeatureSettings = "tnum") + @Deprecated("Not a role in spec 2.3. Workout pages move to pageTitle (step 9).") val headline = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Light, fontSize = 26.sp, letterSpacing = (-0.02).em, lineHeight = 30.sp) @@ -69,7 +78,7 @@ object GlowType { * the same kind of number is the same size on every screen. */ enum class DotSize(val dot: Dp) { - /** List row values. */ + /** Four or more figures side by side ([FigureRow]). List rows are plain text ([GlowType.rowValue]). */ Row(2.4.dp), /** Edge cards and Smalls. */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/SleepDetail.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/SleepDetail.kt index 00119203..910e6d62 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/SleepDetail.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/SleepDetail.kt @@ -84,10 +84,12 @@ private fun Caption(text: String, modifier: Modifier = Modifier) = /** A chart's title inside a detail's body, with an optional [note] on the right. */ @Composable -private fun ChartTitle(title: String, note: String?, modifier: Modifier = Modifier) { +private fun ChartTitle(title: String, note: String?, modifier: Modifier = Modifier, scrubbed: DetailHeadline? = null) { Row(modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { Text(title, color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) - if (note != null) Text(note, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + // While the line is scrubbed, its reading takes the note's place, in white so it reads as the live value. + if (scrubbed != null) Text("${scrubbed.value} ${scrubbed.label}", color = Color.White, style = GlowType.secondary, maxLines = 1) + else if (note != null) Text(note, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) } } @@ -234,7 +236,8 @@ private fun StageBars(b: Summaries.SleepBreakdown, modifier: Modifier = Modifier /** * The night's heart rate and breathing as lines with scrub (chart kit), each scrub moving the detail's headline - * ([onScrub], null on release). Either is left out without readings. + * ([onScrub], null on release). Either is left out without readings. They sit below the fold, where the headline has + * scrolled away, so a scrub also puts the reading in the line's own title row. */ @Composable private fun NightLines(state: DayUiState, window: LongRange, onScrub: (DetailHeadline?) -> Unit) { @@ -242,14 +245,16 @@ private fun NightLines(state: DayUiState, window: LongRange, onScrub: (DetailHea val breath = state.breathRate.filter { it.x in window } if (hr.isEmpty() && breath.isEmpty()) return val labels = listOf(clock(window.first), clock(window.last)) + var hrAt by remember(window) { mutableStateOf(null) } + var breathAt by remember(window) { mutableStateOf(null) } Column { if (hr.isNotEmpty()) { - ChartTitle("Heart rate", "Lowest ${hr.minOf { it.y }.toInt()}, average ${hr.map { it.y }.average().toInt()} bpm") - ScrubLine(hr, window, 130.dp, labels, MetricColors.heart, "bpm") { onScrub(it) } + ChartTitle("Heart rate", "Lowest ${hr.minOf { it.y }.toInt()}, average ${hr.map { it.y }.average().toInt()} bpm", scrubbed = hrAt) + ScrubLine(hr, window, 130.dp, labels, MetricColors.heart, "bpm") { hrAt = it; onScrub(it) } } if (breath.isNotEmpty()) { - ChartTitle("Breathing rate", "Average %.1f br/min".format(breath.map { it.y }.average())) - ScrubLine(breath, window, 100.dp, labels, MetricColors.sleepLight, "br/min", decimals = 1) { onScrub(it) } + ChartTitle("Breathing rate", "Average %.1f br/min".format(breath.map { it.y }.average()), scrubbed = breathAt) + ScrubLine(breath, window, 100.dp, labels, MetricColors.sleepLight, "br/min", decimals = 1) { breathAt = it; onScrub(it) } } } } 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 d9172082..078401df 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 @@ -536,6 +536,8 @@ internal fun SmallCard( waiting: Boolean = false, /** How far toward its goal or usual, 0..1, for a [EdgeKind.Progress] Small's bar ([EdgeCard]). */ progress: Float? = null, + /** The number as plain text ([GlowType.smallValue]), for a Small that breaks down the card beside it. */ + plain: Boolean = false, ) { EdgeCard(color, modifier, status = edgeStatus, kind = kind, progress = progress, onClick = onClick) { CardTitle(title) @@ -546,7 +548,11 @@ internal fun SmallCard( return@EdgeCard } Row(verticalAlignment = Alignment.Bottom) { - if (value != null) DotMatrixText(value, Modifier.cardNumber(value), dot = DotSize.Edge) else WaitingDash(Modifier.padding(vertical = 8.dp)) + when { + value == null -> WaitingDash(Modifier.padding(vertical = 8.dp)) + plain -> Text(value, Modifier.cardNumber(value), color = Color.White, style = GlowType.smallValue, maxLines = 1) + else -> DotMatrixText(value, Modifier.cardNumber(value), dot = DotSize.Edge) + } if (unit != null && value != null) Text(unit, color = Color.White.copy(alpha = 0.7f), style = GlowType.secondary, modifier = Modifier.padding(start = 6.dp)) } extra?.invoke() diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayCopyTest.kt index 670e2aa2..653727b6 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayCopyTest.kt @@ -115,6 +115,9 @@ class TodayCopyTest { assertEquals("A quiet day. 2,100 steps and a Load below your usual.", TodayCopy.evening(emptyList(), 2_100, 8_000, LoadStatus.Below, Locale.UK)) assertEquals("A steady day. 2 workouts and 6,000 steps.", TodayCopy.evening(listOf("run", null), 6_000, 8_000, null, Locale.UK)) assertEquals("A steady day. A workout and 6,000 steps.", TodayCopy.evening(listOf(null), 6_000, 8_000, LoadStatus.SoFar, Locale.UK)) + // The app's hero says the steps against the goal, since it shows the count in dots. + assertEquals("A quiet day. Well short of your step goal and a Load below your usual.", TodayCopy.evening(emptyList(), 2_100, 8_000, LoadStatus.Below, Locale.UK, stepCount = false)) + assertEquals("A full day. A run, past your step goal and a Load above your usual.", TodayCopy.evening(listOf("run"), 13_860, 8_000, LoadStatus.Above, Locale.UK, stepCount = false)) } @Test fun `bedtime counts down and stops once passed`() { @@ -193,4 +196,12 @@ class TodayCopyTest { ) lines.forEach { l -> listOf("·", ";", "—").forEach { assertFalse("$l has $it", l.contains(it)) } } } + + @Test + fun `steps against the goal read as words`() { + assertEquals("well short of your step goal", TodayCopy.stepsAgainstGoal(3_999, 8_000)) + assertEquals("short of your step goal", TodayCopy.stepsAgainstGoal(4_000, 8_000)) + assertEquals("close to your step goal", TodayCopy.stepsAgainstGoal(6_400, 8_000)) + assertEquals("past your step goal", TodayCopy.stepsAgainstGoal(8_000, 8_000)) + } } diff --git a/docs/design/v2/design-system.html b/docs/design/v2/design-system.html index a264c977..0a64999e 100644 --- a/docs/design/v2/design-system.html +++ b/docs/design/v2/design-system.html @@ -412,11 +412,11 @@

Levels: the light rises to the value

This week, goal 150SecondarySans 400, 13 on 18, at 72%. Status lines, notes, the title row's note. 12.5
00  06  12  18  24CaptionSans 500, 11 on 14, at 50%, tabular figures. Axes, times, tags.
-
  • One dotted number per card. A card has at most one number in dots, its answer. Times, ranges, totals and second figures are text in Body or Secondary. A list has one per row.
+
  • One dotted number per card. A card has at most one number in dots, its answer. Times, ranges, totals and second figures are text in Body or Secondary. List rows never use dots: a row's value is plain Sans 500, 20 on 24, tabular. A Small that breaks down the card beside it (Recharged, Drained) is plain Display 300, 34.

Dot sizes

Six dot sizes, each tied to where the number sits. The gap between dots is 0.38 of the dot. The build uses eight sizes today (1.7 to 9 dp). Drawn here with Doto, the app draws its own.

-
482.4 dp, list row
+
482.4 dp, four or more figures
523 dp, edge card, Small
743.6 dp, Tall, second glow
784.5 dp, hero
diff --git a/docs/design/v2/spec.md b/docs/design/v2/spec.md index 962ebc49..22d6490a 100644 --- a/docs/design/v2/spec.md +++ b/docs/design/v2/spec.md @@ -160,13 +160,15 @@ steps with the thousands comma ("8,432", the dot font has the comma), breathing Breathing disturbances are "an hour" on the Wide and the Small. **One dotted number per card.** A card has at most one number in dots, its answer. Times, ranges, totals and second -figures are text in Body or Secondary. A list has one per row. This holds everywhere: the sleep hero (5.2), the Sleep +figures are text in Body or Secondary. List rows never use dots: a row's value is plain Row value text (Sans 500, +20 on 24, tabular), and a Small that breaks down the card beside it (Recharged, Drained) is plain Display 300, 34. +This holds everywhere: the sleep hero (5.2), the Sleep score detail, Your week (steps a day in dots, sleep and workouts as text under it), the workout pages (6.3). Six dot sizes, the gap between dots 0.38 of the dot: | Dot | Where | |---|---| -| 2.4 dp | List row values | +| 2.4 dp | Four or more figures side by side (`FigureRow`) | | 3 dp | Edge cards, Smalls | | 3.6 dp | Talls, second glow, glow cards that aren't the hero | | 4.5 dp | The hero's number | @@ -283,7 +285,7 @@ Static. The edge only changes when the data changes. optional note in Secondary right). `ListRow`: the metric's **icon**, 20 dp, stroke 1.5, in the metric's ink, then 14 dp to the name (Body) with its -status (Secondary) under it, the value in 2.4 dp dots right-aligned in its own column, and the caret when the row +status (Secondary) under it, the value as plain Row value text right-aligned in its own column, and the caret when the row opens something. The name stays white, and on one line in every list row (a settings row on You and Strap too): where a row's name and the value on its right don't both fit, as in a narrow list beside a pane, the value drops under the name in Secondary, ellipsized only as the last resort. The @@ -587,7 +589,7 @@ Rows in order. Sizes per 2.4, tiers per 2.5. | Midday | Energy: sentence, value with `lightLevel`, recharged and drained | Strip: Last night collapsed. Edge: Steps (ghost line), Heart rate. List "Right now" (stress, HRV). Edge: Recovery (left edge) | | Workout window | "Ready for effort. You usually run around now." Start button, Other sport | Second glow: Energy (wide), a level glow, the light filling across from the left to the value (Recovery's battery on its side). List "Before you start" (Load today, time to recover). Edge: Steps. Strip: Last night | | After a workout | "Since your run, 40 min. Heart rate settling." Drop curve into your resting range (a dim band), calm glow | Edge: the workout (left, with its zone bar). Small pair: Load (from below, its bar filling toward your usual day's Load) and To recover (flips to the top). Second glow: Energy, as in the workout window, with its line so far. Edge: Steps | -| Evening | "A full day. A run, 13,860 steps and a Load above your usual." Steps, Load, Energy left. "Bedtime in 1 h 10". Dim mood | Second glow: Energy, as in the workout window. Edge: Steps (line so far), Heart rate (the day's line, flips to the top under Steps unless live). Strip: Last night | +| Evening | "A full day. A run, past your step goal and a Load above your usual." The steps said against the goal, since the count is in dots under it. Load only in the sentence and Energy only on its card right under the hero; with that card hidden in Edit Today, "Energy 31 left" as the last line. "Bedtime in 1 h 10". Dim mood | Second glow: Energy, as in the workout window. Edge: Steps (line so far), Heart rate (the day's line, flips to the top under Steps unless live). Strip: Last night | | Night | As evening, Night mood | As evening | | During a workout | The workout, live heart rate | As midday without Heart rate (the hero has it) | From 5b13c736c289be813cc90bc9881388a25ca66bc1 Mon Sep 17 00:00:00 2001 From: Sayed Ameer Date: Thu, 29 Oct 2026 20:08:43 +0400 Subject: [PATCH 2/2] Calmer Today and Activity, Health alerts as dropdowns, pages grow from their button - Today: only cards whose light means something stay lit; Last night, Your week and Start a workout are unlit, and the evening Steps card drops its repeated count. - Activity: Start, Steps, the pair, Load, the Today list, Heart rate, then Workouts unlit. Start a workout has a slow moving border on Today and Activity. - "Found by strap" is now "Auto-detected". Habit Age shows your age until it's learned. - Hugeicons for All sports, run, walk, strength, lap, search, location, sync, find strap and restart; the hand-drawn sport icons are gone. - Shorter Phone alerts copy. Health alerts is four rows, each with a menu of the strap's values, and the measuring note only when it turns the alerts off. - The strap card has even padding with the strap centred. - You and Strap grow out of their button as a container transform: the page scales with its outline, keeps round corners until the end, fades its content in, and the tab behind dims and sinks. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/org/openstrap/app/MainActivity.kt | 7 +- .../openstrap/app/ui/alarms/AlarmsScreen.kt | 54 ++++---- .../app/ui/alarms/PhoneAlertsScreen.kt | 9 +- .../app/ui/components/GrowFromButton.kt | 130 ++++++++++++++---- .../openstrap/app/ui/components/PullToSync.kt | 4 +- .../openstrap/app/ui/dashboard/CardLooks.kt | 3 +- .../org/openstrap/app/ui/dashboard/TabCopy.kt | 5 +- .../openstrap/app/ui/dashboard/TabPlans.kt | 18 +-- .../openstrap/app/ui/dashboard/TodayLooks.kt | 14 +- .../openstrap/app/ui/device/DeviceScreen.kt | 67 ++++----- .../org/openstrap/app/ui/glow/EdgeCard.kt | 20 ++- .../app/ui/glow/GlowActivityScreen.kt | 11 +- .../openstrap/app/ui/glow/GlowHealthspan.kt | 38 +++-- .../openstrap/app/ui/glow/GlowTodayScreen.kt | 12 +- .../org/openstrap/app/ui/glow/MovingBorder.kt | 83 +++++++++++ .../org/openstrap/app/ui/glow/PageParts.kt | 89 ++++++++++-- .../org/openstrap/app/ui/glow/TabParts.kt | 8 +- .../org/openstrap/app/ui/glow/TodayCards.kt | 14 +- .../org/openstrap/app/ui/theme/AppIcons.kt | 22 ++- .../openstrap/app/ui/workout/SportIcons.kt | 47 ++----- .../org/openstrap/app/widget/StartWidget.kt | 2 +- .../org/openstrap/app/widget/WidgetParts.kt | 2 +- .../org/openstrap/app/ui/FrameRulesTest.kt | 20 ++- .../openstrap/app/ui/dashboard/TabCopyTest.kt | 7 +- .../app/ui/dashboard/TabPlansTest.kt | 4 +- .../app/ui/dashboard/TodayLooksTest.kt | 20 ++- docs/design/v2/spec.md | 46 +++++-- 27 files changed, 533 insertions(+), 223 deletions(-) create mode 100644 app/src/main/kotlin/org/openstrap/app/ui/glow/MovingBorder.kt diff --git a/app/src/main/kotlin/org/openstrap/app/MainActivity.kt b/app/src/main/kotlin/org/openstrap/app/MainActivity.kt index 379d6a6d..c7537783 100644 --- a/app/src/main/kotlin/org/openstrap/app/MainActivity.kt +++ b/app/src/main/kotlin/org/openstrap/app/MainActivity.kt @@ -83,6 +83,7 @@ import org.openstrap.app.ui.components.Motion import org.openstrap.app.ui.components.ListDetail import org.openstrap.app.ui.components.SideBySideWidth import androidx.compose.foundation.pager.HorizontalPager +import org.openstrap.app.ui.components.sinkBehind import androidx.compose.foundation.pager.rememberPagerState import org.openstrap.app.ui.theme.OpenStrapTheme import org.openstrap.app.ui.theme.AppIcons @@ -493,6 +494,8 @@ private fun AppRoot( // The first page over the tab grows out of the header button tapped for it, if one was (spec 3.5). val growOrigin = remember { org.openstrap.app.ui.components.GrowOrigin() } var growFrom by remember { mutableStateOf(null) } + // How far that page is open, for the tab behind it to sink as it comes forward. + val pageGrow = org.openstrap.app.ui.components.rememberGrowProgress(pages.isNotEmpty()) val openPage: (Page) -> Unit = { p -> val tapped = growOrigin.take() if (pages.isEmpty()) growFrom = tapped @@ -919,7 +922,8 @@ private fun AppRoot( // Every tab stays composed, so a swipe never builds a whole dashboard mid-gesture (with only the neighbours // kept, crossing halfway built the next one); glows on tabs off screen, or while the pager moves, hold still. HorizontalPager( - pager, androidx.compose.ui.Modifier.padding(start = if (rail) org.openstrap.app.ui.components.NavRailWidth else 0.dp).hazeSource(navGlass), + pager, androidx.compose.ui.Modifier.padding(start = if (rail) org.openstrap.app.ui.components.NavRailWidth else 0.dp) + .sinkBehind(pageGrow).hazeSource(navGlass), userScrollEnabled = !subScreenOpen, key = { it }, beyondViewportPageCount = Tab.entries.size - 1, ) { page -> androidx.compose.runtime.CompositionLocalProvider( // Remembered: a new lambda each time would be a new static local, recomposing the whole tab under it. @@ -1030,6 +1034,7 @@ private fun AppRoot( org.openstrap.app.ui.components.GrowFromButton( pages.isNotEmpty(), growFrom, androidx.compose.ui.Modifier.fillMaxSize().padding(start = if (rail) org.openstrap.app.ui.components.NavRailWidth else 0.dp), + progress = pageGrow, ) { closing -> androidx.compose.foundation.layout.Box( androidx.compose.ui.Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).hazeSource(navGlass, zIndex = 1f), ) { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmsScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmsScreen.kt index 468ddbd7..629c1d5d 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmsScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.Color -import org.openstrap.app.ui.glow.ChoicePills import org.openstrap.app.ui.glow.DestructiveInk import org.openstrap.app.ui.glow.GlowLayout import org.openstrap.app.ui.glow.GlowType @@ -37,6 +36,7 @@ import org.openstrap.app.ui.glow.PageConfirm import org.openstrap.app.ui.glow.PageSheet import org.openstrap.app.ui.glow.PageTextButton import org.openstrap.app.ui.glow.PageHeader +import org.openstrap.app.ui.glow.PageMenuRow import org.openstrap.app.ui.glow.PageNote import org.openstrap.app.ui.glow.PagePanel import org.openstrap.app.ui.dashboard.ScreenList @@ -197,39 +197,43 @@ private fun AlarmsPanel( @Composable private fun HeartAlertsPanel(state: AlarmsUiState, vm: AlarmsViewModel) { val g = state.health ?: return + val alerts = state.alertsAvailable + val off: (Int) -> Boolean = { it == 0 } PagePanel(title = "Heart rate and blood oxygen") { - g[SettingsCodec.KEY_HR_INTERVAL]?.let { s -> - ChoiceRow("Heart rate measuring", s.choices.orEmpty(), s.number?.toInt(), state.busy, ::intervalLabel) { vm.setHrInterval(it) } - } - val alerts = state.alertsAvailable - if (!alerts) PageNote("Heart rate alerts need continuous or every-minute measuring.") - g[SettingsCodec.KEY_HIGH_HR_ALERT]?.let { s -> - ChoiceRow("High heart rate alert", s.choices.orEmpty(), s.number?.toInt(), state.busy || !alerts, { if (it == 0) "Off" else "Above $it" }) { - vm.setEnum(SettingsCodec.KEY_HIGH_HR_ALERT, it) + // Rows with a menu each, in place of 21 chips; the measuring note only when it turns the heart rate alerts off. + Column { + var first = true + fun divider() = !first.also { first = false } + g[SettingsCodec.KEY_HR_INTERVAL]?.let { s -> + PageMenuRow( + "Heart rate measuring", s.choices.orEmpty(), s.number?.toInt(), ::intervalLabel, { vm.setHrInterval(it) }, + divider = divider(), enabled = !state.busy, quiet = off, + note = if (alerts) null else "Heart rate alerts need Continuous or every minute.", + ) } - } - g[SettingsCodec.KEY_LOW_HR_ALERT]?.let { s -> - ChoiceRow("Low heart rate alert", s.choices.orEmpty(), s.number?.toInt(), state.busy || !alerts, { if (it == 0) "Off" else "Below $it" }) { - vm.setEnum(SettingsCodec.KEY_LOW_HR_ALERT, it) + g[SettingsCodec.KEY_HIGH_HR_ALERT]?.let { s -> + PageMenuRow( + "High heart rate alert", s.choices.orEmpty(), s.number?.toInt(), { if (it == 0) "Off" else "Above $it" }, + { vm.setEnum(SettingsCodec.KEY_HIGH_HR_ALERT, it) }, divider = divider(), enabled = !state.busy && alerts, quiet = off, + ) } - } - g[SettingsCodec.KEY_LOW_SPO2_ALERT]?.let { s -> - ChoiceRow("Low blood oxygen alert", s.choices.orEmpty(), s.number?.toInt(), state.busy, { if (it == 0) "Off" else "Below $it%" }) { - vm.setEnum(SettingsCodec.KEY_LOW_SPO2_ALERT, it) + g[SettingsCodec.KEY_LOW_HR_ALERT]?.let { s -> + PageMenuRow( + "Low heart rate alert", s.choices.orEmpty(), s.number?.toInt(), { if (it == 0) "Off" else "Below $it" }, + { vm.setEnum(SettingsCodec.KEY_LOW_HR_ALERT, it) }, divider = divider(), enabled = !state.busy && alerts, quiet = off, + ) + } + g[SettingsCodec.KEY_LOW_SPO2_ALERT]?.let { s -> + PageMenuRow( + "Low blood oxygen alert", s.choices.orEmpty(), s.number?.toInt(), { if (it == 0) "Off" else "Below $it%" }, + { vm.setEnum(SettingsCodec.KEY_LOW_SPO2_ALERT, it) }, divider = divider(), enabled = !state.busy, quiet = off, + ) } } PageNote("The strap vibrates when an alert goes off. OpenStrap isn't a medical device.") } } -@Composable -private fun ChoiceRow(title: String, choices: List, current: Int?, disabled: Boolean, label: (Int) -> String, onPick: (Int) -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(title, style = GlowType.body, color = Color.White) - ChoicePills(choices, current, label, onPick, enabled = !disabled) - } -} - /** * A new alarm or one being changed, in a sheet: the time, the days as chips, then Cancel and Save, with Delete as quiet * text under them. The time wheel is Material's clock, recoloured to white on the sheet. diff --git a/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsScreen.kt index f945e09a..05e56e72 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsScreen.kt @@ -92,7 +92,7 @@ fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPaddi item { PagePanel { PageSwitchRow( - "Incoming calls", "The strap buzzes while your phone rings, and for calls from apps like WhatsApp with notification access. With Do Not Disturb on, only if it allows calls from anyone.", + "Incoming calls", "Buzzes while your phone rings. App calls like WhatsApp need notification access.", calls && callPermission, { on -> when { @@ -117,12 +117,12 @@ fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPaddi callPreviewError?.let { PageNote(it, color = MetricColors.warn) } } } - PageSwitchRow("Notifications", "The strap buzzes for notifications that would make a sound.", notifications && notificationAccess, { on -> + PageSwitchRow("Notifications", "Buzzes for notifications that make a sound.", notifications && notificationAccess, { on -> scope.launch { settings.setBuzzOnNotifications(on) } if (on && !notificationAccess) openNotificationAccess() }) if (notifications && !notificationAccess) { - PageNote("Allow notification access for OpenStrap to turn this on.", color = MetricColors.warn) + PageNote("Needs notification access.", color = MetricColors.warn) PageButton("Open settings", openNotificationAccess, Modifier.fillMaxWidth()) } if (notifications && notificationAccess) { @@ -139,7 +139,8 @@ fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPaddi previewError?.let { PageNote(it, color = MetricColors.warn) } } } - PageNote("Silent apps and Do Not Disturb are followed. OpenStrap never reads what a notification says.") + // Do Not Disturb is said once, here, for calls and notifications both. + PageNote("Follows Do Not Disturb and silent apps. Never reads what a notification says.") } } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/components/GrowFromButton.kt b/app/src/main/kotlin/org/openstrap/app/ui/components/GrowFromButton.kt index 4bb5c62a..d48f8091 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/components/GrowFromButton.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/components/GrowFromButton.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -25,10 +26,16 @@ import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp /* * Avatar to You, strap chip to Strap (spec 3.5): the page over the tab grows out of the round button tapped, and - * closing it shrinks it back in. The same springs as card to detail, 500 in and 300 out, with no bounce. + * closing it shrinks it back in, as a container transform. The same springs as card to detail, 500 in and 300 out, + * with no bounce. */ /** @@ -59,55 +66,124 @@ fun growClick(onClick: () -> Unit): GrowClick { } } -/** How far a page has grown out of its button: its clip at [progress], from the button's [from] to [page]. */ +/** + * How far a page has grown out of its button, as a container transform: the outline goes from the button to the page, + * the page's content scales with it (to the outline's width) and is clipped to it, and the tab behind dims and sinks. + */ object GrowMotion { - /** By this much of the way the page is fully opaque: it fades in over the start of its growth only. */ - const val FADE_PART = 0.25f + /** The surface comes up over this first bit of the way, so the button turns into it rather than flashing. */ + const val SURFACE_PART = 0.06f + /** The content fades in over this stretch, once the outline has some size to show it in. */ + const val CONTENT_FROM = 0.15f + const val CONTENT_TO = 0.45f + /** The outline keeps a card's round corners until this far, then squares off to the screen. */ + const val SQUARE_FROM = 0.85f + /** The tab behind at the page fully open: dimmed by this much black, and scaled down to this. */ + const val SCRIM = 0.5f + const val SINK = 0.94f - /** The page's outline at [progress] (0 the button, 1 the page): the rect between them, round as the button at the start. */ - fun outline(from: Rect, page: Rect, progress: Float): RoundRect { + /** + * The page's outline at [progress] (0 the button, 1 the page): the rect between them, round as the button at the + * start, easing to [cornerRadius] (a card's) on the way and square only at the end. + */ + fun outline(from: Rect, page: Rect, progress: Float, cornerRadius: Float = 0f): RoundRect { val p = progress.coerceIn(0f, 1f) val r = lerp(from, page, p) - val radius = from.minDimension / 2f * (1f - p) - return RoundRect(r, CornerRadius(radius)) + val button = from.minDimension / 2f + val radius = if (p < SQUARE_FROM) button + (cornerRadius - button) * (p / SQUARE_FROM) + else cornerRadius * (1f - (p - SQUARE_FROM) / (1f - SQUARE_FROM)) + return RoundRect(r, CornerRadius(radius.coerceAtLeast(0f))) } - fun alpha(progress: Float): Float = (progress / FADE_PART).coerceIn(0f, 1f) + /** The page's surface: up at once, so it reads as the button growing. */ + fun alpha(progress: Float): Float = (progress / SURFACE_PART).coerceIn(0f, 1f) + + /** The page's content: none while the outline is button sized, all of it before half way. */ + fun contentAlpha(progress: Float): Float = ((progress - CONTENT_FROM) / (CONTENT_TO - CONTENT_FROM)).coerceIn(0f, 1f) + + /** The scale the content is drawn at inside an outline [outlineWidth] wide on a page [pageWidth] wide. */ + fun contentScale(outlineWidth: Float, pageWidth: Float): Float = if (pageWidth <= 0f) 1f else (outlineWidth / pageWidth).coerceIn(0.01f, 1f) + + fun scrim(progress: Float): Float = SCRIM * progress.coerceIn(0f, 1f) + + fun sink(progress: Float): Float = 1f - (1f - SINK) * progress.coerceIn(0f, 1f) /** Spring stiffness opening (500) and closing (300), as card to detail. */ fun stiffness(opening: Boolean): Float = if (opening) CardMotion.STIFFNESS_IN else CardMotion.STIFFNESS_OUT } +/** How far the page over the tab is open, shared by [GrowFromButton] and the tab behind it ([sinkBehind]). */ +class GrowProgress internal constructor(open: Boolean) { + internal val anim = Animatable(if (open) 1f else 0f) + val value: Float get() = anim.value +} + +@Composable +fun rememberGrowProgress(open: Boolean): GrowProgress = remember { GrowProgress(open) } + +/** The tab behind a growing page: it sinks a little as the page comes forward (the dimming is the page's scrim). */ +fun Modifier.sinkBehind(progress: GrowProgress): Modifier = graphicsLayer { + val s = GrowMotion.sink(progress.value) + scaleX = s; scaleY = s +} + /** * [content] while [visible], growing out of [from] (a button's bounds in the window) as it turns visible and shrinking - * back into it as it turns hidden; with no [from] it fades. [content] stays composed until it has shrunk away, told - * it's closing meanwhile (so the caller can keep back away from a page on its way out). With the system's animations - * off it jumps. + * back into it as it turns hidden; with no [from] it fades. The outline goes from the button, on [buttonColor], to the + * page, round as a card ([cornerRadius]) until the end, with the content scaled to it and fading in once it has some + * size; a scrim dims what's behind. [content] stays composed until it has shrunk away, told it's closing meanwhile + * (so the caller can keep back away from a page on its way out). With the system's animations off it jumps. */ @Composable -fun GrowFromButton(visible: Boolean, from: Rect?, modifier: Modifier = Modifier, content: @Composable (closing: Boolean) -> Unit) { +fun GrowFromButton( + visible: Boolean, + from: Rect?, + modifier: Modifier = Modifier, + progress: GrowProgress = rememberGrowProgress(visible), + buttonColor: Color = Color(0xFF1F1F22), + pageColor: Color = Color.Black, + cornerRadius: Dp = 28.dp, + content: @Composable (closing: Boolean) -> Unit, +) { val animate = rememberAnimationsOn() - val progress = remember { Animatable(if (visible) 1f else 0f) } + val anim = progress.anim LaunchedEffect(visible, animate) { val target = if (visible) 1f else 0f - if (!animate) progress.snapTo(target) - else progress.animateTo(target, spring(Spring.DampingRatioNoBouncy, GrowMotion.stiffness(visible))) + if (!animate) anim.snapTo(target) + else anim.animateTo(target, spring(Spring.DampingRatioNoBouncy, GrowMotion.stiffness(visible))) } - val shrunk by remember { derivedStateOf { progress.value == 0f && !progress.isRunning } } + val shrunk by remember { derivedStateOf { anim.value == 0f && !anim.isRunning } } if (!visible && shrunk) return val self = remember { arrayOf(Rect.Zero) } val origin by rememberUpdatedState(from) - Box( - modifier.onGloballyPositioned { self[0] = it.boundsInRoot() }.graphicsLayer { - val p = progress.value - alpha = GrowMotion.alpha(p) - val start = origin?.translate(-self[0].topLeft) - if (start != null && p < 1f) { - shape = GrowShape(GrowMotion.outline(start, Rect(Offset.Zero, size), p)) + Box(modifier.onGloballyPositioned { self[0] = it.boundsInRoot() }) { + Box(Modifier.matchParentSize().drawBehind { drawRect(Color.Black, alpha = GrowMotion.scrim(anim.value)) }) + Box( + Modifier.fillMaxSize().graphicsLayer { + val p = anim.value + val start = origin?.translate(-self[0].topLeft) + if (start == null || p >= 1f) { + alpha = if (start == null) p else 1f + return@graphicsLayer + } + // The content laid out at the page's size, scaled to the outline's width and moved onto it, then + // clipped to the outline in its own (unscaled) coordinates. + val o = GrowMotion.outline(start, Rect(Offset.Zero, size), p, cornerRadius.toPx()) + val k = GrowMotion.contentScale(o.width, size.width) + transformOrigin = TransformOrigin(0f, 0f) + scaleX = k; scaleY = k + translationX = o.left; translationY = o.top + alpha = GrowMotion.alpha(p) + shape = GrowShape(RoundRect(0f, 0f, size.width, o.height / k, CornerRadius(o.topLeftCornerRadius.x / k))) clip = true - } - }, - ) { content(!visible) } + }.drawBehind { + // The surface turns from the button's colour to the page's while the content is still coming in. + drawRect(androidx.compose.ui.graphics.lerp(buttonColor, pageColor, GrowMotion.contentAlpha(anim.value))) + }, + ) { + Box(Modifier.fillMaxSize().graphicsLayer { alpha = if (origin == null) 1f else GrowMotion.contentAlpha(anim.value) }) { content(!visible) } + } + } } /** A clip of exactly [outline], wherever the layer is. */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/components/PullToSync.kt b/app/src/main/kotlin/org/openstrap/app/ui/components/PullToSync.kt index c2944df3..bd640992 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/components/PullToSync.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/components/PullToSync.kt @@ -120,7 +120,7 @@ fun PullToSync( } } -/** The gap a pull opens in the header: a strap icon that turns as you pull and spins while syncing. */ +/** The gap a pull opens in the header: the sync arrows, turning as you pull and spinning while syncing. */ @Composable fun PullSyncReveal() { val pull = LocalPullSync.current ?: return @@ -142,7 +142,7 @@ fun PullSyncReveal() { verticalAlignment = Alignment.CenterVertically, ) { Icon( - AppIcons.Watch, null, + AppIcons.Refresh04, null, Modifier.size(18.dp).graphicsLayer { rotationZ = if (syncing) turn else pull.fraction() * 180f }, tint = if (ready || syncing) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CardLooks.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CardLooks.kt index 513df2cd..6cadba6c 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CardLooks.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CardLooks.kt @@ -28,7 +28,8 @@ enum class Hue { Recovery, Charge, Sleep, Heart, Steps, Load, HabitAge, Neutral, * tier and edge alone. */ enum class Shape { - Card, Small, Tall; + /** An unlit strip ([Tier.List] at [CardSize.Strip]): with no light to tell them apart, its height does. */ + Card, Small, Tall, Strip; companion object { fun of(size: CardSize): Shape = when (size) { 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 edaab69a..a75d2121 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 @@ -277,7 +277,7 @@ object TabCopy { /* ---------------------------------------------- Workouts --------------------------------------------- */ /** The quiet tag beside a workout the strap found by itself, or filed under its generic "Activity". */ - const val FOUND_BY_STRAP = "Found by the strap" + const val AUTO_DETECTED = "Auto-detected" /** Which fact leads a workout's line, by sport. */ enum class SportFact { Run, Walk, Strength, Other } @@ -349,6 +349,9 @@ object TabCopy { fun firstEstimate(sunday: LocalDate, locale: Locale = Locale.getDefault()): String = "First estimate " + sunday.format(DateTimeFormatter.ofPattern("EEEE d MMM", locale)) + /** Habit Age before its first estimate, under your own age in its place: "Your age for now", then what it waits for. */ + fun habitAgeForNow(waiting: String?): String = if (waiting.isNullOrBlank()) "Your age for now" else "Your age for now. $waiting" + /** Habit Age's one status line, and whether the number moved, which alone takes the orchid. */ data class HabitAgeLine(val text: String, val moved: Boolean) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabPlans.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabPlans.kt index ece7ea28..b8480481 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabPlans.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabPlans.kt @@ -107,21 +107,23 @@ enum class ActivityCard { Start, Steps, ActiveMinutes, HeartPoints, Load, HeartR data class ActivityFacts(val isToday: Boolean = true, val suggestion: Boolean = false, val live: Boolean = false) /** - * Activity (spec 5.3): a Wide edge card to start a sport (today only, the neutral light from below), steps glowing, + * Activity (spec 5.3): a Wide card to start a sport (today only, unlit with a light moving round its border), steps glowing, * active minutes (from below) and Heart points (from above) as a Small pair, Load filling across toward your usual, - * the day's heart rate (from the right while live, else from below like any usual value), the day's facts as a list. - * The workouts list comes right after another list, so it sits on an edge card in the neutral light, from the top. + * the day's facts as a list, the day's heart rate (from the right while live, else from below like any usual value), + * then the workouts list, unlit like the facts. Heart rate sits between the two lists, so they never touch and + * neither needs a light that says nothing. * - * Wide: the Start a workout card and steps span both columns; the Small pair and Load on the left, heart rate, the - * facts and workouts on the right. + * Wide: the Start a workout card and steps span both columns; the Small pair and Load on the left, the facts, heart + * rate and workouts on the right. */ fun activityCards(facts: ActivityFacts = ActivityFacts()): List> = listOfNotNull( - TabSlot(ActivityCard.Start, edge(Hue.Neutral, Edge.Bottom), Pane.Full).takeIf { facts.isToday }, + // Plain, not a list: an unlit card whose border carries the moving light. + TabSlot(ActivityCard.Start, CardLook(Tier.Plain, Hue.Heart), Pane.Full).takeIf { facts.isToday }, TabSlot(ActivityCard.Steps, glow(Hue.Steps), Pane.Full), small(ActivityCard.ActiveMinutes, Hue.Steps, Edge.Bottom, Pane.Left), small(ActivityCard.HeartPoints, Hue.Heart, Edge.Top, Pane.Left), TabSlot(ActivityCard.Load, CardLook(Tier.Level, Hue.Load, Edge.Left), Pane.Left), - TabSlot(ActivityCard.HeartRate, edge(Hue.Heart, if (facts.live) Edge.Right else Edge.Bottom), Pane.Right), TabSlot(ActivityCard.Facts, ListLook, Pane.Right), - TabSlot(ActivityCard.Workouts, edge(Hue.Neutral, Edge.Top), Pane.Right), + TabSlot(ActivityCard.HeartRate, edge(Hue.Heart, if (facts.live) Edge.Right else Edge.Bottom), Pane.Right), + TabSlot(ActivityCard.Workouts, ListLook, Pane.Right), ) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayLooks.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayLooks.kt index 1c1d6992..93723443 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayLooks.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayLooks.kt @@ -29,9 +29,13 @@ private fun heroHue(moment: Moment?): Hue = when (moment) { * The look [slot] has on its own (spec 2.4): the edge follows what the card is about, and a Small or Tall is a shape * of its own. Heart rate is lit from the right only while the reading is [live]. Your week is a neutral edge from the * bottom: a summary of steps, sleep and workouts at their usual, in no one metric's colour. After a workout Load is a - * Small toward your usual (from below) and Time to recover the Small beside it. + * Small toward your usual (from below) and Time to recover the Small beside it. Last night, Your week and Start a + * workout are unlit, like a list: light is kept for cards where it says something (Steps toward the goal, heart rate + * live), so it still points at them. An unlit strip reads apart from an unlit card ([Shape.Strip]). */ -fun defaultLook(slot: TodaySlot, moment: Moment?, live: Boolean = false): CardLook = ownLook(slot, moment, live).copy(shape = Shape.of(slot.size)) +fun defaultLook(slot: TodaySlot, moment: Moment?, live: Boolean = false): CardLook = ownLook(slot, moment, live).let { l -> + l.copy(shape = if (l.tier == Tier.List && slot.size == CardSize.Strip) Shape.Strip else Shape.of(slot.size)) +} private fun ownLook(slot: TodaySlot, moment: Moment?, live: Boolean): CardLook = when (slot.card) { TodayCard.Hero -> glow(heroHue(moment)) @@ -43,7 +47,7 @@ private fun ownLook(slot: TodaySlot, moment: Moment?, live: Boolean): CardLook = TodayCard.BodyMorning, TodayCard.RightNow -> ListLook TodayCard.LoadToday -> if (slot.size == CardSize.Small) edge(Hue.Load, Edge.Bottom) else ListLook TodayCard.Recover -> edge(Hue.Load, Edge.Bottom) - TodayCard.LastNight -> if (slot.size == CardSize.Strip) strip(Hue.Sleep, Edge.Top) else edge(Hue.Sleep, Edge.Top) + TodayCard.LastNight -> ListLook TodayCard.HeartRate -> edge(Hue.Heart, if (live) Edge.Right else Edge.Bottom) TodayCard.Activity -> edge(Hue.Steps, Edge.Bottom) TodayCard.Recovery -> edge(Hue.Recovery, Edge.Left) @@ -51,9 +55,9 @@ private fun ownLook(slot: TodaySlot, moment: Moment?, live: Boolean): CardLook = // so it doesn't read as the hero's glow above it. The level edge card is only its step down ([options]). TodayCard.Charge -> if (slot.secondGlow) CardLook(Tier.Glow, Hue.Charge, Edge.Left) else CardLook(Tier.Level, Hue.Charge, Edge.Left) TodayCard.Workout -> edge(Hue.Heart, Edge.Left) - TodayCard.Week -> edge(Hue.Neutral, Edge.Bottom) + TodayCard.Week -> ListLook TodayCard.HabitAge -> edge(Hue.HabitAge, Edge.Left) - TodayCard.StartWorkout -> strip(Hue.Heart, Edge.Bottom) + TodayCard.StartWorkout -> ListLook TodayCard.EditToday -> CardLook(Tier.Plain, Hue.Neutral) } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceScreen.kt index 5218aea0..3c6ac240 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceScreen.kt @@ -198,7 +198,7 @@ fun DeviceScreen( is ConnectionState.Connected -> { PageButton( "Sync now", vm.strap::syncNow, Modifier.weight(1f).height(56.dp), enabled = sync !is SyncState.Running, - icon = AppIcons.ArrowsClockwise, + icon = AppIcons.Refresh04, ) if (info?.findDevice == true) FindStrapButton(vm, Modifier.weight(1f).height(56.dp)) } @@ -235,7 +235,7 @@ fun DeviceScreen( first = false } if (connection is ConnectionState.Connected && info?.canRestart == true) { - PageRow("Restart strap", onClick = { confirmRestart = true }, divider = !first, icon = AppIcons.ArrowClockwise) + PageRow("Restart strap", onClick = { confirmRestart = true }, divider = !first, icon = AppIcons.ShutDown) first = false } PageRow("Forget this strap", onClick = { confirmForget = true }, divider = !first, titleColor = DestructiveInk, icon = AppIcons.Trash) @@ -250,7 +250,7 @@ private fun FindStrapButton(vm: DeviceViewModel, modifier: Modifier) { val finding by vm.strap.findingStrap.collectAsState() PageButton( if (finding) "Stop buzzing" else "Find strap", { if (finding) vm.stopFindingStrap() else vm.findStrap() }, modifier, - icon = if (finding) AppIcons.StopFill else AppIcons.BellRinging, + icon = if (finding) AppIcons.StopFill else AppIcons.SearchArea, ) } @@ -334,38 +334,41 @@ private fun StrapHero(name: String, connection: ConnectionState, sync: SyncState style = GlowType.secondary, modifier = Modifier.padding(top = 4.dp), ) } - StrapDrawing( - led, lit = connected != null || connection is ConnectionState.Failed, syncing = connected != null && sync is SyncState.Running, - Modifier.align(Alignment.TopEnd).size(width = 100.dp, height = 125.dp), - ) - } - Column(Modifier.fillMaxWidth().padding(top = 128.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - // The card's one dotted number (spec 2.3); the word after it sits on the digits' baseline in Body at 72%. - if (battery != null) Row(verticalAlignment = Alignment.Bottom) { - DotMatrixText("$battery", dot = org.openstrap.app.ui.glow.DotSize.Hero) - Text("% battery", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) - } - if (sync is SyncState.Running) { - PageProgress() - Text(sync.step, style = GlowType.secondary, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA)) - } - when { - connection is ConnectionState.Failed && !connection.keyRejected -> { - Text(connection.reason, color = MetricColors.warn, style = GlowType.secondary) - Text( - "OpenStrap keeps retrying. Keep the strap on its charger near the phone, and make sure the Zepp app " + - "isn't running. The strap talks to one app at a time.", - style = GlowType.secondary, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), - ) + // 5 dp under it, so the space below the battery matches the space the title's line height leaves above it. + // Clear of the strap on the right too, which sits centred and so lower as this grows. + Column(Modifier.fillMaxWidth().padding(top = 128.dp, bottom = 5.dp, end = 112.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + // The card's one dotted number (spec 2.3); the word after it sits on the digits' baseline in Body at 72%. + if (battery != null) Row(verticalAlignment = Alignment.Bottom) { + DotMatrixText("$battery", dot = org.openstrap.app.ui.glow.DotSize.Hero) + Text("% battery", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) } - sync is SyncState.Failed -> { - Text("Sync stopped. ${sync.reason}", color = MetricColors.warn, style = GlowType.secondary) - Text( - "$DATA_ON_STRAP OpenStrap tries again by itself.", - style = GlowType.secondary, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), - ) + if (sync is SyncState.Running) { + PageProgress() + Text(sync.step, style = GlowType.secondary, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA)) + } + when { + connection is ConnectionState.Failed && !connection.keyRejected -> { + Text(connection.reason, color = MetricColors.warn, style = GlowType.secondary) + Text( + "OpenStrap keeps retrying. Keep the strap on its charger near the phone, and make sure the Zepp app " + + "isn't running. The strap talks to one app at a time.", + style = GlowType.secondary, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + ) + } + sync is SyncState.Failed -> { + Text("Sync stopped. ${sync.reason}", color = MetricColors.warn, style = GlowType.secondary) + Text( + "$DATA_ON_STRAP OpenStrap tries again by itself.", + style = GlowType.secondary, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + ) + } } } + // The strap at the card's right, centred top to bottom. + StrapDrawing( + led, lit = connected != null || connection is ConnectionState.Failed, syncing = connected != null && sync is SyncState.Running, + Modifier.align(Alignment.CenterEnd).size(width = 100.dp, height = 125.dp), + ) } } } 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 bb018e7b..79f1691f 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 @@ -1,6 +1,8 @@ package org.openstrap.app.ui.glow import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.ui.draw.clip import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -95,10 +97,11 @@ val EdgeNeutral = Color.hsl(220f, 0.10f, 0.55f) /** * A screen's say over the next edge card's look, so two cards in a row never look the same (spec 2.4): the [edge] the - * light comes in from and the [color] it's lit in, each null to keep the card's own. + * light comes in from and the [color] it's lit in, each null to keep the card's own. [plain] draws the card unlit, a + * dark panel with a hairline like a list's, for a card whose light would say nothing. */ @Immutable -data class EdgeOverride(val edge: Edge? = null, val color: Color? = null) +data class EdgeOverride(val edge: Edge? = null, val color: Color? = null, val plain: Boolean = false) val LocalEdgeOverride = staticCompositionLocalOf { EdgeOverride() } @@ -133,11 +136,14 @@ fun EdgeCard( modifier .then(opening?.modifier ?: Modifier) .let { if (onClick != null) it.pressClickable(opening?.onClick ?: onClick, EdgeShape) else it } - .drawWithCache { - // 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) } + .let { + if (override.plain) it.clip(EdgeShape).background(ListSurface, EdgeShape).border(1.dp, RowHairline, EdgeShape) + else it.drawWithCache { + // 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), ) { 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 5def6aaa..cd48cb17 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 @@ -178,7 +178,8 @@ fun GlowActivityScreen( */ @Composable private fun StartCard(sports: List, onStart: (Int) -> Unit, onAllSports: () -> Unit) { - EdgeCard(EdgeNeutral, Modifier.fillMaxWidth(), kind = EdgeKind.Value) { + // Unlit (its look), with the heart-red light travelling round its border, as on Today. + EdgeCard(EdgeNeutral, Modifier.fillMaxWidth().movingBorder(MetricColors.heart), kind = EdgeKind.Value) { EdgeTitle("Start a workout", null) Spacer(Modifier.height(GlowLayout.TitleGap)) // Four even columns up to the phone's width, so the tiles keep together on an unfolded screen. @@ -188,7 +189,7 @@ private fun StartCard(sports: List, onStart: (Int) -> Unit, onAllSport onStart(s.sportType) } } - SportTile(AppIcons.GridView, "All sports", outdoor = false, Modifier.weight(1f), onClick = onAllSports) + SportTile(AppIcons.MoreHorizontalCircle01, "All sports", outdoor = false, Modifier.weight(1f), onClick = onAllSports) } } } @@ -429,7 +430,7 @@ private val EffortGap = 12.dp /** * A workout as a list row: its sport's toned white icon on the effort light (a heart-red glow as strong as the - * workout's Load against your [usual], [Workouts.effort]), the sport with "Found by the strap" when the strap logged + * workout's Load against your [usual], [Workouts.effort]), the sport with "Auto-detected" when the strap logged * it by itself, the sport's one fact and the start time ([TabCopy.workoutFact]), and the duration in dots. A thin zone * bar under it, lined up with the name. [divider] draws the hairline above. */ @@ -502,11 +503,11 @@ private fun EffortLight(icon: androidx.compose.ui.graphics.vector.ImageVector, e } } -/** "Found by the strap": a quiet outlined label beside the sport's name. */ +/** "Auto-detected": a quiet outlined label beside the sport's name. */ @Composable internal fun FoundTag() { Text( - TabCopy.FOUND_BY_STRAP, color = Color.White.copy(alpha = 0.7f), style = GlowType.secondary.copy(fontSize = 11.sp, lineHeight = 14.sp), + TabCopy.AUTO_DETECTED, color = Color.White.copy(alpha = 0.7f), style = GlowType.secondary.copy(fontSize = 11.sp, lineHeight = 14.sp), maxLines = 1, modifier = Modifier.border(1.dp, Color.White.copy(alpha = 0.19f), RoundedCornerShape(6.dp)).padding(horizontal = 6.dp, vertical = 1.dp), ) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowHealthspan.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowHealthspan.kt index f1dd651c..c49a5920 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowHealthspan.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowHealthspan.kt @@ -113,9 +113,9 @@ private fun dateOf(epoch: Long) = java.time.Instant.ofEpochSecond(epoch).atZone( * something and (i) at the right of the title row (spec 5.4), "Updated Sun 18 Oct" under it, the age in dots, one * status line against last week (the range until there's a last week, orchid only when the number moved) and the age * scale. The method, the pace of aging and what moves it are in its info sheet, behind the (i). Before the first - * estimate it says when that is, with no number; with no birth year or sex it says what's needed and opens the profile - * ([onOpenProfile]) instead of the Habit Age page ([onClick], null on the page itself). Nothing shows until it has been - * worked out. + * estimate your own age from your birth year stands in, "Your age for now" and when the first estimate comes under it; + * without a birth year it says so, with no number. With no birth year or sex it opens the profile ([onOpenProfile]) + * instead of the Habit Age page ([onClick], null on the page itself). Nothing shows until it has been worked out. */ @Composable fun HabitAgeEdgeCard(state: Healthspan.Outcome?, onClick: (() -> Unit)?, onOpenProfile: (() -> Unit)?, modifier: Modifier = Modifier) { @@ -125,8 +125,8 @@ fun HabitAgeEdgeCard(state: Healthspan.Outcome?, onClick: (() -> Unit)?, onOpenP val needsProfile = r == null && state.missing.isNotEmpty() && withheld?.profile == true var info by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableStateOf(false) } EdgeCard( - // Without an estimate it's one line, so it's only as high as that line. - Orchid, modifier.fillMaxWidth().let { if (r != null) it.gridSize(CardSize.Wide) else it }, kind = EdgeKind.Earlier, + // Without a number it's one line, so it's only as high as that line. + Orchid, modifier.fillMaxWidth().let { if (r != null || state.age != null) it.gridSize(CardSize.Wide) else it }, kind = EdgeKind.Earlier, onClick = if (needsProfile) onOpenProfile else onClick, ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -139,16 +139,24 @@ fun HabitAgeEdgeCard(state: Healthspan.Outcome?, onClick: (() -> Unit)?, onOpenP style = GlowType.secondary, maxLines = 1, ) } - when { - r != null -> { - val line = org.openstrap.app.ui.dashboard.TabCopy.habitAgeLine(r.estimate, state.lastWeek, r.low, r.high) - EdgeValue(r.estimate.roundToInt().toString(), "years", line.text, if (line.moved) Orchid else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA)) - Spacer(Modifier.height(10.dp)) - AgeScale(r.age, r.low, r.high, r.estimate) - } - needsProfile -> EdgeValue(null, null, profileReminder(state.missing), waiting = false) - state.firstEstimate != null -> EdgeValue(null, null, org.openstrap.app.ui.dashboard.TabCopy.firstEstimate(dateOf(state.firstEstimate)), waiting = false) - else -> EdgeValue(null, null, withheld?.reason, waiting = false) + val line = r?.let { org.openstrap.app.ui.dashboard.TabCopy.habitAgeLine(it.estimate, state.lastWeek, it.low, it.high) } + val waiting = when { + r != null -> null + needsProfile -> profileReminder(state.missing) + state.firstEstimate != null -> org.openstrap.app.ui.dashboard.TabCopy.firstEstimate(dateOf(state.firstEstimate)) + else -> withheld?.reason + } + // The estimate once there is one; until then your own age stands in. Without a birth year, no number. + val shown = r?.estimate?.roundToInt() ?: state.age + EdgeValue( + shown?.toString(), shown?.let { "years" }, + line?.text ?: if (shown != null) org.openstrap.app.ui.dashboard.TabCopy.habitAgeForNow(waiting) else waiting, + if (line?.moved == true) Orchid else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + waiting = shown != null, + ) + if (r != null) { + Spacer(Modifier.height(10.dp)) + AgeScale(r.age, r.low, r.high, r.estimate) } } if (info) { 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 aae8b5b1..8a855f93 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 @@ -43,6 +43,7 @@ import org.openstrap.app.ui.components.rememberHaptics import org.openstrap.app.ui.dashboard.DailyHistory import org.openstrap.app.ui.dashboard.DayUiState import org.openstrap.app.ui.dashboard.Hue +import org.openstrap.app.ui.dashboard.Tier import org.openstrap.app.ui.dashboard.defaultLook import org.openstrap.app.ui.dashboard.lookOr import org.openstrap.app.ui.dashboard.todayPanes @@ -232,6 +233,8 @@ fun GlowTodayScreen( val override = EdgeOverride( edge = look.edge.takeIf { it != own.edge }, color = EdgeNeutral.takeIf { look.hue == Hue.Neutral && own.hue != Hue.Neutral }, + // An unlit card drawn as an edge card (Last night, Your week, Start) skips its light. + plain = look.tier == Tier.List, ) CompositionLocalProvider(LocalEdgeOverride provides override) { Column(m.then(dim)) { @@ -294,6 +297,9 @@ private class TodayContext( /** Today's Recovery is the first real score (E4). */ val firstScore: Boolean, ) { + /** The evening and night hero shows the day's steps in dots ([EveningHero]), so the Steps card needn't. */ + val stepsInHero: Boolean get() = (moment == Moment.Evening || moment == Moment.Night) && !stuck + /** "Up to 09:40" for a card whose [newest] reading is stale while syncing has stopped ([upToMarker]). */ fun upTo(newest: Long?): String? = upToMarker(newest, now, stopped, isToday, zone, staleAfter) @@ -744,7 +750,11 @@ private fun TodayRow( upTo = if (ctx.live == null) ctx.upTo(s.heartRate.lastOrNull()?.x) else null, ) // Steps come in the same records as the rest of the day, so the day's newest reading is how far they go. - TodayCard.Activity -> ActivityCard(s, { onOpenMetric(Metric.Steps) }, usual = usualSteps, upTo = ctx.upTo(ctx.newest), tag = ctx.partial) + // In the evening and at night the hero has the day's steps in dots, so the card leads with its sentence. + TodayCard.Activity -> ActivityCard( + s, { onOpenMetric(Metric.Steps) }, usual = usualSteps, upTo = ctx.upTo(ctx.newest), tag = ctx.partial, + count = !ctx.stepsInHero, + ) TodayCard.Recovery -> RecoveryEarlierCard(ctx.recoveryShown, { openScore(org.openstrap.app.ui.dashboard.Score.Recovery) }, failure = ctx.recoveryFailure, onRetry = onRetryRecovery) TodayCard.Charge -> { val c = ctx.rec?.charge diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/MovingBorder.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/MovingBorder.kt new file mode 100644 index 00000000..7285b49f --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/MovingBorder.kt @@ -0,0 +1,83 @@ +package org.openstrap.app.ui.glow + +import android.graphics.Matrix +import android.graphics.SweepGradient +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +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.Color +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.first + +/** + * A light travelling round a card's border, once every [periodMillis], brightest at its head and fading along its + * tail, with a soft halo outside it. The one moving light on a screen, kept for the action it offers (Start a workout, + * spec 2.5). It holds still while the list scrolls or the page is off screen ([LocalGlowPaused], + * [LocalGlowPagePaused]), as the glows do, and with the system's animations off the light rests at the top. + */ +@Composable +fun Modifier.movingBorder(color: Color, periodMillis: Int = MOVING_BORDER_PERIOD): Modifier { + val context = LocalContext.current + val animate = remember(context) { Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f } + val listPaused = LocalGlowPaused.current + val pagePaused = LocalGlowPagePaused.current + // Degrees round the border; only the draw phase reads it, so a turn never recomposes the card. + val angle = remember { mutableFloatStateOf(0f) } + if (animate) LaunchedEffect(periodMillis) { + val paused = { listPaused() || pagePaused() } + var last = withFrameNanos { it } + while (true) { + if (paused()) { + snapshotFlow { paused() }.first { !it } + last = withFrameNanos { it } + } + withFrameNanos { now -> + angle.floatValue = (angle.floatValue + (now - last) / 1e6f / periodMillis * 360f) % 360f + last = now + } + } + } + return drawWithCache { + val corner = GlowLayout.CardCorner.toPx() + val thin = 1.5.dp.toPx() + val halo = 7.dp.toPx() + val cx = size.width / 2f + val cy = size.height / 2f + // The tail fades in over the last third of the turn to the white-hot head; the rest of the border is dark. + val head = lerp(color, Color.White, 0.7f) + val stops = floatArrayOf(0f, 0.68f, 0.88f, 0.97f, 0.995f, 1f) + val colors = intArrayOf( + Color.Transparent.toArgb(), color.copy(alpha = 0f).toArgb(), color.copy(alpha = 0.55f).toArgb(), + color.toArgb(), head.toArgb(), Color.Transparent.toArgb(), + ) + val shader = SweepGradient(cx, cy, colors, stops) + val matrix = Matrix() + val brush = ShaderBrush(shader) + val inset = thin / 2f + onDrawWithContent { + drawContent() + // Starting at the top: the sweep's zero is at three o'clock. + matrix.setRotate(angle.floatValue - 90f, cx, cy) + shader.setLocalMatrix(matrix) + drawRoundRect(brush, Offset(inset, inset), Size(size.width - thin, size.height - thin), CornerRadius(corner), alpha = 0.28f, style = Stroke(halo)) + drawRoundRect(brush, Offset(inset, inset), Size(size.width - thin, size.height - thin), CornerRadius(corner - inset), style = Stroke(thin)) + } + } +} + +/** One turn of [movingBorder] in milliseconds: slow enough to read as a light, not a spinner. */ +const val MOVING_BORDER_PERIOD = 7_000 diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/PageParts.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/PageParts.kt index 13c09ceb..eaf95d8f 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/PageParts.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/PageParts.kt @@ -7,8 +7,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -16,11 +14,18 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -35,6 +40,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Constraints import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloat @@ -283,6 +289,73 @@ internal fun RowNameValue(name: String, nameColor: Color, value: String?, modifi } } +/** + * A setting picked from a short list: the [title] and the chosen value on the right, then a down caret. A tap opens a + * menu at the row's end with every option and a tick on the chosen one; picking one calls [onPick] and closes it. + * [note] is a line under the row (shown only when something needs saying). Takes the place of a row of chips. + */ +@Composable +fun PageMenuRow( + title: String, + options: List, + selected: T?, + label: (T) -> String, + onPick: (T) -> Unit, + divider: Boolean = true, + enabled: Boolean = true, + note: String? = null, + quiet: (T) -> Boolean = { false }, +) { + var open by remember { mutableStateOf(false) } + Column( + Modifier.fillMaxWidth() + .let { if (divider) it.drawBehind { drawLine(Hairline, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else it }, + ) { + Box(Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth() + .clickable(enabled = enabled, onClickLabel = "Change", role = Role.DropdownList) { open = true } + .alpha(if (enabled) 1f else 0.45f) + .padding(vertical = GlowLayout.RowPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + val value = selected?.let(label) + Text(title, color = Color.White, style = GlowType.body, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (value != null) { + Spacer(Modifier.width(RowValueGap)) + Text( + value, style = GlowType.secondary, maxLines = 1, + color = if (selected.let(quiet)) Color.White.copy(alpha = 0.4f) else RowValueColor, + ) + } + Spacer(Modifier.width(RowCaretGap)) + Icon(AppIcons.CaretDown, null, tint = Color.White.copy(alpha = ROW_CARET_ALPHA), modifier = Modifier.size(CaretSize)) + } + Box(Modifier.align(Alignment.BottomEnd)) { + DropdownMenu( + expanded = open, onDismissRequest = { open = false }, + offset = DpOffset(0.dp, 4.dp), shape = MenuShape, containerColor = MenuSurface, + tonalElevation = 0.dp, shadowElevation = 12.dp, border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)), + ) { + options.forEach { o -> + val on = o == selected + DropdownMenuItem( + text = { Text(label(o), style = GlowType.body, color = if (on) Color.White else RowValueColor) }, + onClick = { open = false; if (!on) onPick(o) }, + trailingIcon = if (on) ({ Icon(AppIcons.Tick02, null, tint = Color.White, modifier = Modifier.size(18.dp)) }) else null, + modifier = Modifier.widthIn(min = 180.dp).semantics { this.selected = on }, + ) + } + } + } + } + if (note != null) Text(note, color = RowValueColor, style = GlowType.secondary, modifier = Modifier.padding(bottom = GlowLayout.RowPadding)) + } +} + +private val MenuShape = RoundedCornerShape(18.dp) +private val MenuSurface = Color(0xFF232326) + /** A row with a switch: the [title], an optional [subtitle] under it, and the switch on the right. */ @Composable fun PageSwitchRow(title: String, subtitle: String?, checked: Boolean, onChange: (Boolean) -> Unit, divider: Boolean = true, enabled: Boolean = true) { @@ -500,15 +573,3 @@ fun PageChip(text: String, selected: Boolean, onClick: () -> Unit, modifier: Mod fun PageNote(text: String, color: Color = Color.White.copy(alpha = 0.6f)) { Text(text, color = color, style = GlowType.secondary) } - -/** A short choice between a few values, as [PageChip]s; the chosen one is white. */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun ChoicePills(options: List, selected: T?, label: (T) -> String, onPick: (T) -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier) { - FlowRow(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - options.forEach { v -> - val on = v == selected - PageChip(label(v), on, { if (!on) onPick(v) }, enabled = enabled, role = Role.RadioButton) - } - } -} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/TabParts.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/TabParts.kt index 20f8e889..8cee25df 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/TabParts.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/TabParts.kt @@ -42,8 +42,12 @@ import org.openstrap.app.ui.dashboard.items * The edge card override that draws [this] look: the side it fixes, and the neutral light for a neutral card. Glow * cards and lists get none, so a list stays a list. */ -internal fun CardLook.edgeOverride(): EdgeOverride = - if (tier == Tier.Edge || tier == Tier.Strip) EdgeOverride(edge = edge, color = EdgeNeutral.takeIf { hue == Hue.Neutral }) else EdgeOverride() +internal fun CardLook.edgeOverride(): EdgeOverride = when (tier) { + Tier.Edge, Tier.Strip -> EdgeOverride(edge = edge, color = EdgeNeutral.takeIf { hue == Hue.Neutral }) + // An unlit card drawn as an edge card (Activity's Start a workout, Plain) has no light; a list ignores it. + Tier.List, Tier.Plain -> EdgeOverride(plain = true) + else -> EdgeOverride() +} /** * Adds a tab's [slots] to a dashboard as grid rows ([packGrid]), each row in its first card's pane ([tabPanes]), every card drawn 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 078401df..4c96729e 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 @@ -203,15 +203,15 @@ internal fun CheckInStrip(feeling: Int?, onCheckIn: (Int?) -> Unit, modifier: Mo } /** - * Start a workout (spec 4.2): heart light from below, the title with your usual sport under it ("Outdoor run, your - * usual") and a white play button. One thing to do, so the whole strip is the button and has no caret; it opens Ready - * with that sport. + * Start a workout (spec 4.2): unlit, with a heart-red light travelling round its border ([movingBorder]), the title + * with your usual sport under it ("Outdoor run, your usual") and a white play button. One thing to do, so the whole + * strip is the button and has no caret; it opens Ready with that sport. */ @Composable internal fun StartWorkoutStrip(sport: Int, onStart: (Int) -> Unit, modifier: Modifier = Modifier) { val name = Workouts.sportName(sport) // A strip that does one thing is a button: its title is plain text, with no caret (spec 2.5, Tappable says so). - TodayStrip(MetricColors.heart, EdgeKind.Value, modifier, onClick = { onStart(sport) }) { + TodayStrip(MetricColors.heart, EdgeKind.Value, modifier.movingBorder(MetricColors.heart), onClick = { onStart(sport) }) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text("Start a workout", color = Color.White, style = GlowType.cardTitle, maxLines = 1) @@ -370,6 +370,8 @@ internal fun ActivityCard( state: DayUiState, onClick: () -> Unit, modifier: Modifier = Modifier, usual: List? = null, upTo: String? = null, /** The Partial day tag when the strap was worn for part of the day ([TitleTag]). */ tag: TitleTag? = null, + /** The day's count in dots; false leads with the sentence, where the hero above already has the count. */ + count: Boolean = true, ) { val goal = org.openstrap.app.ui.dashboard.LocalGoals.current.steps val isToday = state.date == java.time.LocalDate.now() @@ -386,7 +388,9 @@ internal fun ActivityCard( progress = if (chart || goal <= 0) null else state.steps / goal.toFloat(), onClick = onClick, ) { EdgeTitle("Steps", "Goal %,d".format(goal), marker = upTo, tag = tag) - EdgeValue("%,d".format(state.steps), "steps", org.openstrap.app.ui.dashboard.ActivityCopy.activityLine(state.steps, goal, pace, isToday)) + val sentence = org.openstrap.app.ui.dashboard.ActivityCopy.activityLine(state.steps, goal, pace, isToday) + if (count) EdgeValue("%,d".format(state.steps), "steps", sentence) + else Text(sentence, color = Color.White, style = GlowType.body, modifier = Modifier.padding(top = 10.dp), maxLines = 2, overflow = TextOverflow.Ellipsis) if (chart) { Spacer(Modifier.height(10.dp)) GhostLine( 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 d0dcd76f..e45fe61e 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 @@ -24,7 +24,6 @@ object AppIcons { val MoonFill: ImageVector by lazy { bold("MoonFill", "M21.5 14.0784C20.3003 14.7189 18.9301 15.0821 17.4751 15.0821C12.7491 15.0821 8.91792 11.2509 8.91792 6.52485C8.91792 5.06986 9.28105 3.69968 9.92163 2.5C5.66765 3.49698 2.5 7.31513 2.5 11.8731C2.5 17.1899 6.8101 21.5 12.1269 21.5C16.6849 21.5 20.503 18.3324 21.5 14.0784Z") } val Watch: ImageVector by lazy { line("Watch", "M5 12A7 7 0 1 0 19 12A7 7 0 1 0 5 12Z", "M16 6L15.7276 4.91043C15.3931 3.5724 15.2258 2.90339 14.7499 2.49004C14.6973 2.44433 14.6423 2.40141 14.5852 2.36145C14.0688 2 13.3792 2 12 2C10.6208 2 9.93119 2 9.41476 2.36145C9.35765 2.40141 9.30268 2.44433 9.25006 2.49004C8.77415 2.90339 8.6069 3.5724 8.27239 4.91043L8 6", "M8 18L8.27239 19.0896C8.6069 20.4276 8.77415 21.0966 9.25006 21.51C9.30268 21.5557 9.35765 21.5986 9.41476 21.6386C9.93119 22 10.6208 22 12 22C13.3792 22 14.0688 22 14.5852 21.6386C14.6423 21.5986 14.6973 21.5557 14.7499 21.51C15.2258 21.0966 15.3931 20.4276 15.7276 19.0896L16 18", "M12 10V12.005L13 13") } val WatchFill: ImageVector by lazy { bold("WatchFill", "M5 12A7 7 0 1 0 19 12A7 7 0 1 0 5 12Z", "M16 6L15.7276 4.91043C15.3931 3.5724 15.2258 2.90339 14.7499 2.49004C14.6973 2.44433 14.6423 2.40141 14.5852 2.36145C14.0688 2 13.3792 2 12 2C10.6208 2 9.93119 2 9.41476 2.36145C9.35765 2.40141 9.30268 2.44433 9.25006 2.49004C8.77415 2.90339 8.6069 3.5724 8.27239 4.91043L8 6", "M8 18L8.27239 19.0896C8.6069 20.4276 8.77415 21.0966 9.25006 21.51C9.30268 21.5557 9.35765 21.5986 9.41476 21.6386C9.93119 22 10.6208 22 12 22C13.3792 22 14.0688 22 14.5852 21.6386C14.6423 21.5986 14.6973 21.5557 14.7499 21.51C15.2258 21.0966 15.3931 20.4276 15.7276 19.0896L16 18", "M12 10V12.005L13 13") } - val BellRinging: ImageVector by lazy { line("BellRinging", "M20 18.5011L18.349 7.93407C17.8603 4.80601 15.166 2.5 12 2.5C8.83398 2.5 6.13971 4.80601 5.65098 7.93407L4 18.5011", "M20 18.5C20 16.8431 16.4183 15.5 12 15.5C7.58172 15.5 4 16.8431 4 18.5C4 20.1569 7.58172 21.5 12 21.5C16.4183 21.5 20 20.1569 20 18.5Z", "M13 18.5H11") } val Heartbeat: ImageVector by lazy { line("Heartbeat", "M10.4107 19.9677C7.58942 17.858 2 13.0348 2 8.69444C2 5.82563 4.10526 3.5 7 3.5C8.5 3.5 10 4 12 6C14 4 15.5 3.5 17 3.5C19.8947 3.5 22 5.82563 22 8.69444C22 13.0348 16.4106 17.858 13.5893 19.9677C12.6399 20.6776 11.3601 20.6776 10.4107 19.9677Z", "M20.001 13.0001H16.0288C15.8168 13.0001 15.7107 13.0001 15.619 12.9639C15.5691 12.9442 15.5229 12.9169 15.4821 12.8831C15.4072 12.8209 15.3598 12.7303 15.2649 12.5491C14.9921 12.0278 14.8557 11.7672 14.6597 11.7045C14.5567 11.6716 14.4453 11.6716 14.3422 11.7045C14.1462 11.7672 14.0098 12.0278 13.737 12.5491L13.1172 13.7335C12.6442 14.6372 12.4078 15.089 12.0706 15.0624C11.7335 15.0357 11.578 14.5529 11.267 13.5872L10.8024 12.1447C10.4668 11.1027 10.299 10.5817 9.95039 10.5639C9.60176 10.5462 9.377 11.0472 8.92748 12.0493L8.76073 12.4211C8.63475 12.7019 8.57176 12.8423 8.44652 12.9212C8.32129 13.0001 8.16139 13.0001 7.84158 13.0001H4.00098") } val Drop: ImageVector by lazy { line("Drop", "M3.5 13.678C3.5 9.49387 7.08079 5.35907 9.59413 2.97222C10.9591 1.67593 13.0409 1.67593 14.4059 2.97222C16.9192 5.35907 20.5 9.49387 20.5 13.678C20.5 17.7804 17.2812 22 12 22C6.71878 22 3.5 17.7804 3.5 13.678Z", "M16 14C16 16.2091 14.2091 18 12 18") } val Wind: ImageVector by lazy { line("Wind", "M6 6C6 4.34315 7.34315 3 9 3C10.6569 3 12 4.34315 12 6C12 7.65685 10.6569 9 9 9H3", "M9 18C9 19.6569 10.3431 21 12 21C13.6569 21 15 19.6569 15 18C15 16.3431 13.6569 15 12 15H3", "M15 9C15 7.34315 16.3431 6 18 6C19.6569 6 21 7.34315 21 9C21 10.6569 19.6569 12 18 12H6.5") } @@ -66,6 +65,8 @@ object AppIcons { val CaretDownFill: ImageVector by lazy { solid("CaretDownFill", "M5.5 8.5L12 15.5L18.5 8.5Z") } val User: ImageVector by lazy { line("User", "M20 21.0001C19.713 17.269 16.7289 14.3151 12.995 14.0662L12 13.9999C11.6446 14.0096 11.3134 14.0225 11.0008 14.0378C7.3 14.2192 4.28417 17.3057 4 21.0001", "M8 6.9999A4 4 0 1 0 16 6.9999A4 4 0 1 0 8 6.9999Z") } val CaretDown: ImageVector by lazy { line("CaretDown", "M18 9.00005C18 9.00005 13.5811 15 12 15C10.4188 15 6 9 6 9") } + val Tick02: ImageVector by lazy { line("Tick02", "M5 14L8.5 17.5L19 6.5") } + val ShutDown: ImageVector by lazy { line("ShutDown", "M7.86907 4C4.97674 5.49689 3 8.51664 3 11.9981C3 16.9686 7.02944 20.9981 12 20.9981C16.9706 20.9981 21 16.9686 21 11.9981C21 8.51664 19.0233 5.49689 16.1309 4", "M12 3V10") } val CalendarBlank: ImageVector by lazy { line("CalendarBlank", "M16 2V6M8 2V6", "M13 4H11C7.22876 4 5.34315 4 4.17157 5.17157C3 6.34315 3 8.22876 3 12V14C3 17.7712 3 19.6569 4.17157 20.8284C5.34315 22 7.22876 22 11 22H13C16.7712 22 18.6569 22 19.8284 20.8284C21 19.6569 21 17.7712 21 14V12C21 8.22876 21 6.34315 19.8284 5.17157C18.6569 4 16.7712 4 13 4Z", "M3 10H21", "M12.1258 14H12.0008M12.1258 18H12.0008M7.625 14H7.5M7.625 18H7.5M16.625 14H16.5M12.2508 14C12.2508 14.1381 12.1389 14.25 12.0008 14.25C11.8628 14.25 11.7508 14.1381 11.7508 14C11.7508 13.8619 11.8628 13.75 12.0008 13.75C12.1389 13.75 12.2508 13.8619 12.2508 14ZM12.2508 18C12.2508 18.1381 12.1389 18.25 12.0008 18.25C11.8628 18.25 11.7508 18.1381 11.7508 18C11.7508 17.8619 11.8628 17.75 12.0008 17.75C12.1389 17.75 12.2508 17.8619 12.2508 18ZM7.75 14C7.75 14.1381 7.63807 14.25 7.5 14.25C7.36193 14.25 7.25 14.1381 7.25 14C7.25 13.8619 7.36193 13.75 7.5 13.75C7.63807 13.75 7.75 13.8619 7.75 14ZM7.75 18C7.75 18.1381 7.63807 18.25 7.5 18.25C7.36193 18.25 7.25 18.1381 7.25 18C7.25 17.8619 7.36193 17.75 7.5 17.75C7.63807 17.75 7.75 17.8619 7.75 18ZM16.75 14C16.75 14.1381 16.6381 14.25 16.5 14.25C16.3619 14.25 16.25 14.1381 16.25 14C16.25 13.8619 16.3619 13.75 16.5 13.75C16.6381 13.75 16.75 13.8619 16.75 14Z") } val SlidersHorizontal: ImageVector by lazy { line("SlidersHorizontal", "M3.99963 5.00055L9.99963 5.00031", "M12.9996 5.00031L19.9996 5.00031", "M15.9996 9.00031L15.9996 15.0003", "M9.99963 2.00031L9.99963 8.00031", "M11.9996 16.0003L11.9996 22.0003", "M15.9996 12.0001L19.9996 12.0003", "M3.99963 12.0005L12.9996 12.0003", "M11.9996 19.0003L19.9996 19.0003", "M3.99963 19.0005L8.99963 19.0003") } val PlayFill: ImageVector by lazy { solid("PlayFill", "M18.8906 12.846C18.5371 14.189 16.8667 15.138 13.5257 17.0361C10.296 18.8709 8.6812 19.7884 7.37983 19.4196C6.8418 19.2671 6.35159 18.9776 5.95624 18.5787C5 17.6139 5 15.7426 5 12C5 8.2574 5 6.3861 5.95624 5.42132C6.35159 5.02245 6.8418 4.73288 7.37983 4.58042C8.6812 4.21165 10.296 5.12907 13.5257 6.96393C16.8667 8.86197 18.5371 9.811 18.8906 11.154C19.0365 11.7084 19.0365 12.2916 18.8906 12.846Z") } @@ -85,9 +86,7 @@ object AppIcons { val Vibrate: ImageVector by lazy { line("Vibrate", "M17 15V9C17 6.64298 17 5.46447 16.2678 4.73223C15.5355 4 14.357 4 12 4C9.64298 4 8.46447 4 7.73223 4.73223C7 5.46447 7 6.64298 7 9V15C7 17.357 7 18.5355 7.73223 19.2678C8.46447 20 9.64298 20 12 20C14.357 20 15.5355 20 16.2678 19.2678C17 18.5355 17 17.357 17 15Z", "M22 8L20.3578 9.23167C20.1325 9.40059 20 9.66569 20 9.94721C20 10.286 20.1914 10.5957 20.4944 10.7472L21.5528 11.2764C21.8269 11.4134 22 11.6936 22 12C22 12.3064 21.8269 12.5866 21.5528 12.7236L20.4944 13.2528C20.1914 13.4043 20 13.714 20 14.0528C20 14.3343 20.1325 14.5994 20.3578 14.7683L22 16", "M2 8L3.64223 9.23167C3.86745 9.40059 4 9.66569 4 9.94721C4 10.286 3.80859 10.5957 3.50557 10.7472L2.44721 11.2764C2.17313 11.4134 2 11.6936 2 12C2 12.3064 2.17313 12.5866 2.44721 12.7236L3.50557 13.2528C3.80859 13.4043 4 13.714 4 14.0528C4 14.3343 3.86745 14.5994 3.64223 14.7683L2 16") } val HandPalm: ImageVector by lazy { line("HandPalm", "M18.5 11.5V8.75C18.5 7.7835 19.2835 7 20.25 7C21.2165 7 22 7.7835 22 8.75V14.8C22 18.7765 18.7764 22 14.8 22H12.0588M8 14L5.61507 11.2176C5.22468 10.7621 4.65476 10.5 4.05489 10.5H3.91898C2.85916 10.5 2 11.3592 2 12.419C2 12.7978 2.11214 13.1682 2.32229 13.4834L5.7812 18.6718C7.16738 20.7511 9.50102 22 12 22", "M18.5 8.5V5.75C18.5 4.7835 17.7165 4 16.75 4C15.7835 4 15 4.7835 15 5.75V10.5", "M15 10.5V3.75C15 2.7835 14.2165 2 13.25 2C12.2835 2 11.5 2.7835 11.5 3.75V10", "M11.5 10V5.75C11.5 4.7835 10.7165 4 9.75 4C8.7835 4 8 4.7835 8 5.75V14") } val PersonSimpleRun: ImageVector by lazy { line("PersonSimpleRun", "M17 4.5C17 5.32843 16.3284 6 15.5 6C14.6716 6 14 5.32843 14 4.5C14 3.67157 14.6716 3 15.5 3C16.3284 3 17 3.67157 17 4.5Z", "M15 21.0008L14.3359 18.3848C14.1161 17.5191 13.6615 16.7284 13.0207 16.0974L11.5 14.5998M20 8.19913C17.9627 10.4921 16.1547 10.1433 15 9.27743C14.8878 9.19326 14.7664 9.06672 14.6482 8.92548C14.2356 8.43256 14.0293 8.18609 13.8282 8.09214C13.6271 7.99818 13.3747 7.99813 12.8698 7.99805C12.5444 7.99799 12.2186 7.99877 12 8.00136C8.53767 8.0423 7 9.18366 6 11.1534M12 8.00136L10.7309 9.95956C10.0332 11.0362 9.68429 11.5745 9.67069 12.1397C9.66463 12.3914 9.70617 12.642 9.79313 12.8784C9.98834 13.409 10.4922 13.8059 11.5 14.5998M15 9.27743L11.5 14.5998", "M4 17.7303L4.67822 17.8916C6.40663 18.3028 8.20324 17.5164 9 16") } - val ArrowClockwise: ImageVector by lazy { line("ArrowClockwise", "M16.5 7.99976H18C19.4142 7.99976 20.1213 7.99976 20.5607 7.56042C21 7.12108 21 6.41397 21 4.99976V3.49976", "M3 11.9998C3 7.02919 7.0293 2.99976 12 2.99976C15.571 2.99976 18.0948 4.73029 20 7.08347M21 11.9998C21 16.9703 16.9707 20.9998 12 20.9998C8.42904 20.9998 5.90524 19.2692 4 16.916", "M7.5 15.9998H6C4.58579 15.9998 3.87868 15.9998 3.43934 16.4391C3 16.8784 3 17.5855 3 18.9998V20.4998") } val Trash: ImageVector by lazy { line("Trash", "M19.5 5.5L18.8803 15.5251C18.7219 18.0864 18.6428 19.3671 18.0008 20.2879C17.6833 20.7431 17.2747 21.1273 16.8007 21.416C15.8421 22 14.559 22 11.9927 22C9.42312 22 8.1383 22 7.17905 21.4149C6.7048 21.1257 6.296 20.7408 5.97868 20.2848C5.33688 19.3626 5.25945 18.0801 5.10461 15.5152L4.5 5.5", "M3 5.5H21M16.0557 5.5L15.3731 4.09173C14.9196 3.15626 14.6928 2.68852 14.3017 2.39681C14.215 2.3321 14.1231 2.27454 14.027 2.2247C13.5939 2 13.0741 2 12.0345 2C10.9688 2 10.436 2 9.99568 2.23412C9.8981 2.28601 9.80498 2.3459 9.71729 2.41317C9.32164 2.7167 9.10063 3.20155 8.65861 4.17126L8.05292 5.5", "M9.5 16.5L9.5 10.5", "M14.5 16.5L14.5 10.5") } - val ArrowsClockwise: ImageVector by lazy { line("ArrowsClockwise", "M20.4879 15C19.2524 18.4956 15.9187 21 12 21C7.02943 21 3 16.9706 3 12C3 7.02943 7.02943 3 12 3C15.7292 3 18.9286 5.26806 20.2941 8.5", "M15 9H18C19.4142 9 20.1213 9 20.5607 8.56066C21 8.12132 21 7.41421 21 6V3") } val Cpu: ImageVector by lazy { line("Cpu", "M4 12C4 8.22876 4 6.34315 5.17157 5.17157C6.34315 4 8.22876 4 12 4C15.7712 4 17.6569 4 18.8284 5.17157C20 6.34315 20 8.22876 20 12C20 15.7712 20 17.6569 18.8284 18.8284C17.6569 20 15.7712 20 12 20C8.22876 20 6.34315 20 5.17157 18.8284C4 17.6569 4 15.7712 4 12Z", "M9.5 2V4", "M14.5 2V4", "M9.5 20V22", "M14.5 20V22", "M13 9L9 13", "M15 13L13 15", "M22 14.5L20 14.5", "M4 9.5L2 9.5", "M4 14.5L2 14.5", "M22 9.5L20 9.5") } // Sports (the picker's grid). @@ -127,7 +126,22 @@ object AppIcons { val Route01: ImageVector by lazy { line("Route01", "M15 5A3 3 0 1 0 21 5A3 3 0 1 0 15 5Z", "M3 19A3 3 0 1 0 9 19A3 3 0 1 0 3 19Z", "M12 5H8.5C6.567 5 5 6.567 5 8.5C5 10.433 6.567 12 8.5 12H15.5C17.433 12 19 13.567 19 15.5C19 17.433 17.433 19 15.5 19H12") } val FastWind: ImageVector by lazy { line("FastWind", "M2 5.94145C5.5 9.37313 10.5755 7.90241 11.7324 5.94145C11.9026 5.65301 12 5.31814 12 4.96096C12 3.87795 11.1046 3 10 3C8.89543 3 8 3.87795 8 4.96096", "M17 8.92814C17 7.31097 18.1193 6 19.5 6C20.8807 6 22 7.31097 22 8.92814C22 9.6452 21.7799 10.3021 21.4146 10.8111C19.3463 14.1915 9.2764 12.9164 4 11.8563", "M13.0854 19.8873C13.2913 20.5356 13.8469 21 14.5 21C15.3284 21 16 20.2528 16 19.331C16 19.0176 15.9224 18.7244 15.7873 18.4738C14.4999 15.9925 7.99996 14.3239 2 18.7746", "M19 15.5H21") } val Location01: ImageVector by lazy { line("Location01", "M13.6177 21.367C13.1841 21.773 12.6044 22 12.0011 22C11.3978 22 10.8182 21.773 10.3845 21.367C6.41302 17.626 1.09076 13.4469 3.68627 7.37966C5.08963 4.09916 8.45834 2 12.0011 2C15.5439 2 18.9126 4.09916 20.316 7.37966C22.9082 13.4393 17.599 17.6389 13.6177 21.367Z", "M15.5 11C15.5 12.933 13.933 14.5 12 14.5C10.067 14.5 8.5 12.933 8.5 11C8.5 9.067 10.067 7.5 12 7.5C13.933 7.5 15.5 9.067 15.5 11Z") } - val GridView: ImageVector by lazy { line("GridView", "M2 6C2 4.11438 2 3.17157 2.58579 2.58579C3.17157 2 4.11438 2 6 2C7.88562 2 8.82843 2 9.41421 2.58579C10 3.17157 10 4.11438 10 6C10 7.88562 10 8.82843 9.41421 9.41421C8.82843 10 7.88562 10 6 10C4.11438 10 3.17157 10 2.58579 9.41421C2 8.82843 2 7.88562 2 6Z", "M14 6C14 4.11438 14 3.17157 14.5858 2.58579C15.1716 2 16.1144 2 18 2C19.8856 2 20.8284 2 21.4142 2.58579C22 3.17157 22 4.11438 22 6C22 7.88562 22 8.82843 21.4142 9.41421C20.8284 10 19.8856 10 18 10C16.1144 10 15.1716 10 14.5858 9.41421C14 8.82843 14 7.88562 14 6Z", "M2 18C2 16.1144 2 15.1716 2.58579 14.5858C3.17157 14 4.11438 14 6 14C7.88562 14 8.82843 14 9.41421 14.5858C10 15.1716 10 16.1144 10 18C10 19.8856 10 20.8284 9.41421 21.4142C8.82843 22 7.88562 22 6 22C4.11438 22 3.17157 22 2.58579 21.4142C2 20.8284 2 19.8856 2 18Z", "M14 18C14 16.1144 14 15.1716 14.5858 14.5858C15.1716 14 16.1144 14 18 14C19.8856 14 20.8284 14 21.4142 14.5858C22 15.1716 22 16.1144 22 18C22 19.8856 22 20.8284 21.4142 21.4142C20.8284 22 19.8856 22 18 22C16.1144 22 15.1716 22 14.5858 21.4142C14 20.8284 14 19.8856 14 18Z") } + /** Walking, the sport tile and every walk. */ + val Walking: ImageVector by lazy { line("Walking", "M6 12.5L7.73811 9.89287C7.91034 9.63452 8.14035 9.41983 8.40993 9.26578L10.599 8.01487C11.1619 7.69323 11.8483 7.67417 12.4282 7.9641C13.0851 8.29255 13.4658 8.98636 13.7461 9.66522C14.2069 10.7814 15.3984 12 18 12", "M13 9L11.7772 14.5951M10.5 8.5L9.77457 11.7645C9.6069 12.519 9.88897 13.3025 10.4991 13.777L14 16.5L15.5 21", "M9.5 16L9 17.5L6.5 20.5", "M15 4.5C15 5.32843 14.3284 6 13.5 6C12.6716 6 12 5.32843 12 4.5C12 3.67157 12.6716 3 13.5 3C14.3284 3 15 3.67157 15 4.5Z") } + /** Running, the sport tile and every run. */ + val WorkoutRun: ImageVector by lazy { line("WorkoutRun", "M17 4.5C17 5.32843 16.3284 6 15.5 6C14.6716 6 14 5.32843 14 4.5C14 3.67157 14.6716 3 15.5 3C16.3284 3 17 3.67157 17 4.5Z", "M15 21.0008L14.3359 18.3848C14.1161 17.5191 13.6615 16.7284 13.0207 16.0974L11.5 14.5998M20 8.19913C17.9627 10.4921 16.1547 10.1433 15 9.27743C14.8878 9.19326 14.7664 9.06672 14.6482 8.92548C14.2356 8.43256 14.0293 8.18609 13.8282 8.09214C13.6271 7.99818 13.3747 7.99813 12.8698 7.99805C12.5444 7.99799 12.2186 7.99877 12 8.00136C8.53767 8.0423 7 9.18366 6 11.1534M12 8.00136L10.7309 9.95956C10.0332 11.0362 9.68429 11.5745 9.67069 12.1397C9.66463 12.3914 9.70617 12.642 9.79313 12.8784C9.98834 13.409 10.4922 13.8059 11.5 14.5998M15 9.27743L11.5 14.5998", "M4 17.7303L4.67822 17.8916C6.40663 18.3028 8.20324 17.5164 9 16") } + /** Strength training, the sport tile and every strength workout. */ + val Dumbbell01: ImageVector by lazy { line("Dumbbell01", "M7.73438 13.7323C7.73438 13.7323 8.63984 12.5102 9.23438 12.2319C11.0292 11.3915 11.3943 11.0263 12.2344 9.23091C12.5127 8.63618 13.7344 7.73044 13.7344 7.73044M10.2344 16.2331C10.2344 16.2331 11.4561 15.3273 11.7344 14.7326C12.5745 12.9373 12.9396 12.5721 14.7344 11.7317C15.3289 11.4533 16.2344 10.2312 16.2344 10.2312", "M14.4311 2.89207C14.938 2.38343 15.7611 2.38212 16.2696 2.88916L21.0814 7.68726C21.5899 8.1943 21.5912 9.01767 21.0843 9.52632L19.5557 11.0603C19.0488 11.5689 18.2257 11.5702 17.7172 11.0632L12.9054 6.26507C12.397 5.75803 12.3957 4.93466 12.9025 4.42601L14.4311 2.89207Z", "M4.41377 12.9022C4.92065 12.3936 5.74376 12.3923 6.25225 12.8993L11.0641 17.6974C11.5725 18.2045 11.5738 19.0278 11.067 19.5365L9.53836 21.0704C9.03148 21.5791 8.20837 21.5804 7.69988 21.0733L2.88808 16.2752C2.37959 15.7682 2.37829 14.9448 2.88517 14.4362L4.41377 12.9022Z", "M17.9377 3.45254C19.8201 0.985467 23.14 3.8401 20.5431 6.02872M3.37811 17.9773C0.998473 19.9687 3.99782 23.1586 6.06742 20.4657") } + /** A pennant, for marking a lap. */ + val Flag03: ImageVector by lazy { line("Flag03", "M15.8785 3L10.2827 3C7.32099 3 5.84015 3 4.92007 3.87868C4 4.75736 4 6.17157 4 9L4.10619 15L15.8785 15C18.1016 15 19.2131 15 19.6847 14.4255C19.8152 14.2666 19.9108 14.0841 19.9656 13.889C20.1639 13.184 19.497 12.3348 18.1631 10.6364L18.1631 10.6364C17.6083 9.92985 17.3309 9.57659 17.2814 9.1751C17.2671 9.05877 17.2671 8.94123 17.2814 8.8249C17.3309 8.42341 17.6083 8.07015 18.1631 7.36364L18.1631 7.36364C19.497 5.66521 20.1639 4.816 19.9656 4.11098C19.9108 3.91591 19.8152 3.73342 19.6847 3.57447C19.2131 3 18.1016 3 15.8785 3L15.8785 3Z", "M4 21L4 8") } + /** The sport picker's search field. */ + val Search01: ImageVector by lazy { line("Search01", "M17 17L21 21", "M19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11Z") } + /** Sync now, pull to sync, and a stale sync on the widgets' status line. */ + val Refresh04: ImageVector by lazy { line("Refresh04", "M3.05493 13C3.01863 12.6717 3 12.338 3 12C3 7.02944 7.02944 3 12 3C14.8273 3 17.35 4.30367 19 6.34267M20.9451 11C20.9814 11.3283 21 11.662 21 12C21 16.9706 16.9706 21 12 21C9.17273 21 6.64996 19.6963 5 17.6573", "M16 7H17C18.4142 7 19.1213 7 19.5607 6.56066C20 6.12132 20 5.41421 20 4V3", "M8 17H7C5.58579 17 4.87868 17 4.43934 17.4393C4 17.8787 4 18.5858 4 20V21") } + /** Find strap on the Strap page. */ + val SearchArea: ImageVector by lazy { line("SearchArea", "M18.5016 18.5L21 21M20 14.5C20 11.4624 17.5376 9 14.5 9C11.4624 9 9 11.4624 9 14.5C9 17.5376 11.4624 20 14.5 20C17.5376 20 20 17.5376 20 14.5Z", "M10 3H14M3 10V14M6.5 21C4.567 21 3 19.433 3 17.5M17.5 3C19.433 3 21 4.567 21 6.5M3 6.5C3 4.567 4.567 3 6.5 3") } + /** All sports: three dots in a circle, "more" at the end of the sport tiles, apart from All metrics' grid. */ + val MoreHorizontalCircle01: ImageVector by lazy { line("MoreHorizontalCircle01", "M21 12C21 11.1716 20.3284 10.5 19.5 10.5C18.6716 10.5 18 11.1716 18 12C18 12.8284 18.6716 13.5 19.5 13.5C20.3284 13.5 21 12.8284 21 12Z", "M13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716 10.5 12C10.5 12.8284 11.1716 13.5 12 13.5C12.8284 13.5 13.5 12.8284 13.5 12Z", "M6 12C6 11.1716 5.32843 10.5 4.5 10.5C3.67157 10.5 3 11.1716 3 12C3 12.8284 3.67157 13.5 4.5 13.5C5.32843 13.5 6 12.8284 6 12Z") } } /** Hugeicons' own stroke, and the bolder one that marks a selected tab. */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/SportIcons.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/SportIcons.kt index 76359a4d..c2a88840 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/workout/SportIcons.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/SportIcons.kt @@ -1,21 +1,15 @@ package org.openstrap.app.ui.workout -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.PathParser -import androidx.compose.ui.unit.dp import org.openstrap.app.ui.theme.AppIcons -/** Line icons for the sports people start most, on a 24-unit grid with a 2-unit stroke; Icon() tints them. */ +/** Each sport's icon, and the few the workout screens use, all from [AppIcons] (Hugeicons); Icon() tints them. */ internal object SportIcons { - val Run: ImageVector by lazy { stroke("Run", circle(14f, 4f, 2f), "m6 21 3-6 3 2v5", "m8 11 3-4 4 3 3 1", "M11 7 9 12l3 2") } - val Walk: ImageVector by lazy { stroke("Walk", circle(13f, 4f, 2f), "M10 21l2-6-2-3 1-4 3 3h3", "m8 12-2 3") } - val Strength: ImageVector by lazy { stroke("Strength", "M3 9v6M6 7v10M18 7v10M21 9v6M6 12h12") } - /** A flag, for marking a lap. */ - val Lap: ImageVector by lazy { stroke("Lap", "M5 21V4", "M5 4h11l-2 4 2 4H5") } + val Run: ImageVector get() = AppIcons.WorkoutRun + val Walk: ImageVector get() = AppIcons.Walking + val Strength: ImageVector get() = AppIcons.Dumbbell01 + /** A pennant, for marking a lap. */ + val Lap: ImageVector get() = AppIcons.Flag03 private val RUN = setOf(1, 2, 11) private val WALK = setOf(3, 15, 40, 215, 224, 225) @@ -57,31 +51,10 @@ internal object SportIcons { else -> AppIcons.Heartbeat } - private fun circle(cx: Float, cy: Float, r: Float) = "M${cx - r} ${cy}a$r $r 0 1 0 ${2 * r} 0a$r $r 0 1 0 ${-2 * r} 0" + /** The sport picker's search field. */ + val Search: ImageVector get() = AppIcons.Search01 - /** Hugeicons Search01, for the sport picker's search field. */ - val Search: ImageVector by lazy { - stroke( - "Search", "M17 17L21 21", "M19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11Z", - width = 1.5f, - ) - } - - /** Hugeicons Location01: the GPS row, and the mark on a sport done only outdoors. */ - val Location: ImageVector by lazy { - stroke( - "Location", - "M13.6177 21.367C13.1841 21.773 12.6044 22 12.0011 22C11.3978 22 10.8182 21.773 10.3845 21.367C6.41302 17.626 1.09076 13.4469 3.68627 7.37966C5.08963 4.09916 8.45834 2 12.0011 2C15.5439 2 18.9126 4.09916 20.316 7.37966C22.9082 13.4393 17.599 17.6389 13.6177 21.367Z", - "M15.5 11C15.5 12.933 13.933 14.5 12 14.5C10.067 14.5 8.5 12.933 8.5 11C8.5 9.067 10.067 7.5 12 7.5C13.933 7.5 15.5 9.067 15.5 11Z", - width = 1.5f, - ) - } + /** The GPS row, and the mark on a sport done only outdoors. */ + val Location: ImageVector get() = AppIcons.Location01 - private fun stroke(name: String, vararg paths: String, width: Float = 2f): ImageVector = - ImageVector.Builder("Sport.$name", 24.dp, 24.dp, viewportWidth = 24f, viewportHeight = 24f).apply { - for (d in paths) addPath( - PathParser().parsePathString(d).toNodes(), stroke = SolidColor(Color.Black), strokeLineWidth = width, - strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round, - ) - }.build() } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt index 48fd4551..867cea9e 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt @@ -500,7 +500,7 @@ 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 icons = tiles.sports.map { SportIcons.of(it.shownType) } + AppIcons.MoreHorizontalCircle01 val captions = tiles.captions val n = captions.size val icon = (tiles.tile * StartLayout.ICON_DP / StartLayout.SMALL_TILE_DP).coerceIn(16f, 24f) diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt index 92d2cef7..032e3805 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt @@ -458,7 +458,7 @@ internal fun StatusButtonView(line: StatusLine, f: WidgetFrame) { return } val (icon, color) = when (status) { - is StrapStatus.StaleSync -> AppIcons.ArrowsClockwise to MetricColors.warn + is StrapStatus.StaleSync -> AppIcons.Refresh04 to MetricColors.warn is StrapStatus.LowBattery -> AppIcons.BatteryLow to MetricColors.warn else -> AppIcons.Watch to Color.White } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/FrameRulesTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/FrameRulesTest.kt index 9a58d108..ea4aac97 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/FrameRulesTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/FrameRulesTest.kt @@ -72,12 +72,26 @@ class FrameRulesTest { assertEquals(13f, GrowMotion.outline(chip, page, 0f).topLeftCornerRadius.x, 1e-4f) } - @Test fun `it grows at 500 and shrinks at 300, fading in only at the start`() { + @Test fun `it grows at 500 and shrinks at 300, the surface at once and the content once it has some size`() { assertEquals(500f, GrowMotion.stiffness(opening = true)) assertEquals(300f, GrowMotion.stiffness(opening = false)) assertEquals(0f, GrowMotion.alpha(0f)) - assertEquals(1f, GrowMotion.alpha(GrowMotion.FADE_PART)) - assertEquals(1f, GrowMotion.alpha(1f)) + assertEquals(1f, GrowMotion.alpha(GrowMotion.SURFACE_PART)) + assertEquals(0f, GrowMotion.contentAlpha(GrowMotion.CONTENT_FROM)) + assertEquals(1f, GrowMotion.contentAlpha(0.5f)) + assertEquals(1f, GrowMotion.contentAlpha(1f)) + } + + @Test fun `the page keeps a card's corners until the end, its content scaled to the outline`() { + val button = Rect(Offset(16f, 40f), androidx.compose.ui.geometry.Size(38f, 38f)) + val page = Rect(0f, 0f, 400f, 800f) + assertEquals(28f, GrowMotion.outline(button, page, GrowMotion.SQUARE_FROM, 28f).topLeftCornerRadius.x, 1e-3f) + assertEquals(0f, GrowMotion.outline(button, page, 1f, 28f).topLeftCornerRadius.x, 1e-3f) + assertEquals(0.5f, GrowMotion.contentScale(200f, 400f), 1e-4f) + // The tab behind: dimmed by half and at 94% with the page open, untouched with it closed. + assertEquals(0.5f, GrowMotion.scrim(1f), 1e-4f) + assertEquals(0.94f, GrowMotion.sink(1f), 1e-4f) + assertEquals(1f, GrowMotion.sink(0f), 1e-4f) } @Test fun `You and Strap grow from the header's buttons`() { diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabCopyTest.kt index 1fadabf0..aa29a216 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabCopyTest.kt @@ -236,9 +236,14 @@ class TabCopyTest { @Test fun `workout copy never uses the banned punctuation`() { val lines = listOf( - TabCopy.FOUND_BY_STRAP, TabCopy.workoutFact(TabCopy.SportFact.Run, 5200f, false, 1685, null, 152)!!, + TabCopy.AUTO_DETECTED, TabCopy.workoutFact(TabCopy.SportFact.Run, 5200f, false, 1685, null, 152)!!, TabCopy.workoutLine("14 sets", "07:10"), TabCopy.workoutDay(java.time.LocalDate.of(2026, 10, 21), java.time.LocalDate.of(2026, 10, 23)), ) lines.forEach { l -> listOf("·", ";", "—").forEach { assertFalse("$l has $it", l.contains(it)) } } } + + @Test fun `before Habit Age is learned your age stands in`() { + assertEquals("Your age for now", TabCopy.habitAgeForNow(null)) + assertEquals("Your age for now. First estimate Sunday 1 Nov", TabCopy.habitAgeForNow("First estimate Sunday 1 Nov")) + } } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabPlansTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabPlansTest.kt index 66ad322f..ef151c2b 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabPlansTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabPlansTest.kt @@ -83,7 +83,7 @@ class TabPlansTest { assertEquals(Edge.Left, look(sleep, SleepCard.Naps).edge) val act = activityCards() assertEquals(CardLook(Tier.Level, Hue.Load, Edge.Left), look(act, ActivityCard.Load)) - assertEquals(CardLook(Tier.Edge, Hue.Neutral, Edge.Top), look(act, ActivityCard.Workouts)) + assertEquals(CardLook(Tier.List, Hue.Neutral), look(act, ActivityCard.Workouts)) assertEquals(Edge.Bottom, look(act, ActivityCard.HeartRate).edge) assertEquals(Edge.Right, look(activityCards(ActivityFacts(live = true)), ActivityCard.HeartRate).edge) } @@ -105,7 +105,7 @@ class TabPlansTest { assertEquals(CardSize.Small, act[ActivityCard.ActiveMinutes]) assertEquals(CardSize.Small, act[ActivityCard.HeartPoints]) assertEquals( - listOf(ActivityCard.Load, ActivityCard.HeartRate, ActivityCard.Facts), + listOf(ActivityCard.Load, ActivityCard.Facts, ActivityCard.HeartRate), activityCards().map { it.card }.dropWhile { it != ActivityCard.Load }.take(3), ) } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayLooksTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayLooksTest.kt index 2fd31f9d..9f90dadd 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayLooksTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayLooksTest.kt @@ -72,12 +72,24 @@ class TodayLooksTest { val after = today(Moment.After, TodayFacts(workout = true), wide = wide) assertEquals(CardLook(Tier.Edge, Hue.Load, Edge.Bottom, Shape.Small), after.first { it.card == LoadToday }.look) assertEquals(CardLook(Tier.Edge, Hue.Load, Edge.Top, Shape.Small), after.first { it.card == TodayCard.Recover }.look) - // Activity is lit from below, so Your week under it flips to the top. - assertEquals(Edge.Top, after.first { it.card == Week }.look?.edge) + // Your week is unlit, so it needs no flip under Activity's light. + assertEquals(CardLook(Tier.List, Hue.Neutral), after.first { it.card == Week }.look) assertVaried(after, wide, "after wide=$wide") } } + @Test fun `in the evening only the glows, Steps and heart rate are lit`() { + for (wide in listOf(false, true)) { + val evening = today(Moment.Evening, TodayFacts(), wide = wide) + val unlit = evening.filter { it.look?.tier == Tier.List }.map { it.card } + assertEquals(listOf(LastNight, Week, TodayCard.StartWorkout), unlit) + // Two unlit strips and an unlit card between them: told apart by their height. + assertEquals(Shape.Strip, evening.first { it.card == LastNight }.look?.shape) + assertEquals(Shape.Card, evening.first { it.card == Week }.look?.shape) + assertVaried(evening, wide, "evening wide=$wide") + } + } + @Test fun `in the evening heart rate flips to the top under Activity`() { val evening = today(Moment.Evening, TodayFacts()) assertEquals(listOf(Hero, TodayCard.Charge, Activity, TodayCard.HeartRate, LastNight, Week, TodayCard.StartWorkout, EditToday), evening.map { it.card }) @@ -131,9 +143,9 @@ class TodayLooksTest { val midday = today(Moment.Midday, TodayFacts()) val rec = midday.first { it.card == Recovery }.look val week = midday.first { it.card == Week }.look - assertEquals(CardLook(Tier.Edge, Hue.Neutral, Edge.Bottom), week) + assertEquals(CardLook(Tier.List, Hue.Neutral), week) assertNotEquals(rec?.hue, week?.hue) - assertNotEquals(rec?.edge, week?.edge) + assertNotEquals(rec?.tier, week?.tier) } @Test fun `a clash flips the later card's edge first`() { diff --git a/docs/design/v2/spec.md b/docs/design/v2/spec.md index 22d6490a..72dd12fe 100644 --- a/docs/design/v2/spec.md +++ b/docs/design/v2/spec.md @@ -105,7 +105,7 @@ reads as the same light: - **Energy is one colour.** Ember orange on every surface. Its level shows as the light's height, never as a hue, so `chargeScale`, `chargeAt` and `chargeDeep` go. The old gold `charge` #FFD166 becomes #F66C28. - **Recovery is one teal.** `readinessDeep` goes, and so does the band hue (teal, amber, coral): the word says the band. -- **Neutral light** for cards that speak for no one metric (Your week, Workouts, strips): hsl 220 10% 55%. +- **Neutral light** for cards that speak for no one metric (strips): hsl 220 10% 55%. Your week and Workouts are unlit. - **Green and amber** (`good`, `warn`) are for the strap and sync only, never a health value. `bad` goes. Surfaces and greys: @@ -249,6 +249,16 @@ a neutral edge card, the second glow as its level edge card) or the neutral ligh whose place is open: a pin, Your week or Habit Age, to the nearest place it fits, never splitting a Small pair. `varyLooks` applies this to Today after Edit Today's pins and hides. +**Light means something on Today.** Light is kept for cards where it says something: the hero and the second glow, +Steps (its line toward the goal) and Heart rate (live). Last night, Your week and Start a workout are unlit, a dark +panel with a hairline like a list (`EdgeOverride.plain`). An unlit strip and an unlit card read apart by height +(`Shape.Strip`), so they may follow each other. + +**The moving border.** Start a workout, on Today and on Activity, is the one action the screens offer, so it has the +one moving light: a heart-red light travelling round its border once every 7 s, brightest at its head and fading +along its tail, with a soft halo (`movingBorder`). It holds still while the list scrolls or the page is off screen, +as the glows do, and rests at the top with the system's animations off. + #### Glow card The recipe stays: lit fill, rim light, second-tone band, dark core, grain, hairline. Its light is L4 or L3 (2.1). @@ -513,6 +523,11 @@ Rows with values: Alarms, Phone alerts, Health alerts, Off-body reminder, Auto-d Stay connected in background, Firmware, Zepp account (moved out of Firmware). Then Restart and Forget. Alarms, Firmware and Profile pages move to the same edge and list styling (no Material panels). +**Health alerts**: one row per setting with its value on the right and a down caret (`PageMenuRow`). A tap opens a +menu at the row with every value the strap offers and a tick on the chosen one, in place of a row of chips per +setting. An Off value is dimmer. The measuring row says "Heart rate alerts need Continuous or every minute." under it +only when the chosen measuring turns the heart rate alerts off. + **Every page over a tab** (You, Strap, details, Habit Age, All workouts, Alarms, Phone alerts, Health alerts, Firmware, Profile, Goals, Diagnostics): a 44 dp round back button on the Control surface, the page title in Page title type (2.3), then cards in the tiers of 2.5. Material list items, cards, outlined text fields and dividers are replaced @@ -589,7 +604,7 @@ Rows in order. Sizes per 2.4, tiers per 2.5. | Midday | Energy: sentence, value with `lightLevel`, recharged and drained | Strip: Last night collapsed. Edge: Steps (ghost line), Heart rate. List "Right now" (stress, HRV). Edge: Recovery (left edge) | | Workout window | "Ready for effort. You usually run around now." Start button, Other sport | Second glow: Energy (wide), a level glow, the light filling across from the left to the value (Recovery's battery on its side). List "Before you start" (Load today, time to recover). Edge: Steps. Strip: Last night | | After a workout | "Since your run, 40 min. Heart rate settling." Drop curve into your resting range (a dim band), calm glow | Edge: the workout (left, with its zone bar). Small pair: Load (from below, its bar filling toward your usual day's Load) and To recover (flips to the top). Second glow: Energy, as in the workout window, with its line so far. Edge: Steps | -| Evening | "A full day. A run, past your step goal and a Load above your usual." The steps said against the goal, since the count is in dots under it. Load only in the sentence and Energy only on its card right under the hero; with that card hidden in Edit Today, "Energy 31 left" as the last line. "Bedtime in 1 h 10". Dim mood | Second glow: Energy, as in the workout window. Edge: Steps (line so far), Heart rate (the day's line, flips to the top under Steps unless live). Strip: Last night | +| Evening | "A full day. A run, past your step goal and a Load above your usual." The steps said against the goal, since the count is in dots under it. Load only in the sentence and Energy only on its card right under the hero; with that card hidden in Edit Today, "Energy 31 left" as the last line. "Bedtime in 1 h 10". Dim mood | Second glow: Energy, as in the workout window. Edge: Steps (the line so far, led by its sentence, since the hero has the count in dots), Heart rate (the day's line, flips to the top under Steps unless live). Strip: Last night, unlit | | Night | As evening, Night mood | As evening | | During a workout | The workout, live heart rate | As midday without Heart rate (the hero has it) | @@ -618,10 +633,10 @@ Present in every moment when they apply: - Workout offer and suggestion (strip), from today only. - Health alerts from the strap (strip, "From your strap"), opening the metric. -- "Start a workout" (strip, heart light from below) above Edit Today, in every moment except during a workout and +- "Start a workout" (strip, unlit, the moving border) above Edit Today, in every moment except during a workout and the workout window, whose hero has Start. The title, your usual sport under it ("Outdoor run, your usual") and a 44 dp white play button. Tapping opens Ready with that sport. -- "Your week" (wide edge card, neutral light from the bottom) Monday through Sunday, opening the week story. Steps a +- "Your week" (wide card, unlit) Monday through Sunday, opening the week story. Steps a day in dots, the other two figures as text (2.3). - Habit Age (edge, left, the same card as on Recovery) only when there's a new estimate: from the Sunday it updates until it has been opened or the Monday ends. Pinned in Edit Today it shows every day. Never before the first @@ -702,7 +717,7 @@ each nap's heart rate (the naps in the Sleep detail, step 6). ### 5.3 Activity tab -1. Wide edge card "Start a workout", neutral light from below. Today only. Your three most used sports as 64 dp +1. Wide card "Start a workout", unlit, with the moving border. Today only. Your three most used sports as 64 dp tiles with their names under them, then All sports. Outdoor sports carry a small location mark on the tile. A tile opens Ready with that sport (6.3). 2. Level glow: Steps, drawn like the Energy battery (2.1 Levels, the page's level card): the light rises from @@ -717,13 +732,13 @@ each nap's heart rate (the naps in the Sleep detail, step 6). 4. Level: Load, its light filling across toward your usual, "12 today", "Your usual is 40", balance word ("Building", "Steady", "Easing off") from the last 7 days against the last 6 weeks, with Today's 25% band for usual. No bar. The ghost line of your usual Load by now waits for Load by the hour (step 6). Strain lives in its detail. -5. Edge: Heart rate, the day's line with your resting range as a dim band, hold to scrub (the number follows). Lit - from the right while live, otherwise from below like any usual value. -6. List "Today", "So far": VO2 max, time to recover, energy, distance. -7. Edge, neutral light from the top: "Workouts", the day's and the latest before it (5 in all), under small dim day +5. List "Today", "So far": VO2 max, time to recover, energy, distance. +6. Edge: Heart rate, the day's line with your resting range as a dim band, hold to scrub (the number follows). Lit + from the right while live, otherwise from below like any usual value. It sits between the two lists, so they + never touch. +7. List, unlit: "Workouts", the day's and the latest before it (5 in all), under small dim day headers ("Today", "Yesterday", "Wed 21 Oct") instead of a date on each row, a pending suggestion as a row with - Save, "Add a workout you didn't record", "All workouts" always. A list right after another list, so it sits on - the edge card 2.5 allows. Each row (version A of the workout mocks): + Save, "Add a workout you didn't record", "All workouts" always. Each row (version A of the workout mocks): - **Effort light:** the sport's toned white icon on a small dark tile (38 dp, 12 dp corners) with a soft heart-red glow behind it, as strong as the workout's Load (its EPOC, the strap's or OpenStrap's) against your usual workout's: the median of the last 20 with one, a usual one at half, twice it at full. Under 3 workouts @@ -732,13 +747,13 @@ each nap's heart rate (the naps in the Sleep detail, step 6). distance for walks, sets for strength ("14 sets, 07:10"), average heart rate otherwise ("Average 128 bpm, 09:04"). Each falls back to the next the workout has. Distance (GPS or "≈" from steps, no pace from steps) and sets come from phone workouts only. The strap's workouts carry neither, so theirs say heart rate. - - **Found by the strap:** a quiet outlined tag beside the name of a workout the strap detected or filed as + - **Auto-detected:** a quiet outlined tag beside the name of a workout the strap detected or filed as "Activity". - - The duration in dots on the right and the thin zone bar under the row, lined up with the name. + - The duration as a plain row value on the right and the thin zone bar under the row, lined up with the name. - A personal best would be one warm line after the fact. The app doesn't detect them yet. All workouts uses the same row inside its day panels. -Wide: the strip and Steps span both columns. The Small pair and Load on the left, Heart rate, the list and Workouts on +Wide: the strip and Steps span both columns. The Small pair and Load on the left, the list, Heart rate and Workouts on the right. Gone from the screen: the offer card (Today only), active hours, the step counter mismatch note and every method @@ -766,7 +781,8 @@ The method, the pace of aging and what moves it live in its info sheet. the number moved. - **Last week:** each Sunday's estimate is stored as shown (`habit_age_week`, database version 18) when it's worked out, and the next week compares with the latest one from the 8 days before. -- **Before the first estimate:** "First estimate Sunday 22 Nov" and no number. Never on Today. +- **Before the first estimate:** your own age from your birth year in its place, "Your age for now. First estimate + Sunday 22 Nov" under it (or what the profile still needs). No number without a birth year. Never on Today. - **No birth year or sex:** says what's needed and opens the profile. - **Where:** always at the bottom of Recovery. On Today only while new (4.2), or pinned.