Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions android/app/src/main/java/com/noop/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,27 @@ object NoopPrefs {
of(context).edit().putBoolean(KEY_QUIET_MOTION, enabled).apply()
}

/** Which gauge Today draws: the GlowRing arc (default) or the liquid vessel it replaced (#2311).
*
* Android-only, and deliberately NOT Apple's `noop.liquidTodayEnabled`. That key switches between two
* whole Today SCREENS on iOS and macOS, `LiquidTodayView` (the default there) and the classic
* `TodayView`. Android has a single Today screen, so this chooses a gauge inside it and nothing else.
*
* Sharing the key would also INVERT it: `true` means liquid on Apple and rings (not liquid) here, so
* one stored value would drive two opposite looks. Two unrelated meanings on one setting, and a future
* divergence on either platform silently wrong.
*
* Defaults to the rings, which is what #2311 shipped; the vessels stay available for anyone who
* preferred them. */
const val KEY_TODAY_RING_GAUGES = "noop.todayRingGauges"

fun todayRingGauges(context: Context): Boolean =
of(context).getBoolean(KEY_TODAY_RING_GAUGES, true)

fun setTodayRingGauges(context: Context, enabled: Boolean) {
of(context).edit().putBoolean(KEY_TODAY_RING_GAUGES, enabled).apply()
}

/**
* When the live /models catalogue was last pulled for a provider, epoch millis, keyed per
* provider so switching does not hide a stale list behind another provider's refresh.
Expand Down
17 changes: 17 additions & 0 deletions android/app/src/main/java/com/noop/ui/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,9 @@ fun SettingsScreen(
// In-app quiet motion (#941), default OFF. The process-wide preference observer in NoopMotion makes
// this take effect on every currently composed looping surface as soon as the switch is flipped.
var quietMotion by remember { mutableStateOf(NoopPrefs.quietMotion(context)) }
// Ring vs vessel gauges on Today (#2311 follow-up), default ON (rings). Unlike quietMotion directly
// above, this one is NOT live: Today reads it on entry, like the other Today-screen display toggles.
var todayRingGauges by remember { mutableStateOf(NoopPrefs.todayRingGauges(context)) }
// HRV window (#141) — whole-night vs deep-sleep (WHOOP-style). NOT display-only: it changes the computed
// avgHrv, so a switch clears the analyze watermark to force a re-score + re-baseline on the next pass.
var hrvWindow by remember { mutableStateOf(UnitPrefs.hrvWindow(context)) }
Expand Down Expand Up @@ -1589,6 +1592,20 @@ fun SettingsScreen(
},
)

// Which gauge Today draws (#2311 follow-up). Sits with the display toggles rather than in an
// experimental section: neither option is a prototype, one replaced the other. Like the
// day-cycle background below, the pref is read when Today is entered.
SettingsRowDivider()
SettingsToggleRow(
title = uiString(R.string.l10n_settings_screen_ring_gauges_on_today_7a532de2),
detail = uiString(R.string.l10n_settings_screen_off_returns_the_liquid_vessels_6bb9236e),
checked = todayRingGauges,
onCheckedChange = {
todayRingGauges = it
NoopPrefs.setTodayRingGauges(context, it)
},
)

// Day-cycle background (#698): the time-of-day scene behind Today. On by default. Off swaps it
// for a plain dark canvas for people who find the moving scene distracting. Takes effect next
// time Today is opened (the pref is read once on entry, like the other Today-screen toggles).
Expand Down
156 changes: 121 additions & 35 deletions android/app/src/main/java/com/noop/ui/TodayScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2985,6 +2985,16 @@ private fun ScoreHeroRow(
val effortOutOf = if (effortScale == EffortScale.WHOOP) 21.0 else 100.0
val effortVal = strain?.let { UnitFormatter.effortValue(it, effortScale) } ?: 0.0

// Vessel path only: the vessels run LIVE (per-frame slosh + tilt) once the row has any real score to
// show; a wholly empty/calibrating hero poses them static so a brand-new user's launch churn isn't
// fighting live canvases (the Android equivalent of the iOS `dataLoaded` gate on HeroScoreCell).
// LiquidVessel + the count-up both honour Reduce Motion internally, so this is purely a "don't animate
// an empty hero" cost gate. A carried Charge counts as data (its dimmed vessel should slosh like the
// Rest one). The ring rendering ignores it: GlowRing springs once to its target and then sits still,
// so there is no per-frame cost to gate.
val heroVesselsAnimated =
recovery != null || strain != null || restScore != null || lastScoredCharge != null

Box(
modifier = Modifier
.fillMaxWidth(),
Expand Down Expand Up @@ -3041,6 +3051,8 @@ private fun ScoreHeroRow(
tint = Palette.recoveryColor(carried.value),
diameter = ring,
showsValue = true,
animated = heroVesselsAnimated,
onTap = onChargeTap,
)
} else {
HeroScoreVessel(
Expand All @@ -3049,6 +3061,8 @@ private fun ScoreHeroRow(
tint = Palette.recoveryColor(recovery ?: 0.0),
diameter = ring,
showsValue = recovery != null,
animated = heroVesselsAnimated,
onTap = onChargeTap,
)
// Empty ring + calibrating / no-data overlay (the carried case is above).
if (recovery == null) RingEmptyOverlay(recoveryCalibration, diameter = ring)
Expand Down Expand Up @@ -3076,6 +3090,8 @@ private fun ScoreHeroRow(
diameter = ring,
showsValue = strain != null,
format = { if (effortScale == EffortScale.WHOOP) String.format(Locale.getDefault(), "%.1f", it) else it.toInt().toString() },
animated = heroVesselsAnimated,
onTap = effortRingTap,
)
if (strain == null) RingNoData(diameter = ring)
}
Expand Down Expand Up @@ -3107,6 +3123,8 @@ private fun ScoreHeroRow(
tint = Palette.recoveryColor(restScore ?: 0.0),
diameter = ring,
showsValue = restScore != null,
animated = heroVesselsAnimated,
onTap = restRingTap,
)
if (restScore == null) {
// #898: an aggregate-import user (a daily HRV/RHR import, no in-bed session) gets a
Expand Down Expand Up @@ -3269,16 +3287,26 @@ private fun HeroRingColumn(
}

/**
* One hero score as a [GlowRing] — the ring-style gauge shared with the Intelligence forecast card.
* Replaces the former liquid-vessel hero with the same crisp-arc + count-up primitive the rest of the
* app already uses: a 10%-of-diameter stroke, spring arc from 12 o'clock, and a tween count-up number
* at `diameter * 0.36` Bold (the [glowRingCenterTextStyle] house numeral). The caller overlays the
* calibrating / No-Data / Needs-a-Tracked-Night text when [showsValue] is false, matching the prior
* liquid-ring branch structure.
* One hero score, drawn as whichever gauge the Today preference selects.
*
* DEFAULT, and what #2311 shipped: a [GlowRing] — the ring-style gauge shared with the Intelligence
* forecast card, the same crisp-arc + count-up primitive the rest of the app already uses: a
* 10%-of-diameter stroke, spring arc from 12 o'clock, and a tween count-up number at `diameter * 0.36`
* Bold (the [glowRingCenterTextStyle] house numeral).
*
* OFF: the [LiquidVessel] the ring replaced, filling to [fraction] in the domain [tint] with a
* [CountUpText] rolling the number up over it (white, tabular, a soft shadow so it reads on the vessel),
* matching the iOS `HeroScoreCell`. The number is hit-transparent (clearAndSetSemantics, no clickable)
* so a tap falls THROUGH to the vessel, which owns its own tap to splash + haptic.
*
* Both renderings take the SAME bindings — same fraction, same value, same value-sampled tint — so
* whichever one is on agrees with every other surface that shows the metric. The caller overlays the
* calibrating / No-Data / Needs-a-Tracked-Night text when [showsValue] is false, for either gauge.
*
* Values/bindings are UNCHANGED from the LiquidVessel this replaced — same fraction, same value, same
* value-sampled tint — so the ring colour, fill and number agree with every other surface that shows
* the metric.
* The preference is read on entry via [remember], the convention every other Today-screen display toggle
* follows (day-cycle background, sky behind cards, hydration). Settings is its own NavHost destination, so
* Today leaves composition while it is open and reads the pref again on the way back: a flip is live the
* next time Today is opened, which is the first moment it could be seen anyway.
*/
@Composable
private fun HeroScoreVessel(
Expand All @@ -3289,17 +3317,52 @@ private fun HeroScoreVessel(
modifier: Modifier = Modifier,
showsValue: Boolean = true,
format: (Double) -> String = { it.roundToInt().toString() },
// Both of these are VESSEL-only; the ring branch ignores them.
//
// Whether the vessel sloshes live. The caller passes its empty-hero cost gate, so a brand-new user's
// launch churn isn't fighting live canvases. GlowRing needs no equivalent.
animated: Boolean = true,
// Its animated render owns a clickable that consumes the touch, so a
// clickable parent never sees a tap landing on it (#1995). The ring attaches none, which is why
// #2311 could move the handler up to HeroRingColumn.onRingTap and drop this. With the vessel
// selectable again the forwarding has to come back, for that rendering only.
onTap: (() -> Unit)? = null,
) {
GlowRing(
fraction = fraction.coerceIn(0.0, 1.0).toFloat(),
value = value,
color = tint,
diameter = diameter,
lineWidth = diameter * 0.10f,
modifier = modifier,
showsLabel = showsValue,
format = format,
)
val context = LocalContext.current
val ringGauges = remember { NoopPrefs.todayRingGauges(context) }
if (ringGauges) {
GlowRing(
fraction = fraction.coerceIn(0.0, 1.0).toFloat(),
value = value,
color = tint,
diameter = diameter,
lineWidth = diameter * 0.10f,
modifier = modifier,
showsLabel = showsValue,
format = format,
)
} else Box(modifier = modifier.size(diameter), contentAlignment = Alignment.Center) {
LiquidVessel(
value = fraction.coerceIn(0.0, 1.0),
tint = tint,
animated = animated,
modifier = Modifier.size(diameter),
onTap = onTap,
)
if (showsValue) {
// Count-up number over the vessel — white, tabular, a soft shadow for legibility, hit-transparent
// so the tap reaches the vessel (splash). Size ≈ diameter × 0.27 (iOS 96→26 ratio), capped.
val numberSp = (diameter.value * 0.27f).coerceIn(20f, 30f)
CountUpText(
value = value,
format = format,
style = NoopType.number(numberSp, weight = FontWeight.Bold)
.copy(shadow = Shadow(color = Color.Black.copy(alpha = 0.5f), offset = Offset(0f, 1f), blurRadius = 6f)),
color = Color.White,
modifier = Modifier.clearAndSetSemantics {},
)
}
}
}

/**
Expand Down Expand Up @@ -3723,14 +3786,25 @@ private fun HeroVitalRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Metrics.space12),
) {
GlowRing(
fraction = (fraction ?: 0.0).coerceIn(0.0, 1.0).toFloat(),
value = 0.0,
color = tint,
diameter = 26.dp,
lineWidth = 2.6.dp,
showsLabel = false,
)
// Ring or vessel per the Today gauge preference; both are static here.
val gaugeCtx = LocalContext.current
if (remember { NoopPrefs.todayRingGauges(gaugeCtx) }) {
GlowRing(
fraction = (fraction ?: 0.0).coerceIn(0.0, 1.0).toFloat(),
value = 0.0,
color = tint,
diameter = 26.dp,
lineWidth = 2.6.dp,
showsLabel = false,
)
} else {
LiquidVessel(
value = fraction,
tint = tint,
animated = false,
modifier = Modifier.size(26.dp),
)
}
Text(label, style = NoopType.subhead, color = Palette.textSecondary, modifier = Modifier.weight(1f))
Text(
displayValue,
Expand Down Expand Up @@ -4443,14 +4517,26 @@ private fun DashboardCardRow(
) {
// A 30dp mini GlowRing filled to this card's fraction, tinted its domain colour — the
// "small ring per icon" iOS shows. Static (GlowRing starts empty, springs to target once).
GlowRing(
fraction = (fraction ?: 0.0).coerceIn(0.0, 1.0).toFloat(),
value = 0.0,
color = tint,
diameter = 30.dp,
lineWidth = 3.dp,
showsLabel = false,
)
// Ring or vessel per the Today gauge preference. Static either way: the many small gauges
// cost nothing per frame, which is what the vessel's animated=false bought here too.
val cardGaugeCtx = LocalContext.current
if (remember { NoopPrefs.todayRingGauges(cardGaugeCtx) }) {
GlowRing(
fraction = (fraction ?: 0.0).coerceIn(0.0, 1.0).toFloat(),
value = 0.0,
color = tint,
diameter = 30.dp,
lineWidth = 3.dp,
showsLabel = false,
)
} else {
LiquidVessel(
value = fraction,
tint = tint,
animated = false,
modifier = Modifier.size(30.dp),
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(1.dp),
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2802,4 +2802,6 @@
<string name="l10n_live_screen_connected_waiting_for_live_heart_rate_01d216bb">Verbunden, warte auf Live-Herzfrequenz.</string>
<string name="l10n_live_screen_live_heart_rate_is_flowing_from_f7f6c4fe">Live-Herzfrequenz kommt vom Ring.</string>
<string name="l10n_live_screen_reconnect_drops_the_current_link_if_afc27801">„Neu verbinden“ trennt die aktuelle Verbindung, falls vorhanden, und verbindet den Ring erneut. Zum Koppeln oder Wechseln von Bändern öffne „Geräte“.</string>
<string name="l10n_settings_screen_ring_gauges_on_today_7a532de2">Ring-Anzeigen auf Heute</string>
<string name="l10n_settings_screen_off_returns_the_liquid_vessels_6bb9236e">Aus zeigt wieder die Liquid-Anzeigen.</string>
</resources>
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2789,4 +2789,6 @@
<string name="l10n_live_screen_connected_waiting_for_live_heart_rate_01d216bb">Conectado, esperando la frecuencia cardíaca en vivo.</string>
<string name="l10n_live_screen_live_heart_rate_is_flowing_from_f7f6c4fe">La frecuencia cardíaca en vivo llega desde el anillo.</string>
<string name="l10n_live_screen_reconnect_drops_the_current_link_if_afc27801">Reconectar corta el enlace actual, si lo hay, y vuelve a conectar con el anillo. Para emparejar o cambiar de banda, abre Dispositivos.</string>
<string name="l10n_settings_screen_ring_gauges_on_today_7a532de2">Indicadores de anillo en Hoy</string>
<string name="l10n_settings_screen_off_returns_the_liquid_vessels_6bb9236e">Desactivado vuelve a los indicadores líquidos.</string>
</resources>
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2788,4 +2788,6 @@
<string name="l10n_live_screen_connected_waiting_for_live_heart_rate_01d216bb">Connecté, en attente de la fréquence cardiaque en direct.</string>
<string name="l10n_live_screen_live_heart_rate_is_flowing_from_f7f6c4fe">La fréquence cardiaque en direct arrive de l\'anneau.</string>
<string name="l10n_live_screen_reconnect_drops_the_current_link_if_afc27801">Reconnecter coupe le lien actuel, s\'il existe, et se reconnecte à l\'anneau. Pour associer ou changer de bande, ouvrez Appareils.</string>
<string name="l10n_settings_screen_ring_gauges_on_today_7a532de2">Jauges en anneau sur Aujourd\'hui</string>
<string name="l10n_settings_screen_off_returns_the_liquid_vessels_6bb9236e">Désactivé rétablit les jauges liquides.</string>
</resources>
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-pl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2797,4 +2797,6 @@
<string name="l10n_live_screen_connected_waiting_for_live_heart_rate_01d216bb">Połączono, czekam na tętno na żywo.</string>
<string name="l10n_live_screen_live_heart_rate_is_flowing_from_f7f6c4fe">Tętno na żywo płynie z pierścienia.</string>
<string name="l10n_live_screen_reconnect_drops_the_current_link_if_afc27801">Połącz ponownie zrywa bieżące połączenie, jeśli istnieje, i ponownie łączy się z pierścieniem. Aby sparować lub przełączyć pasma, otwórz Urządzenia.</string>
<string name="l10n_settings_screen_ring_gauges_on_today_7a532de2">Wskaźniki pierścieniowe w Dzisiaj</string>
<string name="l10n_settings_screen_off_returns_the_liquid_vessels_6bb9236e">Wyłączone przywraca wskaźniki płynne.</string>
</resources>
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-pt-rPT/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2781,4 +2781,6 @@
<string name="l10n_live_screen_connected_waiting_for_live_heart_rate_01d216bb">Ligado, aguardando a frequência cardíaca em direto.</string>
<string name="l10n_live_screen_live_heart_rate_is_flowing_from_f7f6c4fe">A frequência cardíaca em direto está a chegar do anel.</string>
<string name="l10n_live_screen_reconnect_drops_the_current_link_if_afc27801">Voltar a ligar termina a ligação atual, se existir, e liga-se novamente ao anel. Para emparelhar ou mudar de banda, abra Dispositivos.</string>
<string name="l10n_settings_screen_ring_gauges_on_today_7a532de2">Indicadores em anel no Hoje</string>
<string name="l10n_settings_screen_off_returns_the_liquid_vessels_6bb9236e">Desligado repõe os indicadores líquidos.</string>
</resources>
2 changes: 2 additions & 0 deletions android/app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2676,4 +2676,6 @@
<string name="l10n_live_screen_connected_waiting_for_live_heart_rate_01d216bb">Подключено, ожидание пульса в реальном времени.</string>
<string name="l10n_live_screen_live_heart_rate_is_flowing_from_f7f6c4fe">Пульс в реальном времени поступает с кольца.</string>
<string name="l10n_live_screen_reconnect_drops_the_current_link_if_afc27801">«Переподключить» разрывает текущее соединение, если оно есть, и снова подключается к кольцу. Чтобы выполнить сопряжение или сменить браслет, откройте раздел «Устройства».</string>
<string name="l10n_settings_screen_ring_gauges_on_today_7a532de2">Кольцевые шкалы в «Сегодня»</string>
<string name="l10n_settings_screen_off_returns_the_liquid_vessels_6bb9236e">Выключено возвращает жидкие шкалы.</string>
</resources>
Loading
Loading