diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift
index 8532eb16a9..3f4ae341ed 100644
--- a/Strand/Liquid/LiquidTodayView.swift
+++ b/Strand/Liquid/LiquidTodayView.swift
@@ -2559,9 +2559,11 @@ extension LiquidTodayView {
case offline
/// Linked, but no charge reading has landed yet. `charging` is still knowable on its own.
case pending(charging: Bool)
- /// A reading from the current link.
- case charge(pct: Double, charging: Bool)
- /// The strap is not the active device, so this control has nothing to say and is not drawn.
+ /// A reading from the current link. `isRing` says whose: the ring's own charge under an active
+ /// ring, the strap's under an active strap — the label names the device the number belongs to.
+ case charge(pct: Double, charging: Bool, isRing: Bool)
+ /// The active device is neither the strap nor a ring that has reported its charge this link, so
+ /// this control has nothing to say and is not drawn.
///
/// Distinct from [offline], which asserts a strap that IS active is not connected. Collapsing the
/// two put a crossed-out bolt and "strap not connected" on the header of a wearer whose ring was
@@ -2572,16 +2574,26 @@ extension LiquidTodayView {
/// #2208: `activeIsWhoop` is required, not defaulted. `connected` alone was never enough: it is
/// true the moment ANY source streams, `batteryPct` is the strap's and is never cleared, so under
/// an active ring both halves of the old gate passed and this drew the strap's charge. Charging
- /// is strap-only for the same reason, so a non-WHOOP active device reports neither.
+ /// is strap-only for the same reason, so a non-WHOOP active device reports neither of the strap's.
///
- /// No default value on purpose. A defaulted flag is one a future call site can forget, and
+ /// A ring reports its OWN charge into `ringPct` (`LiveState.ouraBatteryPct`), cleared with the
+ /// link, so under a non-WHOOP active device a non-nil `ringPct` is a reading from the ring that is
+ /// live right now and is drawn as such; nil (no ring, or none has reported yet) keeps the control
+ /// off the header. `ringCharging` is the ring's charger state (`OuraWearState.charging`), the only
+ /// charging evidence a ring gives. Same resolution `LiveConsoleReadout.batteryPercent` applies.
+ ///
+ /// No default values on purpose. A defaulted flag is one a future call site can forget, and
/// forgetting it reinstates exactly this bug in a form that still compiles.
static func resolve(activeIsWhoop: Bool, connected: Bool,
- batteryPct: Double?, charging: Bool?) -> StrapBatteryDisplay {
- guard activeIsWhoop else { return .notActiveDevice }
+ batteryPct: Double?, charging: Bool?,
+ ringPct: Int?, ringCharging: Bool) -> StrapBatteryDisplay {
+ guard activeIsWhoop else {
+ guard let ringPct else { return .notActiveDevice }
+ return .charge(pct: Double(ringPct), charging: ringCharging, isRing: true)
+ }
guard connected else { return .offline }
guard let pct = batteryPct else { return .pending(charging: charging == true) }
- return .charge(pct: pct, charging: charging == true)
+ return .charge(pct: pct, charging: charging == true, isRing: false)
}
}
@@ -2666,8 +2678,9 @@ extension LiquidTodayView {
}
}
-/// Strap-battery ring. At sync start it briefly expands within the trailing control row, then settles into
-/// an in-place spinner; the layered header keeps either state from moving the Today content. Tap → Devices.
+/// Active-device battery ring: the strap's charge under an active strap, the ring's own under an active
+/// ring. At sync start it briefly expands within the trailing control row, then settles into an in-place
+/// spinner; the layered header keeps either state from moving the Today content. Tap → Devices.
private struct LiquidBatteryButton: View {
@EnvironmentObject var live: LiveState
@EnvironmentObject var router: NavRouter
@@ -2704,7 +2717,9 @@ private struct LiquidBatteryButton: View {
activeIsWhoop: true,
connected: true,
batteryPct: DemoSyncHarness.batteryPercent,
- charging: DemoSyncHarness.charging
+ charging: DemoSyncHarness.charging,
+ ringPct: nil,
+ ringCharging: false
)
}
#endif
@@ -2712,7 +2727,9 @@ private struct LiquidBatteryButton: View {
activeIsWhoop: live.activeIsWhoop,
connected: live.connected,
batteryPct: live.batteryPct,
- charging: live.charging
+ charging: live.charging,
+ ringPct: live.ouraBatteryPct,
+ ringCharging: live.ouraWearState == .charging
)
}
@@ -2722,16 +2739,18 @@ private struct LiquidBatteryButton: View {
return .offline
case .pending(let charging):
return .pending(charging: charging)
- case .charge(let percent, let charging):
+ case .charge(let percent, let charging, _):
return .charge(percent: percent, charging: charging)
}
}
var body: some View {
- // Not drawn at all when the strap is not the active device. The alternative is a glyph that
- // has to say SOMETHING about a strap nobody is wearing, and every option is a claim: a charge
- // that is not the active device's, or a crossed-out bolt asserting a disconnection that is not
- // the interesting fact. The two Today rows already resolve it this way. (#2208)
+ // Not drawn at all when the active device is neither the strap nor a ring with a charge of its
+ // own to show. The alternative is a glyph that has to say SOMETHING about a strap nobody is
+ // wearing, and every option is a claim: a charge that is not the active device's, or a crossed-out
+ // bolt asserting a disconnection that is not the interesting fact. (#2208) A ring that HAS
+ // reported its charge is the active device's own reading, and #2208's fix left it undrawn only
+ // because the control could not yet tell whose number it held.
if case .notActiveDevice = batteryDisplay {
EmptyView()
} else {
@@ -2825,8 +2844,15 @@ private struct LiquidBatteryButton: View {
return charging
? String(localized: "Strap battery charging, no reading yet")
: String(localized: "Strap battery, no reading yet")
- case .charge(let percent, let charging):
+ case .charge(let percent, let charging, let isRing):
let n = Int(percent.rounded())
+ // Named for the device the number belongs to: "Strap battery" over a ring's charge would be
+ // the #2208 misattribution again, in the label instead of the number.
+ if isRing {
+ return charging
+ ? String(localized: "Ring battery \(n) percent, charging")
+ : String(localized: "Ring battery \(n) percent")
+ }
return charging
? String(localized: "Strap battery \(n) percent, charging")
: String(localized: "Strap battery \(n) percent")
diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings
index d62de7d5b3..f48591a1fd 100644
--- a/Strand/Resources/Localizable.xcstrings
+++ b/Strand/Resources/Localizable.xcstrings
@@ -1,6 +1,12 @@
{
"sourceLanguage": "en",
"strings": {
+ "Ring battery %lld percent": { "localizations": {
+ "de": {"stringUnit": {"state": "translated", "value": "Ring-Akku %lld Prozent"}}, "es": {"stringUnit": {"state": "translated", "value": "Batería del anillo %lld por ciento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Batterie de la bague %lld pour cent"}}, "it": {"stringUnit": {"state": "translated", "value": "Batteria dell'anello %lld percento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bateria pierścienia %lld procent"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bateria do anel %lld por cento"}}, "ru": {"stringUnit": {"state": "translated", "value": "Заряд кольца %lld процентов"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "戒指电量百分之 %lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "戒指電量百分之 %lld"}}
+ } },
+ "Ring battery %lld percent, charging": { "localizations": {
+ "de": {"stringUnit": {"state": "translated", "value": "Ring-Akku %lld Prozent, wird geladen"}}, "es": {"stringUnit": {"state": "translated", "value": "Batería del anillo %lld por ciento, cargando"}}, "fr": {"stringUnit": {"state": "translated", "value": "Batterie de la bague %lld pour cent, en charge"}}, "it": {"stringUnit": {"state": "translated", "value": "Batteria dell'anello %lld percento, in carica"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bateria pierścienia %lld procent, ładowanie"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bateria do anel %lld por cento, a carregar"}}, "ru": {"stringUnit": {"state": "translated", "value": "Заряд кольца %lld процентов, заряжается"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "戒指电量百分之 %lld,正在充电"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "戒指電量百分之 %lld,正在充電"}}
+ } },
"Oura notification mask ff (experimental)": { "localizations": {
"de": {"stringUnit": {"state": "translated", "value": "Oura-Benachrichtigungsmaske ff (experimentell)"}}, "es": {"stringUnit": {"state": "translated", "value": "Máscara de notificación Oura ff (experimental)"}}, "fr": {"stringUnit": {"state": "translated", "value": "Masque de notification Oura ff (expérimental)"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Máscara de notificação Oura ff (experimental)"}}, "it": {"stringUnit": {"state": "translated", "value": "Maschera di notifica Oura ff (sperimentale)"}}, "pl": {"stringUnit": {"state": "translated", "value": "Maska powiadomień Oura ff (eksperymentalne)"}}, "ru": {"stringUnit": {"state": "translated", "value": "Маска уведомлений Oura ff (экспериментально)"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "Oura 通知掩码 ff(实验性)"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "Oura 通知遮罩 ff(實驗性)"}}
} },
diff --git a/StrandTests/LiquidBatteryDisplayTests.swift b/StrandTests/LiquidBatteryDisplayTests.swift
index 352ea98174..c2f067b56e 100644
--- a/StrandTests/LiquidBatteryDisplayTests.swift
+++ b/StrandTests/LiquidBatteryDisplayTests.swift
@@ -19,7 +19,7 @@ final class LiquidBatteryDisplayTests: XCTestCase {
/// ever) telling us a number. The old view nested the bolt inside `if let pct`, making this state
/// unrenderable — it drew `bolt.slash` at a wearer who was sitting on the charger.
func testChargingIsReportedEvenWithNoChargeReadingYet() {
- let d = Display.resolve(activeIsWhoop: true, connected: true, batteryPct: nil, charging: true)
+ let d = Display.resolve(activeIsWhoop: true, connected: true, batteryPct: nil, charging: true, ringPct: nil, ringCharging: false)
XCTAssertEqual(d, .pending(charging: true),
"a known charging state must survive a missing % — it is the wearer's live question")
}
@@ -28,7 +28,7 @@ final class LiquidBatteryDisplayTests: XCTestCase {
/// `.pending(charging: false)` and `.offline` must stay distinguishable so the view can render one as
/// a pending ellipsis and the other as a crossed-out bolt.
func testConnectedWithNoReadingIsPendingNotOffline() {
- let d = Display.resolve(activeIsWhoop: true, connected: true, batteryPct: nil, charging: nil)
+ let d = Display.resolve(activeIsWhoop: true, connected: true, batteryPct: nil, charging: nil, ringPct: nil, ringCharging: false)
XCTAssertEqual(d, .pending(charging: false))
XCTAssertNotEqual(d, .offline, "connected-but-silent is not the same claim as no link")
}
@@ -40,29 +40,29 @@ final class LiquidBatteryDisplayTests: XCTestCase {
/// forever, and a view keying off `batteryPct` alone shows a dead strap's stale charge as if live.
/// During the incident that rendered a 21 h old 11% identically to a fresh one.
func testStaleChargeIsNotShownOnceTheLinkIsGone() {
- let d = Display.resolve(activeIsWhoop: true, connected: false, batteryPct: 11, charging: false)
+ let d = Display.resolve(activeIsWhoop: true, connected: false, batteryPct: 11, charging: false, ringPct: nil, ringCharging: false)
XCTAssertEqual(d, .offline, "a % with no link behind it must not render as a live reading")
}
/// Disconnect must also drop a charging bit — nothing about the old link is still true.
func testStaleChargingFlagIsNotShownOnceTheLinkIsGone() {
- XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: false, batteryPct: nil, charging: true), .offline)
+ XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: false, batteryPct: nil, charging: true, ringPct: nil, ringCharging: false), .offline)
}
// MARK: - The normal path still reads normally
func testConnectedReadingCarriesPctAndChargingThrough() {
- XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 87.4, charging: true),
- .charge(pct: 87.4, charging: true))
- XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 87.4, charging: false),
- .charge(pct: 87.4, charging: false))
+ XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 87.4, charging: true, ringPct: nil, ringCharging: false),
+ .charge(pct: 87.4, charging: true, isRing: false))
+ XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 87.4, charging: false, ringPct: nil, ringCharging: false),
+ .charge(pct: 87.4, charging: false, isRing: false))
}
/// `charging` is `Bool?` — nil means "the strap hasn't said" (no BATTERY_LEVEL event this session),
/// which must read as not-charging, never as charging. Same `== true` posture as the rest of the app.
func testUnknownChargingReadsAsNotCharging() {
- XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 50, charging: nil),
- .charge(pct: 50, charging: false))
+ XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 50, charging: nil, ringPct: nil, ringCharging: false),
+ .charge(pct: 50, charging: false, isRing: false))
}
// MARK: - #2208 whose charge is this
@@ -71,20 +71,20 @@ final class LiquidBatteryDisplayTests: XCTestCase {
/// is never cleared, so under an active ring both halves of the old gate passed and Today drew the
/// strap's charge. A non-WHOOP active device must show nothing rather than someone else's number.
func testRingActiveShowsNothingEvenWithAStalestrapCharge() {
- XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 72.4, charging: false),
+ XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 72.4, charging: false, ringPct: nil, ringCharging: false),
.notActiveDevice)
}
/// Charging is the strap's BATTERY_LEVEL event, so it is strap-only for the same reason. A ring must
/// not inherit the strap's charger state either.
func testRingActiveShowsNothingWhileTheStrapCharges() {
- XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 88, charging: true),
+ XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 88, charging: true, ringPct: nil, ringCharging: false),
.notActiveDevice)
}
/// A ring active with no strap charge ever recorded is the same answer, reached the other way.
func testRingActiveWithNoStrapChargeIsAlsoNotDrawn() {
- XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: nil, charging: nil),
+ XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: nil, charging: nil, ringPct: nil, ringCharging: false),
.notActiveDevice)
}
@@ -92,15 +92,51 @@ final class LiquidBatteryDisplayTests: XCTestCase {
/// a real claim worth making. A strap that is not the active device is a different answer entirely,
/// and collapsing the two told a wearer with a streaming ring that their strap was not connected.
func testNotActiveIsNotTheSameAnswerAsOffline() {
- XCTAssertNotEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 72, charging: false),
- Display.resolve(activeIsWhoop: true, connected: false, batteryPct: 72, charging: false))
- XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: false, batteryPct: 72, charging: false),
+ XCTAssertNotEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 72, charging: false, ringPct: nil, ringCharging: false),
+ Display.resolve(activeIsWhoop: true, connected: false, batteryPct: 72, charging: false, ringPct: nil, ringCharging: false))
+ XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: false, batteryPct: 72, charging: false, ringPct: nil, ringCharging: false),
.offline)
}
/// The WHOOP path is unchanged: this fix must not blank a strap that IS the active device.
func testWhoopActiveStillReportsItsCharge() {
- XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 61, charging: false),
- .charge(pct: 61, charging: false))
+ XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 61, charging: false, ringPct: nil, ringCharging: false),
+ .charge(pct: 61, charging: false, isRing: false))
+ }
+
+ // MARK: - The ring's own charge
+
+ /// The other half of #2208. Hiding the strap's number under a ring was right; hiding the RING's was
+ /// only ever a limitation of the control, which could not say whose number it held. A ring that has
+ /// reported its charge this link is the active device's own reading, and is drawn as the ring's.
+ func testRingActiveDrawsTheRingsOwnCharge() {
+ XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 72.4, charging: true,
+ ringPct: 93, ringCharging: false),
+ .charge(pct: 93, charging: false, isRing: true),
+ "the strap's 72 % and its charger bit must not leak into the ring's reading")
+ }
+
+ /// The ring's charging evidence is its own charger state, never the strap's BATTERY_LEVEL bit.
+ func testRingChargingComesFromTheRingsChargerState() {
+ XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: nil, charging: nil,
+ ringPct: 40, ringCharging: true),
+ .charge(pct: 40, charging: true, isRing: true))
+ }
+
+ /// `LiveState.ouraBatteryPct` is cleared with the link (#2075), so under a non-WHOOP active device a
+ /// nil ring charge means "no ring is live, or it has not reported yet" — and a generic HR strap or a
+ /// machine, which never write it, keep the control off the header exactly as before.
+ func testRingActiveWithNoRingChargeYetIsStillNotDrawn() {
+ XCTAssertEqual(Display.resolve(activeIsWhoop: false, connected: true, batteryPct: 72, charging: false,
+ ringPct: nil, ringCharging: false),
+ .notActiveDevice)
+ }
+
+ /// A ring charge that somehow sits beside an active STRAP is not the strap's reading and must not
+ /// replace it: `activeIsWhoop` decides whose number is shown, the same way `batteryPercent` does.
+ func testStrapActiveIgnoresARingCharge() {
+ XCTAssertEqual(Display.resolve(activeIsWhoop: true, connected: true, batteryPct: 61, charging: false,
+ ringPct: 93, ringCharging: true),
+ .charge(pct: 61, charging: false, isRing: false))
}
}
diff --git a/android/app/src/main/java/com/noop/ui/HeaderBatteryDisplay.kt b/android/app/src/main/java/com/noop/ui/HeaderBatteryDisplay.kt
new file mode 100644
index 0000000000..e4aa20b738
--- /dev/null
+++ b/android/app/src/main/java/com/noop/ui/HeaderBatteryDisplay.kt
@@ -0,0 +1,43 @@
+package com.noop.ui
+
+/**
+ * What the Today header's battery ring can honestly show for the ACTIVE device. Pure, so the truth table
+ * pins with no strap and no BLE. Twin of iOS `LiquidTodayView.StrapBatteryDisplay.resolve`, minus the
+ * charging bit the Android ring never drew.
+ */
+object HeaderBatteryDisplay {
+ sealed class State {
+ /** The active device is neither the strap nor a ring that has reported its charge this link, so
+ * the control has nothing to say and is not drawn. Distinct from [Offline], which asserts a strap
+ * that IS active is not connected — collapsing the two told a wearer with a streaming ring that
+ * their strap was not connected (#2208 / #2216). */
+ data object NotActiveDevice : State()
+ /** The strap is active with no link — say nothing about charge. A stale % is worse than no %. */
+ data object Offline : State()
+ /** The strap is linked, but no charge reading has landed yet. */
+ data object Pending : State()
+ /** A reading from the current link. [isRing] says whose: the ring's own charge under an active
+ * ring, the strap's under an active strap — the label names the device the number belongs to. */
+ data class Charge(val pct: Double, val isRing: Boolean) : State()
+ }
+
+ /**
+ * #2208: `activeIsWhoop` decides whose number is shown. `connected` alone was never enough: it is true
+ * the moment ANY source streams and the strap's `batteryPct` is never cleared, so under an active
+ * ring both halves of the old gate passed and Today drew the strap's charge.
+ *
+ * A ring reports its OWN charge into [ringPct] (`SourceCoordinator.ouraBatteryPct`), cleared with the
+ * source, so under a non-WHOOP active device a non-null [ringPct] is a reading from the ring that is
+ * live right now and is drawn as such; null (no ring, or none has reported yet) keeps the control off
+ * the header — a generic HR strap or a machine never writes it. Same resolution
+ * [LiveConsoleReadout.batteryPercent] applies.
+ */
+ fun resolve(activeIsWhoop: Boolean, connected: Boolean, strapPct: Double?, ringPct: Int?): State {
+ if (!activeIsWhoop) {
+ return if (ringPct == null) State.NotActiveDevice else State.Charge(ringPct.toDouble(), isRing = true)
+ }
+ if (!connected) return State.Offline
+ if (strapPct == null) return State.Pending
+ return State.Charge(strapPct, isRing = false)
+ }
+}
diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt
index 6f57032f45..aa37a65eaa 100644
--- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt
@@ -337,6 +337,9 @@ fun TodayScreen(
// source streams, and the strap's percentage is never cleared, so under an active ring both halves of
// the old gate passed and Today drew the strap's charge. Same seam the Devices list already uses.
val activeIsWhoop by viewModel.activeIsWhoop.collectAsStateWithLifecycle()
+ // The ring's OWN charge while a ring source is live (null otherwise, #2075), so the header can draw the
+ // active ring's battery instead of nothing. Changes a few times a session, no per-tick churn.
+ val ouraBatteryPct by viewModel.ouraBatteryPct.collectAsStateWithLifecycle()
val v5Signals by viewModel.v5Signals.collectAsStateWithLifecycle()
val cycleEnabled by viewModel.cycleTrackingEnabled.collectAsStateWithLifecycle()
val cycleHidden by viewModel.cycleAwarenessHidden.collectAsStateWithLifecycle()
@@ -1402,8 +1405,10 @@ fun TodayScreen(
dayTitle = dayTitle,
humanDate = humanDate,
selectedDay = selectedDay,
- batteryPct = if (liveSnap.connected) liveSnap.batteryPct else null,
- strapIsActiveDevice = activeIsWhoop,
+ battery = HeaderBatteryDisplay.resolve(
+ activeIsWhoop = activeIsWhoop, connected = liveSnap.connected,
+ strapPct = liveSnap.batteryPct, ringPct = ouraBatteryPct,
+ ),
backfilling = liveSnap.backfilling,
syncChunksThisSession = liveSnap.syncChunksThisSession,
lastSyncAt = liveSnap.lastSyncAt,
@@ -2524,12 +2529,10 @@ private fun LiquidTodayHeader(
dayTitle: String,
humanDate: String,
selectedDay: LocalDate,
- batteryPct: Double?,
- /** Whether the STRAP is the active device. Separate from [batteryPct] on purpose: that says what
- * the control reads, this says whether the control should exist. A null percentage while the strap
- * IS active means "connected, no reading yet" and is worth drawing; a strap that is not the active
- * device has nothing to say and is not drawn at all. (#2208) */
- strapIsActiveDevice: Boolean,
+ /** What the battery ring shows for the ACTIVE device, resolved by [HeaderBatteryDisplay]: the strap's
+ * charge (or its offline / no-reading-yet glyph) under an active strap, the ring's own charge under an
+ * active ring, and nothing at all when the active device is neither. (#2208) */
+ battery: HeaderBatteryDisplay.State,
// #245: sync state for the compact header chip (twin of iOS SyncStatusChip).
backfilling: Boolean = false,
syncChunksThisSession: Int = 0,
@@ -2658,11 +2661,18 @@ private fun LiquidTodayHeader(
// (b) Quick-add (+), the accented primary. Mirrors iOS's LiquidAddButton (a glyph on a translucent
// disc → the quick-actions menu). Sized to match the rest of the liquid cluster (shared HeaderClusterControl).
QuickActionDisc(onClick = onQuickActions)
- // (c) Strap battery ring showing the % (iOS LiquidBatteryButton). Tap → Devices.
- // Not drawn when the strap is not the active device: an empty "Strap battery" ring under a
- // streaming ring is a control asserting something about a strap nobody is wearing.
- if (strapIsActiveDevice) {
- LiquidBatteryRing(batteryPct = batteryPct, onClick = onOpenDevices)
+ // (c) Active-device battery ring showing the % (iOS LiquidBatteryButton). Tap → Devices.
+ // Not drawn when the active device is neither the strap nor a ring with a charge of its own to
+ // show: an empty "Strap battery" ring under a streaming ring is a control asserting something
+ // about a strap nobody is wearing. A ring that HAS reported its charge is the active device's
+ // own reading, and #2208's fix left it undrawn only because the control could not yet tell
+ // whose number it held.
+ when (battery) {
+ HeaderBatteryDisplay.State.NotActiveDevice -> Unit
+ HeaderBatteryDisplay.State.Offline, HeaderBatteryDisplay.State.Pending ->
+ LiquidBatteryRing(batteryPct = null, isRing = false, onClick = onOpenDevices)
+ is HeaderBatteryDisplay.State.Charge ->
+ LiquidBatteryRing(batteryPct = battery.pct, isRing = battery.isRing, onClick = onOpenDevices)
}
}
}
@@ -2795,14 +2805,16 @@ private fun ChipCapsule(
}
}
-/** The liquid header strap-battery ring: when connected + a reading exists it draws a trimmed ring in
- * the charge/warning/critical hue plus the % inside, else a
- * bolt-slash glyph. Tap → Devices. Mirrors the iOS liquid header battery ring. */
+/** The liquid header active-device battery ring: when connected + a reading exists it draws a trimmed
+ * ring in the charge/warning/critical hue plus the % inside, else a bolt-slash glyph. [isRing] names the
+ * device in the label — "Strap battery" over a ring's charge would be the #2208 misattribution again, in
+ * the label instead of the number. Tap → Devices. Mirrors the iOS liquid header battery ring. */
@Composable
-private fun LiquidBatteryRing(batteryPct: Double?, onClick: () -> Unit) {
+private fun LiquidBatteryRing(batteryPct: Double?, isRing: Boolean, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
- val label = batteryPct?.let { uiString(R.string.today_strap_battery_percent, it.roundToInt()) }
- ?: uiString(R.string.today_strap_battery)
+ val label = batteryPct?.let {
+ uiString(if (isRing) R.string.today_ring_battery_percent else R.string.today_strap_battery_percent, it.roundToInt())
+ } ?: uiString(R.string.today_strap_battery)
Box(
modifier = Modifier
.size(HeaderClusterControl)
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index cf46a33e15..e5a174428b 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -2232,6 +2232,7 @@
So wird dieser Wert berechnet
Strap-Akku
Strap-Akku: %1$d Prozent
+ Ring-Akku: %1$d Prozent
STABIL
Charge-Wert · %1$s
Erholung · %1$s
diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml
index 1e9bd799f7..1bf83dfa45 100644
--- a/android/app/src/main/res/values-es/strings.xml
+++ b/android/app/src/main/res/values-es/strings.xml
@@ -2219,6 +2219,7 @@
Cómo se calcula esta puntuación
Batería de la pulsera
Batería de la pulsera: %1$d por ciento
+ Batería del anillo: %1$d por ciento
SÓLIDO
Carga · %1$s
Recuperación · %1$s
diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml
index d2c582f3f8..1832d62aca 100644
--- a/android/app/src/main/res/values-fr/strings.xml
+++ b/android/app/src/main/res/values-fr/strings.xml
@@ -2218,6 +2218,7 @@
Comment ce score est calculé
Batterie du bracelet
Batterie du bracelet : %1$d pour cent
+ Batterie de la bague : %1$d pour cent
FIABLE
Score de Charge · %1$s
Récupération · %1$s
diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml
index 71a46f8e9f..0aa63c4839 100644
--- a/android/app/src/main/res/values-pl/strings.xml
+++ b/android/app/src/main/res/values-pl/strings.xml
@@ -2215,6 +2215,7 @@
Jak obliczany jest ten wynik
Bateria opaski
Bateria opaski: %1$d procent
+ Bateria pierścienia: %1$d procent
PEWNY
Energia · %1$s
Regeneracja · %1$s
diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml
index 847ab8712a..a44a5a40f7 100644
--- a/android/app/src/main/res/values-pt-rPT/strings.xml
+++ b/android/app/src/main/res/values-pt-rPT/strings.xml
@@ -2211,6 +2211,7 @@
Como é calculada esta pontuação
Bateria da pulseira
Bateria da pulseira: %1$d por cento
+ Bateria do anel: %1$d por cento
FIÁVEL
Carga · %1$s
Recuperação · %1$s
diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml
index 64d0f5f45d..d0b2993d2c 100644
--- a/android/app/src/main/res/values-ru/strings.xml
+++ b/android/app/src/main/res/values-ru/strings.xml
@@ -2172,6 +2172,7 @@
Как рассчитывается этот показатель
Батарея браслета
Заряд браслета %1$d процентов
+ Заряд кольца %1$d процентов
Стабильный
Заряд · %1$s
Восстановление · %1$s
diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml
index 205f425817..24136f2979 100644
--- a/android/app/src/main/res/values-zh/strings.xml
+++ b/android/app/src/main/res/values-zh/strings.xml
@@ -2140,6 +2140,7 @@
此分数的计算方式
手环电量
手环电量:%1$d%%
+ 戒指电量:%1$d%%
可靠
充能 · %1$s
恢复 · %1$s
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 6b9642a3b4..8d3555b1a0 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2340,6 +2340,7 @@
How this score is calculated
Strap battery
Strap battery %1$d percent
+ Ring battery %1$d percent
SOLID
Charge · %1$s
Recovery · %1$s
diff --git a/android/app/src/test/java/com/noop/ui/HeaderBatteryDisplayOracleTest.kt b/android/app/src/test/java/com/noop/ui/HeaderBatteryDisplayOracleTest.kt
new file mode 100644
index 0000000000..0bf22d3150
--- /dev/null
+++ b/android/app/src/test/java/com/noop/ui/HeaderBatteryDisplayOracleTest.kt
@@ -0,0 +1,121 @@
+package com.noop.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Pins [HeaderBatteryDisplay.resolve] against the iOS `LiquidTodayView.StrapBatteryDisplay.resolve` twin
+ * BY ORACLE: the expected lines below are the verbatim stdout of the Swift enum compiled standalone
+ * (`swiftc -O twin.swift main.swift`) over the same grid — every `activeIsWhoop` × `connected` × strap %
+ * × ring % combination, with the strap's charging bit at nil and the ring's at false, which the Android
+ * ring never drew. `pct` prints as a Swift Double (`0.0`, `72.4`), matched by Kotlin's `Double.toString`.
+ * Regenerate the literal from the Swift side; never edit it to make this pass.
+ */
+class HeaderBatteryDisplayOracleTest {
+
+ private fun show(s: HeaderBatteryDisplay.State): String = when (s) {
+ HeaderBatteryDisplay.State.Offline -> "Offline"
+ HeaderBatteryDisplay.State.Pending -> "Pending"
+ HeaderBatteryDisplay.State.NotActiveDevice -> "NotActiveDevice"
+ is HeaderBatteryDisplay.State.Charge -> "Charge(pct:${s.pct},isRing:${s.isRing})"
+ }
+
+ private val expected = listOf(
+ "true true nil nil -> Pending",
+ "true true nil 0 -> Pending",
+ "true true nil 93 -> Pending",
+ "true true 0.0 nil -> Charge(pct:0.0,isRing:false)",
+ "true true 0.0 0 -> Charge(pct:0.0,isRing:false)",
+ "true true 0.0 93 -> Charge(pct:0.0,isRing:false)",
+ "true true 11.0 nil -> Charge(pct:11.0,isRing:false)",
+ "true true 11.0 0 -> Charge(pct:11.0,isRing:false)",
+ "true true 11.0 93 -> Charge(pct:11.0,isRing:false)",
+ "true true 72.4 nil -> Charge(pct:72.4,isRing:false)",
+ "true true 72.4 0 -> Charge(pct:72.4,isRing:false)",
+ "true true 72.4 93 -> Charge(pct:72.4,isRing:false)",
+ "true true 100.0 nil -> Charge(pct:100.0,isRing:false)",
+ "true true 100.0 0 -> Charge(pct:100.0,isRing:false)",
+ "true true 100.0 93 -> Charge(pct:100.0,isRing:false)",
+ "true false nil nil -> Offline",
+ "true false nil 0 -> Offline",
+ "true false nil 93 -> Offline",
+ "true false 0.0 nil -> Offline",
+ "true false 0.0 0 -> Offline",
+ "true false 0.0 93 -> Offline",
+ "true false 11.0 nil -> Offline",
+ "true false 11.0 0 -> Offline",
+ "true false 11.0 93 -> Offline",
+ "true false 72.4 nil -> Offline",
+ "true false 72.4 0 -> Offline",
+ "true false 72.4 93 -> Offline",
+ "true false 100.0 nil -> Offline",
+ "true false 100.0 0 -> Offline",
+ "true false 100.0 93 -> Offline",
+ "false true nil nil -> NotActiveDevice",
+ "false true nil 0 -> Charge(pct:0.0,isRing:true)",
+ "false true nil 93 -> Charge(pct:93.0,isRing:true)",
+ "false true 0.0 nil -> NotActiveDevice",
+ "false true 0.0 0 -> Charge(pct:0.0,isRing:true)",
+ "false true 0.0 93 -> Charge(pct:93.0,isRing:true)",
+ "false true 11.0 nil -> NotActiveDevice",
+ "false true 11.0 0 -> Charge(pct:0.0,isRing:true)",
+ "false true 11.0 93 -> Charge(pct:93.0,isRing:true)",
+ "false true 72.4 nil -> NotActiveDevice",
+ "false true 72.4 0 -> Charge(pct:0.0,isRing:true)",
+ "false true 72.4 93 -> Charge(pct:93.0,isRing:true)",
+ "false true 100.0 nil -> NotActiveDevice",
+ "false true 100.0 0 -> Charge(pct:0.0,isRing:true)",
+ "false true 100.0 93 -> Charge(pct:93.0,isRing:true)",
+ "false false nil nil -> NotActiveDevice",
+ "false false nil 0 -> Charge(pct:0.0,isRing:true)",
+ "false false nil 93 -> Charge(pct:93.0,isRing:true)",
+ "false false 0.0 nil -> NotActiveDevice",
+ "false false 0.0 0 -> Charge(pct:0.0,isRing:true)",
+ "false false 0.0 93 -> Charge(pct:93.0,isRing:true)",
+ "false false 11.0 nil -> NotActiveDevice",
+ "false false 11.0 0 -> Charge(pct:0.0,isRing:true)",
+ "false false 11.0 93 -> Charge(pct:93.0,isRing:true)",
+ "false false 72.4 nil -> NotActiveDevice",
+ "false false 72.4 0 -> Charge(pct:0.0,isRing:true)",
+ "false false 72.4 93 -> Charge(pct:93.0,isRing:true)",
+ "false false 100.0 nil -> NotActiveDevice",
+ "false false 100.0 0 -> Charge(pct:0.0,isRing:true)",
+ "false false 100.0 93 -> Charge(pct:93.0,isRing:true)",
+ )
+
+ @Test fun `resolve matches the Swift twin over the whole grid`() {
+ val strap = listOf(null, 0.0, 11.0, 72.4, 100.0)
+ val ring = listOf(null, 0, 93)
+ val actual = mutableListOf()
+ for (w in listOf(true, false)) for (c in listOf(true, false)) for (s in strap) for (r in ring) {
+ val d = HeaderBatteryDisplay.resolve(activeIsWhoop = w, connected = c, strapPct = s, ringPct = r)
+ actual += "$w $c ${s ?: "nil"} ${r ?: "nil"} -> ${show(d)}"
+ }
+ assertEquals(expected.joinToString("\n"), actual.joinToString("\n"))
+ }
+
+ // MARK: the cases #2208 / #2216 were about, named so a failure reads as the regression it is
+
+ /** Hiding the strap's number under a ring was right; hiding the RING's was only ever a limitation of
+ * the control. A ring that has reported its charge this link is drawn as the ring's. */
+ @Test fun `ring active draws the ring's own charge, not the strap's stale one`() {
+ assertEquals(HeaderBatteryDisplay.State.Charge(93.0, isRing = true),
+ HeaderBatteryDisplay.resolve(activeIsWhoop = false, connected = true, strapPct = 72.4, ringPct = 93))
+ }
+
+ /** `ouraBatteryPct` is null with no live ring source (#2075), so a generic strap or a machine — which
+ * never write it — keep the control off the header exactly as before. */
+ @Test fun `ring active with no ring charge yet is still not drawn`() {
+ assertEquals(HeaderBatteryDisplay.State.NotActiveDevice,
+ HeaderBatteryDisplay.resolve(activeIsWhoop = false, connected = true, strapPct = 72.4, ringPct = null))
+ }
+
+ /** A not-active answer is not an offline answer: the strap that IS active and disconnected is offline,
+ * a real claim worth making; a strap that is not the active device says nothing. */
+ @Test fun `not active is not the same answer as offline`() {
+ assertEquals(HeaderBatteryDisplay.State.Offline,
+ HeaderBatteryDisplay.resolve(activeIsWhoop = true, connected = false, strapPct = 72.0, ringPct = null))
+ assertEquals(HeaderBatteryDisplay.State.NotActiveDevice,
+ HeaderBatteryDisplay.resolve(activeIsWhoop = false, connected = true, strapPct = 72.0, ringPct = null))
+ }
+}