From b4cedef5be71ef7ff1801e2214e113e8fdffbf1e Mon Sep 17 00:00:00 2001 From: Pipiche Date: Mon, 21 Sep 2026 12:37:50 +0200 Subject: [PATCH] fix(today): draw the active ring's own battery in the Liquid header instead of nothing (#2208 second half) #2208 stopped the Liquid Today header drawing the STRAP's charge under an active ring, and the control has shown nothing under a ring since: `StrapBatteryDisplay.resolve` answered `.notActiveDevice` for every non-WHOOP active device because it could not tell whose number it held. The ring's own charge was already there - `LiveState.ouraBatteryPct` on iOS and `SourceCoordinator.ouraBatteryPct` on Android, both cleared with the link (#2075), both already resolved by `LiveConsoleReadout.batteryPercent` for the Live console and the Devices list. The header was the one surface still blind to it. - iOS `StrapBatteryDisplay.resolve` takes `ringPct` + `ringCharging` (no defaults, for the same reason `activeIsWhoop` has none): a non-WHOOP active device with a ring charge reported this link is `.charge(isRing: true)`; with none it stays `.notActiveDevice`, so a generic HR strap or a machine - which never write `ouraBatteryPct` - keep the control off the header exactly as before. Charging under a ring is the ring's charger state (`OuraWearState.charging`), never the strap's BATTERY_LEVEL bit. - `.charge` carries `isRing` so the VoiceOver label names the device the number belongs to: "Ring battery N percent(, charging)" - "Strap battery" over a ring's charge would be the #2208 misattribution again, in the label instead of the number. Strings in every catalog locale. - Android: `HeaderBatteryDisplay.resolve`, a pure twin of the iOS resolver (minus the charging bit the Android ring never drew), replaces the `strapIsActiveDevice` gate + raw `batteryPct` pair on `LiquidTodayHeader`; `LiquidBatteryRing` takes `isRing` for its label (`today_ring_battery_percent`, every locale). Verification: `LiquidBatteryDisplayTests` 15/15 under `xcodebuild test` (Strand, macOS), four new ring cases; `HeaderBatteryDisplayOracleTest` 4/4 - the 60-line expected literal is the verbatim stdout of the Swift enum compiled standalone (`swiftc -O`) over the whole activeIsWhoop x connected x strap-% x ring-% grid, plus the named #2208/#2216 cases; `Strand` (macOS) and `NOOPiOS` build; `compileFullDebugKotlin` clean; `i18n_audit --ci` (both platforms) and `doc_comment_lint` green. Display-only - the values come from the same fields the Live console already shows on hardware. Refs #2208, #2216, #2075. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QrsZczdGcUyQSJVuYZuwUF --- Strand/Liquid/LiquidTodayView.swift | 62 ++++++--- Strand/Resources/Localizable.xcstrings | 6 + StrandTests/LiquidBatteryDisplayTests.swift | 72 ++++++++--- .../java/com/noop/ui/HeaderBatteryDisplay.kt | 43 +++++++ .../src/main/java/com/noop/ui/TodayScreen.kt | 50 +++++--- .../app/src/main/res/values-de/strings.xml | 1 + .../app/src/main/res/values-es/strings.xml | 1 + .../app/src/main/res/values-fr/strings.xml | 1 + .../app/src/main/res/values-pl/strings.xml | 1 + .../src/main/res/values-pt-rPT/strings.xml | 1 + .../app/src/main/res/values-ru/strings.xml | 1 + .../app/src/main/res/values-zh/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + .../noop/ui/HeaderBatteryDisplayOracleTest.kt | 121 ++++++++++++++++++ 14 files changed, 307 insertions(+), 55 deletions(-) create mode 100644 android/app/src/main/java/com/noop/ui/HeaderBatteryDisplay.kt create mode 100644 android/app/src/test/java/com/noop/ui/HeaderBatteryDisplayOracleTest.kt 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)) + } +}