diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index b7ce6b914b..f278b32f99 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -5927,6 +5927,9 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // re-derives it. (The honest flag is per-link, like the syncing pill / reject counters above.) whoop5EmptyOffload.reset() state.historySyncExperimental = false + // #689/#815: the backlog sample is "at connect" by definition, so it must not survive the link it + // was taken on — a stale figure under a fresh connection would be a plain lie. + state.pagesBehindAtConnect = nil lastBatteryReadAt = nil // #battery: next connect's first enableLiveNotifications re-seeds the 5/MG battery reading // #612: the display flag only, not the underlying emptySyncTracker streak (that counter // deliberately survives a reconnect — unchanged, existing behaviour). A fresh link re-derives @@ -6719,6 +6722,9 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { if !DataRange.isPendingResponse(frame, cmdOff: cmdOff) { if let pages = DataRange.pagesBehind(from: frame, cmdOff: cmdOff) { log("Strap backlog pages behind: \(pages) (#689 — GET_DATA_RANGE ring backlog, diagnostic only)") + // #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally. The Today sync + // chip reads this while backfilling is true. Twin of the Android FrameRouter branch. + state.pagesBehindAtConnect = Int(pages) } else { log("Strap backlog pages behind: not decodable from this frame (#689 — offsets may have moved; " + "the raw frame above is the input). Diagnostic only, sync is unaffected.") diff --git a/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift index 99258b531c..92cfd508a0 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -381,6 +381,16 @@ public final class LiveState: ObservableObject { /// experimental on 5.0" instead of a WHOOP-4-style "not recording"/sync-error. Reset on connect/disconnect. @Published public var historySyncExperimental: Bool = false + /// #689/#815 — the strap's ring-buffer page backlog, sampled ONCE from the connect-time + /// GET_DATA_RANGE reply and never re-polled mid-offload: the link is already firmware-paced, and #377 + /// rules out re-polling just to feed a readout. So this is a figure AT CONNECT rather than a live one, + /// and the Today sync chip's copy says so — a static number under a "syncing" label otherwise reads as + /// a stalled live one. A bounded ring measure (write pointer − read pointer against the ring size), + /// never a percentage: the strap never reveals a total record count. Confirmed against real captures + /// on WHOOP 4.0 and 5.0/MG. nil before the first reply this session, or when the frame did not decode. + /// Twin of Android `LiveState.pagesBehindAtConnect`. + @Published public var pagesBehindAtConnect: Int? = nil + /// #612 — true when the WHOOP-4/generic empty-offload streak (`EmptySyncTracker`, `BLEManager`) is /// currently SUSTAINED (3+ consecutive completed-but-empty offloads). Not 5/MG-specific and not /// coupled to HR: a connected strap that keeps handing over nothing has this true regardless of diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index f491def734..a5f71164eb 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -2735,9 +2735,19 @@ private struct LiquidBatteryButton: View { // VoiceOver announcing a real count while the ring showed the synthetic one — i.e. the // harness could not be used to check the read-out it exists to exercise. let n = syncChunks - return n > 0 - ? String(localized: "Syncing strap history, \(n) chunks") - : String(localized: "Syncing strap history") + guard n > 0 else { return String(localized: "Syncing strap history") } + // #689/#815: the connect-time ring backlog, when the strap reported one. Zero is dropped by + // `SyncChipState.resolve`, and dropped here for the same reason: "0 pages behind" beside a + // running sync contradicts itself. Both counts inflect — the phrase is built from its own + // entry and joined through a template, so "1 chunk" and "1 page" read correctly and the + // joining punctuation stays inside the translated template rather than being concatenated. + let behind = live.pagesBehindAtConnect + .flatMap { $0 > 0 ? $0 : nil } + .map { String(localized: "\($0) pages behind at connect") } + if let behind { + return String(localized: "Syncing strap history, \(n) chunks, \(behind)") + } + return String(localized: "Syncing strap history, \(n) chunks") } switch batteryDisplay { diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index e0d704868d..ff1c267adc 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -155462,9 +155462,427 @@ } }, "pl": { - "stringUnit": { - "state": "translated", - "value": "Synchronizowanie historii pasków, fragmenty %lld" + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragment" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmenty" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentów" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentu" + } + } + } + } + } + } + }, + "%lld pages behind at connect": { + "extractionState": "manual", + "localizations": { + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld Seite Rückstand bei Verbindungsaufbau" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld Seiten Rückstand bei Verbindungsaufbau" + } + } + } + } + }, + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld page behind at connect" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld pages behind at connect" + } + } + } + } + }, + "es": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld página de retraso al conectar" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld páginas de retraso al conectar" + } + } + } + } + }, + "fr": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld page de retard à la connexion" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld pages de retard à la connexion" + } + } + } + } + }, + "it": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld pagina di ritardo alla connessione" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld pagine di ritardo alla connessione" + } + } + } + } + }, + "pl": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld strona zaległości przy połączeniu" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "%lld strony zaległości przy połączeniu" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "%lld stron zaległości przy połączeniu" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld strony zaległości przy połączeniu" + } + } + } + } + }, + "pt-PT": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld página em atraso na ligação" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld páginas em atraso na ligação" + } + } + } + } + }, + "ru": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld страница отставания при подключении" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "%lld страницы отставания при подключении" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "%lld страниц отставания при подключении" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld страницы отставания при подключении" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "连接时落后 %lld 页" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "連線時落後 %lld 頁" + } + } + } + } + } + } + }, + "Syncing strap history, %lld chunks, %@": { + "extractionState": "manual", + "localizations": { + "de": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunk, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunks, %@" + } + } + } + } + }, + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Syncing strap history, %lld chunk, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Syncing strap history, %lld chunks, %@" + } + } + } + } + }, + "es": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando el historial de la pulsera, %lld bloque, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Sincronizando el historial de la pulsera, %lld bloques, %@" + } + } + } + } + }, + "fr": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation de l'historique du bracelet, %lld bloc, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Synchronisation de l'historique du bracelet, %lld blocs, %@" + } + } + } + } + }, + "it": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della cronologia della fascia, %lld blocco, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Sincronizzazione della cronologia della fascia, %lld blocchi, %@" + } + } + } + } + }, + "pl": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragment, %@" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmenty, %@" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentów, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Synchronizowanie historii paska, %lld fragmentu, %@" + } + } + } + } + }, + "pt-PT": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar o histórico da bracelete, %lld pedaço, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "A sincronizar o histórico da bracelete, %lld pedaços, %@" + } + } + } + } + }, + "ru": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмент, %@" + } + }, + "few": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмента, %@" + } + }, + "many": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагментов, %@" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "Синхронизация истории браслета, %lld фрагмента, %@" + } + } + } + } + }, + "zh-Hans": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "正在同步手环历史记录,%lld 个数据块,%@" + } + } + } + } + }, + "zh-Hant": { + "variations": { + "plural": { + "other": { + "stringUnit": { + "state": "translated", + "value": "正在同步手環歷史記錄,%lld 個資料塊,%@" + } + } + } } } } diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index d5d0432e23..12743f287e 100644 --- a/Strand/Screens/DevicesView.swift +++ b/Strand/Screens/DevicesView.swift @@ -641,7 +641,7 @@ private struct DeviceSyncStatusCard: View { var body: some View { switch SyncChipState.resolve(live: live) { - case .syncing(let chunks): + case .syncing(let chunks, _): statusCard( systemImage: "arrow.triangle.2.circlepath", detail: chunks > 0 diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 62d1e5967c..d7f32d6c92 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -5543,14 +5543,26 @@ struct TodayDayScopedCache { /// yet) → `✓ live`. `.hidden` only on a true cold start (the building-scores note owns that case). Twin /// of Android `SyncStatusChip`. enum SyncChipState: Equatable { - case syncing(chunks: Int) + /// #689/#815 follow-up: `pagesBehind` is the strap's GET_DATA_RANGE ring backlog, sampled once at + /// connect (`LiveState.pagesBehindAtConnect`) and never re-polled, so the copy reports it "at + /// connect" rather than as a live figure. nil when no reply has landed this session, when the frame + /// did not decode, AND when the backlog is zero: a chip that is actively syncing while claiming + /// "0 pages behind" contradicts itself, and a zero sample carries nothing a reader can act on. + /// `resolve` applies that rule so both platforms drop the same case. Twin of Android + /// `SyncChipState.Syncing`. + case syncing(chunks: Int, pagesBehind: Int?) case synced(agoText: String) case experimentalLive case hidden @MainActor static func resolve(live: LiveState) -> SyncChipState { - if live.backfilling { return .syncing(chunks: live.syncChunksThisSession) } + if live.backfilling { + // The zero rule above. Negative cannot come off the wire (the decoder returns a ring delta), + // but the bound reads the same either way. Android spells this `?.takeIf { it > 0 }`. + return .syncing(chunks: live.syncChunksThisSession, + pagesBehind: live.pagesBehindAtConnect.flatMap { $0 > 0 ? $0 : nil }) + } if let ts = live.lastSyncedAt { return .synced(agoText: shortAgo(ts)) } if live.historySyncExperimental { return .experimentalLive } return .hidden diff --git a/StrandTests/SyncChipStateTests.swift b/StrandTests/SyncChipStateTests.swift index 9b35a2919f..39b88a935a 100644 --- a/StrandTests/SyncChipStateTests.swift +++ b/StrandTests/SyncChipStateTests.swift @@ -15,7 +15,7 @@ final class SyncChipStateTests: XCTestCase { let live = LiveState() live.backfilling = true live.syncChunksThisSession = 7 - XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 7)) + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 7, pagesBehind: nil)) } func testLastSyncedAt_isSyncedWithAgeText() { @@ -50,7 +50,7 @@ final class SyncChipStateTests: XCTestCase { live.backfilling = true live.syncChunksThisSession = 2 live.lastSyncedAt = Date().timeIntervalSince1970 - 5 - XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 2)) + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 2, pagesBehind: nil)) } func testLastSyncedAt_takesPriorityOverHistorySyncExperimental() { @@ -63,4 +63,46 @@ final class SyncChipStateTests: XCTestCase { XCTFail("A known last-sync should win over the experimental fallback") } } + + // #689/#815: the connect-time backlog sample. Twin of the Android cases in `SyncChipStateTest`. + + @MainActor + func testBackfillingWithBacklogCarriesThePagesFigure() { + let live = LiveState() + live.backfilling = true + live.syncChunksThisSession = 3 + live.pagesBehindAtConnect = 120 + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 3, pagesBehind: 120)) + } + + /// A zero backlog is dropped rather than rendered: "0 pages behind at connect" beside a running + /// sync contradicts itself, and a zero sample gives a reader nothing to act on. The rule lives in + /// `resolve` on both platforms so neither view layer decides it alone. + @MainActor + func testBackfillingWithZeroBacklogDropsTheDetail() { + let live = LiveState() + live.backfilling = true + live.syncChunksThisSession = 3 + live.pagesBehindAtConnect = 0 + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 3, pagesBehind: nil)) + } + + /// No reply yet this session, or a frame that did not decode. + @MainActor + func testBackfillingWithoutASampleIsUnchanged() { + let live = LiveState() + live.backfilling = true + live.syncChunksThisSession = 3 + XCTAssertEqual(SyncChipState.resolve(live: live), .syncing(chunks: 3, pagesBehind: nil)) + } + + /// The backlog only qualifies an in-progress sync; a stale figure must not survive into `.synced`. + @MainActor + func testNotBackfillingIgnoresTheBacklog() { + let live = LiveState() + live.backfilling = false + live.lastSyncedAt = Date().timeIntervalSince1970 - 65 + live.pagesBehindAtConnect = 120 + XCTAssertEqual(SyncChipState.resolve(live: live), .synced(agoText: "1m")) + } } diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 7ac97d54e4..934a428a7f 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -287,6 +287,15 @@ data class LiveState( * once empty offloads are SUSTAINED; cleared on connect or once the strap banks real records. Twin of * macOS LiveState.historySyncExperimental. */ val historySyncExperimental: Boolean = false, + /** #689/#815: the strap's ring-buffer page backlog, sampled ONCE from the connect-time + * GET_DATA_RANGE reply and never re-polled mid-offload — the link is already firmware-paced, and + * #377 rules out re-polling for a readout. So this is a figure AT CONNECT, not a live one, and the + * Today sync chip's copy says so rather than letting a static number read as a stalled live one. + * A bounded ring measure (write pointer − read pointer against the ring size), never a percentage: + * the strap never reveals a total record count. Confirmed against real captures on WHOOP 4.0 and + * 5.0/MG. null before the first reply this session, or when the frame did not decode. Twin of + * macOS LiveState.pagesBehindAtConnect. */ + val pagesBehindAtConnect: Int? = null, /** #612: TRUE when the WHOOP-4/generic empty-offload streak ([emptySyncTracker]) is currently * SUSTAINED (3+ consecutive completed-but-empty offloads). Not 5/MG-specific and not coupled to HR: * a connected strap that keeps handing over nothing has this true regardless of live-HR status. @@ -1371,6 +1380,9 @@ class WhoopBleClient( // #580: the 5/MG "history experimental" note is per-link — a fresh connect re-derives it // from the next offload, so it must not outlive the dropped link. historySyncExperimental = false, + // #689/#815: the backlog sample is "at connect" by definition, so it must not survive the + // link it was taken on — a stale figure under a fresh connection would be a plain lie. + pagesBehindAtConnect = null, // #612: the display flag only, not the underlying emptySyncTracker streak (that counter // deliberately survives a reconnect, unchanged existing behaviour). sustainedEmptyOffload = false, @@ -7586,6 +7598,9 @@ class WhoopBleClient( val pagesBehind = com.noop.protocol.DataRange.pagesBehind(frame, cmdOff) if (pagesBehind != null) { log("Strap backlog pages behind: $pagesBehind (#689 — GET_DATA_RANGE ring backlog, diagnostic only)") + // #815: confirmed on both WHOOP 4.0 and 5.0/MG, so bank it unconditionally. + // The Today sync chip reads this while backfilling is true. + _state.update { it.copy(pagesBehindAtConnect = pagesBehind.toInt()) } } else { log( "Strap backlog pages behind: not decodable from this frame (#689 — offsets may " + diff --git a/android/app/src/main/java/com/noop/ui/TodayScoring.kt b/android/app/src/main/java/com/noop/ui/TodayScoring.kt index 566886025a..0365a2a7d7 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScoring.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScoring.kt @@ -416,7 +416,13 @@ internal fun recordingStateFor( * [Hidden] only on a true cold start (the building-scores note owns that case). Previously this * priority order lived inline inside the `@Composable`, where it could not be unit-tested. */ sealed class SyncChipState { - data class Syncing(val chunks: Int) : SyncChipState() + /** #689/#815 follow-up: [pagesBehind] is the strap's GET_DATA_RANGE ring backlog, sampled ONCE at + * connect (`LiveState.pagesBehindAtConnect`) and never re-polled, so it is a figure "at connect" + * rather than a live one — the copy says so. null when no reply has landed this session, when the + * frame did not decode, AND when the backlog is zero: a chip that is actively syncing while + * claiming "0 pages behind" contradicts itself, and a zero sample carries nothing a reader can + * act on. [resolve] applies that rule so both platforms drop the same case. */ + data class Syncing(val chunks: Int, val pagesBehind: Int? = null) : SyncChipState() data class Synced(val agoText: String) : SyncChipState() object ExperimentalLive : SyncChipState() object Hidden : SyncChipState() @@ -439,8 +445,12 @@ sealed class SyncChipState { lastSyncAtSec: Long?, historySyncExperimental: Boolean, nowSec: Long, + pagesBehind: Int? = null, ): SyncChipState = when { - backfilling -> Syncing(chunks) + // `takeIf { it > 0 }` is the zero rule from [Syncing.pagesBehind], applied here so the + // decision is pure and testable rather than sitting in the composable. Negative can't come + // off the wire (the decoder returns a ring delta), but the bound reads the same either way. + backfilling -> Syncing(chunks, pagesBehind?.takeIf { it > 0 }) lastSyncAtSec != null -> Synced(shortSyncAgo(lastSyncAtSec, nowSec)) historySyncExperimental -> ExperimentalLive else -> Hidden 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 6d3443a2d5..be0fdded18 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -263,6 +263,9 @@ private data class TodayLiveSnapshot( * most once per connection, so it costs the snapshot nothing. */ val historyReady: Boolean, val historySyncExperimental: Boolean, + /** #689/#815: the connect-time GET_DATA_RANGE backlog sample, when known. Set once per connection, + * so it adds no per-tick churn to this snapshot. */ + val pagesBehindAtConnect: Int?, val batteryPct: Double?, /** True once a WHOOP 5/MG strap has been seen this session, picks the 5/MG rated-life fallback for the * battery runtime estimate (#713). Changes at most once per connection, so it doesn't reintroduce the @@ -347,6 +350,7 @@ fun TodayScreen( syncChunksThisSession = s.syncChunksThisSession, historyReady = s.historyReady, historySyncExperimental = s.historySyncExperimental, + pagesBehindAtConnect = s.pagesBehindAtConnect, batteryPct = s.batteryPct, whoop5 = s.whoop5Detected, charging = s.charging, @@ -1338,6 +1342,7 @@ fun TodayScreen( syncChunksThisSession = liveSnap.syncChunksThisSession, lastSyncAt = liveSnap.lastSyncAt, historySyncExperimental = liveSnap.historySyncExperimental, + pagesBehindAtConnect = liveSnap.pagesBehindAtConnect, onPickDay = { offset -> selectedDayOffset = offset }, onQuickActions = onQuickActions, onOpenSettings = onOpenSettings, @@ -2355,6 +2360,8 @@ private fun LiquidTodayHeader( syncChunksThisSession: Int = 0, lastSyncAt: Long? = null, historySyncExperimental: Boolean = false, + // #689/#815: the connect-time backlog sample, when known. + pagesBehindAtConnect: Int? = null, onPickDay: (Int) -> Unit, onQuickActions: () -> Unit, onOpenSettings: () -> Unit, @@ -2452,6 +2459,7 @@ private fun LiquidTodayHeader( SyncStatusChip( backfilling = backfilling, chunks = syncChunksThisSession, lastSyncAt = lastSyncAt, historySyncExperimental = historySyncExperimental, + pagesBehind = pagesBehindAtConnect, ) // (a) Profile avatar (the photo set in Settings, or the NOOP loop mark) → Settings. Mirrors iOS. Box( @@ -2490,6 +2498,7 @@ private fun SyncStatusChip( chunks: Int, lastSyncAt: Long?, historySyncExperimental: Boolean, + pagesBehind: Int? = null, ) { // The clock and the translated "now" word are resolved HERE, in the composable that already depends // on both, and handed down — so `SyncChipState.resolve` stays a genuinely pure decision that a plain @@ -2501,11 +2510,28 @@ private fun SyncStatusChip( lastSyncAtSec = lastSyncAt, historySyncExperimental = historySyncExperimental, nowSec = System.currentTimeMillis() / 1000L, + pagesBehind = pagesBehind, ) when (state) { - is SyncChipState.Syncing -> ChipCapsule( - Icons.Filled.Autorenew, "${state.chunks}", Palette.accent, - uiString(R.string.l10n_today_screen_sync_chip_syncing_desc_bfc290e7, state.chunks)) + is SyncChipState.Syncing -> { + // Both counts are inflected, so "1 chunk" and "1 page" read correctly. Android + // inflects one quantity per resource, so each count resolves separately and a template + // sentence joins them — the joiner sits inside the template, never on a resource edge, + // because aapt2 strips edge whitespace (#2041). + val chunksText = uiPlural(R.plurals.sync_chip_chunks_count, state.chunks, state.chunks) + val pagesText = state.pagesBehind?.let { + uiPlural(R.plurals.sync_chip_pages_behind_count, it, it) + } + ChipCapsule( + Icons.Filled.Autorenew, "${state.chunks}", Palette.accent, + desc = if (pagesText != null) { + uiString(R.string.l10n_today_screen_sync_chip_syncing_pages_desc_ad763b39, chunksText, pagesText) + } else { + uiString(R.string.l10n_today_screen_sync_chip_syncing_desc_92daf60c, chunksText) + }, + detail = pagesText, + ) + } is SyncChipState.Synced -> ChipCapsule( Icons.Filled.Check, state.agoText, Palette.textSecondary, uiString(R.string.l10n_today_screen_sync_chip_synced_desc_4d255944, state.agoText)) @@ -2519,7 +2545,7 @@ private fun SyncStatusChip( /** The shared sync-chip capsule (icon + terse label). Twin of the iOS `SyncStatusChip.chip`. */ @Composable -private fun ChipCapsule(icon: ImageVector, text: String, tint: Color, desc: String) { +private fun ChipCapsule(icon: ImageVector, text: String, tint: Color, desc: String, detail: String? = null) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -2533,6 +2559,19 @@ private fun ChipCapsule(icon: ImageVector, text: String, tint: Color, desc: Stri ) { Icon(icon, contentDescription = desc, tint = tint, modifier = Modifier.size(14.dp)) Text(text, style = NoopType.caption, color = tint) + // #689/#815: the optional secondary line (the backlog figure). Dimmer than the count it follows, + // because the count is the live thing and this is a fixed sample taken at connect. maxLines = 1 + // with ellipsis so a long localisation cannot push the header cluster off a narrow screen + // (#1835); the accessibility description carries the full sentence either way. + if (detail != null) { + Text( + detail, + style = NoopType.caption, + color = Palette.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index fecfe61bee..1eac6cfd7a 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1715,7 +1715,16 @@ Erholung jetzt Live - Verlaufssynchronisierung des Straps läuft, %1$d Chunks + Verlaufssynchronisierung des Straps läuft, %1$s + Verlaufssynchronisierung des Straps läuft, %1$s, %2$s + + %1$d Chunk + %1$d Chunks + + + %1$d Seite Rückstand bei Verbindungsaufbau + %1$d Seiten Rückstand bei Verbindungsaufbau + Verlauf des Straps vor %1$s synchronisiert Verbunden; die Verlaufssynchronisierung ist bei diesem Strap experimentell. %1$s%% Ladung diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 3526edf6db..b8b8746eea 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1515,7 +1515,16 @@ Recuperación ahora en vivo - Sincronizando el historial de la pulsera, %1$d bloques + Sincronizando el historial de la pulsera, %1$s + Sincronizando el historial de la pulsera, %1$s, %2$s + + %1$d bloque + %1$d bloques + + + %1$d página de retraso al conectar + %1$d páginas de retraso al conectar + Historial de la pulsera sincronizado hace %1$s Conectado; la sincronización del historial es experimental en esta pulsera. %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 481e022779..2e0e15939c 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1533,7 +1533,16 @@ Récupération à l\'instant en direct - Synchronisation de l\'historique du bracelet, %1$d blocs + Synchronisation de l\'historique du bracelet, %1$s + Synchronisation de l\'historique du bracelet, %1$s, %2$s + + %1$d bloc + %1$d blocs + + + %1$d page de retard à la connexion + %1$d pages de retard à la connexion + Historique du bracelet synchronisé il y a %1$s Connecté ; la synchronisation de l\'historique est expérimentale sur ce bracelet. Charge %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 851df1c5c1..829b31bb90 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -1672,7 +1672,20 @@ Regeneracja Teraz na żywo - Synchronizowanie historii pasków, fragmenty %1$d + Synchronizowanie historii paska, %1$s + Synchronizowanie historii paska, %1$s, %2$s + + %1$d fragment + %1$d fragmenty + %1$d fragmentów + %1$d fragmentu + + + %1$d strona zaległości przy połączeniu + %1$d strony zaległości przy połączeniu + %1$d stron zaległości przy połączeniu + %1$d strony zaległości przy połączeniu + Historia paska zsynchronizowana %1$s temu Połączony; Synchronizacja historii paska jest eksperymentalna w przypadku tego paska %1$s%% Energia 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 68baedd659..e4e9d7d043 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1678,7 +1678,16 @@ Recuperação agora em direto - A sincronizar o histórico da bracelete, %1$d pedaços + A sincronizar o histórico da bracelete, %1$s + A sincronizar o histórico da bracelete, %1$s, %2$s + + %1$d pedaço + %1$d pedaços + + + %1$d página em atraso na ligação + %1$d páginas em atraso na ligação + Histórico da bracelete sincronizado há %1$s Ligada; a sincronização do histórico é experimental nesta bracelete. %1$s%% Carga diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 2127b658c6..f9c9291ccc 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -1723,7 +1723,20 @@ Восстановление сейчас в реальном времени - Синхронизация истории браслета, %1$d фрагментов + Синхронизация истории браслета, %1$s + Синхронизация истории браслета, %1$s, %2$s + + %1$d фрагмент + %1$d фрагмента + %1$d фрагментов + %1$d фрагмента + + + %1$d страница отставания при подключении + %1$d страницы отставания при подключении + %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 df15601682..a47d24f015 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1655,7 +1655,14 @@ 恢复分数 刚刚 实时 - 正在同步腕带历史记录,%1$d 个数据块 + 正在同步腕带历史记录,%1$s + 正在同步腕带历史记录,%1$s,%2$s + + %1$d 个数据块 + + + 连接时落后 %1$d 页 + 手环历史记录已在 %1$s 前同步 已连接;此手环的历史同步功能为实验性功能。 %1$s%% 充能(Charge) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index cca34ba3d3..05b2f75469 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1820,7 +1820,16 @@ Recovery now live - Syncing strap history, %1$d chunks + Syncing strap history, %1$s + Syncing strap history, %1$s, %2$s + + %1$d chunk + %1$d chunks + + + %1$d page behind at connect + %1$d pages behind at connect + Strap history synced %1$s ago Connected; strap history sync is experimental on this strap %1$s%% Charge diff --git a/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt b/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt index ae3f2c5ebe..403a4abe10 100644 --- a/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt +++ b/android/app/src/test/java/com/noop/ui/SyncChipStateTest.kt @@ -89,4 +89,53 @@ class SyncChipStateTest { ) assertEquals(SyncChipState.Synced("<1m"), state) } + + // #689/#815: the connect-time backlog sample. Twin of the iOS cases in `SyncChipStateTests`. + + @Test + fun backfillingWithBacklog_carriesThePagesFigure() { + val state = SyncChipState.resolve( + backfilling = true, chunks = 3, lastSyncAtSec = null, historySyncExperimental = false, + nowSec = NOW, pagesBehind = 120, + ) + assertEquals(SyncChipState.Syncing(3, 120), state) + } + + /** + * A zero backlog is dropped rather than rendered. The chip only exists while a sync is running, so + * "0 pages behind at connect" next to a spinning icon contradicts itself, and a zero sample gives a + * reader nothing to act on. Pinned because the rule lives in `resolve` precisely so both platforms + * drop the same case rather than each deciding in its own view layer. + */ + @Test + fun backfillingWithZeroBacklog_dropsTheDetail() { + val state = SyncChipState.resolve( + backfilling = true, chunks = 3, lastSyncAtSec = null, historySyncExperimental = false, + nowSec = NOW, pagesBehind = 0, + ) + assertEquals(SyncChipState.Syncing(3, null), state) + } + + /** No reply yet this session, or a frame that did not decode: the chip is exactly what it was before. */ + @Test + fun backfillingWithoutASample_isUnchanged() { + val state = SyncChipState.resolve( + backfilling = true, chunks = 3, lastSyncAtSec = null, historySyncExperimental = false, + nowSec = NOW, pagesBehind = null, + ) + assertEquals(SyncChipState.Syncing(3, null), state) + } + + /** + * The backlog only qualifies an in-progress sync. Once the strap has finished, the chip reports when + * it last synced, and a stale "behind" figure must not survive into that state. + */ + @Test + fun notBackfilling_ignoresTheBacklog() { + val state = SyncChipState.resolve( + backfilling = false, chunks = 0, lastSyncAtSec = NOW - 65, historySyncExperimental = false, + nowSec = NOW, pagesBehind = 120, + ) + assertEquals(SyncChipState.Synced("1m"), state) + } }