diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 65f18ac64..f7a5337f3 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3685,5 +3685,6 @@ "stepCalDoneHousehold": "Gespeichert. Dein Smartphone hat {count} der {band} Schritte gezählt, die das Band erkannt hat. Bewegung im Haushalt wird jetzt auf {gain} skaliert.", "whoopFirmwareWarningTitle": "WHOOP-Nutzer: Verbinde dich nicht wieder mit der WHOOP-App", "whoopFirmwareWarningAction": "Wenn du OpenStrap oder eine andere Open-Source-App nutzt oder später zu einer wechseln möchtest, verbinde dein Band nicht wieder mit der offiziellen WHOOP-App. Sie kann Firmware-Updates automatisch installieren. Deinstalliere oder deaktiviere die WHOOP-App, damit sie dein Band nicht aktualisiert.", - "whoopFirmwareWarningDetails": "Berichte zur WHOOP 5.0 / MG Firmware 50.43.1.0 und 50.43.2.0 beschreiben einen blockierten Datenzugriff. Bluetooth kann sich weiterhin verbinden. OpenStrap kann das von WHOOP signierte Mitgliedschafts-Token nicht ausstellen, das betroffene Bänder entsperrt. Wir arbeiten an einer Lösung, aber es gibt noch keine Abhilfe." + "whoopFirmwareWarningDetails": "Berichte zur WHOOP 5.0 / MG Firmware 50.43.1.0 und 50.43.2.0 beschreiben einen blockierten Datenzugriff. Bluetooth kann sich weiterhin verbinden. OpenStrap kann das von WHOOP signierte Mitgliedschafts-Token nicht ausstellen, das betroffene Bänder entsperrt. Wir arbeiten an einer Lösung, aber es gibt noch keine Abhilfe.", + "dayStepsWhoop4NoCounter": "Ein WHOOP 4.0 hat keinen eigenen Schrittzähler und zählt Schritte nur, solange diese App seine Live-Daten empfängt." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b43507676..23afe32cf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16803,6 +16803,10 @@ "@strengthDelete": {"description": "Strength set editor copy."}, "strengthSaveFailed": "Failed to save changes", "@strengthSaveFailed": {"description": "Strength set editor copy."}, + "dayStepsWhoop4NoCounter": "A WHOOP 4.0 has no step counter of its own, so it only counts steps while this app is receiving its live data.", + "@dayStepsWhoop4NoCounter": { + "description": "Steps screen: appended when the paired band is a WHOOP 4.0 and it counted nothing that day, because it has no on-chip step counter" + }, "napAutoLogTitle": "Log clear naps automatically", "napAutoLogSub": "A detected nap with strong evidence is added without asking. You can still edit or remove it", "napAutoLogSaveFailed": "That setting could not be saved. Try again." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2cf903bc1..55a0ff182 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3385,5 +3385,6 @@ "stepCalDoneHousehold": "Guardado. Tu teléfono contó {count} de los {band} pasos que vio la banda. El movimiento en casa ahora se escala a {gain}.", "whoopFirmwareWarningTitle": "Usuarios de WHOOP: no vuelvas a conectarte a la app de WHOOP", "whoopFirmwareWarningAction": "Si usas OpenStrap u otra app de código abierto, o piensas cambiarte a una más adelante, no vuelvas a conectar tu banda a la app oficial de WHOOP. Puede instalar actualizaciones de firmware automáticamente. Desinstala o desactiva la app de WHOOP para evitar que actualice tu banda.", - "whoopFirmwareWarningDetails": "Hay informes de que el firmware 50.43.1.0 y 50.43.2.0 de WHOOP 5.0 / MG bloquea el acceso a los datos. Es posible que el Bluetooth siga conectándose. OpenStrap no puede emitir el token de membresía firmado por WHOOP que se necesita para desbloquear las bandas afectadas. Estamos trabajando en una solución, pero todavía no hay ningún arreglo disponible." + "whoopFirmwareWarningDetails": "Hay informes de que el firmware 50.43.1.0 y 50.43.2.0 de WHOOP 5.0 / MG bloquea el acceso a los datos. Es posible que el Bluetooth siga conectándose. OpenStrap no puede emitir el token de membresía firmado por WHOOP que se necesita para desbloquear las bandas afectadas. Estamos trabajando en una solución, pero todavía no hay ningún arreglo disponible.", + "dayStepsWhoop4NoCounter": "Una WHOOP 4.0 no tiene contador de pasos propio, así que solo cuenta pasos mientras esta app recibe sus datos en vivo." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 112586647..62c710adc 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3385,5 +3385,6 @@ "pairOuraExistingKeyGuidance": "Vous utilisez déjà l’application Oura ? Gardez l’anneau associé dans cette application et associez-le avec sa clé actuelle. Passez les étapes de réinitialisation ci-dessous. Réinitialisez-le uniquement si vous voulez une nouvelle clé.", "whoopFirmwareWarningTitle": "Utilisateurs WHOOP : ne vous reconnectez pas à l’application WHOOP", "whoopFirmwareWarningAction": "Si vous utilisez OpenStrap ou une autre application open source, ou si vous comptez y passer plus tard, ne reconnectez pas votre bracelet à l’application WHOOP officielle. Elle peut installer des mises à jour du firmware automatiquement. Désinstallez ou désactivez l’application WHOOP pour l’empêcher de mettre à jour votre bracelet.", - "whoopFirmwareWarningDetails": "Des signalements concernant les firmwares 50.43.1.0 et 50.43.2.0 des WHOOP 5.0 / MG décrivent un accès aux données bloqué. Le Bluetooth peut encore se connecter. OpenStrap ne peut pas émettre le jeton d’abonnement signé par WHOOP nécessaire pour déverrouiller les bracelets concernés. Nous travaillons sur une solution, mais aucun correctif n’est encore disponible." + "whoopFirmwareWarningDetails": "Des signalements concernant les firmwares 50.43.1.0 et 50.43.2.0 des WHOOP 5.0 / MG décrivent un accès aux données bloqué. Le Bluetooth peut encore se connecter. OpenStrap ne peut pas émettre le jeton d’abonnement signé par WHOOP nécessaire pour déverrouiller les bracelets concernés. Nous travaillons sur une solution, mais aucun correctif n’est encore disponible.", + "dayStepsWhoop4NoCounter": "Un WHOOP 4.0 n’a pas de podomètre intégré : il ne compte les pas que lorsque cette application reçoit ses données en direct." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9ed1242ae..7f260f62b 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -3386,5 +3386,6 @@ "stepCalRoutineTooShort": "वह अभ्यास टाइमर ख़त्म होने से पहले रुक गया। पूरा होने तक चलते रहें।", "stepCalSaveFailed": "वह कैलिब्रेशन सहेजा नहीं जा सका।", "stepCalDoneGait": "सहेजा गया। आपने {count} कदम गिने और बैंड ने {band} कदम देखे। अब से इस तरह की चाल {pct}% से ठीक की जाएगी।", - "stepCalDoneHousehold": "सहेजा गया। बैंड द्वारा देखे गए {band} कदमों में से आपके फ़ोन ने {count} कदम गिने। घर के कामों की हलचल अब {gain} के अनुपात में समायोजित होगी।" + "stepCalDoneHousehold": "सहेजा गया। बैंड द्वारा देखे गए {band} कदमों में से आपके फ़ोन ने {count} कदम गिने। घर के कामों की हलचल अब {gain} के अनुपात में समायोजित होगी।", + "dayStepsWhoop4NoCounter": "WHOOP 4.0 में अपना कोई स्टेप काउंटर नहीं है, इसलिए यह कदम तभी गिनता है जब यह ऐप उसका लाइव डेटा प्राप्त कर रहा हो।" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index cb40872e8..a2ceeaa18 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -8159,5 +8159,6 @@ "pairTroubleDiscordSub": "Сообщество OpenStrap", "pairModeTitle": "Переведите в режим сопряжения", "pairModeNoSite": "Это универсальное устройство без единого производителя, поэтому инструкции производителя нет.", - "pairOuraExistingKeyGuidance": "Уже пользуетесь приложением Oura? Оставьте кольцо сопряжённым в этом приложении и выполните сопряжение с его текущим ключом. Пропустите шаги сброса ниже. Сбрасывайте кольцо только для получения нового ключа." + "pairOuraExistingKeyGuidance": "Уже пользуетесь приложением Oura? Оставьте кольцо сопряжённым в этом приложении и выполните сопряжение с его текущим ключом. Пропустите шаги сброса ниже. Сбрасывайте кольцо только для получения нового ключа.", + "dayStepsWhoop4NoCounter": "У WHOOP 4.0 нет собственного шагомера, поэтому он считает шаги только пока это приложение получает его данные в реальном времени." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 227ddbff6..7734a48f5 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16627,5 +16627,6 @@ "@pairModeNoSite": { "description": "Shown instead of steps for a generic device with no maker’s page." }, - "pairOuraExistingKeyGuidance": "ใช้แอป Oura อยู่แล้วใช่ไหม คงการจับคู่แหวนในแอปนั้นไว้ แล้วจับคู่ด้วยคีย์เดิมของแหวน ข้ามขั้นตอนรีเซ็ตด้านล่าง รีเซ็ตเฉพาะเมื่อต้องการคีย์ใหม่เท่านั้น" + "pairOuraExistingKeyGuidance": "ใช้แอป Oura อยู่แล้วใช่ไหม คงการจับคู่แหวนในแอปนั้นไว้ แล้วจับคู่ด้วยคีย์เดิมของแหวน ข้ามขั้นตอนรีเซ็ตด้านล่าง รีเซ็ตเฉพาะเมื่อต้องการคีย์ใหม่เท่านั้น", + "dayStepsWhoop4NoCounter": "WHOOP 4.0 ไม่มีตัวนับก้าวในตัว จึงนับก้าวได้เฉพาะตอนที่แอปนี้กำลังรับข้อมูลสดจากสายรัดเท่านั้น" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 52eae2f7c..85f27dd53 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -3385,5 +3385,6 @@ "stepCalRoutineTooShort": "这项测试在计时结束前就停止了。请坚持到它完成。", "stepCalSaveFailed": "无法保存此次校准。", "stepCalDoneGait": "已保存。你数了 {count} 步,腕带记录了 {band} 步。今后这类走路将按 {pct}% 进行校正。", - "stepCalDoneHousehold": "已保存。在腕带记录的 {band} 步中,你的手机记录了 {count} 步。居家活动现在将按 {gain} 进行缩放。" + "stepCalDoneHousehold": "已保存。在腕带记录的 {band} 步中,你的手机记录了 {count} 步。居家活动现在将按 {gain} 进行缩放。", + "dayStepsWhoop4NoCounter": "WHOOP 4.0 没有自带计步器,因此只有在本应用接收其实时数据时才会计步。" } diff --git a/lib/ui2/screens/day_steps.dart b/lib/ui2/screens/day_steps.dart index 90ed0e988..03c9ab9e6 100644 --- a/lib/ui2/screens/day_steps.dart +++ b/lib/ui2/screens/day_steps.dart @@ -75,6 +75,7 @@ class DayStepsData { this.daySource, this.note, this.bandLabel = _defaultBand, + this.bandHasNoCounter = false, }); /// The fallback name for the strap. Nothing is paired, or the link has not @@ -106,11 +107,18 @@ class DayStepsData { /// or [_defaultBand] when nothing has told us which band it is. final String bandLabel; + /// The paired band has no step counter of its own (a WHOOP 4.0): it only + /// counts while the app is receiving its live movement stream, so a walk + /// with the phone left behind leaves no steps at all. Said on the screen so + /// "it isn't tracking my steps" has its answer where the steps are. + final bool bandHasNoCounter; + bool get mixed => strap > 0 && phone > 0; static Future load( LocalRepository repo, { String bandLabel = _defaultBand, + bool bandHasNoCounter = false, String? want, }) async { final days = await repo.availableDays(); @@ -140,6 +148,7 @@ class DayStepsData { daySource: d['day_source'] as String?, note: d['note'] as String?, bandLabel: bandLabel, + bandHasNoCounter: bandHasNoCounter, ); } } @@ -273,7 +282,9 @@ class _DayStepsDetailState extends State { } try { final d = await DayStepsData.load(repo, - bandLabel: bandLabel(context), want: _day); + bandLabel: bandLabel(context), + bandHasNoCounter: pairedBandHasNoCounter(context), + want: _day); if (mounted && token == _loadToken) { setState(() => (_d = d, _loading = false)); } @@ -334,9 +345,13 @@ class _DayStepsDetailState extends State { 'The ${thousands(d.dayTotal)} steps $when came from the ' 'strap’s own counter, which didn’t keep times for this day, ' 'so they can’t go on a clock.') - : whyFromNote(d.note, unit: 'days') ?? - (l?.dayStepsNothingCounted(when) ?? - 'Nothing that can count steps recorded $when.'), + : _withNoCounterHint( + c, + d, + whyFromNote(d.note, unit: 'days') ?? + (l?.dayStepsNothingCounted(when) ?? + 'Nothing that can count steps recorded $when.'), + ), icon: chip ? LucideIcons.watch : LucideIcons.footprints, ); } @@ -431,9 +446,31 @@ class _DayStepsDetailState extends State { ? (l?.dayStepsHonestyStrap ?? 'Counted at your wrist, where a real walk tends to read low and ' 'rhythmic hand work can read as walking.') - : (l?.dayStepsHonestyPhone ?? - 'Counted by your phone, so only the steps you had it on you for ' - 'are here.'); + : _withNoCounterHint( + c, + d, + l?.dayStepsHonestyPhone ?? + 'Counted by your phone, so only the steps you had it on you ' + 'for are here.', + ); + } + + /// [text], followed by why the band added nothing when it has no counter of + /// its own. Only ever on a day the band did not count: a day it did count + /// already shows that it can. + String _withNoCounterHint(BuildContext c, DayStepsData d, String text) { + // A day whose total came off a strap's own counter was counted by a + // band that HAS one (an earlier WHOOP 5.0 / MG): the hint would be false. + // The band can be unpaired or swapped after the day was loaded, so the + // live answer wins whenever the app state is reachable. + if (!bandStillHasNoCounter(d.bandHasNoCounter, liveBandHasNoCounter(c)) || + d.daySource == 'strap_counter') { + return text; + } + final hint = AppLocalizations.of(c)?.dayStepsWhoop4NoCounter ?? + 'A WHOOP 4.0 has no step counter of its own, so it only counts steps ' + 'while this app is receiving its live data.'; + return '$text $hint'; } // ── the stretches themselves ─────────────────────────────────────────────── @@ -489,3 +526,33 @@ String bandLabel(BuildContext c) { return fallback; } } + +/// Whether a band of [generation] has no on-chip step counter: a WHOOP 4.0 +/// (`gen4`, see `derivation_engine.dart`'s v101→102 note). +bool generationHasNoCounter(String? generation) => generation == 'gen4'; + +/// The answer to show: the live one when the app state could be read, else +/// what was loaded with the day. +bool bandStillHasNoCounter(bool loaded, bool? live) => live ?? loaded; + +/// Whether the band paired NOW has no on-chip step counter. False when +/// nothing is paired or the generation is not known, so the hint never names +/// a band we were not told about. +bool pairedBandHasNoCounter(BuildContext c) => + _bandHasNoCounter(c, listen: false) ?? false; + +/// [pairedBandHasNoCounter] for a build: listens, so the hint follows an +/// unpair or a swap. Null when there is no app state to ask (a golden). +bool? liveBandHasNoCounter(BuildContext c) => + _bandHasNoCounter(c, listen: true); + +bool? _bandHasNoCounter(BuildContext c, {required bool listen}) { + try { + final app = listen ? c.watch() : c.read(); + if (!app.isPaired) return false; + return generationHasNoCounter( + app.paired?.generation ?? app.device.generation); + } catch (_) { + return null; + } +} diff --git a/test/ui2_day_steps_test.dart b/test/ui2_day_steps_test.dart index 920b0f61a..4d09847e7 100644 --- a/test/ui2_day_steps_test.dart +++ b/test/ui2_day_steps_test.dart @@ -12,6 +12,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/sync/paired_device.dart'; import 'package:openstrap_edge/ui2/screens/day_steps.dart'; import 'package:openstrap_edge/ui2/ui2.dart'; @@ -32,19 +36,40 @@ DayStepSpan _span( activity: activity, ); -Future _pump(WidgetTester t, DayStepsData d, {double scale = 1}) async { +/// An app state whose pairing a test can swap, the way an unpair or a re-pair +/// does, and announce. +class _Paired extends AppState { + _Paired() : super.forTesting(); + + void pair(String? generation) { + paired = generation == null + ? null + : PairedDevice('remote', 'SN1', generation: generation); + notifyListeners(); + } +} + +Future _pump( + WidgetTester t, + DayStepsData d, { + double scale = 1, + AppState? app, +}) async { t.view.physicalSize = Size(390 * 3, 3000 * 3 * scale); t.view.devicePixelRatio = 3; addTearDown(t.view.reset); - await t.pumpWidget( - MediaQuery( - data: MediaQueryData(textScaler: TextScaler.linear(scale)), - child: MaterialApp( - theme: buildTheme(Brightness.light), - home: DayStepsDetail(data: d), - ), + final screen = MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: MaterialApp( + theme: buildTheme(Brightness.light), + home: DayStepsDetail(data: d), ), ); + await t.pumpWidget( + app == null + ? screen + : ChangeNotifierProvider.value(value: app, child: screen), + ); await t.pumpAndSettle(); } @@ -87,6 +112,112 @@ void main() { expect(find.text('WHOOP 4'), findsNothing); }); + testWidgets('with a WHOOP 4.0 paired, a phone-only day says why the band ' + 'added nothing', (t) async { + await _pump( + t, + DayStepsData( + spans: [_span(0, 60, 1200)], + total: 1200, + phone: 1200, + bandHasNoCounter: true, + ), + ); + expect(find.textContaining('had it on you for'), findsOneWidget); + expect( + find.textContaining('A WHOOP 4.0 has no step counter'), + findsOneWidget, + ); + }); + + testWidgets('with a WHOOP 4.0 paired, a day with no steps says why', ( + t, + ) async { + await _pump(t, const DayStepsData(bandHasNoCounter: true)); + expect(find.textContaining('No steps counted'), findsOneWidget); + expect( + find.textContaining('A WHOOP 4.0 has no step counter'), + findsOneWidget, + ); + }); + + testWidgets('the WHOOP 4.0 hint follows the band the app has paired', ( + t, + ) async { + // The day loaded with no hint; the live pairing decides. + SharedPreferences.setMockInitialValues({}); + final app = _Paired()..pair('gen4'); + addTearDown(app.dispose); + await _pump( + t, + DayStepsData(spans: [_span(0, 60, 1200)], total: 1200, phone: 1200), + app: app, + ); + const hint = 'A WHOOP 4.0 has no step counter'; + expect(find.textContaining(hint), findsOneWidget); + + app.pair('gen5'); + await t.pumpAndSettle(); + expect(find.textContaining(hint), findsNothing, reason: 'a 5.0 counts'); + + app.pair('gen4'); + await t.pumpAndSettle(); + expect(find.textContaining(hint), findsOneWidget); + + app.pair(null); + await t.pumpAndSettle(); + expect(find.textContaining(hint), findsNothing, reason: 'unpaired'); + }); + + testWidgets('the WHOOP 4.0 hint never shows on a day the band counted, or ' + 'for another band', (t) async { + // The band counted: it evidently can, so the hint would be wrong. + await _pump( + t, + DayStepsData( + spans: [_span(240, 300, 3240, band: true)], + total: 3240, + strap: 3240, + bandLabel: 'WHOOP 4', + bandHasNoCounter: true, + ), + ); + expect(find.textContaining('has no step counter'), findsNothing); + // A band with its own counter (or none paired): never mentioned. + await _pump( + t, + DayStepsData(spans: [_span(0, 60, 1200)], total: 1200, phone: 1200), + ); + expect(find.textContaining('has no step counter'), findsNothing); + // An older day a WHOOP 5.0's own counter published, viewed with a WHOOP + // 4.0 paired now: that band could count, so the hint would be false. + await _pump( + t, + DayStepsData( + spans: [_span(0, 60, 1200)], + total: 1200, + phone: 1200, + dayTotal: 6012, + daySource: 'strap_counter', + bandHasNoCounter: true, + ), + ); + expect(find.textContaining('has no step counter'), findsNothing); + }); + + test('the no-counter hint follows the live band, not the day it loaded', () { + expect(generationHasNoCounter('gen4'), isTrue); + expect(generationHasNoCounter('gen5'), isFalse); + expect(generationHasNoCounter(null), isFalse); + // Unpaired since the day loaded: the live answer wins. + expect(bandStillHasNoCounter(true, false), isFalse); + // Paired since: likewise. + expect(bandStillHasNoCounter(false, true), isTrue); + // No app state to ask: what the day loaded with. + expect(bandStillHasNoCounter(true, null), isTrue); + expect(bandStillHasNoCounter(false, null), isFalse); + }); + testWidgets('a day counted only by the strap\'s on-chip counter says the ' 'TIMES are missing, not the steps', (t) async { await _pump( @@ -136,19 +267,16 @@ void main() { _span(660, 720, 2074), ]; final merged = mergeAdjacent(rows); - expect( - merged.map((s) => (s.startTs - _nine, s.endTs - _nine, s.steps)), - [ - (0, 180 * 60, 1913), // three contiguous phone hours, joined - (240 * 60, 300 * 60, 228), // the gap kept it separate - (480 * 60, 540 * 60, 1649), // a band stretch starts inside the next - (532 * 60, 534 * 60, 51), - (534 * 60, 540 * 60, 737), - (540 * 60, 600 * 60, 5753), - (541 * 60, 544 * 60, 352), - (600 * 60, 720 * 60, 2288), - ], - ); + expect(merged.map((s) => (s.startTs - _nine, s.endTs - _nine, s.steps)), [ + (0, 180 * 60, 1913), // three contiguous phone hours, joined + (240 * 60, 300 * 60, 228), // the gap kept it separate + (480 * 60, 540 * 60, 1649), // a band stretch starts inside the next + (532 * 60, 534 * 60, 51), + (534 * 60, 540 * 60, 737), + (540 * 60, 600 * 60, 5753), + (541 * 60, 544 * 60, 352), + (600 * 60, 720 * 60, 2288), + ]); }); test('an hour gets one bar, in the colour of the sensor that counted most '