diff --git a/ios/Runner/AccessorySetup.swift b/ios/Runner/AccessorySetup.swift index 2525c91f9..801317a5d 100644 --- a/ios/Runner/AccessorySetup.swift +++ b/ios/Runner/AccessorySetup.swift @@ -108,6 +108,13 @@ enum AccessorySetup { message: "AccessorySetupKit requires iOS 18", details: nil)) } + case "removeBand": + if #available(iOS 18.0, *), let id = call.arguments as? String { + Impl.shared.removeBand(id: id) { result(nil) } + } else { + result(nil) + } + case "removeAll": if #available(iOS 18.0, *) { Impl.shared.removeAll { result(nil) } @@ -468,6 +475,16 @@ private final class Impl { remove({ a in !(self.service(of: a).map(sensors.contains) ?? false) }, completion) } + /// Retire exactly the former main band; keep the replacement and all sensors. + func removeBand(id: String, _ completion: @escaping () -> Void) { + let sensors = sensorServices + let wanted = id.uppercased() + remove({ a in + a.bluetoothIdentifier?.uuidString.uppercased() == wanted + && !(self.service(of: a).map(sensors.contains) ?? false) + }, completion) + } + /// Deprovisions the SENSOR with CoreBluetooth id [id] (called when it is forgotten), so /// the next `showSensorPicker` opens a sheet instead of handing back the forgotten one. /// Never removes a band. diff --git a/lib/ble/accessory_setup.dart b/lib/ble/accessory_setup.dart index 616d0228a..8feae30bb 100644 --- a/lib/ble/accessory_setup.dart +++ b/lib/ble/accessory_setup.dart @@ -114,6 +114,15 @@ class AccessorySetup { } catch (_) {} } + /// Retire only the old band after the replacement is durable. Best-effort; + /// a failed native removal leaves its approval, never clears the new one. + static Future removeBand(String id) async { + if (!Platform.isIOS) return; + try { + await _ch.invokeMethod('removeBand', id.toUpperCase()); + } catch (_) {} + } + /// Deprovision every ASK band, sensors kept (called on unpair). Best-effort. static Future removeAll() async { if (!Platform.isIOS) return; diff --git a/lib/ble/slot_comparison.dart b/lib/ble/slot_comparison.dart new file mode 100644 index 000000000..921f20176 --- /dev/null +++ b/lib/ble/slot_comparison.dart @@ -0,0 +1,345 @@ +// What a device gives in each of the two columns the pairing screens draw: +// "Every day" (the main band, or the active wearable that fills the days the +// band left empty) and "Workouts only" (a workout-armed sensor). Capabilities +// come from the compute layer's eligibility rules, never from raw-signal +// membership. +import '../compute/inputs/canonical.dart' + show Cell, MetricClass, capabilities, columnFor, kWorkoutArmedSensors; +import 'adapters/_registry.dart'; + +/// The two columns. A WHOOP fills [everyDay] as the main band; a ring or a +/// watch fills it as the active wearable (canonical.dart `activeWearable`, +/// `wearableSubstrate`); a workout-armed sensor fills [workoutsOnly]. +enum DeviceSlot { everyDay, workoutsOnly } + +enum DeviceCapability { + sleep, + recovery, + strain, + + /// Heart rate and resting heart rate (the column's `resting_hr` row). + heartRate, + hrv, + respiration, + skinTemperature, + steps, + bloodOxygen, + workoutHeartRate, + workoutBeatToBeat, +} + +/// How a capability is delivered. +/// native: this app computes it. conditional: only when [CapabilityStatus.reasonKey] +/// holds (an l10n-able key). vendorReported: the device's own number, shown as +/// the device's (never passed off as native). +enum CapabilityKind { native, conditional, vendorReported } + +class CapabilityStatus { + final CapabilityKind kind; + + /// Required (nonblank) when [kind] is conditional, else null. + final String? reasonKey; + const CapabilityStatus(this.kind, {this.reasonKey}); +} + +class SlotSide { + final Map sends; + + /// The table's rows ([kTableRows]) this side does not send. + final Set doesNotSend; + const SlotSide({required this.sends, required this.doesNotSend}); +} + +class SlotComparison { + /// What the entry gives every day: as the main band (a WHOOP) or as the + /// active wearable (a columned ring or watch). Empty for a chest strap. + final SlotSide everyDay; + + /// What the entry gives during workouts as a workout-armed sensor (a WHOOP + /// kept for workouts is read as one). Empty for a ring. + final SlotSide workoutsOnly; + + /// The column the device goes into, or null when it fits neither (a Coros + /// watch, a scale). + final DeviceSlot? slot; + const SlotComparison( + {required this.everyDay, required this.workoutsOnly, this.slot}); + + SlotSide side(DeviceSlot s) => switch (s) { + DeviceSlot.everyDay => everyDay, + DeviceSlot.workoutsOnly => workoutsOnly, + }; +} + +/// The whole-day rows of the table, in the order they are drawn. +const List kDayCapabilities = [ + DeviceCapability.sleep, + DeviceCapability.recovery, + DeviceCapability.strain, + DeviceCapability.heartRate, + DeviceCapability.hrv, + DeviceCapability.respiration, + DeviceCapability.skinTemperature, + DeviceCapability.steps, +]; + +/// Capabilities decided per workout session, not per day: the band records a +/// workout itself; a sensor only through the sensor path +/// (`kWorkoutArmedSensors`, canonical.dart; `HrsLink.deriveTier`). +const Set kSessionCapabilities = { + DeviceCapability.workoutHeartRate, + DeviceCapability.workoutBeatToBeat, +}; + +/// Every row the table draws: the whole-day rows, then the workout rows. +/// Blood oxygen is never one of them. +const List kTableRows = [ + ...kDayCapabilities, + DeviceCapability.workoutHeartRate, + DeviceCapability.workoutBeatToBeat, +]; + +/// THE mapping from a whole-day capability to its row in the compute layer's +/// metric x device columns (`columnFor`, compute/inputs/canonical.dart). A +/// capability's status for a wearable is read off `capabilities(column)[row]`. +const Map kCapabilityRow = { + DeviceCapability.sleep: 'sleep_window', + DeviceCapability.recovery: 'readiness', + DeviceCapability.strain: 'strain', + DeviceCapability.heartRate: 'resting_hr', + DeviceCapability.hrv: 'hrv', + DeviceCapability.respiration: 'respiratory_rate', + DeviceCapability.skinTemperature: 'skin_temp', + DeviceCapability.steps: 'steps', +}; + +// Blood oxygen is never claimed by a pairing comparison, for any device: the +// app refuses its own SpO2 (onehz_pipeline.dart kSpo2Refusal), and a vendor's +// reading is not a promise made at pairing. It is not in [kCapabilityRow]. +// (The enum value and its label stay so the absence can be asserted.) + +/// Where a claimed capability comes from: [rule] names the eligibility rule, +/// [source] is the repo file that holds it. +typedef CapabilityTrace = ({String rule, String source}); + +/// l10n keys (lib/l10n/app_*.arb) of the one-line captions a conditional +/// capability carries. +const String kReasonSkinTempNights = 'capReasonSkinTempNights'; + +// Ring settle bands use synthetic nights (oura_inputs.dart), so waiting +// for the minimum nights does not remove their provisional status. +const String kReasonSkinTempProvisional = 'capReasonSkinTempProvisional'; +const String kReasonEstimated = 'capReasonEstimated'; +const String kReasonEarly = 'capReasonEarly'; + +/// A wearable's readiness is provisional until its inputs have 14 nights of +/// baseline (oura_inputs.dart `readiness`, onehz_pipeline.dart +/// kSkinTempDevMinNights). +const String kReasonFirstNights = 'capReasonFirstNights'; + +/// A wearable's sleep window is ours, its stages the device's own +/// (`sleep_stages` is a device-only row). +const String kReasonOwnStagesRing = 'capReasonOwnStagesRing'; +const String kReasonOwnStages = 'capReasonOwnStages'; + +/// WHOOP 4 has no step counter: its steps come from the strap's 100 Hz +/// pedometer over a live link only (live_step_runs.dart). +const String kReasonLiveSessions = 'capReasonLiveSessions'; + +/// A row that depends on whether the device sends it, where nobody has +/// confirmed it does: RR intervals are optional in the Heart Rate Service, so +/// a generic strap's (or a WHOOP read as one) beat timing is unconfirmed. A +/// device people use and send it (Polar's PPI stream) carries no caption. +const String kReasonUnconfirmed = 'capReasonUnconfirmed'; + +/// Pebble 2 SE has no HR sensor; only Pebble 2 can supply HR-based rows. +const String kReasonPebble2Only = 'capReasonPebble2Only'; + +/// [kReasonPebble2Only] on a row that is also an estimate on Pebble 2. +const String kReasonPebble2OnlyEstimated = 'capReasonPebble2OnlyEstimated'; + +/// l10n key of each capability's user-facing name. +const Map kCapabilityLabelKey = { + DeviceCapability.sleep: 'capLabelSleep', + DeviceCapability.recovery: 'capLabelRecovery', + DeviceCapability.strain: 'capLabelStrain', + DeviceCapability.heartRate: 'capLabelHeartRate', + DeviceCapability.hrv: 'capLabelHrv', + DeviceCapability.respiration: 'capLabelRespiration', + DeviceCapability.skinTemperature: 'capLabelSkinTemperature', + DeviceCapability.steps: 'capLabelSteps', + DeviceCapability.bloodOxygen: 'capLabelBloodOxygen', + DeviceCapability.workoutHeartRate: 'capLabelWorkoutHeartRate', + DeviceCapability.workoutBeatToBeat: 'capLabelWorkoutBeatToBeat', +}; + +typedef _Claim = ({CapabilityStatus status, CapabilityTrace trace}); + +const String _canonical = 'lib/compute/inputs/canonical.dart'; +const String _pipeline = 'lib/compute/onehz_pipeline.dart'; +const String _engine = 'lib/compute/derivation_engine.dart'; + +const CapabilityStatus _native = CapabilityStatus(CapabilityKind.native); + +CapabilityStatus _when(String reasonKey) => + CapabilityStatus(CapabilityKind.conditional, reasonKey: reasonKey); + +bool _nativeWhoop(BandEntry e) => kWhoopGen4.id == e.id || kWhoopGen5.id == e.id; + +/// The column [e] goes into, or null when it fits neither. +DeviceSlot? _slotOf(BandEntry e) { + if (_nativeWhoop(e)) return DeviceSlot.everyDay; + if (e.category == DeviceCategory.wearable && columnFor(e.id) != null) { + return DeviceSlot.everyDay; + } + if (kWorkoutArmedSensors.contains(e.id)) return DeviceSlot.workoutsOnly; + return null; +} + +/// Every capability [e] gives in [slot], each with the rule behind it. The one +/// place claims are made, so a claim cannot exist without its trace. +Map _claims(BandEntry e, DeviceSlot slot, + {bool modelKnown = true}) => + switch (slot) { + DeviceSlot.everyDay => _everyDayClaims(e, modelKnown: modelKnown), + DeviceSlot.workoutsOnly => _workoutClaims(e), + }; + +Map _everyDayClaims(BandEntry e, + {bool modelKnown = true}) { + if (!_nativeWhoop(e)) { + // The active wearable: its column's rows, behind its own switch. Coros has + // no column (and wearableEnabled() is false for it): nothing. + return e.category == DeviceCategory.wearable ? _columnClaims(e) : {}; + } + // The native pipeline (onehz_pipeline.dart): sleep, rmssd, resting HR, + // respiration, strain, readiness. Skin temperature needs a nightly mean, + // >= 3 prior nights and a baseline spread >= 1; WHOOP 5's deg C needs 14. + final out = { + for (final c in kDayCapabilities) + c: ( + status: c == DeviceCapability.skinTemperature + ? _when(kReasonSkinTempNights) + : _native, + trace: (rule: 'native WHOOP pipeline (${c.name})', source: _pipeline), + ), + }; + // Steps: WHOOP 5's cumulative on-chip counter rides every synced row; + // WHOOP 4 has none (derivation_engine.dart _stepCounterModulus), so only + // the live link's 100 Hz pedometer counts for it. + out[DeviceCapability.steps] = ( + status: !modelKnown + ? _when(kReasonUnconfirmed) + : e.id == kWhoopGen5.id ? _native : _when(kReasonLiveSessions), + trace: (rule: 'band steps (counter or live pedometer)', source: _engine), + ); + // The band records a workout itself. + for (final c in kSessionCapabilities) { + out[c] = (status: _native, trace: (rule: 'band workout session', source: _pipeline)); + } + return out; +} + +Map _workoutClaims(BandEntry e) { + // Only the sensors in kWorkoutArmedSensors (ble_hrs, polar_pmd) are session + // sources, and only with their switch on. A WHOOP kept for workouts is + // paired under ble_hrs, so it gets ble_hrs's claims. Anything else (a ring, + // Coros) sends nothing here. + final slotId = _nativeWhoop(e) ? kBleHrs.id : e.id; + if (!kWorkoutArmedSensors.contains(slotId)) return {}; + final out = {}; + final sig = declaredSignals(slotId).map((s) => s.name).toSet(); + const trace = ( + rule: 'workout session sensor (kWorkoutArmedSensors, flag on)', + source: _canonical, + ); + if (sig.contains('hr1Hz') || sig.contains('hrSparse')) { + out[DeviceCapability.workoutHeartRate] = (status: _native, trace: trace); + } + if (sig.contains('rrIntervals')) { + out[DeviceCapability.workoutBeatToBeat] = ( + status: slotId != kBleHrs.id ? _native : _when(kReasonUnconfirmed), + trace: trace, + ); + } + return out; +} + +/// [e]'s rows of the metric x device table, or none without a column. +Map _columnClaims(BandEntry e) { + final out = {}; + final col = columnFor(e.id); + if (col == null) return out; // Coros, sensors, scales: no column, no claim. + final cls = capabilities(col); + for (final m in kCapabilityRow.entries) { + var status = _statusOf(cls[m.value], col[m.value]); + if (status == null) continue; + if (status.kind == CapabilityKind.native) { + status = switch (m.key) { + // Ours is the window; the stages are the device's own. + DeviceCapability.sleep when cls['sleep_stages'] == MetricClass.device => + _when(e.kind == DeviceKind.ring + ? kReasonOwnStagesRing + : kReasonOwnStages), + _ => status, + }; + } + if (status.reasonKey == kReasonEarly) { + status = switch (m.key) { + DeviceCapability.recovery => _when(kReasonFirstNights), + DeviceCapability.skinTemperature => _when(kReasonSkinTempProvisional), + _ => status, + }; + } + // The shared Pebble entry covers the 2 SE too. It still serves the + // watch's reported sleep and steps without HR (dayCells), but cannot + // derive resting HR, strain or the resting-HR recovery estimate. + final pebbleHr = e.id == kPebble.id && + const {DeviceCapability.recovery, DeviceCapability.strain, + DeviceCapability.heartRate}.contains(m.key); + out[m.key] = ( + status: !pebbleHr + ? status + : _when(status.reasonKey == kReasonEstimated + ? kReasonPebble2OnlyEstimated + : kReasonPebble2Only), + trace: pebbleHr + ? (rule: 'Pebble 2 HR required for ${m.value}; SE has no HR sensor', + source: 'lib/compute/inputs/pebble_inputs.dart') + : (rule: 'metric x device column row ${m.value}', source: _canonical), + ); + } + return out; +} + +CapabilityStatus? _statusOf(MetricClass? cls, Cell? cell) => switch (cls) { + null || MetricClass.unavailable => null, + MetricClass.device => const CapabilityStatus(CapabilityKind.vendorReported), + MetricClass.estimated => _when(kReasonEstimated), + MetricClass.ours => cell?.provisionalAt != null ? _when(kReasonEarly) : _native, +}; + +/// The rule behind [c] for [e] in [slot], or null when no eligibility rule +/// supports the claim. +CapabilityTrace? capabilityTrace( + BandEntry e, + DeviceCapability c, { + required DeviceSlot slot, + bool modelKnown = true, +}) => _claims(e, slot, modelKnown: modelKnown)[c]?.trace; + +SlotSide _side(BandEntry e, DeviceSlot slot, {bool modelKnown = true}) { + final sends = { + for (final m in _claims(e, slot, modelKnown: modelKnown).entries) m.key: m.value.status, + }; + return SlotSide( + sends: sends, + doesNotSend: kTableRows.toSet().difference(sends.keys.toSet()), + ); +} + +SlotComparison slotComparisonFor(BandEntry e, {bool modelKnown = true}) => SlotComparison( + everyDay: _side(e, DeviceSlot.everyDay, modelKnown: modelKnown), + workoutsOnly: _side(e, DeviceSlot.workoutsOnly, modelKnown: modelKnown), + slot: _slotOf(e), +); diff --git a/lib/data/db.dart b/lib/data/db.dart index 050cb427c..e147ae681 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -4323,17 +4323,24 @@ class LocalDb { // phone is the primary device's own pedometer, so it keeps `''` too — // `source` is what tells wrist from pocket. String deviceId = kPrimaryDeviceId, + // The generation when the caller's session began, before async reads. + int? resetGeneration, }) async { + final generation = resetGeneration ?? ResetGate.generation; final w = sanitizeCoverageWindow(startTs, endTs, steps); if (w == null) return; - final db = await instance; - await db.insert('live_coverage', { - 'start_ts': w.startTs, - 'end_ts': w.endTs, - 'steps': steps, - 'day': day, - 'source': source, - 'device_id': deviceId, + // The wipe uses this gate too: a check before queuing can still race it. + await _withCommitGate(() async { + _refuseAfterReset(generation); + final db = await instance; + await db.insert('live_coverage', { + 'start_ts': w.startTs, + 'end_ts': w.endTs, + 'steps': steps, + 'day': day, + 'source': source, + 'device_id': deviceId, + }); }); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 65f18ac64..4c53f3640 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3613,6 +3613,95 @@ "pairModeTitle": "In den Kopplungsmodus versetzen", "pairModeNoSite": "Ein generisches Gerät ohne einzelnen Hersteller, daher gibt es keine Herstelleranleitung.", "pairOuraExistingKeyGuidance": "Nutzt du schon die Oura-App? Lass den Ring in der App gekoppelt und kopple ihn mit seinem vorhandenen Schlüssel. Überspringe die Schritte zum Zurücksetzen unten. Setze ihn nur zurück, wenn du einen neuen Schlüssel möchtest.", + "pairAddDevice": "Gerät hinzufügen", + "pairKindAll": "Alle", + "pairKindStrap": "Brustgurte & Armsensoren", + "pairKindRing": "Ringe", + "pairKindWatch": "Uhren", + "pairGoBack": "Zurück", + "pairAddDeviceSub": "Ringe, Uhren und Herzfrequenzgurte", + "pairAddDeviceSubtitle": "Bei jedem Gerät steht, wofür es passt: jeden Tag oder nur beim Training.", + "pairBandFoundBody": "Jeden Tag kann es viel mehr als nur beim Training. Was möchtest du?", + "pairBandFoundBodyHasBand": "Du hast schon ein Band. Es kann immer nur eines dein Band sein.", + "pairIdentifiedBody": "Es sah wie ein Herzfrequenzgurt aus, hat sich beim Verbinden aber als {label} gemeldet. Es wurde nichts gespeichert.", + "pairAsMyBand": "Als mein Band koppeln", + "pairSecondaryPaired": "Fürs Training gekoppelt.", + "pairBandNow": "Dein Band jetzt: {name}. Deine Daten bleiben beim Wechsel erhalten.", + "pairReplaceBand": "Dieses Band koppeln, dann {name} trennen", + "pairReplaceConfirmTitle": "{name} nach erfolgreicher Verbindung ersetzen?", + "pairReplaceConfirmBody": "Dein bisheriges Band bleibt bei Fehlern oder Abbruch gekoppelt. Deine Daten bleiben erhalten.", + "pairReplaceConfirmYes": "Ersatz koppeln", + "pairCurrentBandFallback": "dein Band", + "pairWearableLead": "Es kann deine Tage mit Schlaf, Erholung und Herzfrequenz füllen.", + "pairWearableNote": "Füllt Tage ohne dein WHOOP. Experimentell, also schalte es unter Geräte ein.", + "pairStrapLead": "Es liefert Herzfrequenz beim Training. Schlaf, Erholung und tägliche Belastung zeichnet es nicht auf.", + "pairStrapNote": "Für beides muss „Beim Training nutzen“ an sein.", + "pairDeviceNamed": "{name} koppeln", + "pairUseForWorkouts": "Nur fürs Training nutzen", + "pairReplaceUnpairFailed": "Dein Band ließ sich nicht trennen, daher wurde das neue nicht gekoppelt. Versuche es erneut.", + "pairWearableLeadPebble": "Pebble 2 kann deine Tage mit Schlaf, Erholung und Herzfrequenz füllen. Pebble 2 SE zeigt die eigenen Schlaf- und Schrittdaten der Uhr. Sie hat keinen Herzfrequenzsensor, daher kann diese App daraus weder die tägliche Belastung berechnen noch die Erholung schätzen.", + "pairMainBandWhoopOnly": "Hauptband: nur WHOOP 4.0 oder WHOOP 5.0/MG. Andere Geräte lassen sich als zusätzliche Geräte hinzufügen.", + "pairBandTitle": "Band koppeln", + "pairBandSubtitle": "Wähle das, das du trägst. Wir suchen es in der Nähe.", + "pairStrapOnlyLink": "Ich habe nur einen Herzfrequenzgurt", + "pairWhoopRowLine": "Schlaf, Erholung, Belastung, Herzfrequenz und mehr", + "pairSetUpTitle": "Einrichten", + "capLabelSleep": "Schlaf", + "capLabelRecovery": "Erholung", + "capLabelStrain": "Belastung", + "capLabelHrv": "Herzfrequenzvariabilität", + "capLabelSkinTemperature": "Hauttemperatur", + "capLabelBloodOxygen": "Blutsauerstoff", + "capLabelWorkoutHeartRate": "Herzfrequenz beim Training", + "capLabelWorkoutBeatToBeat": "Abstand von Herzschlag zu Herzschlag beim Training", + "capLabelSteps": "Schritte", + "capReasonSkinTempNights": "Nach ein paar Nächten", + "capReasonEstimated": "Eine Schätzung, keine direkte Messung", + "capReasonEarly": "Erste Schätzung; wird in den ersten Wochen genauer", + "capVendorReported": "Eigener Messwert des Geräts", + "slotSendsHeading": "Liefert", + "slotDoesntSendHeading": "Liefert nicht", + "pairSectionTitle": "Gerät koppeln", + "pairCardBandBody": "Nur WHOOP 4.0 oder WHOOP 5.0/MG. Schlaf, Erholung und Belastung rund um die Uhr.", + "pairUnpairBand": "Band trennen", + "capLabelHeartRate": "Herzfrequenz und Ruhepuls", + "capLabelRespiration": "Atmung", + "capReasonFirstNights": "In den ersten 14 Nächten geschätzt", + "capReasonOwnStagesRing": "Aus den eigenen Schlafphasen des Rings", + "capReasonOwnStages": "Aus den eigenen Schlafphasen des Geräts", + "capReasonLiveSessions": "Während Live-Sitzungen", + "slotEveryDay": "Jeden Tag", + "slotWorkoutsOnly": "Nur Training", + "pairWearableCardNote": "Füllt Tage ohne dein WHOOP. Experimentell, also schalte es unten ein.", + "pairWhatItSends": "Was es sendet", + "capReasonUnconfirmed": "Falls das Gerät es sendet, nicht bestätigt", + "capReasonPebble2Only": "Nur Pebble 2. Die 2 SE hat keinen Herzfrequenzsensor.", + "capReasonPebble2OnlyEstimated": "Nur Pebble 2, und eine Schätzung, keine direkte Messung. Die 2 SE hat keinen Herzfrequenzsensor.", + "slotColumnReason": "{column}: {reason}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } + }, "welcomeCoachKeyReentry": "Dein API-Schlüssel für den Coach bleibt im sicheren Speicher und wurde nicht gesichert. Gib ihn in den KI-Einstellungen erneut ein, um den Coach auf diesem Telefon zu nutzen.", "welcomeSettingsSkipped": "{n, plural, =1{1 Einstellung in der Sicherung war nicht lesbar und wurde übersprungen} other{{n} Einstellungen in der Sicherung waren nicht lesbar und wurden übersprungen}}", "@welcomeSettingsSkipped": { @@ -3685,5 +3774,8 @@ "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.", + "pairSearchDevices": "Nach Geräten suchen", + "pairPrimaryCannotBeSensor": "Dies ist bereits dein Hauptarmband. Es kann nicht zusätzlich als Trainingssensor gekoppelt werden.", + "capReasonSkinTempProvisional": "Vorläufige Schätzung; benötigt einige Nächte" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b43507676..94d7fd925 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16765,6 +16765,314 @@ "@pairOuraExistingKeyGuidance": { "description": "Before any Oura reset steps: existing-key users keep the ring paired in Oura and skip resets; reset only for a new key." }, + "pairAddDevice": "Add a device", + "@pairAddDevice": { + "description": "Label of the button that starts adding a device (band or sensor is decided after the scan)." + }, + "pairKindAll": "All", + "@pairKindAll": { + "description": "Filter pill that shows every device in the list." + }, + "pairKindStrap": "Chest straps & armbands", + "@pairKindStrap": { + "description": "Filter pill: heart-rate straps and armbands." + }, + "pairKindRing": "Rings", + "@pairKindRing": { + "description": "Filter pill for smart rings." + }, + "pairKindWatch": "Watches", + "@pairKindWatch": { + "description": "Filter pill for watches." + }, + "pairGoBack": "Go back", + "@pairGoBack": { + "description": "Link that closes a confirm sheet without pairing." + }, + "pairAddDeviceSub": "Rings, watches and heart-rate straps", + "@pairAddDeviceSub": { + "description": "Line under the Add a device row in Settings." + }, + "pairAddDeviceSubtitle": "Each device shows where it fits: every day, or workouts only.", + "@pairAddDeviceSubtitle": { + "description": "Lead of the Add a device screen." + }, + "pairBandFoundBody": "It does much more every day than during workouts only. Which do you want?", + "@pairBandFoundBody": { + "description": "WHOOP sheet lead when no band is paired yet." + }, + "pairBandFoundBodyHasBand": "You already have a band. Only one can be your band at a time.", + "@pairBandFoundBodyHasBand": { + "description": "WHOOP sheet lead when a band is already paired." + }, + "pairIdentifiedBody": "It looked like a heart-rate strap, but when we connected it identified itself as {label}. Nothing was saved.", + "@pairIdentifiedBody": { + "description": "Body of the sheet shown when a device picked as a sensor identifies itself as a band after connecting. Nothing was saved.", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pairAsMyBand": "Pair it as my band", + "@pairAsMyBand": { + "description": "Button that pairs the device as the user’s main band." + }, + "pairSecondaryPaired": "Paired for workouts only.", + "@pairSecondaryPaired": { + "description": "Shown after a device is paired for workouts only." + }, + "pairBandNow": "Your band now: {name}. Your data stays when you switch.", + "@pairBandNow": { + "description": "Note under the table when a band is already paired. {name} is that band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceBand": "Pair this band, then disconnect {name}", + "@pairReplaceBand": { + "description": "Main button when a band is already paired: unpair it and pair the tapped one. {name} is the paired band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmTitle": "Replace {name} after this band connects?", + "@pairReplaceConfirmTitle": { + "description": "Confirm dialog before replacing the band. {name} is the paired band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmBody": "Your current band stays paired if setup fails or you cancel. Your data stays.", + "@pairReplaceConfirmBody": { + "description": "Confirm dialog body: replacing the band keeps the data." + }, + "pairReplaceConfirmYes": "Pair replacement", + "@pairReplaceConfirmYes": { + "description": "Confirm dialog button: unpair the old band and pair the new one." + }, + "pairCurrentBandFallback": "your band", + "@pairCurrentBandFallback": { + "description": "Stands in for the paired band’s name when it has none, inside a sentence." + }, + "pairWearableLead": "It can fill your days with sleep, recovery and heart rate.", + "@pairWearableLead": { + "description": "Sheet lead for a ring or watch." + }, + "pairWearableNote": "Fills days without your WHOOP. Experimental, so switch it on in Devices.", + "@pairWearableNote": { + "description": "Note under a ring’s or watch’s table on the pairing sheet. “Devices” is the device list in Settings." + }, + "pairStrapLead": "It adds heart rate while you work out. It does not record sleep, recovery or daily strain.", + "@pairStrapLead": { + "description": "Sheet lead for a heart-rate strap or sensor." + }, + "pairStrapNote": "Both need “Use during workouts” on.", + "@pairStrapNote": { + "description": "Note under a strap’s table: both workout rows need the switch. Quotes deviceUseWorkoutsTitle." + }, + "pairDeviceNamed": "Pair {name}", + "@pairDeviceNamed": { + "description": "Main button on a ring’s, watch’s or strap’s sheet. {name} is the device.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairUseForWorkouts": "Use it for workouts only", + "@pairUseForWorkouts": { + "description": "Text link on a WHOOP sheet: keep it for workouts only, not as the main band." + }, + "pairReplaceUnpairFailed": "Could not unpair your band, so the new one was not paired. Try again.", + "@pairReplaceUnpairFailed": { + "description": "Shown when unpairing the old band failed during “Unpair (your band) and pair this one”; nothing else ran." + }, + "pairWearableLeadPebble": "Pebble 2 can fill your days with sleep, recovery and heart rate. Pebble 2 SE shows the watch’s own sleep and steps. It has no heart-rate sensor, so this app cannot calculate daily strain or estimate recovery from it.", + "@pairWearableLeadPebble": { + "description": "Pebble-family pairing lead: SE shows the watch’s own sleep and steps; no HR sensor means no app-calculated daily strain or recovery estimate from the SE alone." + }, + "pairMainBandWhoopOnly": "Main band: WHOOP 4.0 or WHOOP 5.0/MG only. Other devices can be added as additional devices.", + "@pairMainBandWhoopOnly": { + "description": "Plain statement on the pairing screens: only WHOOP can be the main band; other devices are added as additional devices." + }, + "pairBandTitle": "Pair your band", + "@pairBandTitle": { + "description": "Title of the first-run list of bands." + }, + "pairBandSubtitle": "Pick the one you wear. We will look for it nearby.", + "@pairBandSubtitle": { + "description": "Subtitle of the first-run list of bands." + }, + "pairStrapOnlyLink": "I only have a heart-rate strap", + "@pairStrapOnlyLink": { + "description": "Text link under the band list for someone with only a heart-rate strap." + }, + "pairWhoopRowLine": "Sleep, recovery, strain, heart rate and more", + "@pairWhoopRowLine": { + "description": "Line under each WHOOP on the first-run list." + }, + "pairSetUpTitle": "Set up", + "@pairSetUpTitle": { + "description": "Top bar title of the first-run pairing screen." + }, + "capLabelSleep": "Sleep", + "@capLabelSleep": { + "description": "Name of a thing a device sends: sleep." + }, + "capLabelRecovery": "Recovery", + "@capLabelRecovery": { + "description": "Name of a thing a device sends: the morning recovery score." + }, + "capLabelStrain": "Strain", + "@capLabelStrain": { + "description": "Name of a thing a device sends: strain." + }, + "capLabelHrv": "Heart-rate variability", + "@capLabelHrv": { + "description": "Name of a thing a device sends: heart-rate variability." + }, + "capLabelSkinTemperature": "Skin temperature", + "@capLabelSkinTemperature": { + "description": "Name of a thing a device sends: skin temperature." + }, + "capLabelBloodOxygen": "Blood oxygen", + "@capLabelBloodOxygen": { + "description": "Name of a thing a device sends: blood oxygen (only a device’s own reading)." + }, + "capLabelWorkoutHeartRate": "Heart rate during workouts", + "@capLabelWorkoutHeartRate": { + "description": "Table row: heart rate during a workout." + }, + "capLabelWorkoutBeatToBeat": "Beat-to-beat timing during workouts", + "@capLabelWorkoutBeatToBeat": { + "description": "Table row: beat-to-beat timing during a workout." + }, + "capLabelSteps": "Steps", + "@capLabelSteps": { + "description": "Name of a thing a device sends: steps." + }, + "capReasonSkinTempNights": "After a few nights", + "@capReasonSkinTempNights": { + "description": "Caption under Skin temperature: it needs a few nights first." + }, + "capReasonEstimated": "An estimate, not a direct reading", + "@capReasonEstimated": { + "description": "Why a value is conditional: it is an estimate, not a direct reading." + }, + "capReasonEarly": "Early estimate; it firms up over the first weeks", + "@capReasonEarly": { + "description": "Why a value is conditional: it is an early estimate that firms up over the first weeks." + }, + "capVendorReported": "The device’s own reading", + "@capVendorReported": { + "description": "Tag on a value that is the device’s own reading, not calculated by this app." + }, + "slotSendsHeading": "Sends", + "@slotSendsHeading": { + "description": "Heading above the list of what a device sends." + }, + "slotDoesntSendHeading": "Doesn’t send", + "@slotDoesntSendHeading": { + "description": "Heading above the list of what a secondary sensor does not send." + }, + "pairSectionTitle": "Pair a device", + "@pairSectionTitle": { + "description": "Heading of the section in My sources that holds pairing." + }, + "pairCardBandBody": "WHOOP 4.0 or WHOOP 5.0/MG only. Sleep, recovery and strain, around the clock.", + "@pairCardBandBody": { + "description": "Body of the Your band card in Settings." + }, + "pairUnpairBand": "Unpair band", + "@pairUnpairBand": { + "description": "Red outline button that starts forgetting the paired band (a confirm dialog follows)." + }, + "capLabelHeartRate": "Heart rate and resting heart rate", + "@capLabelHeartRate": { + "description": "Table row: heart rate and resting heart rate, as one capability." + }, + "capLabelRespiration": "Respiration", + "@capLabelRespiration": { + "description": "Table row: breathing rate." + }, + "capReasonFirstNights": "Estimated for the first 14 nights", + "@capReasonFirstNights": { + "description": "Caption under Recovery for a ring or watch: its recovery is an estimate until 14 nights of baseline." + }, + "capReasonOwnStagesRing": "From the ring’s own sleep stages", + "@capReasonOwnStagesRing": { + "description": "Caption under Sleep for a ring: the sleep stages are the ring’s own." + }, + "capReasonOwnStages": "From the device’s own sleep stages", + "@capReasonOwnStages": { + "description": "Caption under Sleep for a watch or fitness band: the sleep stages are the device’s own." + }, + "capReasonLiveSessions": "During live sessions", + "@capReasonLiveSessions": { + "description": "Caption under Steps for WHOOP 4: it counts steps only while the app has a live connection." + }, + "slotEveryDay": "Every day", + "@slotEveryDay": { + "description": "Column and chip: the device is used every day (the main band, or the ring/watch that fills days)." + }, + "slotWorkoutsOnly": "Workouts only", + "@slotWorkoutsOnly": { + "description": "Column and chip: the device is used only during workouts." + }, + "pairWearableCardNote": "Fills days without your WHOOP. Experimental, so switch it on below.", + "@pairWearableCardNote": { + "description": "Note under a ring’s or watch’s table on its device page; the switch is below it." + }, + "pairWhatItSends": "What it sends", + "@pairWhatItSends": { + "description": "Heading above the table on a device page." + }, + "capReasonUnconfirmed": "If this device sends it, not confirmed", + "@capReasonUnconfirmed": { + "description": "Caption under a row the device may or may not send; nobody has confirmed it does." + }, + "capReasonPebble2Only": "Pebble 2 only. The 2 SE has no heart-rate sensor.", + "@capReasonPebble2Only": { + "description": "Caption under the heart rate, strain and recovery rows for Pebble: the shared Pebble entry covers the 2 SE, which has no heart-rate sensor." + }, + "capReasonPebble2OnlyEstimated": "Pebble 2 only, and an estimate, not a direct reading. The 2 SE has no heart-rate sensor.", + "@capReasonPebble2OnlyEstimated": { + "description": "Caption under Pebble's recovery row: Pebble 2 only (the 2 SE has no heart-rate sensor), and recovery is an estimate." + }, + "slotColumnReason": "{column}: {reason}", + "@slotColumnReason": { + "description": "Capability caption naming the column it applies to.", + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotCapabilityStatus": { + "description": "Accessible table result with capability, column and sends status.", + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } + }, "welcomeCoachKeyReentry": "Your coach API key stays in secure storage and was not backed up. Enter it again in AI settings to use the coach on this phone.", "welcomeSettingsSkipped": "{n, plural, =1{1 setting in the backup could not be read and was skipped} other{{n} settings in the backup could not be read and were skipped}}", "@welcomeSettingsSkipped": { @@ -16805,5 +17113,8 @@ "@strengthSaveFailed": {"description": "Strength set editor copy."}, "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." + "napAutoLogSaveFailed": "That setting could not be saved. Try again.", + "pairSearchDevices": "Search for devices", + "pairPrimaryCannotBeSensor": "This is already your main band. It cannot also be paired as a workout sensor.", + "capReasonSkinTempProvisional": "Provisional estimate; needs a few nights" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2cf903bc1..138d3c829 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3063,6 +3063,95 @@ "pairModeTitle": "Ponlo en modo de emparejamiento", "pairModeNoSite": "Un dispositivo genérico sin un único fabricante, así que no hay pasos del fabricante.", "pairOuraExistingKeyGuidance": "¿Ya usas la app de Oura? Mantén el anillo en esa app y emparéjalo con la clave que ya tiene. Omite los pasos de restablecimiento de abajo. Restablécelo solo si quieres una clave nueva.", + "pairAddDevice": "Añadir un dispositivo", + "pairKindAll": "Todos", + "pairKindStrap": "Bandas de pecho y brazo", + "pairKindRing": "Anillos", + "pairKindWatch": "Relojes", + "pairGoBack": "Volver", + "pairAddDeviceSub": "Anillos, relojes y bandas de frecuencia cardiaca", + "pairAddDeviceSubtitle": "Cada dispositivo muestra dónde encaja: todos los días o solo en entrenamientos.", + "pairBandFoundBody": "Hace mucho más todos los días que solo en los entrenamientos. ¿Qué prefieres?", + "pairBandFoundBodyHasBand": "Ya tienes una pulsera. Solo una puede ser tu pulsera a la vez.", + "pairIdentifiedBody": "Parecía una banda de frecuencia cardíaca, pero al conectar se identificó como {label}. No se guardó nada.", + "pairAsMyBand": "Emparejar como mi pulsera", + "pairSecondaryPaired": "Emparejado solo para entrenamientos.", + "pairBandNow": "Tu pulsera ahora: {name}. Tus datos se conservan al cambiar.", + "pairReplaceBand": "Vincular esta pulsera y después desconectar {name}", + "pairReplaceConfirmTitle": "¿Reemplazar {name} después de conectar esta pulsera?", + "pairReplaceConfirmBody": "Tu pulsera actual seguirá vinculada si falla la configuración o cancelas. Tus datos se conservan.", + "pairReplaceConfirmYes": "Vincular reemplazo", + "pairCurrentBandFallback": "tu pulsera", + "pairWearableLead": "Puede completar tus días con sueño, recuperación y frecuencia cardiaca.", + "pairWearableNote": "Completa los días sin tu WHOOP. Es experimental, así que actívalo en Dispositivos.", + "pairStrapLead": "Añade la frecuencia cardiaca mientras entrenas. No registra sueño, recuperación ni esfuerzo diario.", + "pairStrapNote": "Ambos necesitan «Usar durante los entrenamientos» activado.", + "pairDeviceNamed": "Emparejar {name}", + "pairUseForWorkouts": "Usarla solo para entrenamientos", + "pairReplaceUnpairFailed": "No se pudo desemparejar tu pulsera, así que no se emparejó la nueva. Inténtalo de nuevo.", + "pairWearableLeadPebble": "Pebble 2 puede completar tus días con sueño, recuperación y frecuencia cardiaca. Pebble 2 SE muestra el sueño y los pasos registrados por el propio reloj. No tiene sensor de frecuencia cardiaca, por lo que esta app no puede calcular la carga diaria ni estimar la recuperación a partir de sus datos.", + "pairMainBandWhoopOnly": "Pulsera principal: solo WHOOP 4.0 o WHOOP 5.0/MG. Los demás dispositivos se pueden añadir como dispositivos adicionales.", + "pairBandTitle": "Empareja tu pulsera", + "pairBandSubtitle": "Elige la que llevas puesta. La buscaremos cerca.", + "pairStrapOnlyLink": "Solo tengo una banda de frecuencia cardíaca", + "pairWhoopRowLine": "Sueño, recuperación, esfuerzo, frecuencia cardiaca y más", + "pairSetUpTitle": "Configurar", + "capLabelSleep": "Sueño", + "capLabelRecovery": "Recuperación", + "capLabelStrain": "Esfuerzo", + "capLabelHrv": "Variabilidad de la frecuencia cardíaca", + "capLabelSkinTemperature": "Temperatura de la piel", + "capLabelBloodOxygen": "Oxígeno en sangre", + "capLabelWorkoutHeartRate": "Frecuencia cardíaca durante los entrenamientos", + "capLabelWorkoutBeatToBeat": "Tiempo entre latidos durante los entrenamientos", + "capLabelSteps": "Pasos", + "capReasonSkinTempNights": "Tras algunas noches", + "capReasonEstimated": "Una estimación, no una medición directa", + "capReasonEarly": "Estimación inicial; mejora durante las primeras semanas", + "capVendorReported": "La medición propia del dispositivo", + "slotSendsHeading": "Envía", + "slotDoesntSendHeading": "No envía", + "pairSectionTitle": "Emparejar un dispositivo", + "pairCardBandBody": "Solo WHOOP 4.0 o WHOOP 5.0/MG. Sueño, recuperación y esfuerzo, todo el día.", + "pairUnpairBand": "Desemparejar pulsera", + "capLabelHeartRate": "Frecuencia cardiaca y en reposo", + "capLabelRespiration": "Respiración", + "capReasonFirstNights": "Estimada durante las primeras 14 noches", + "capReasonOwnStagesRing": "A partir de las fases de sueño del propio anillo", + "capReasonOwnStages": "A partir de las fases de sueño del propio dispositivo", + "capReasonLiveSessions": "Durante las sesiones en vivo", + "slotEveryDay": "Todos los días", + "slotWorkoutsOnly": "Solo entrenamientos", + "pairWearableCardNote": "Completa los días sin tu WHOOP. Es experimental, así que actívalo abajo.", + "pairWhatItSends": "Qué envía", + "capReasonUnconfirmed": "Si este dispositivo lo envía, sin confirmar", + "capReasonPebble2Only": "Solo Pebble 2. El 2 SE no tiene sensor de frecuencia cardiaca.", + "capReasonPebble2OnlyEstimated": "Solo Pebble 2, y es una estimación, no una medición directa. El 2 SE no tiene sensor de frecuencia cardiaca.", + "slotColumnReason": "{column}: {reason}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } + }, "alarmNextLabel": "SIGUIENTE", "alarmScheduleGroup": "Horario semanal", "alarmWakeTimeRowTitle": "Hora de despertar", @@ -3385,5 +3474,8 @@ "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.", + "pairSearchDevices": "Buscar dispositivos", + "pairPrimaryCannotBeSensor": "Esta ya es tu pulsera principal. No se puede emparejar también como sensor de entrenamiento.", + "capReasonSkinTempProvisional": "Estimación provisional; requiere algunas noches" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 112586647..e5f6b2e01 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3383,7 +3383,99 @@ "pairModeTitle": "Mettez-le en mode association", "pairModeNoSite": "Un appareil générique sans fabricant unique : il n’y a donc pas d’étapes du fabricant.", "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é.", + "pairAddDevice": "Ajouter un appareil", + "pairKindAll": "Tous", + "pairKindStrap": "Ceintures et brassards", + "pairKindRing": "Bagues", + "pairKindWatch": "Montres", + "pairGoBack": "Retour", + "pairAddDeviceSub": "Bagues, montres et ceintures cardio", + "pairAddDeviceSubtitle": "Chaque appareil indique où il s’intègre : tous les jours, ou seulement pendant les entraînements.", + "pairBandFoundBody": "Il en fait bien plus tous les jours que pendant les entraînements seulement. Que préférez-vous ?", + "pairBandFoundBodyHasBand": "Vous avez déjà un bracelet. Un seul peut être votre bracelet à la fois.", + "pairIdentifiedBody": "Cela ressemblait à une ceinture cardiaque, mais à la connexion l’appareil s’est identifié comme {label}. Rien n’a été enregistré.", + "pairAsMyBand": "L’associer comme mon bracelet", + "pairSecondaryPaired": "Associé pour les entraînements uniquement.", + "pairBandNow": "Votre bracelet actuel : {name}. Vos données restent quand vous changez.", + "pairReplaceBand": "Associer ce bracelet, puis déconnecter {name}", + "pairReplaceConfirmTitle": "Remplacer {name} après la connexion de ce bracelet ?", + "pairReplaceConfirmBody": "Votre bracelet actuel reste associé si la configuration échoue ou si vous annulez. Vos données restent.", + "pairReplaceConfirmYes": "Associer le remplacement", + "pairCurrentBandFallback": "votre bracelet", + "pairWearableLead": "Il peut compléter vos journées avec le sommeil, la récupération et la fréquence cardiaque.", + "pairWearableNote": "Complète les jours sans votre WHOOP. Expérimental : activez-le dans Appareils.", + "pairStrapLead": "Il ajoute la fréquence cardiaque pendant l’entraînement. Il n’enregistre ni le sommeil, ni la récupération, ni l’effort quotidien.", + "pairStrapNote": "Les deux nécessitent « Utiliser pendant les entraînements » activé.", + "pairDeviceNamed": "Associer {name}", + "pairUseForWorkouts": "L’utiliser seulement pour les entraînements", + "pairReplaceUnpairFailed": "Impossible de dissocier votre bracelet, le nouveau n’a donc pas été associé. Réessayez.", + "pairWearableLeadPebble": "Pebble 2 peut compléter vos journées avec le sommeil, la récupération et la fréquence cardiaque. Pebble 2 SE affiche le sommeil et les pas enregistrés par la montre. Elle n’a pas de capteur de fréquence cardiaque, donc cette app ne peut pas calculer la charge quotidienne ni estimer la récupération à partir de ses données.", + "pairMainBandWhoopOnly": "Bracelet principal : uniquement WHOOP 4.0 ou WHOOP 5.0/MG. Les autres appareils peuvent être ajoutés comme appareils supplémentaires.", + "pairBandTitle": "Associez votre bracelet", + "pairBandSubtitle": "Choisissez celui que vous portez. Nous le chercherons à proximité.", + "pairStrapOnlyLink": "J’ai seulement une ceinture cardiaque", + "pairWhoopRowLine": "Sommeil, récupération, effort, fréquence cardiaque et plus", + "pairSetUpTitle": "Configuration", + "capLabelSleep": "Sommeil", + "capLabelRecovery": "Récupération", + "capLabelStrain": "Effort", + "capLabelHrv": "Variabilité de la fréquence cardiaque", + "capLabelSkinTemperature": "Température de la peau", + "capLabelBloodOxygen": "Oxygène dans le sang", + "capLabelWorkoutHeartRate": "Fréquence cardiaque pendant les séances", + "capLabelWorkoutBeatToBeat": "Intervalle entre les battements pendant les séances", + "capLabelSteps": "Pas", + "capReasonSkinTempNights": "Après quelques nuits", + "capReasonEstimated": "Une estimation, pas une mesure directe", + "capReasonEarly": "Estimation initiale ; elle s’affine au fil des premières semaines", + "capVendorReported": "La mesure propre à l’appareil", + "slotSendsHeading": "Envoie", + "slotDoesntSendHeading": "N’envoie pas", + "pairSectionTitle": "Associer un appareil", + "pairCardBandBody": "WHOOP 4.0 ou WHOOP 5.0/MG uniquement. Sommeil, récupération et effort, 24 h sur 24.", + "pairUnpairBand": "Dissocier le bracelet", + "capLabelHeartRate": "Fréquence cardiaque et au repos", + "capLabelRespiration": "Respiration", + "capReasonFirstNights": "Estimée pendant les 14 premières nuits", + "capReasonOwnStagesRing": "D’après les phases de sommeil de la bague", + "capReasonOwnStages": "D’après les phases de sommeil de l’appareil", + "capReasonLiveSessions": "Pendant les sessions en direct", + "slotEveryDay": "Tous les jours", + "slotWorkoutsOnly": "Entraînements seulement", + "pairWearableCardNote": "Complète les jours sans votre WHOOP. Expérimental : activez-le ci-dessous.", + "pairWhatItSends": "Ce qu’il envoie", + "capReasonUnconfirmed": "Si cet appareil l’envoie, non confirmé", + "capReasonPebble2Only": "Pebble 2 uniquement. La 2 SE n’a pas de capteur de fréquence cardiaque.", + "capReasonPebble2OnlyEstimated": "Pebble 2 uniquement, et c’est une estimation, pas une mesure directe. La 2 SE n’a pas de capteur de fréquence cardiaque.", + "slotColumnReason": "{column} : {reason}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "slotCapabilityStatus": "{capability}, {column} : {status}", + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } + }, "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.", + "pairSearchDevices": "Rechercher des appareils", + "pairPrimaryCannotBeSensor": "C’est déjà votre bracelet principal. Il ne peut pas aussi être jumelé comme capteur d’entraînement.", + "capReasonSkinTempProvisional": "Estimation provisoire ; nécessite quelques nuits" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9ed1242ae..5840ef2bb 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -3034,9 +3034,101 @@ "pairModeTitle": "इसे पेयरिंग मोड में डालें", "pairModeNoSite": "यह किसी एक निर्माता का नहीं, एक सामान्य डिवाइस है, इसलिए निर्माता के कोई चरण नहीं हैं।", "pairOuraExistingKeyGuidance": "क्या आप पहले से Oura ऐप इस्तेमाल करते हैं? रिंग को उस ऐप में पेयर रहने दें और उसकी मौजूदा कुंजी से पेयर करें। नीचे दिए रीसेट के चरण छोड़ दें। केवल नई कुंजी चाहिए तो ही रीसेट करें।", + "pairAddDevice": "डिवाइस जोड़ें", + "pairKindAll": "सभी", + "pairKindStrap": "चेस्ट स्ट्रैप और आर्मबैंड", + "pairKindRing": "रिंग", + "pairKindWatch": "घड़ियाँ", + "pairGoBack": "वापस जाएँ", + "pairAddDeviceSub": "रिंग, घड़ियाँ और हार्ट-रेट स्ट्रैप", + "pairAddDeviceSubtitle": "हर डिवाइस दिखाता है कि वह कहाँ फ़िट होता है: हर दिन, या केवल वर्कआउट में।", + "pairBandFoundBody": "यह सिर्फ़ वर्कआउट की तुलना में हर दिन कहीं ज़्यादा करता है। आप क्या चाहते हैं?", + "pairBandFoundBodyHasBand": "आपके पास पहले से एक बैंड है। एक समय में केवल एक ही आपका बैंड हो सकता है।", + "pairIdentifiedBody": "यह हार्ट-रेट स्ट्रैप जैसा दिखा, लेकिन कनेक्ट होने पर इसने खुद को {label} बताया। कुछ भी सहेजा नहीं गया।", + "pairAsMyBand": "इसे मेरे बैंड के रूप में पेयर करें", + "pairSecondaryPaired": "केवल वर्कआउट के लिए पेयर किया गया।", + "pairBandNow": "अभी आपका बैंड: {name}। बदलने पर आपका डेटा बना रहता है।", + "pairReplaceBand": "यह बैंड पेयर करें, फिर {name} को डिस्कनेक्ट करें", + "pairReplaceConfirmTitle": "यह बैंड कनेक्ट होने के बाद {name} को बदलें?", + "pairReplaceConfirmBody": "सेटअप विफल होने या रद्द करने पर आपका मौजूदा बैंड पेयर रहेगा। आपका डेटा सुरक्षित रहेगा।", + "pairReplaceConfirmYes": "नया बैंड पेयर करें", + "pairCurrentBandFallback": "आपका बैंड", + "pairWearableLead": "यह आपके दिनों को नींद, रिकवरी और हृदय गति से भर सकता है।", + "pairWearableNote": "आपके WHOOP के बिना वाले दिनों को भरता है। प्रायोगिक है, इसलिए इसे डिवाइस में चालू करें।", + "pairStrapLead": "यह वर्कआउट के दौरान हृदय गति जोड़ता है। यह नींद, रिकवरी या दैनिक शारीरिक परिश्रम रिकॉर्ड नहीं करता।", + "pairStrapNote": "दोनों के लिए “वर्कआउट के दौरान इस्तेमाल करें” चालू होना चाहिए।", + "pairDeviceNamed": "{name} पेयर करें", + "pairUseForWorkouts": "इसे केवल वर्कआउट के लिए इस्तेमाल करें", + "pairReplaceUnpairFailed": "आपका बैंड अनपेयर नहीं हो सका, इसलिए नया बैंड पेयर नहीं किया गया। फिर से कोशिश करें।", + "pairWearableLeadPebble": "Pebble 2 आपके दिनों को नींद, रिकवरी और हृदय गति से भर सकता है। Pebble 2 SE घड़ी की अपनी नींद और कदमों की रीडिंग दिखाता है। उसमें हृदय गति सेंसर नहीं है, इसलिए यह ऐप उसके डेटा से रोज़ का शारीरिक भार नहीं निकाल सकता और न ही रिकवरी का अनुमान लगा सकता है।", + "pairMainBandWhoopOnly": "मुख्य बैंड: केवल WHOOP 4.0 या WHOOP 5.0/MG। दूसरे डिवाइस अतिरिक्त डिवाइस के रूप में जोड़े जा सकते हैं।", + "pairBandTitle": "अपना बैंड पेयर करें", + "pairBandSubtitle": "वही चुनें जो आप पहनते हैं। हम उसे आस-पास खोजेंगे।", + "pairStrapOnlyLink": "मेरे पास सिर्फ़ हार्ट-रेट स्ट्रैप है", + "pairWhoopRowLine": "नींद, रिकवरी, शारीरिक परिश्रम, हृदय गति और बहुत कुछ", + "pairSetUpTitle": "सेट अप", + "capLabelSleep": "नींद", + "capLabelRecovery": "रिकवरी", + "capLabelStrain": "शारीरिक परिश्रम", + "capLabelHrv": "हृदय गति में उतार-चढ़ाव (HRV)", + "capLabelSkinTemperature": "त्वचा का तापमान", + "capLabelBloodOxygen": "रक्त में ऑक्सीजन", + "capLabelWorkoutHeartRate": "वर्कआउट के दौरान हृदय गति", + "capLabelWorkoutBeatToBeat": "वर्कआउट के दौरान धड़कन-दर-धड़कन का समय", + "capLabelSteps": "कदम", + "capReasonSkinTempNights": "कुछ रातों के बाद", + "capReasonEstimated": "अनुमान है, सीधी माप नहीं", + "capReasonEarly": "शुरुआती अनुमान; पहले कुछ हफ़्तों में यह बेहतर होता जाता है", + "capVendorReported": "डिवाइस की अपनी माप", + "slotSendsHeading": "भेजता है", + "slotDoesntSendHeading": "नहीं भेजता", + "pairSectionTitle": "डिवाइस पेयर करें", + "pairCardBandBody": "केवल WHOOP 4.0 या WHOOP 5.0/MG। नींद, रिकवरी और शारीरिक परिश्रम, चौबीसों घंटे।", + "pairUnpairBand": "बैंड अनपेयर करें", + "capLabelHeartRate": "हृदय गति और आराम की हृदय गति", + "capLabelRespiration": "श्वसन", + "capReasonFirstNights": "पहली 14 रातों तक अनुमानित", + "capReasonOwnStagesRing": "रिंग के अपने नींद चरणों से", + "capReasonOwnStages": "डिवाइस के अपने नींद चरणों से", + "capReasonLiveSessions": "लाइव सत्रों के दौरान", + "slotEveryDay": "हर दिन", + "slotWorkoutsOnly": "केवल वर्कआउट", + "pairWearableCardNote": "आपके WHOOP के बिना वाले दिनों को भरता है। प्रायोगिक है, इसलिए इसे नीचे चालू करें।", + "pairWhatItSends": "यह क्या भेजता है", + "capReasonUnconfirmed": "अगर यह डिवाइस इसे भेजता है, पुष्टि नहीं हुई", + "capReasonPebble2Only": "केवल Pebble 2। 2 SE में हृदय गति सेंसर नहीं है।", + "capReasonPebble2OnlyEstimated": "केवल Pebble 2, और यह अनुमान है, सीधी माप नहीं। 2 SE में हृदय गति सेंसर नहीं है।", + "slotColumnReason": "{column}: {reason}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } + }, "whoopFirmwareWarningTitle": "WHOOP उपयोगकर्ता: WHOOP ऐप से दोबारा कनेक्ट न करें", "whoopFirmwareWarningAction": "अगर आप OpenStrap या कोई दूसरा ओपन-सोर्स ऐप इस्तेमाल करते हैं, या बाद में किसी पर जाने की सोच रहे हैं, तो अपना बैंड आधिकारिक WHOOP ऐप से दोबारा कनेक्ट न करें। वह फ़र्मवेयर अपडेट अपने-आप इंस्टॉल कर सकता है। WHOOP ऐप को अनइंस्टॉल या अक्षम कर दें, ताकि वह आपके बैंड को अपडेट न कर सके।", "whoopFirmwareWarningDetails": "WHOOP 5.0 / MG के फ़र्मवेयर 50.43.1.0 और 50.43.2.0 के बारे में रिपोर्टें बताती हैं कि डेटा तक पहुँच रोक दी जाती है। ब्लूटूथ फिर भी कनेक्ट हो सकता है। प्रभावित बैंड को अनलॉक करने के लिए WHOOP द्वारा साइन किया गया मेंबरशिप टोकन चाहिए, जिसे OpenStrap जारी नहीं कर सकता। हम समाधान पर काम कर रहे हैं, लेकिन अभी कोई उपाय उपलब्ध नहीं है।", + "pairSearchDevices": "डिवाइस खोजें", + "pairPrimaryCannotBeSensor": "यह पहले से आपका मुख्य बैंड है। इसे वर्कआउट सेंसर के रूप में भी पेयर नहीं किया जा सकता।", + "capReasonSkinTempProvisional": "अस्थायी अनुमान; कुछ रातों का डेटा चाहिए", "alarmNextLabel": "अगला", "alarmScheduleGroup": "साप्ताहिक शेड्यूल", "alarmWakeTimeRowTitle": "जागने का समय", @@ -3337,7 +3429,6 @@ "homeFirstWeekRecovery": "रिकवरी: रात {night} से, जब यह आपका सामान्य स्तर जान ले", "homeFirstWeekSoFar": "{count, plural, =1{अब तक 1 दिन रिकॉर्ड हुआ} other{अब तक {count} दिन रिकॉर्ड हुए}}", "homeFirstWeekDone": "पूरा हुआ: {step}", - "homeCoachSetUpChip": "अपने डेटा के बारे में सवाल पूछें: कोच सेट करें", "homeRecoverySleeping": "सो रहे हैं…", "homeRecoveryAfterWake": "रिकवरी का स्कोर आपके जागने के बाद बनता है।", "homeRecoveryFinishingUp": "पूरा हो रहा है", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index cb40872e8..d15b46acd 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -8159,5 +8159,132 @@ "pairTroubleDiscordSub": "Сообщество OpenStrap", "pairModeTitle": "Переведите в режим сопряжения", "pairModeNoSite": "Это универсальное устройство без единого производителя, поэтому инструкции производителя нет.", - "pairOuraExistingKeyGuidance": "Уже пользуетесь приложением Oura? Оставьте кольцо сопряжённым в этом приложении и выполните сопряжение с его текущим ключом. Пропустите шаги сброса ниже. Сбрасывайте кольцо только для получения нового ключа." + "pairOuraExistingKeyGuidance": "Уже пользуетесь приложением Oura? Оставьте кольцо сопряжённым в этом приложении и выполните сопряжение с его текущим ключом. Пропустите шаги сброса ниже. Сбрасывайте кольцо только для получения нового ключа.", + "pairAddDevice": "Добавить устройство", + "pairKindAll": "Все", + "pairKindStrap": "Нагрудные и плечевые датчики", + "pairKindRing": "Кольца", + "pairKindWatch": "Часы", + "pairGoBack": "Назад", + "pairAddDeviceSub": "Кольца, часы и нагрудные пульсометры", + "pairAddDeviceSubtitle": "У каждого устройства указано, для чего оно подходит: на каждый день или только для тренировок.", + "pairBandFoundBody": "Каждый день он даёт гораздо больше, чем только на тренировках. Что выберете?", + "pairBandFoundBodyHasBand": "У вас уже есть браслет. Основным может быть только один.", + "pairIdentifiedBody": "Выглядело как пульсовой ремень, но при подключении устройство назвалось {label}. Ничего не сохранено.", + "@pairIdentifiedBody": { + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pairAsMyBand": "Подключить как мой браслет", + "pairSecondaryPaired": "Подключено только для тренировок.", + "pairBandNow": "Сейчас ваш браслет: {name}. При замене данные сохраняются.", + "@pairBandNow": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceBand": "Подключить этот браслет, затем отключить {name}", + "@pairReplaceBand": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmTitle": "Заменить {name} после подключения этого браслета?", + "@pairReplaceConfirmTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmBody": "Если настройка не удастся или вы её отмените, текущий браслет останется сопряжённым. Данные сохранятся.", + "pairReplaceConfirmYes": "Подключить новый браслет", + "pairCurrentBandFallback": "ваш браслет", + "pairWearableLead": "Он может заполнять ваши дни данными о сне, восстановлении и пульсе.", + "pairWearableNote": "Заполняет дни без вашего WHOOP. Экспериментально, поэтому включите это в разделе «Устройства».", + "pairStrapLead": "Он добавляет пульс во время тренировки. Сон, восстановление и дневную нагрузку он не записывает.", + "pairStrapNote": "Для обоих нужно включить «Использовать во время тренировок».", + "pairDeviceNamed": "Подключить {name}", + "@pairDeviceNamed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairUseForWorkouts": "Использовать только для тренировок", + "pairReplaceUnpairFailed": "Не удалось удалить сопряжение с вашим браслетом, поэтому новый не сопряжён. Попробуйте ещё раз.", + "pairWearableLeadPebble": "Pebble 2 может заполнять ваши дни данными о сне, восстановлении и пульсе. Pebble 2 SE показывает сон и шаги по данным самих часов. У него нет датчика пульса, поэтому приложение не может рассчитать дневную нагрузку или оценить восстановление по его данным.", + "pairMainBandWhoopOnly": "Основной браслет: только WHOOP 4.0 или WHOOP 5.0/MG. Другие устройства можно добавить как дополнительные.", + "pairBandTitle": "Подключите браслет", + "pairBandSubtitle": "Выберите то, что вы носите. Мы поищем его поблизости.", + "pairStrapOnlyLink": "У меня только пульсовой ремень", + "pairWhoopRowLine": "Сон, восстановление, нагрузка, пульс и многое другое", + "pairSetUpTitle": "Настройка", + "capLabelSleep": "Сон", + "capLabelRecovery": "Восстановление", + "capLabelStrain": "Нагрузка", + "capLabelHrv": "Вариабельность пульса", + "capLabelSkinTemperature": "Температура кожи", + "capLabelBloodOxygen": "Кислород в крови", + "capLabelWorkoutHeartRate": "Пульс во время тренировок", + "capLabelWorkoutBeatToBeat": "Интервалы между ударами во время тренировок", + "capLabelSteps": "Шаги", + "capReasonSkinTempNights": "Через несколько ночей", + "capReasonEstimated": "Оценка, а не прямое измерение", + "capReasonEarly": "Предварительная оценка; за первые недели она становится точнее", + "capVendorReported": "Собственное измерение устройства", + "slotSendsHeading": "Передаёт", + "slotDoesntSendHeading": "Не передаёт", + "pairSectionTitle": "Подключить устройство", + "pairCardBandBody": "Только WHOOP 4.0 или WHOOP 5.0/MG. Сон, восстановление и нагрузка круглые сутки.", + "pairUnpairBand": "Удалить сопряжение с браслетом", + "capLabelHeartRate": "Пульс и пульс в покое", + "capLabelRespiration": "Дыхание", + "capReasonFirstNights": "Первые 14 ночей — оценка", + "capReasonOwnStagesRing": "По фазам сна самого кольца", + "capReasonOwnStages": "По фазам сна самого устройства", + "capReasonLiveSessions": "Во время сеансов в реальном времени", + "slotEveryDay": "Каждый день", + "slotWorkoutsOnly": "Только тренировки", + "pairWearableCardNote": "Заполняет дни без вашего WHOOP. Экспериментально, поэтому включите это ниже.", + "pairWhatItSends": "Что передаёт", + "capReasonUnconfirmed": "Если устройство это передаёт, не подтверждено", + "capReasonPebble2Only": "Только Pebble 2. У 2 SE нет датчика пульса.", + "capReasonPebble2OnlyEstimated": "Только Pebble 2, и это оценка, а не прямое измерение. У 2 SE нет датчика пульса.", + "slotColumnReason": "{column}: {reason}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } + }, + "pairSearchDevices": "Найти устройства", + "pairPrimaryCannotBeSensor": "Это уже ваш основной браслет. Его нельзя также подключить как датчик для тренировок.", + "capReasonSkinTempProvisional": "Предварительная оценка; нужны данные за несколько ночей" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 227ddbff6..bc1c27f45 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16627,5 +16627,305 @@ "@pairModeNoSite": { "description": "Shown instead of steps for a generic device with no maker’s page." }, - "pairOuraExistingKeyGuidance": "ใช้แอป Oura อยู่แล้วใช่ไหม คงการจับคู่แหวนในแอปนั้นไว้ แล้วจับคู่ด้วยคีย์เดิมของแหวน ข้ามขั้นตอนรีเซ็ตด้านล่าง รีเซ็ตเฉพาะเมื่อต้องการคีย์ใหม่เท่านั้น" + "pairOuraExistingKeyGuidance": "ใช้แอป Oura อยู่แล้วใช่ไหม คงการจับคู่แหวนในแอปนั้นไว้ แล้วจับคู่ด้วยคีย์เดิมของแหวน ข้ามขั้นตอนรีเซ็ตด้านล่าง รีเซ็ตเฉพาะเมื่อต้องการคีย์ใหม่เท่านั้น", + "pairAddDevice": "เพิ่มอุปกรณ์", + "@pairAddDevice": { + "description": "Label of the button that starts adding a device (band or sensor is decided after the scan)." + }, + "pairKindAll": "ทั้งหมด", + "@pairKindAll": { + "description": "Filter pill that shows every device in the list." + }, + "pairKindStrap": "สายคาดอกและสายรัดต้นแขน", + "@pairKindStrap": { + "description": "Filter pill: heart-rate straps and armbands." + }, + "pairKindRing": "แหวน", + "@pairKindRing": { + "description": "Filter pill for smart rings." + }, + "pairKindWatch": "นาฬิกา", + "@pairKindWatch": { + "description": "Filter pill for watches." + }, + "pairGoBack": "ย้อนกลับ", + "@pairGoBack": { + "description": "Link that closes a confirm sheet without pairing." + }, + "pairAddDeviceSub": "แหวน นาฬิกา และสายวัดชีพจร", + "@pairAddDeviceSub": { + "description": "Line under the Add a device row in Settings." + }, + "pairAddDeviceSubtitle": "แต่ละอุปกรณ์จะบอกว่าเหมาะกับแบบไหน: ทุกวัน หรือเฉพาะตอนออกกำลังกาย", + "@pairAddDeviceSubtitle": { + "description": "Lead of the Add a device screen." + }, + "pairBandFoundBody": "ใช้ทุกวันได้ข้อมูลมากกว่าใช้เฉพาะตอนออกกำลังกายมาก คุณต้องการแบบไหน", + "@pairBandFoundBody": { + "description": "WHOOP sheet lead when no band is paired yet." + }, + "pairBandFoundBodyHasBand": "คุณมีสายรัดอยู่แล้ว ใช้เป็นสายรัดหลักได้ทีละหนึ่งเส้นเท่านั้น", + "@pairBandFoundBodyHasBand": { + "description": "WHOOP sheet lead when a band is already paired." + }, + "pairIdentifiedBody": "อุปกรณ์นี้ดูเหมือนสายคาดวัดหัวใจ แต่เมื่อเชื่อมต่อแล้วกลับระบุตัวเองว่าเป็น {label} ไม่ได้บันทึกอะไรไว้", + "@pairIdentifiedBody": { + "description": "Body of the sheet shown when a device picked as a sensor identifies itself as a band after connecting. Nothing was saved.", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pairAsMyBand": "จับคู่เป็นสายรัดของฉัน", + "@pairAsMyBand": { + "description": "Button that pairs the device as the user’s main band." + }, + "pairSecondaryPaired": "จับคู่แล้วสำหรับใช้เฉพาะตอนออกกำลังกาย", + "@pairSecondaryPaired": { + "description": "Shown after a device is paired for workouts only." + }, + "pairBandNow": "สายรัดปัจจุบันของคุณ: {name} ข้อมูลของคุณยังอยู่เมื่อเปลี่ยน", + "@pairBandNow": { + "description": "Note under the table when a band is already paired. {name} is that band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceBand": "จับคู่สายรัดนี้ แล้วจึงตัดการเชื่อมต่อ {name}", + "@pairReplaceBand": { + "description": "Main button when a band is already paired: unpair it and pair the tapped one. {name} is the paired band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmTitle": "แทนที่ {name} หลังจากสายรัดนี้เชื่อมต่อสำเร็จหรือไม่", + "@pairReplaceConfirmTitle": { + "description": "Confirm dialog before replacing the band. {name} is the paired band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmBody": "หากตั้งค่าล้มเหลวหรือยกเลิก สายรัดเดิมจะยังคงจับคู่และข้อมูลจะยังอยู่", + "@pairReplaceConfirmBody": { + "description": "Confirm dialog body: replacing the band keeps the data." + }, + "pairReplaceConfirmYes": "จับคู่สายรัดใหม่", + "@pairReplaceConfirmYes": { + "description": "Confirm dialog button: unpair the old band and pair the new one." + }, + "pairCurrentBandFallback": "สายรัดของคุณ", + "@pairCurrentBandFallback": { + "description": "Stands in for the paired band’s name when it has none, inside a sentence." + }, + "pairWearableLead": "อุปกรณ์นี้เติมข้อมูลการนอน การฟื้นตัว และอัตราการเต้นของหัวใจให้แต่ละวันได้", + "@pairWearableLead": { + "description": "Sheet lead for a ring or watch." + }, + "pairWearableNote": "เติมวันที่ไม่มี WHOOP ของคุณ ยังเป็นฟีเจอร์ทดลอง จึงต้องเปิดเองในอุปกรณ์", + "@pairWearableNote": { + "description": "Note under a ring’s or watch’s table on the pairing sheet. “Devices” is the device list in Settings." + }, + "pairStrapLead": "เพิ่มอัตราการเต้นของหัวใจขณะออกกำลังกาย ไม่บันทึกการนอน การฟื้นตัว หรือความหนักรายวัน", + "@pairStrapLead": { + "description": "Sheet lead for a heart-rate strap or sensor." + }, + "pairStrapNote": "ทั้งสองอย่างต้องเปิด “ใช้ระหว่างออกกำลังกาย”", + "@pairStrapNote": { + "description": "Note under a strap’s table: both workout rows need the switch. Quotes deviceUseWorkoutsTitle." + }, + "pairDeviceNamed": "จับคู่ {name}", + "@pairDeviceNamed": { + "description": "Main button on a ring’s, watch’s or strap’s sheet. {name} is the device.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairUseForWorkouts": "ใช้เฉพาะตอนออกกำลังกาย", + "@pairUseForWorkouts": { + "description": "Text link on a WHOOP sheet: keep it for workouts only, not as the main band." + }, + "pairReplaceUnpairFailed": "ยกเลิกการจับคู่สายรัดของคุณไม่สำเร็จ จึงยังไม่ได้จับคู่สายรัดใหม่ ลองอีกครั้ง", + "@pairReplaceUnpairFailed": { + "description": "Shown when unpairing the old band failed during “Unpair (your band) and pair this one”; nothing else ran." + }, + "pairWearableLeadPebble": "Pebble 2 เติมข้อมูลการนอน การฟื้นตัว และอัตราการเต้นของหัวใจให้แต่ละวันได้ Pebble 2 SE แสดงข้อมูลการนอนและจำนวนก้าวที่นาฬิกาบันทึกเอง รุ่นนี้ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ แอปจึงคำนวณภาระร่างกายรายวันหรือประเมินการฟื้นตัวจากข้อมูลของรุ่นนี้ไม่ได้", + "pairMainBandWhoopOnly": "สายรัดหลัก: WHOOP 4.0 หรือ WHOOP 5.0/MG เท่านั้น อุปกรณ์อื่นเพิ่มเป็นอุปกรณ์เสริมได้", + "@pairMainBandWhoopOnly": { + "description": "Plain statement on the pairing screens: only WHOOP can be the main band; other devices are added as additional devices." + }, + "pairBandTitle": "จับคู่สายรัดของคุณ", + "@pairBandTitle": { + "description": "Title of the first-run list of bands." + }, + "pairBandSubtitle": "เลือกอันที่คุณสวมอยู่ เราจะค้นหาในบริเวณใกล้เคียง", + "@pairBandSubtitle": { + "description": "Subtitle of the first-run list of bands." + }, + "pairStrapOnlyLink": "ฉันมีแค่สายคาดวัดหัวใจ", + "@pairStrapOnlyLink": { + "description": "Text link under the band list for someone with only a heart-rate strap." + }, + "pairWhoopRowLine": "การนอน การฟื้นตัว ความหนัก อัตราการเต้นของหัวใจ และอื่นๆ", + "@pairWhoopRowLine": { + "description": "Line under each WHOOP on the first-run list." + }, + "pairSetUpTitle": "ตั้งค่า", + "@pairSetUpTitle": { + "description": "Top bar title of the first-run pairing screen." + }, + "capLabelSleep": "การนอน", + "@capLabelSleep": { + "description": "Name of a thing a device sends: sleep." + }, + "capLabelRecovery": "การฟื้นตัว", + "@capLabelRecovery": { + "description": "Name of a thing a device sends: the morning recovery score." + }, + "capLabelStrain": "ความหนัก", + "@capLabelStrain": { + "description": "Name of a thing a device sends: strain." + }, + "capLabelHrv": "ความแปรปรวนของอัตราการเต้นหัวใจ", + "@capLabelHrv": { + "description": "Name of a thing a device sends: heart-rate variability." + }, + "capLabelSkinTemperature": "อุณหภูมิผิว", + "@capLabelSkinTemperature": { + "description": "Name of a thing a device sends: skin temperature." + }, + "capLabelBloodOxygen": "ออกซิเจนในเลือด", + "@capLabelBloodOxygen": { + "description": "Name of a thing a device sends: blood oxygen (only a device’s own reading)." + }, + "capLabelWorkoutHeartRate": "อัตราการเต้นหัวใจขณะออกกำลังกาย", + "@capLabelWorkoutHeartRate": { + "description": "Table row: heart rate during a workout." + }, + "capLabelWorkoutBeatToBeat": "ช่วงเวลาระหว่างจังหวะหัวใจขณะออกกำลังกาย", + "@capLabelWorkoutBeatToBeat": { + "description": "Table row: beat-to-beat timing during a workout." + }, + "capLabelSteps": "ก้าว", + "@capLabelSteps": { + "description": "Name of a thing a device sends: steps." + }, + "capReasonSkinTempNights": "หลังจากผ่านไปสองสามคืน", + "@capReasonSkinTempNights": { + "description": "Caption under Skin temperature: it needs a few nights first." + }, + "capReasonEstimated": "เป็นค่าประมาณ ไม่ใช่ค่าที่วัดได้โดยตรง", + "@capReasonEstimated": { + "description": "Why a value is conditional: it is an estimate, not a direct reading." + }, + "capReasonEarly": "ค่าประมาณช่วงแรก จะแม่นยำขึ้นในช่วงสัปดาห์แรกๆ", + "@capReasonEarly": { + "description": "Why a value is conditional: it is an early estimate that firms up over the first weeks." + }, + "capVendorReported": "ค่าที่อุปกรณ์วัดเอง", + "@capVendorReported": { + "description": "Tag on a value that is the device’s own reading, not calculated by this app." + }, + "slotSendsHeading": "ส่ง", + "@slotSendsHeading": { + "description": "Heading above the list of what a device sends." + }, + "slotDoesntSendHeading": "ไม่ส่ง", + "@slotDoesntSendHeading": { + "description": "Heading above the list of what a secondary sensor does not send." + }, + "pairSectionTitle": "จับคู่อุปกรณ์", + "@pairSectionTitle": { + "description": "Heading of the section in My sources that holds pairing." + }, + "pairCardBandBody": "เฉพาะ WHOOP 4.0 หรือ WHOOP 5.0/MG การนอน การฟื้นตัว และความหนัก ตลอดวัน", + "@pairCardBandBody": { + "description": "Body of the Your band card in Settings." + }, + "pairUnpairBand": "ยกเลิกการจับคู่สายรัด", + "@pairUnpairBand": { + "description": "Red outline button that starts forgetting the paired band (a confirm dialog follows)." + }, + "capLabelHeartRate": "อัตราการเต้นของหัวใจและขณะพัก", + "@capLabelHeartRate": { + "description": "Table row: heart rate and resting heart rate, as one capability." + }, + "capLabelRespiration": "การหายใจ", + "@capLabelRespiration": { + "description": "Table row: breathing rate." + }, + "capReasonFirstNights": "เป็นค่าประมาณใน 14 คืนแรก", + "@capReasonFirstNights": { + "description": "Caption under Recovery for a ring or watch: its recovery is an estimate until 14 nights of baseline." + }, + "capReasonOwnStagesRing": "จากระยะการนอนที่แหวนบันทึกเอง", + "@capReasonOwnStagesRing": { + "description": "Caption under Sleep for a ring: the sleep stages are the ring’s own." + }, + "capReasonOwnStages": "จากระยะการนอนที่อุปกรณ์บันทึกเอง", + "@capReasonOwnStages": { + "description": "Caption under Sleep for a watch or fitness band: the sleep stages are the device’s own." + }, + "capReasonLiveSessions": "ระหว่างเซสชันสด", + "@capReasonLiveSessions": { + "description": "Caption under Steps for WHOOP 4: it counts steps only while the app has a live connection." + }, + "slotEveryDay": "ทุกวัน", + "@slotEveryDay": { + "description": "Column and chip: the device is used every day (the main band, or the ring/watch that fills days)." + }, + "slotWorkoutsOnly": "เฉพาะตอนออกกำลังกาย", + "@slotWorkoutsOnly": { + "description": "Column and chip: the device is used only during workouts." + }, + "pairWearableCardNote": "เติมวันที่ไม่มี WHOOP ของคุณ ยังเป็นฟีเจอร์ทดลอง จึงต้องเปิดเองด้านล่าง", + "@pairWearableCardNote": { + "description": "Note under a ring’s or watch’s table on its device page; the switch is below it." + }, + "pairWhatItSends": "สิ่งที่ส่งมา", + "@pairWhatItSends": { + "description": "Heading above the table on a device page." + }, + "capReasonUnconfirmed": "หากอุปกรณ์นี้ส่งข้อมูลนี้ ยังไม่ได้ยืนยัน", + "@capReasonUnconfirmed": { + "description": "Caption under a row the device may or may not send; nobody has confirmed it does." + }, + "capReasonPebble2Only": "เฉพาะ Pebble 2 รุ่น 2 SE ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ", + "capReasonPebble2OnlyEstimated": "เฉพาะ Pebble 2 และเป็นค่าประมาณ ไม่ใช่ค่าที่วัดได้โดยตรง รุ่น 2 SE ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ", + "slotColumnReason": "{column}: {reason}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } + }, + "pairSearchDevices": "ค้นหาอุปกรณ์", + "pairPrimaryCannotBeSensor": "นี่คือสายรัดหลักของคุณอยู่แล้ว จึงไม่สามารถจับคู่เป็นเซ็นเซอร์ออกกำลังกายด้วยได้", + "capReasonSkinTempProvisional": "ค่าประมาณที่ยังเป็นข้อมูลเบื้องต้น ต้องมีข้อมูลหลายคืน" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 52eae2f7c..c560e5610 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -3034,9 +3034,99 @@ "pairModeTitle": "让它进入配对模式", "pairModeNoSite": "这是没有单一厂商的通用设备,因此没有厂商步骤可循。", "pairOuraExistingKeyGuidance": "已经在用 Oura 应用?请保留戒指在该应用中的配对,并使用戒指现有的密钥配对。跳过下面的重置步骤。只有想使用新密钥时才重置戒指。", + "pairAddDevice": "添加设备", + "pairKindAll": "全部", + "pairKindStrap": "胸带和臂带", + "pairKindRing": "戒指", + "pairKindWatch": "手表", + "pairGoBack": "返回", + "pairAddDeviceSub": "戒指、手表和心率带", + "pairAddDeviceSubtitle": "每个设备都会标明适合的用法:每天,或仅运动时。", + "pairBandFoundBody": "每天佩戴比仅在运动时使用能提供多得多的数据。你想怎么用?", + "pairBandFoundBodyHasBand": "你已经有一个手环。同一时间只能有一个作为你的手环。", + "pairIdentifiedBody": "它看起来像心率带,但连接后它表明自己是 {label}。没有保存任何内容。", + "pairAsMyBand": "配对为我的手环", + "pairSecondaryPaired": "已配对,仅在运动时使用。", + "pairBandNow": "当前手环:{name}。更换后你的数据仍会保留。", + "pairReplaceBand": "先配对此手环,再断开 {name}", + "pairReplaceConfirmTitle": "此手环连接成功后替换 {name}?", + "pairReplaceConfirmBody": "如果设置失败或取消,当前手环仍保持配对。数据会保留。", + "pairReplaceConfirmYes": "配对新手环", + "pairCurrentBandFallback": "你的手环", + "pairWearableLead": "它可以为你的每一天补上睡眠、恢复和心率。", + "pairWearableNote": "填补没有 WHOOP 数据的日子。这是实验功能,请在“设备”中开启。", + "pairStrapLead": "它在你运动时提供心率,不记录睡眠、恢复或每日负荷。", + "pairStrapNote": "两项都需要开启“在锻炼时使用”。", + "pairDeviceNamed": "配对 {name}", + "pairUseForWorkouts": "仅在运动时使用", + "pairReplaceUnpairFailed": "无法取消配对你的手环,因此没有配对新手环。请重试。", + "pairWearableLeadPebble": "Pebble 2 可以为你的每一天补上睡眠、恢复和心率。Pebble 2 SE 显示手表自身记录的睡眠和步数。它没有心率传感器,因此本应用无法根据它的数据计算每日身体负荷或估算恢复。", + "pairMainBandWhoopOnly": "主手环:仅支持 WHOOP 4.0 或 WHOOP 5.0/MG。其他设备可作为附加设备添加。", + "pairBandTitle": "配对你的手环", + "pairBandSubtitle": "选择你佩戴的那一个,我们会在附近寻找它。", + "pairStrapOnlyLink": "我只有心率带", + "pairWhoopRowLine": "睡眠、恢复、负荷、心率等", + "pairSetUpTitle": "设置", + "capLabelSleep": "睡眠", + "capLabelRecovery": "恢复", + "capLabelStrain": "负荷", + "capLabelHrv": "心率变异性", + "capLabelSkinTemperature": "皮肤温度", + "capLabelBloodOxygen": "血氧", + "capLabelWorkoutHeartRate": "锻炼时的心率", + "capLabelWorkoutBeatToBeat": "锻炼时的逐次心跳间隔", + "capLabelSteps": "步数", + "capReasonSkinTempNights": "几晚之后", + "capReasonEstimated": "估算值,不是直接测量", + "capReasonEarly": "初步估算,前几周会越来越准", + "capVendorReported": "设备自己的读数", + "slotSendsHeading": "提供", + "slotDoesntSendHeading": "不提供", + "pairSectionTitle": "配对设备", + "pairCardBandBody": "仅限 WHOOP 4.0 或 WHOOP 5.0/MG。全天候记录睡眠、恢复和负荷。", + "pairUnpairBand": "取消配对手环", + "capLabelHeartRate": "心率和静息心率", + "capLabelRespiration": "呼吸", + "capReasonFirstNights": "前 14 晚为估算值", + "capReasonOwnStagesRing": "来自戒指自身的睡眠分期", + "capReasonOwnStages": "来自设备自身的睡眠分期", + "capReasonLiveSessions": "仅在实时连接期间", + "slotEveryDay": "每天", + "slotWorkoutsOnly": "仅运动时", + "pairWearableCardNote": "填补没有 WHOOP 数据的日子。这是实验功能,请在下方开启。", + "pairWhatItSends": "它提供的数据", + "capReasonUnconfirmed": "如果此设备发送该数据,尚未确认", + "capReasonPebble2Only": "仅限 Pebble 2。2 SE 没有心率传感器。", + "capReasonPebble2OnlyEstimated": "仅限 Pebble 2,且是估算值,不是直接测量。2 SE 没有心率传感器。", + "slotColumnReason": "{column}:{reason}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "slotCapabilityStatus": "{capability},{column}:{status}", + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } + }, "whoopFirmwareWarningTitle": "WHOOP 用户:请勿重新连接 WHOOP 应用", "whoopFirmwareWarningAction": "如果你在使用 OpenStrap 或其他开源应用,或打算以后改用,请不要再把手环连接到 WHOOP 官方应用。它可能会自动安装固件更新。请卸载或停用 WHOOP 应用,以免它更新你的手环。", "whoopFirmwareWarningDetails": "有报告称,WHOOP 5.0 / MG 固件 50.43.1.0 和 50.43.2.0 会阻止访问数据。蓝牙可能仍能连接。解锁受影响的手环需要由 WHOOP 签名的会员令牌,OpenStrap 无法签发。我们正在寻找解决办法,但目前还没有可用的修复。", + "pairSearchDevices": "搜索设备", "alarmNextLabel": "下一次", "alarmScheduleGroup": "每周计划", "alarmWakeTimeRowTitle": "起床时间", @@ -3385,5 +3475,7 @@ "stepCalRoutineTooShort": "这项测试在计时结束前就停止了。请坚持到它完成。", "stepCalSaveFailed": "无法保存此次校准。", "stepCalDoneGait": "已保存。你数了 {count} 步,腕带记录了 {band} 步。今后这类走路将按 {pct}% 进行校正。", - "stepCalDoneHousehold": "已保存。在腕带记录的 {band} 步中,你的手机记录了 {count} 步。居家活动现在将按 {gain} 进行缩放。" + "stepCalDoneHousehold": "已保存。在腕带记录的 {band} 步中,你的手机记录了 {count} 步。居家活动现在将按 {gain} 进行缩放。", + "pairPrimaryCannotBeSensor": "这已经是你的主手环,不能同时配对为运动传感器。", + "capReasonSkinTempProvisional": "暂定估算;需要几晚的数据" } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index ac93ecf3c..47d479f94 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1840,7 +1840,7 @@ class AppState extends ChangeNotifier { // Gated for the same reason as [_onRecord] — this one is wired straight // to LocalDb, so it bypasses every check AppState makes. onRecordsBatch: (raws, samples) async { - if (_resetting) return; // see [_resetting] + if (_resetting || _replacingBand) return; // candidate is not the primary yet await LocalDb.insertRecordsBatch(raws, samples); }, // RESUMABLE SYNC: atomic commit of decoded rows + continuation cursor @@ -1862,8 +1862,8 @@ class AppState extends ChangeNotifier { // real data loss on a band the user may not be deleting after all if // the reset then fails. A throw blocks the ACK and the records stay // on the strap. - if (ResetGate.active) { - throw StateError('data reset in progress — refusing to commit'); + if (ResetGate.active || _replacingBand) { + throw StateError('data reset or band replacement in progress — refusing to commit'); } await _bandHost.commitNativeBatch(raws, samples, trimTokenHex, archives: archives, @@ -1877,7 +1877,7 @@ class AppState extends ChangeNotifier { }, // Pre-setup fallback only: the drain path archives inside commitSyncBatch. onArchiveRecord: (raw) async { - if (_resetting) return; // see [_resetting] + if (_resetting || _replacingBand) return; // candidate is not the primary yet await LocalDb.archiveRawRecord(raw); }, cursorReader: (base) => @@ -3032,7 +3032,7 @@ class AppState extends ChangeNotifier { proto.BandProfile profile, String deviceId, ) { - if (_resetting) return; // see [_resetting] + if (_resetting || _replacingBand) return; // candidate is not the primary yet LocalDb.insertEvent(id, ts, hex, deviceId: deviceId, profile: profile); // M3: gesture dispatch and the alarm handler stay unscoped — neither is // device-scoped in M3's scope, and a double-tap on either band should @@ -3648,7 +3648,7 @@ class AppState extends ChangeNotifier { // Historical singles only now (live frames go through _onLiveFrame and are // never persisted). Just write the raw record (+ optional decoded sample). Future _onRecord(Sample? sample, RawRecord raw) async { - if (_resetting) return; // see [_resetting] + if (_resetting || _replacingBand) return; // candidate is not the primary yet final ts = raw.recTs ?? sample?.tsEpoch; // AFTER the write, and gated on the write SAYING it wrote. `_lastRecTs` is // the DATA EDGE — what is banked — and it only ever moves forward, so @@ -3781,12 +3781,16 @@ class AppState extends ChangeNotifier { /// save would have every later walk fit the previous gain. int _liveStepCalibrationGeneration = 0; + @visibleForTesting + Future> Function()? debugReadLiveStepCalibration; + Future> _liveStepCalibrationRuns() async { while (true) { final cached = _liveStepCalibration; if (cached != null) return cached; final generation = _liveStepCalibrationGeneration; - final rows = await LocalDb.liveStepCalibrationRuns(); + final rows = await (debugReadLiveStepCalibration?.call() ?? + LocalDb.liveStepCalibrationRuns()); // Invalidated while the read was in flight: these rows may predate the // save that invalidated them, so read again rather than hand the caller // the previous calibration. @@ -4090,6 +4094,7 @@ class AppState extends ChangeNotifier { /// No cadence calibration any more — its only consumer was the deleted 1 Hz /// `dailyStepEstimate` (see kAlgoVersion v55). Future _finalizeLivePedometer() async { + final resetGeneration = ResetGate.generation; // The still-filling partial minute is real signal that was about to be // discarded: count it over the time it actually sampled — but only if that // time says it arrived fast enough to count (GATE 2). The tail has its own @@ -4108,7 +4113,14 @@ class AppState extends ChangeNotifier { } final runs = _gaitRuns.runs; _resetLivePedometer(); - await _bankGaitRuns(runs); + try { + await _bankGaitRuns(runs, resetGeneration: resetGeneration); + } catch (_) { + // Reset cancelled this session; disconnect finalization is unawaited. + if (!ResetGate.changedSince(resetGeneration)) rethrow; + return; + } + if (ResetGate.changedSince(resetGeneration)) return; // The session ended cleanly and is now durably recorded — the checkpoint // that would otherwise let a killed-process session recover is no longer // needed. @@ -4123,7 +4135,8 @@ class AppState extends ChangeNotifier { /// documents as the gain's home. Two formulas, or one formula applied at both /// ends, is the silent x1.23 this method's history records; one shared /// function with one factor per run is the invariant that prevents it. - Future _bankGaitRuns(List runs) async { + Future _bankGaitRuns(List runs, + {required int resetGeneration}) async { if (runs.isEmpty) return; final calibration = await _liveStepCalibrationRuns(); for (final run in runs) { @@ -4149,6 +4162,7 @@ class AppState extends ChangeNotifier { steps, day, source: kStepSourceStrap, + resetGeneration: resetGeneration, ); // AGENTS.md §4.7 — the other two callers of `addLiveCoverage` are // enumerated here so this capability cannot be wired into one path and @@ -4174,10 +4188,12 @@ class AppState extends ChangeNotifier { static const String _kLiveSessionCheckpoint = 'live_session_checkpoint'; Future _checkpointLiveSession() async { + final resetGeneration = ResetGate.generation; if (_gaitRuns.isEmpty) return; try { final prefs = await SharedPreferences.getInstance(); final calibration = await _liveStepCalibrationRuns(); + if (ResetGate.changedSince(resetGeneration)) return; await prefs.setString( _kLiveSessionCheckpoint, // Runs are stored gain-applied, i.e. exactly the numbers @@ -4235,6 +4251,7 @@ class AppState extends ChangeNotifier { }); Future _recoverOrphanedLiveSessionOnce() async { + final resetGeneration = ResetGate.generation; try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_kLiveSessionCheckpoint); @@ -4297,6 +4314,7 @@ class AppState extends ChangeNotifier { s[2], day, source: kStepSourceStrap, + resetGeneration: resetGeneration, ); recovered += s[2]; } @@ -4612,7 +4630,7 @@ class AppState extends ChangeNotifier { } void _onEngineState(String deviceId, DeviceState s) { - if (_resetting) return; // see [_resetting] + if (_resetting || _replacingBand) return; // candidate is not the primary yet _appendLiveHr(deviceId, s.liveHr, s.liveHrAt); // Bank the name the moment the band says it, so it survives the // disconnect. Written through `cleanDeviceLabel` for the same reason the @@ -4770,12 +4788,65 @@ class AppState extends ChangeNotifier { await _persistPaired(d.remoteId.str, serial); } + /// AppState owns the replacement lifetime and cancellation epoch. + bool _replacingBand = false; + bool _replacementPickerInProgress = false; + int _pairingEpoch = 0; bool _pairingInProgress = false; - Future _persistPaired(String remoteId, String? serial) async { - if (_pairingInProgress) throw Exception('Pairing is already in progress.'); + /// Verify and save the replacement before retiring the old phone approval. + /// A failed attempt keeps the old pairing and attempts to reconnect it. + Future replaceWith(BluetoothDevice d) => + _persistPaired(d.remoteId.str, null, replacing: true); + + /// The system sheet chooses the replacement on iOS 18+. The tapped scan + /// identity is not enforced by ASK. Picker errors retain the old pairing. + Future replaceViaAccessorySetup() async { + if (_pairingInProgress || _replacementPickerInProgress) { + throw StateError('Pairing is already in progress.'); + } + final old = paired; + if (old == null) throw StateError('There is no band to replace.'); + final epoch = _pairingEpoch; + _replacementPickerInProgress = true; + try { + await IosBleRestore.releaseCentralForPicker(); + final id = await AccessorySetup.showPicker(addAnother: true); + if (epoch != _pairingEpoch || paired?.remoteId != old.remoteId) { + throw StateError('The current pairing changed.'); + } + await IosBleRestore.provisioned(id); + _replacementPickerInProgress = false; + await _persistPaired(id, null, replacing: true); + } finally { + _replacementPickerInProgress = false; + // A dismissed/refused picker must not leave background restore disabled. + final current = paired; + if (current != null) await IosBleRestore.provisioned(current.remoteId); + } + } + + /// Forget what the link knows about the band it was on, so a new bootstrap + /// starts clean. The remembered strap name is a separate, persisted thing: + /// it is also what the old band is shown under while it is still the paired + /// one, so a replacement that has not committed must leave it alone. + void _resetBandIdentity({bool clearSavedName = true}) { + _clearLiveHrTrace(LocalDb.kPrimaryDeviceId); + device.reset(); + pairedIsMaverick = false; + _lastSeenGeneration = null; + _lastSeenStrapNameRaw = null; + if (clearSavedName) Prefs.setString(_kStrapName, ''); + } + + Future _persistPaired(String remoteId, String? serial, + {bool replacing = false}) async { + if (_pairingInProgress || _replacementPickerInProgress) { + throw StateError('Pairing is already in progress.'); + } final existing = paired; - if (existing != null) { + final existingIsMaverick = pairedIsMaverick; + if (existing != null && !replacing) { if (existing.remoteId != remoteId) { throw Exception('Forget the current band before pairing another.'); } @@ -4783,17 +4854,46 @@ class AppState extends ChangeNotifier { if (!engine.isConnected) throw Exception('Connection setup did not complete.'); return; } + if (replacing && existing == null) throw StateError('There is no band to replace.'); + if (replacing && existing!.remoteId == remoteId) { + throw const SameBandSelectedException(); + } + final epoch = _pairingEpoch; _pairingInProgress = true; + _replacingBand = replacing; + var committed = false; try { final confirmed = await _sync.connectForPairing( PairedDevice(remoteId, serial), + replacingRemoteId: replacing ? existing?.remoteId : null, + prepare: replacing ? () async { + // Replacement suppresses disconnect callbacks; bank the old band's + // still-filling minute before the new session clears its pedometer. + await _finalizeLivePedometer(); + _resetBandIdentity(clearSavedName: false); + } : null, confirm: () async { // Only READY proves we can talk to this band. Until then neither // the persisted record nor isPaired may advance onboarding. Save // the identity THIS bootstrap discovered, not a stale device hint. + if (epoch != _pairingEpoch || _resetting) { + throw StateError('Pairing was cancelled.'); + } await PairedDevice.save(remoteId, serial ?? device.serial, generation: device.generation); + if (epoch != _pairingEpoch || _resetting) { + throw StateError('Pairing was cancelled.'); + } paired = await PairedDevice.load(); + committed = paired?.remoteId == remoteId; + // The old band's name belongs to the old band; the new one banks + // its own as soon as it reports it. + if (committed && replacing) { + Prefs.setString(_kStrapName, ''); + // An epoch confirms the OLD physical band, never the replacement. + // Clear persistence before openSession can reload it and skip SET. + await _clearArmedAlarmState(); + } }, ); if (!confirmed) { @@ -4801,9 +4901,32 @@ class AppState extends ChangeNotifier { if (blocker != null) throw BleUnavailableException(blocker); throw Exception('Connection setup did not complete.'); } + } catch (error, stack) { + // PairedDevice.save rolls back its transaction and preference mirror. + // Only reconnect while the old pairing still owns this attempt. + if (replacing && !committed && epoch == _pairingEpoch && + paired?.remoteId == existing?.remoteId) { + _replacingBand = false; + _resetBandIdentity(clearSavedName: false); + pairedIsMaverick = existingIsMaverick; + try { + await IosBleRestore.provisioned(existing!.remoteId); + await openSession(); + } catch (restoreError) { + _log('Replacement failed; old band is still paired: $restoreError'); + } + } + Error.throwWithStackTrace(error, stack); } finally { + _replacingBand = false; _pairingInProgress = false; } + if (replacing && committed) { + // Never use unpair/removeAll here: the new primary is already durable. + await IosBleRestore.disarmOne(existing!.remoteId); + await AccessorySetup.removeBand(existing.remoteId); + _onEngineState(LocalDb.kPrimaryDeviceId, device); + } // Now that there's a band to alert about, ask for notification permission // (a natural moment; battery/charging alerts depend on it). Best-effort. unawaited(NotificationService.instance.ensurePermission()); @@ -4843,6 +4966,7 @@ class AppState extends ChangeNotifier { AndroidBackground.openOemAutostartSettings(); Future unpair() async { + _pairingEpoch++; await _sync.unpairSession(); await PairedDevice.clear(); pairedIsMaverick = false; @@ -5643,24 +5767,22 @@ class AppState extends ChangeNotifier { /// Drop the armed-alarm state (in-memory + persisted). [AlarmConfirmation]'s /// `firedAt` deliberately survives `disable()`, so the fired-notification's /// dedupeKey still resolves after this runs. - void _clearArmedAlarmState() { + Future _clearArmedAlarmState() async { _savedAlarm = null; final gen = ++_armGen; device.alarmEpoch = null; _alarm.disable(); _alarmGraceTimer?.cancel(); - unawaited(() async { - try { - final prefs = await SharedPreferences.getInstance(); - // An arm that landed during the await owns the persisted epoch now. - if (_armGen != gen) return; - await prefs.remove('alarm_epoch'); - if (_armGen != gen) return; - await prefs.remove('alarm_epoch_confirmed'); - } catch (e) { - _log('[alarm] clearing the persisted epoch failed: $e'); - } - }()); + try { + final prefs = await SharedPreferences.getInstance(); + // An arm that landed during the await owns the persisted epoch now. + if (_armGen != gen) return; + await prefs.remove('alarm_epoch'); + if (_armGen != gen) return; + await prefs.remove('alarm_epoch_confirmed'); + } catch (e) { + _log('[alarm] clearing the persisted epoch failed: $e'); + } } Future _notifyAlarmFired() async { @@ -6177,3 +6299,13 @@ class SleepCorrectionUnavailable implements Exception { ? 'the evening before $day is no longer recorded' : 'no recordings left to re-analyse $day'; } + +/// The band picked to replace the paired one IS the paired one. Nothing was +/// touched. Said out loud because returning quietly made the pairing screen +/// report "Paired" for a replacement that never happened. +class SameBandSelectedException implements Exception { + const SameBandSelectedException(); + @override + String toString() => + 'That band is already paired. Choose a different band to replace it.'; +} diff --git a/lib/state/sync_controller.dart b/lib/state/sync_controller.dart index 17dc758ab..5ea8ffc91 100644 --- a/lib/state/sync_controller.dart +++ b/lib/state/sync_controller.dart @@ -95,6 +95,8 @@ class SyncController { bool _reconnecting = false; DateTime? _attemptStartedAt; int _reconnectGeneration = 0; + Completer? _reconnectFinished; + Completer? _reconnectRetired; Timer? _reconnectSupervisor; Timer? _backfillTimer; BandLease? _foregroundLease; @@ -195,7 +197,7 @@ class SyncController { /// Stop supervising. Called from `dispose` and from every path that stops /// wanting a link at all (unpair / endSession) — otherwise the tick outlives /// its purpose and keeps poking the engine every few minutes forever. - void _stopReconnectSupervisor() { + void _stopReconnectSupervisor({bool waitForAttempt = false}) { _reconnectSupervisor?.cancel(); _reconnectSupervisor = null; // Cancelling the timer is not enough: a `_reconnect()` can still be parked @@ -206,6 +208,10 @@ class SyncController { // reconnecting and re-running the whole post-connect block underneath the // new session. _reconnectGeneration++; + final retired = _reconnectRetired; + if (retired != null && !retired.isCompleted) retired.complete(); + // Pairing waits for the real attempt before releasing its running flag. + if (waitForAttempt) return; _reconnecting = false; _attemptStartedAt = null; engine.clearReconnecting(); @@ -518,11 +524,18 @@ class SyncController { Future connectForPairing( PairedDevice candidate, { required Future Function() confirm, + String? replacingRemoteId, + Future Function()? prepare, }) async { - if (busy || _reconnecting || paired != null || _isDisposed || ResetGate.active) { + final existing = paired; + final allowed = replacingRemoteId == null + ? existing == null + : existing?.remoteId == replacingRemoteId; + if (busy || (_reconnecting && replacingRemoteId == null) || + !allowed || _isDisposed || ResetGate.active) { return false; } - final generation = _reconnectGeneration; + var generation = _reconnectGeneration; final resetGeneration = ResetGate.generation; bool current() => !_isDisposed && generation == _reconnectGeneration && @@ -532,6 +545,24 @@ class SyncController { BandOwnership.markForegroundIntent(true); IosBleRestore.foregroundActive = true; try { + if (replacingRemoteId != null) { + _keepAlive = false; + _stopBackfillTimer(); + final finished = _reconnectFinished; + _stopReconnectSupervisor(waitForAttempt: true); + generation = _reconnectGeneration; + // The engine serializes GATT operations, but that alone cannot stop an + // old loop's post-connect work from running against the new band. + if (finished != null) await finished.future; + if (!current() || paired?.remoteId != existing?.remoteId) return false; + _reconnecting = false; + _attemptStartedAt = null; + engine.clearReconnecting(); + await engine.disconnect(); + if (!current()) return false; + } + await prepare?.call(); + if (!current()) return false; background = false; engine.setBackground(false); _scheduler.setBackground(false); @@ -545,6 +576,8 @@ class SyncController { _log('Pairing: bootstrap did not complete; pairing not confirmed.'); return false; } + // A forget or another primary change during bootstrap wins. + if (paired?.remoteId != existing?.remoteId) return false; await confirm(); if (!current() || paired?.remoteId != candidate.remoteId) return false; confirmed = true; @@ -757,7 +790,7 @@ class SyncController { } Future _reconnect() async { - if (_reconnecting || paired == null) return; + if (_reconnecting || busy || paired == null) return; // Bond-refusal give-up: a band that keeps refusing the bond will never accept // commands, so the auto-reconnect loop is paused (surfaced as needsRepairGuide). // A manual user connect / re-pair clears the pause on the next successful bond. @@ -768,6 +801,9 @@ class SyncController { _reconnecting = true; _attemptStartedAt = DateTime.now(); final generation = ++_reconnectGeneration; + final finished = _reconnectFinished = Completer(); + final retired = _reconnectRetired = Completer(); + bool current() => _keepAlive && generation == _reconnectGeneration; BandOwnership.markForegroundIntent(true); _log('[OWNERSHIP] reconnect intent on (${BandOwnership.debugState})'); try { @@ -811,24 +847,34 @@ class SyncController { if (osPending) { connected = await engine.waitForOsAutoConnect( paired!.remoteId, - keepWaiting: () => _keepAlive && !engine.isConnected, + keepWaiting: () => current() && !engine.isConnected, ); - if (connected && _keepAlive) { + if (!current()) break; + if (connected) { // Mark band ownership before the actual GATT setup so a headless // wake can't fight this reconnect for the peripheral. await _ensureForegroundLease(); + if (!current()) break; connected = await engine.connectToRemoteId(paired!.remoteId, generationHint: paired!.generation); } else { connected = false; } } else { - await Future.delayed(engine.reconnectDelay(attempt)); - if (!_keepAlive) break; + final delayed = Completer(); + final timer = Timer(engine.reconnectDelay(attempt), delayed.complete); + try { + await Future.any([delayed.future, retired.future]); + } finally { + timer.cancel(); + } + if (!current()) break; await _ensureForegroundLease(); + if (!current()) break; connected = await engine.connectToRemoteId(paired!.remoteId, generationHint: paired!.generation); } + if (!current()) break; if (connected) { // Reclaim the band from the iOS restore central so it stops competing. if (Platform.isIOS) { @@ -930,6 +976,7 @@ class SyncController { // the loop exited via a successful connect (phase is `listening`). engine.clearReconnecting(); } + finished.complete(); } } diff --git a/lib/ui2/onboarding/pairing.dart b/lib/ui2/onboarding/pairing.dart index 05d168e3d..fa242c826 100644 --- a/lib/ui2/onboarding/pairing.dart +++ b/lib/ui2/onboarding/pairing.dart @@ -117,16 +117,23 @@ class PairingScreen extends StatefulWidget { final VoidCallback? onSkip; /// The band the user chose on the list; its reset steps show when pairing - /// does not finish, and a WHOOP advertising the other generation is passed - /// over. Null (no model known) shows the plain failure cards. + /// does not finish. With [modelKnown], a WHOOP advertising the other + /// generation is passed over. Null shows the plain failure cards. final BandEntry? entry; + /// False for the shared WHOOP category, whose representative supplies help + /// but does not establish the generation of the band being sought. + final bool modelKnown; + /// The one band the user tapped in a scan, by remote id; null pairs the /// first matching band in range. Android's scan honours it; the iOS /// accessory picker chooses on its own sheet. final String? remoteId; - const PairingScreen({super.key, this.onSkip, this.entry, this.remoteId}); + /// Keep the existing pairing until the new bootstrap succeeds. + final bool replacing; + + const PairingScreen({super.key, this.onSkip, this.entry, this.remoteId, this.replacing = false, this.modelKnown = true}); @override State createState() => _PairingScreenState(); @@ -145,16 +152,27 @@ class _PairingScreenState extends State { _blocker = null; }); try { - if (await app.accessorySetupSupported()) { - await app.pairViaAccessorySetup(); + final accessorySetup = await app.accessorySetupSupported(); + if (!mounted) return; + if (accessorySetup) { + if (widget.replacing) { + await app.replaceViaAccessorySetup(); + } else { + await app.pairViaAccessorySetup(); + } } else { final found = await app.scanForBand( - remoteId: widget.remoteId, generation: widget.entry?.id); + remoteId: widget.remoteId, generation: widget.modelKnown ? widget.entry?.id : null); + if (!mounted) return; if (found == null) { if (mounted) setState(() => _phase = PairPhase.notFound); return; } - await app.pairWith(found); + if (widget.replacing) { + await app.replaceWith(found); + } else { + await app.pairWith(found); + } } if (!mounted) return; setState(() => _phase = PairPhase.paired); @@ -183,7 +201,7 @@ class _PairingScreenState extends State { blocker: _blocker, onPair: _pair, onSkip: widget.onSkip, - resetEntry: widget.entry, + resetEntry: widget.modelKnown ? widget.entry : null, onPickDifferent: Navigator.of(c).canPop() ? () => Navigator.of(c).maybePop() : null, diff --git a/lib/ui2/pairing/add_device_flow.dart b/lib/ui2/pairing/add_device_flow.dart new file mode 100644 index 000000000..2ed0d691b --- /dev/null +++ b/lib/ui2/pairing/add_device_flow.dart @@ -0,0 +1,247 @@ +// What happens when the user taps a device found by the "Add a device" scan. +// One scan covers sensors and bands; which it is gets decided here, after the +// scan, and the user confirms before anything is saved. The pairing operation +// and the main-band flow are injected, so this runs without a radio. +import 'package:flutter/widgets.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../ble/band_identity.dart'; +import '../../ble/hrs_link.dart' show BandCandidate; +import '../../l10n/app_localizations.dart'; +import 'pair_sheets.dart'; +import 'pair_text.dart' show pairL10n; + +enum AddDeviceOutcome { + /// The user backed out; nothing was saved. + cancelled, + + /// Saved as a secondary sensor ("You paired a secondary sensor."). + pairedSecondary, + + /// Saved, and not a secondary sensor (a scale, a thermometer). + pairedOther, + + /// Handed to the main-band flow; nothing was saved here. + openedBandFlow, + + /// Pairing failed; [AddDeviceResult.message] says why. + failed, + + /// The main-band flow cannot open right now (iOS, no provisioned band, so + /// the system pairing sheet would fail until a restart); + /// [AddDeviceResult.message] is the restart guidance. Nothing was saved. + bandFlowHeldBack, +} + +typedef AddDeviceResult = ({AddDeviceOutcome outcome, String? message}); + +AddDeviceResult _r(AddDeviceOutcome o, [String? message]) => + (outcome: o, message: message); + +/// Entries that cannot be paired from a list tap: their pairing installs a key +/// on their own screen (the Oura ring asks for it, a Mi Band gets one), so +/// every choice for them ends in the band flow. A plain save would leave a row +/// sync cannot use. ui2_pair_sheets_test checks every keyed sensor is here. +const Set _ownScreen = {'oura', 'miband234'}; + +/// What the "Add a device" scan listens for: every registry entry, the framed +/// WHOOP bands included, so a WHOOP that advertises only its own band service +/// still turns up and goes to the band-choice flow. +List addDeviceScanEntries() => List.unmodifiable(kBandRegistry); + +/// What the flow needs from the screen and the radio. +abstract interface class AddDeviceIo { + /// Whether a main band is paired right now (read at tap time). + bool get hasMainBand; + + /// The primary peripheral must never also acquire a secondary connection. + String? get mainBandRemoteId; + + /// The paired band's name, shown when choosing its replacement; null + /// when it has none (the sheet then says "your band"). + String? get mainBandName; + + /// The pairing operation for [cand] under registry [entry]; the real one is + /// `HrsLink.pairNotifySensor`, tests pass a fake. + Future pair( + BandCandidate cand, BandEntry entry, PairConfirmation? confirmation); + + /// Ends this screen's own scan (awaited), so the next screen starts on a + /// radio that is not scanning. + Future stopScan(); + + /// Why the main-band flow cannot open right now, as restart guidance + /// (`HrsLink.scanHeldBackReason`); null when it can. + Future mainBandBlockedReason(); + + /// Opens the main-band pairing for [band], bound to the tapped device + /// [remoteId]. [modelKnown] false: only the name said WHOOP, so the + /// pairing must not assume [band]'s generation. + Future openBandFlow(BandEntry band, + {required String remoteId, bool modelKnown = true, bool replacing = false}); +} + +class AddDeviceFlow { + final AddDeviceIo io; + AddDeviceFlow(this.io); + + bool _running = false; + + /// A tap's flow is under way (a sheet, a confirm, an unpair, a pairing). + /// [onTap] refuses a second one until it ends. + bool get running => _running; + + static BandEntry? _entry(String id) => + kBandRegistry.where((e) => e.id == id).firstOrNull; + + /// Capture [c]'s navigator and localizations before the first await; the + /// sheets use it again after each one, so the caller must keep it mounted. + Future onTap(BuildContext c, BandCandidate cand) async { + if (_running) return _r(AddDeviceOutcome.cancelled); + _running = true; + final l = AppLocalizations.of(c); + try { + return await _onTap(c, cand); + } catch (e) { + return _r(AddDeviceOutcome.failed, + l?.pairSensorCouldNotPair(e.toString()) ?? 'Could not pair that device: $e'); + } finally { + _running = false; + } + } + + Future _onTap(BuildContext c, BandCandidate cand) async { + final refusal = _primaryRefusal(c, cand); + if (refusal != null) return refusal; + final entry = _entry(cand.entryId); + if (entry == null) return _r(AddDeviceOutcome.cancelled); + // A band in its own right, or a sensor-looking device that is also a band. + final band = entry.role == DeviceRole.band ? entry : cand.alsoBand?.entry; + if (band != null) { + // A WHOOP heard by name only: the name cannot tell WHOOP 4 from 5. + final modelKnown = cand.alsoBand?.modelCertain != false; + final choice = await showBandFoundSheet(c, + band: band, + hasMainBand: io.hasMainBand, + mainBandName: io.mainBandName, + modelKnown: modelKnown, + deviceName: cand.label); + if (choice == null) return _r(AddDeviceOutcome.cancelled); + if (!c.mounted) return _r(AddDeviceOutcome.cancelled); + if (choice == BandSheetChoice.replaceBand) { + return _replace(c, band, cand, modelKnown: modelKnown); + } + // The band's own entry decides: a ring heard as a heart-rate strap + // still pairs only on its own (authenticated) screen. + if (choice == BandSheetChoice.asBand || _ownScreen.contains(band.id)) { + return _handoff(band, cand, modelKnown: modelKnown); + } + // A WHOOP kept as a secondary sensor is paired as a heart-rate sensor; + // a ring or a watch pairs under its own entry. + final pairAs = band.canBeMainDevice ? kBleHrs : band; + return _pair(c, cand, pairAs, + confirmation: PairConfirmation( + remoteId: cand.device.remoteId.str, bandEntryId: band.id)); + } + if (!await confirmBrowse(c, entry, deviceName: cand.label) || + !c.mounted) { + return _r(AddDeviceOutcome.cancelled); + } + return _pair(c, cand, entry); + } + + /// The confirmation owed before pairing [entry]: a workout sensor and no + /// band shows the strap sheet (everything every day is a cross) before it + /// is saved as all there is. False when the user went back. A device paired + /// as its own entry (Colmi as Colmi) needs none. + Future confirmBrowse(BuildContext c, BandEntry entry, + {String? deviceName}) async { + if (!_isSecondarySensor(entry) || io.hasMainBand) return true; + return showStrapSheet(c, sensor: entry, deviceName: deviceName); + } + + /// Confirm replacement, then verify the new band while the current one + /// remains paired. The pairing owner commits the switch only after READY. + Future _replace( + BuildContext c, BandEntry band, BandCandidate cand, + {bool modelKnown = true}) async { + if (!await showReplaceBandConfirm(c, mainBandName: io.mainBandName)) { + return _r(AddDeviceOutcome.cancelled); + } + return _handoff(band, cand, modelKnown: modelKnown, replacing: true); + } + + /// Hands over to the main-band flow: the scan ends first, and where the + /// system pairing sheet cannot open the user gets restart guidance instead. + Future _handoff(BandEntry band, BandCandidate cand, + {bool modelKnown = true, bool replacing = false}) async { + await io.stopScan(); + if (band.isFramed) { + final held = await io.mainBandBlockedReason(); + if (held != null) return _r(AddDeviceOutcome.bandFlowHeldBack, held); + } + await io.openBandFlow(band, + remoteId: cand.device.remoteId.str, modelKnown: modelKnown, replacing: replacing); + return _r(AddDeviceOutcome.openedBandFlow); + } + + static bool _isSecondarySensor(BandEntry e) => + e.role == DeviceRole.sensor && e.category == DeviceCategory.workoutSensor; + + AddDeviceResult? _primaryRefusal(BuildContext c, BandCandidate cand) => + cand.device.remoteId.str == io.mainBandRemoteId + ? _r(AddDeviceOutcome.failed, pairL10n(c).pairPrimaryCannotBeSensor) + : null; + + Future _pair(BuildContext c, BandCandidate cand, + BandEntry entry, + {PairConfirmation? confirmation}) async { + final refusal = _primaryRefusal(c, cand); + if (refusal != null) return refusal; + final outcome = await io.pair(cand, entry, confirmation); + // afterConnect checks `c.mounted` before it touches the context. + // ignore: use_build_context_synchronously + return afterConnect(c, cand, entry, outcome, confirmation: confirmation); + } + + /// What happens after a connect that returned [outcome] for [cand] under + /// [entry]: a band found asks again (nothing was saved) and re-runs only on + /// an explicit yes. Shared by the nearby list and the category screen. + Future afterConnect( + BuildContext c, BandCandidate cand, BandEntry entry, PairOutcome outcome, + {PairConfirmation? confirmation}) async { + final found = outcome.isBand; + if (found != null) { + // The connect showed it is a band. Nothing was saved; ask again with + // that in hand, and re-run only on an explicit yes. + // A WHOOP named only by its name keeps the name's uncertain model. + final modelKnown = outcome.modelCertain; + if (!c.mounted) return _r(AddDeviceOutcome.cancelled); + final choice = await showBandFoundSheet(c, + band: found, + hasMainBand: io.hasMainBand, + mainBandName: io.mainBandName, + afterConnect: true, + modelKnown: modelKnown, + deviceName: cand.label); + if (choice == null) return _r(AddDeviceOutcome.cancelled); + if (!c.mounted) return _r(AddDeviceOutcome.cancelled); + if (choice == BandSheetChoice.replaceBand) { + return _replace(c, found, cand, modelKnown: modelKnown); + } + if (choice == BandSheetChoice.asBand || _ownScreen.contains(found.id)) { + return _handoff(found, cand, modelKnown: modelKnown); + } + if (!c.mounted) return _r(AddDeviceOutcome.cancelled); + // A ring or a watch pairs under its own entry, never as a plain sensor. + return _pair(c, cand, found.canBeMainDevice ? kBleHrs : found, + confirmation: PairConfirmation( + remoteId: cand.device.remoteId.str, bandEntryId: found.id)); + } + final error = outcome.error; + if (error != null) return _r(AddDeviceOutcome.failed, error); + return _r(confirmation != null || _isSecondarySensor(entry) + ? AddDeviceOutcome.pairedSecondary + : AddDeviceOutcome.pairedOther); + } +} diff --git a/lib/ui2/pairing/category_filter_pills.dart b/lib/ui2/pairing/category_filter_pills.dart new file mode 100644 index 000000000..5b629e48f --- /dev/null +++ b/lib/ui2/pairing/category_filter_pills.dart @@ -0,0 +1,86 @@ +// filter pills over a device list ("All · WHOOP · Rings · Watches · +// Straps"). One widget, one file, so the list grouping can be swapped for +// another style (accordions, plain headers) without touching a screen. +import 'package:flutter/material.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../ui2.dart'; +import 'pair_text.dart'; + +/// [entries] narrowed to [group]; null keeps them all. +List filterByGroup(Iterable entries, PairFilter? group) => [ + for (final e in entries) + if (group == null || pairFilterOf(e.kind) == group) e, +]; + +class CategoryFilterPills extends StatelessWidget { + /// The kind of every entry currently in the list (repeats are fine). Only + /// groups that have one get a pill; they show in [PairFilter] order. + final Iterable kinds; + + /// The chosen group; null means All. Set by a tap, listened to by the screen. + final ValueNotifier selection; + + const CategoryFilterPills({ + super.key, + required this.kinds, + required this.selection, + }); + + @override + Widget build(BuildContext c) { + final present = {for (final k in kinds) ?pairFilterOf(k)}; + final shown = [ + for (final f in PairFilter.values) + if (present.contains(f)) f, + ]; + // One group (or none) has nothing to filter between. + if (shown.length < 2) return const SizedBox.shrink(); + final l = pairL10n(c); + return ValueListenableBuilder( + valueListenable: selection, + builder: (_, selected, _) => Wrap( + spacing: S.x2, + runSpacing: S.x2, + children: [ + _Pill(const ValueKey('pill-all'), l.pairKindAll, selected == null, + () => selection.value = null), + for (final f in shown) + _Pill(ValueKey('pill-${f.name}'), pairFilterLabel(l, f), + selected == f, () => selection.value = f), + ], + ), + ); + } +} + +class _Pill extends StatelessWidget { + final String text; + final bool selected; + final VoidCallback onTap; + const _Pill(Key key, this.text, this.selected, this.onTap) : super(key: key); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + return Semantics( + selected: selected, + child: Pressable( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: S.x3, vertical: 9), + decoration: BoxDecoration( + color: selected ? Color.alphaBlend(p.wash(C.blue), p.card) : null, + borderRadius: R.rPill, + border: Border.all( + color: selected ? p.on(C.blue).withValues(alpha: .7) : p.line), + ), + child: Text(text, + style: F.cap.copyWith( + color: selected ? p.ink : p.ink2, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500)), + ), + ), + ); + } +} diff --git a/lib/ui2/pairing/device_picker.dart b/lib/ui2/pairing/device_picker.dart index 3a439eb34..5ca191b08 100644 --- a/lib/ui2/pairing/device_picker.dart +++ b/lib/ui2/pairing/device_picker.dart @@ -38,19 +38,34 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../ble/adapters/_registry.dart'; +import '../../ble/band_identity.dart' show PairConfirmation, PairOutcome; import '../../ble/band_status_l10n.dart' show localizedBandStatus; import '../../ble/ble_state.dart' - show BleUnavailableException, bandStatusFor, classifyBleBlocker; + show + BleUnavailableException, + bandStatusFor, + classifyBleBlocker, + kMaxConcurrentSecondaryLinks; import '../../ble/hrs_link.dart'; -import '../pairing/pair_outcome_text.dart'; import '../../ble/oura_link.dart' show pairOuraRingWithTypedKey; +import '../../data/db.dart' show LocalDb; import '../../l10n/app_localizations.dart'; import '../../state/app_state.dart'; import '../onboarding/pairing.dart' show PairingScreen; import '../ui2.dart'; +import 'add_device_flow.dart'; +import 'pair_band_view.dart'; +import 'category_filter_pills.dart'; +import 'pair_text.dart' + show PairFilter, pairFilterOf, pairL10n, slotPill; import 'whoop_firmware_warning.dart'; +import '../../ble/slot_comparison.dart' show slotComparisonFor; import '../profile/devices.dart' - show kPairableSensors, refuseIfSensorCapReached, sensorIcon; + show + kPairableSensors, + refuseIfSensorCapReached, + sensorCapReached, + sensorIcon; import '../profile/pair_sensor.dart' show PairSensorScreen; import '../profile/profile.dart' show SetRow; @@ -71,14 +86,22 @@ class DevicePickerScreen extends StatefulWidget { /// not something to offer beside a chest strap in the same list. final bool includeBand; + @visibleForTesting + final Future Function()? debugScanHeldBackReason; + const DevicePickerScreen( - {super.key, this.onSkip, this.onBack, this.includeBand = true}); + {super.key, + this.onSkip, + this.onBack, + this.includeBand = true, + this.debugScanHeldBackReason}); @override State createState() => _DevicePickerScreenState(); } -class _DevicePickerScreenState extends State { +class _DevicePickerScreenState extends State + implements AddDeviceIo, PairBandHost { final _query = TextEditingController(); String _q = ''; @@ -87,9 +110,21 @@ class _DevicePickerScreenState extends State { String? _heldBack; String? _problem; + /// The filter pill chosen over the results (null = All) and the good-news + /// line shown after a secondary sensor paired. + final _kind = ValueNotifier(null); + String? _notice; + /// Remote id of whichever candidate — nearby row or category tile — is /// mid-pair, so the rest of the screen can stop accepting taps rather /// than starting a second pairing attempt underneath the first. + /// The one flow every tap goes through, so a second tap mid-flow is + /// refused ([AddDeviceFlow.running]). + late final AddDeviceFlow _flow = AddDeviceFlow(this); + + /// True while [_flow] runs; disables the list (see [DevicePickerView.locked]). + bool _locked = false; + String? _busy; static List get _notifyEntries => @@ -99,12 +134,16 @@ class _DevicePickerScreenState extends State { void initState() { super.initState(); _query.addListener(() => setState(() => _q = _query.text)); - unawaited(_start()); + _kind.addListener(() => setState(() {})); + // The first-run band list does not scan: on iOS a scan creates the central + // that keeps the system pairing sheet from opening. + if (!widget.includeBand) unawaited(_start()); } @override void dispose() { _query.dispose(); + _kind.dispose(); // Ends THIS screen's scan early if it is the one running — see // `PairSensorScreen.dispose` for why this is never a bare // `FlutterBluePlus.stopScan()`, and `HrsLink._scanOwner` for why the @@ -116,8 +155,10 @@ class _DevicePickerScreenState extends State { } Future _start() async { - final held = await HrsLink.scanHeldBackReason(); - if (!mounted) return; + if (_locked || _scanning) return; + final held = await (widget.debugScanHeldBackReason?.call() ?? + HrsLink.scanHeldBackReason()); + if (!mounted || _locked || _scanning) return; if (held != null) { setState(() => _heldBack = held); return; @@ -134,7 +175,7 @@ class _DevicePickerScreenState extends State { }); try { await HrsLink.scanForAny( - _notifyEntries, + addDeviceScanEntries(), owner: this, onResults: (c) { if (mounted) setState(() => _found = c); @@ -155,64 +196,155 @@ class _DevicePickerScreenState extends State { } } - /// Pair a candidate found by the live "Nearby" scan directly — one tap, - /// no intermediate screen, because the scan already told us which - /// registry entry it is. + /// A device found by the live scan was tapped. [AddDeviceFlow] decides what + /// it is (a band or a sensor), asks the user where that matters, and calls + /// back here to do the pairing. Future _pickNearby(BandCandidate cand) async { + // One flow at a time: a second tap while a sheet, a confirm or the + // unpair of the old band is still running starts nothing. + if (_locked || _flow.running) return; + final l = AppLocalizations.of(context); + setState(() { + _problem = null; + _notice = null; + _locked = true; + }); + final AddDeviceResult result; + try { + result = await _flow.onTap(context, cand); + } finally { + if (mounted) setState(() => _locked = false); + } + if (!mounted) return; + switch (result.outcome) { + case AddDeviceOutcome.pairedSecondary: + case AddDeviceOutcome.pairedOther: + setState(() { + _found = const []; + if (result.outcome == AddDeviceOutcome.pairedSecondary) { + _notice = l?.pairSecondaryPaired ?? 'You paired a secondary sensor.'; + } + }); + await _afterPair(); + case AddDeviceOutcome.failed: + setState(() => _problem = result.message); + case AddDeviceOutcome.bandFlowHeldBack: + // The same restart guidance a held-back scan shows. + setState(() => _heldBack = result.message); + case AddDeviceOutcome.cancelled: + case AddDeviceOutcome.openedBandFlow: + break; + } + } + + @override + bool get hasMainBand => context.read().isPaired; + + @override + String? get mainBandRemoteId => context.read().paired?.remoteId; + + @override + String? get mainBandName => context.read().strapName; + + @override + Future openBandFlow(BandEntry band, + {required String remoteId, bool modelKnown = true, bool replacing = false}) => + _openEntry(band, remoteId: remoteId, modelKnown: modelKnown, replacing: replacing); + + @override + Future stopScan() => HrsLink.stopScanIfRunning(this); + + @override + Future mainBandBlockedReason() => HrsLink.scanHeldBackReason(); + + /// A category tile, tapped deliberately: the same confirmation a nearby row + /// gets (a workout sensor and no band asks first), then the entry's own + /// screen, whose connect-time band refusals go through the same flow. + Future _browseEntry(BandEntry entry, + {bool modelKnown = false, bool confirmed = false}) async { + if (_locked || _flow.running) return; + setState(() => _locked = true); + final l = AppLocalizations.of(context); + try { + if (!confirmed && !await _flow.confirmBrowse(context, entry)) return; + if (!mounted) return; + await _openEntry(entry, modelKnown: modelKnown); + } catch (e) { + if (mounted) { + setState(() => _problem = l?.pairSensorCouldNotPair(e.toString()) ?? + 'Could not pair that device: $e'); + } + } finally { + if (mounted) setState(() => _locked = false); + } + } + + @override + void pickBand(BandEntry entry) => unawaited(_browseEntry(entry, modelKnown: true)); + + @override + void strapOnlyConfirmed() => unawaited(_browseEntry(kBleHrs, confirmed: true)); + + /// The pairing operation itself, for [AddDeviceFlow]. + @override + Future pair( + BandCandidate cand, BandEntry entry, PairConfirmation? confirmation) async { final sensor = - kPairableSensors.where((s) => s.entry.id == cand.entryId).firstOrNull; - if (sensor == null) return; // Framed entries never reach this list. - // Only the ring's own screen asks for the key it may already hold; a - // direct pair here would install a new one and steer the user to a reset. - if (sensor.entry.id == kOura.id) return _openEntry(sensor.entry); + kPairableSensors.where((s) => s.entry.id == entry.id).firstOrNull; + final l = AppLocalizations.of(context); + if (sensor == null) { + return PairOutcome.failed(l?.devicePickerCannotPairFromHere ?? + 'That device cannot be paired from this screen.'); + } // Re-checked on every pair: this screen stays open after one for the // next, so `addSensor`'s check at the door does not bound a visit. // The minted id is the row only `pairNotifySensor` writes; a `pick` - // callback mints its own, so it is checked by adapter alone. - if (await refuseIfSensorCapReached(context, - repairingId: sensor.pick == null - ? HrsLink.mintDeviceId(sensor.entry, cand.device.remoteId.str) - : null, - adapterId: sensor.entry.id) || - !mounted) { - return; + // callback mints its own, so it is checked by adapter alone. The reason + // shows as this pair's failure, like any other. + if (sensorCapReached(await LocalDb.deviceRows(), + repairingId: sensor.pick == null + ? HrsLink.mintDeviceId(sensor.entry, cand.device.remoteId.str) + : null, + adapterId: sensor.entry.id)) { + return PairOutcome.failed( + l?.devicesSensorLimit(kMaxConcurrentSecondaryLinks) ?? + 'This phone will pair at most $kMaxConcurrentSecondaryLinks ' + 'sensors alongside your band. Remove one to add another.'); } - setState(() { - _busy = cand.device.remoteId.str; - _problem = null; - }); + if (!mounted) return const PairOutcome.failed(''); + setState(() => _busy = cand.device.remoteId.str); // OUR OWN SCAN, ENDED FIRST. It runs for a 15 s window and the user has // tapped a row several seconds into it, so without this the radio keeps // scanning through the connect — and every `onResults` batch reorders the - // list under the finger that is already committed to one row. `PairSensor` - // does not do this because it has one entry and a shorter list; here the - // reorder is visible. - // - // AWAITED, so the connect below starts on a radio that has really stopped - // scanning rather than one still tearing the scan down. Read before the - // await, because `context` after one is the lint's whole point. - final l = AppLocalizations.of(context); + // list under the finger that is already committed to one row. AWAITED, so + // the connect below starts on a radio that has really stopped scanning. await HrsLink.stopScanIfRunning(this); - String? failure; + PairOutcome outcome; try { - failure = sensor.pick != null - ? await sensor.pick!(cand.device) - : pairOutcomeText(l, await HrsLink.pairNotifySensor(sensor.entry, cand.device, - label: cand.label, earlierBand: cand.alsoBand)); + if (sensor.pick != null) { + final failure = await sensor.pick!(cand.device); + outcome = failure == null + ? const PairOutcome.saved() + : PairOutcome.failed(failure); + } else { + outcome = await HrsLink.pairNotifySensor(sensor.entry, cand.device, + label: cand.label, + earlierBand: cand.alsoBand, + confirmation: confirmation); + } } catch (e) { - failure = l?.pairSensorCouldNotPair(e.toString()) ?? 'Could not pair that device: $e'; + outcome = PairOutcome.failed( + l?.pairSensorCouldNotPair(e.toString()) ?? 'Could not pair that device: $e'); } - if (!mounted) return; - setState(() { - _busy = null; - _problem = failure; - if (failure == null) _found = const []; - }); - if (failure == null) await _afterPair(); + if (mounted) setState(() => _busy = null); + return outcome; } - /// A category or brand row, tapped deliberately rather than found live. - Future _openEntry(BandEntry entry) async { + /// A category or brand row, tapped deliberately rather than found live; or + /// a scanned band handed over by [AddDeviceFlow] ([remoteId], [modelKnown] + /// as [AddDeviceIo.openBandFlow]). + Future _openEntry(BandEntry entry, + {String? remoteId, bool modelKnown = true, bool replacing = false}) async { if (entry.isFramed) { // The pushed screen covers this one without disposing it, so this // screen's 15 s scan would keep the scan lock — and "Find my band" @@ -221,7 +353,8 @@ class _DevicePickerScreenState extends State { await HrsLink.stopScanIfRunning(this); if (!mounted) return; await Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const PairingScreen(), + builder: (_) => PairingScreen( + entry: entry, modelKnown: modelKnown, remoteId: remoteId, replacing: replacing), )); if (mounted) await _afterPair(); return; @@ -282,21 +415,25 @@ class _DevicePickerScreenState extends State { @override Widget build(BuildContext c) { final l = AppLocalizations.of(c); - final entries = [ - if (widget.includeBand) kWhoopGen4, - ..._notifyEntries, - ]; + if (widget.includeBand) { + return PairBandView( + entries: firstRunBandEntries(), host: this, onSkip: widget.onSkip, + onBack: widget.onBack, problem: _problem, + ); + } + final entries = _notifyEntries; return DevicePickerView( query: _query, - title: l?.devicePickerTitle ?? 'Connect your devices', - subtitle: - l?.devicePickerSubtitle ?? 'Bring whatever you use. You can add more anytime.', + title: l?.pairAddDevice ?? 'Add a device', + subtitle: l?.pairAddDeviceSubtitle ?? + 'Scan once. After the scan we tell you whether a device is a band or a sensor.', found: _found, showWhoopWarning: widget.includeBand, scanning: _scanning, heldBack: _heldBack, problem: _problem, busyRemoteId: _busy, + locked: _locked, categories: [ for (final e in entries) if ((_matches( @@ -315,9 +452,11 @@ class _DevicePickerScreenState extends State { icon: _categoryIcon(e), ), ], - onScan: _scan, + onScan: _heldBack == null ? _start : _scan, onPickNearby: _pickNearby, - onOpenEntry: _openEntry, + onOpenEntry: _browseEntry, + kindSelection: _kind, + notice: _notice, onSkip: widget.onSkip, onBack: widget.onBack, ); @@ -348,6 +487,18 @@ class DevicePickerView extends StatelessWidget { final VoidCallback? onSkip; final VoidCallback? onBack; + /// The filter pills over the results: the chosen kind (null = All) and + /// its setter. Null [kindSelection] draws no pills. The owner listens and + /// rebuilds this view when it changes. + final ValueNotifier? kindSelection; + + /// A good-news line shown above the results, e.g. after a sensor paired. + final String? notice; + + /// A pairing flow is running (a sheet, a confirm, the old band's unpair): + /// every row and the scan button wait for it. + final bool locked; + const DevicePickerView({ super.key, required this.query, @@ -365,13 +516,49 @@ class DevicePickerView extends StatelessWidget { this.onOpenEntry, this.onSkip, this.onBack, + this.kindSelection, + this.notice, + this.locked = false, }); + static DeviceKind? _kindOf(String entryId) => + kBandRegistry.where((e) => e.id == entryId).firstOrNull?.kind; + + /// What a found device physically is: the band it turned out to be when + /// it passed itself off as a heart-rate sensor, else its own entry's kind. + static DeviceKind? _foundKind(BandCandidate f) => + f.alsoBand?.entry.kind ?? _kindOf(f.entryId); + + static PairFilter? _groupOf(BandCandidate f) { + final k = _foundKind(f); + return k == null ? null : pairFilterOf(k); + } + @override Widget build(BuildContext c) { final p = P.of(c); final l = AppLocalizations.of(c); - final busy = busyRemoteId != null; + final busy = busyRemoteId != null || locked; + // The pills read the whole list; the filter narrows what is drawn. + final kinds = [ + for (final f in found) ?_foundKind(f), + for (final e in this.categories) e.entry.kind, + ]; + // A kind the search has emptied out is no filter: the pills hide when + // one kind is left, and a hidden selection would hide every row. + final groups = {for (final k in kinds) ?pairFilterOf(k)}; + final selected = kindSelection?.value; + final group = groups.length > 1 && groups.contains(selected) ? selected : null; + final shownFound = group == null + ? found + : [for (final f in found) if (_groupOf(f) == group) f]; + // Shadows the field on purpose: the rows below keep their original text. + final categories = group == null + ? this.categories + : [ + for (final e in this.categories) + if (pairFilterOf(e.entry.kind) == group) e, + ]; return Scaffold( backgroundColor: p.bg, body: SafeArea( @@ -391,7 +578,17 @@ class DevicePickerView extends StatelessWidget { Text(subtitle, style: F.body.copyWith(color: p.ink3)), const SizedBox(height: S.x4), _SearchField(controller: query), + if (kindSelection != null && groups.length > 1) ...[ + const SizedBox(height: S.x3), + CategoryFilterPills( + kinds: kinds, selection: kindSelection!), + ], const SizedBox(height: S.x5), + if (notice != null) ...[ + StatusCard(notice!, '', + icon: LucideIcons.circleCheck), + const SizedBox(height: S.x4), + ], if (heldBack != null) StatusCard( l?.pairSensorSearchWouldHideSheet ?? @@ -403,11 +600,19 @@ class DevicePickerView extends StatelessWidget { ) else _NearbySection( - found: found, + found: shownFound, scanning: scanning, busyRemoteId: busyRemoteId, - onTap: onPickNearby, + onTap: locked ? null : onPickNearby, ), + if (heldBack == null && onScan != null) ...[ + const SizedBox(height: S.x3), + BigButton(l?.pairSearchDevices ?? 'Search for devices', + key: const ValueKey('device-picker-scan'), + icon: LucideIcons.search, + soft: true, + onTap: busy || scanning ? null : onScan), + ], if (problem != null) ...[ const SizedBox(height: S.x4), StatusCard( @@ -433,6 +638,7 @@ class DevicePickerView extends StatelessWidget { ? null : () => onOpenEntry?.call(categories[i].entry), ), + ?_slotChip(c, categories[i].entry), if (i < categories.length - 1) Divider(color: p.line, height: 1), ], @@ -581,7 +787,9 @@ class _NearbySection extends StatelessWidget { ? (l?.devicesConnected ?? 'Connected') : '${cand.rssi} dBm'; final idle = cand.label == null ? '…$tail · $signal' : signal; - return SetRow( + // Where it fits, read off the band it turned out to be when it passed + // itself off as a heart-rate sensor. + final row = SetRow( sensorIcon(cand.entryId), C.green, cand.label ?? cand.entryId, @@ -600,9 +808,30 @@ class _NearbySection extends StatelessWidget { chevron: !busy, onTap: busy || onTap == null ? null : () => onTap!(cand), ); + final entry = cand.alsoBand?.entry ?? + kBandRegistry.where((e) => e.id == cand.entryId).firstOrNull; + final chip = entry == null ? null : _slotChip(c, entry); + if (chip == null) return row; + return Column(children: [row, chip]); } } +/// The "Every day" / "Workouts only" chip under a device row, or null when +/// the device fits neither column. + +Widget? _slotChip(BuildContext c, BandEntry e) { + final slot = slotComparisonFor(e).slot; + if (slot == null) return null; + return Align( + key: ValueKey('row-slot-${e.id}'), + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsets.only(bottom: S.x3), + child: slotPill(pairL10n(c), slot), + ), + ); +} + /// The one-line description under each device in the picker, localised. String deviceCategoryBlurb(AppLocalizations? l, BandEntry e) { return localizedText(l, switch (e.id) { diff --git a/lib/ui2/pairing/device_slot_card.dart b/lib/ui2/pairing/device_slot_card.dart new file mode 100644 index 000000000..5c246ec6c --- /dev/null +++ b/lib/ui2/pairing/device_slot_card.dart @@ -0,0 +1,59 @@ +// a paired device's own card, said plainly: where it fits ("Every day" or +// "Workouts only"), and THE table the pairing sheets show, with the column it +// is not in faded. The note says which switch it waits for; the switch itself +// sits right under the card (DeviceUseSwitch). +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../ble/slot_comparison.dart'; +import '../ui2.dart'; +import 'pair_text.dart'; +import 'pair_widgets.dart'; +import 'slot_comparison_view.dart'; + +/// The column [e] goes into when it is paired beside (or instead of) the +/// band: a ring or a watch fills every day, a workout sensor workouts only. +/// Null for a WHOOP (its own page), a Coros watch or a scale. +DeviceSlot? pairedDeviceSlot(BandEntry e) => + e.canBeMainDevice ? null : slotComparisonFor(e).slot; + +class DeviceSlotCard extends StatelessWidget { + final BandEntry entry; + final DeviceSlot slot; + + const DeviceSlotCard({super.key, required this.entry, required this.slot}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final other = slot == DeviceSlot.everyDay + ? DeviceSlot.workoutsOnly + : DeviceSlot.everyDay; + return Column( + key: const ValueKey('device-slot-card'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // A Wrap, not a Row: at 2x text the chip drops under the heading + // instead of overflowing beside it. + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: S.x2, + runSpacing: S.x2, + children: [ + Text(l.pairWhatItSends, style: F.head.copyWith(color: p.ink)), + slotPill(l, slot, key: const ValueKey('device-slot-pill')), + ], + ), + const SizedBox(height: S.x3), + SlotTable(comparison: slotComparisonFor(entry), dim: other), + slot == DeviceSlot.everyDay + ? PairNote(LucideIcons.flaskConical, l.pairWearableCardNote, + color: C.orange) + : PairNote(LucideIcons.info, l.pairStrapNote), + ], + ); + } +} diff --git a/lib/ui2/pairing/pair_band_view.dart b/lib/ui2/pairing/pair_band_view.dart new file mode 100644 index 000000000..0830b053a --- /dev/null +++ b/lib/ui2/pairing/pair_band_view.dart @@ -0,0 +1,197 @@ +// First-run "Pair your band": the devices that can be the user's main band, +// each marked "Every day", the always-open "Trouble connecting?" card, and +// the ways out (a heart-rate strap only, skip). +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../ble/slot_comparison.dart'; +import '../../l10n/display_text.dart'; +import '../ui2.dart'; +import 'category_filter_pills.dart'; +import 'pair_help.dart'; +import 'pair_sheets.dart'; +import 'pair_text.dart'; +import 'pair_widgets.dart'; +import 'whoop_firmware_warning.dart'; + +/// The devices the first run offers as the main band, in registry order. +List firstRunBandEntries() => [ + for (final e in kBandRegistry) + if (e.canBeMainDevice) e, +]; + +/// What the first-run list asks of the screen around it. +abstract interface class PairBandHost { + /// A device row was tapped. + void pickBand(BandEntry entry); + + /// The user confirmed pairing a heart-rate strap as a sensor (no band). + void strapOnlyConfirmed(); +} + +class PairBandView extends StatefulWidget { + final List entries; + final PairBandHost host; + + /// Non-null only at true first run: walk past pairing. + final VoidCallback? onSkip; + + /// Where the back chevron goes when there is no route underneath to pop + /// (first-run onboarding); null keeps the default `Navigator.maybePop`. + final VoidCallback? onBack; + + /// The last failure, in a sentence. + final String? problem; + final PairLinkOpener openLink; + + const PairBandView({ + super.key, + required this.entries, + required this.host, + this.onSkip, + this.onBack, + this.problem, + this.openLink = const SystemLinkOpener(), + }); + + @override + State createState() => _PairBandViewState(); +} + +class _PairBandViewState extends State { + final _group = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _group.addListener(() => setState(() {})); + } + + @override + void dispose() { + _group.dispose(); + super.dispose(); + } + + Future _strapOnly() async { + if (await showStrapSheet(context, sensor: kBleHrs)) { + widget.host.strapOnlyConfirmed(); + } + } + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final shown = filterByGroup(widget.entries, _group.value); + return Scaffold( + backgroundColor: p.bg, + body: SafeArea( + child: Column(children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: S.x4), + child: NavBar(l.pairSetUpTitle, onBack: widget.onBack), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(S.x4, S.x2, S.x4, S.x10), + children: [ + const WhoopFirmwareWarning(), + const SizedBox(height: S.x4), + Text(l.pairBandTitle, + key: const ValueKey('pair-band-title'), + style: F.t1.copyWith(color: p.ink)), + const SizedBox(height: S.x2), + Text(l.pairBandSubtitle, style: F.body.copyWith(color: p.ink2)), + const SizedBox(height: S.x2), + Text(l.pairMainBandWhoopOnly, + key: const ValueKey('main-band-whoop-only-note'), + style: F.cap.copyWith(color: p.ink3)), + const SizedBox(height: S.x4), + CategoryFilterPills( + kinds: [for (final e in widget.entries) e.kind], + selection: _group, + ), + if (shown.isNotEmpty) const SizedBox(height: S.x3), + for (var i = 0; i < shown.length; i++) ...[ + if (i > 0) const SizedBox(height: S.x3), + _BandRow( + entry: shown[i], + onTap: () => widget.host.pickBand(shown[i]), + ), + ], + if (widget.problem != null) ...[ + const SizedBox(height: S.x4), + StatusCard(l.pairSensorThatDidNotWork, widget.problem!, + icon: LucideIcons.circleAlert), + ], + const SizedBox(height: S.x4), + TroubleConnectingCard( + entries: widget.entries, openLink: widget.openLink), + TextLinkButton(l.pairStrapOnlyLink, onTap: _strapOnly), + if (widget.onSkip != null) ...[ + BigButton(l.pairingSkipForNow, + color: C.blue, soft: true, onTap: widget.onSkip), + const SizedBox(height: S.x2), + Text(l.pairingSkipNote, + style: F.cap.copyWith(color: p.ink3)), + ], + ], + ), + ), + ]), + ), + ); + } +} + +/// The line under a row says what a WHOOP sends. It is WHOOP copy, so another +/// main-band entry added later gets no line until it has its own. +bool _isWhoop(BandEntry e) => e.id == kWhoopGen4.id || e.id == kWhoopGen5.id; + +class _BandRow extends StatelessWidget { + final BandEntry entry; + final VoidCallback onTap; + const _BandRow({required this.entry, required this.onTap}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final slot = slotComparisonFor(entry).slot; + return Surface( + key: ValueKey('band-row-${entry.id}'), + onTap: onTap, + child: Row(children: [ + Container( + width: 48, + height: 48, + alignment: Alignment.center, + decoration: BoxDecoration(color: p.card2, borderRadius: R.rMd), + child: Icon(deviceKindIcon(entry.kind), size: 22, color: p.ink2), + ), + const SizedBox(width: S.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(uiText(c, entry.label), + style: F.body + .copyWith(color: p.ink, fontWeight: FontWeight.w600)), + if (_isWhoop(entry)) ...[ + const SizedBox(height: 2), + Text(l.pairWhoopRowLine, + style: F.cap.copyWith(color: p.ink2)), + ], + if (slot != null) ...[ + const SizedBox(height: S.x2), + slotPill(l, slot), + ], + ]), + ), + Icon(LucideIcons.chevronRight, size: 18, color: p.ink3), + ]), + ); + } +} diff --git a/lib/ui2/pairing/pair_choice_sheet.dart b/lib/ui2/pairing/pair_choice_sheet.dart new file mode 100644 index 000000000..03cb444ff --- /dev/null +++ b/lib/ui2/pairing/pair_choice_sheet.dart @@ -0,0 +1,123 @@ +// The confirm sheet the pairing screens share: the device, a title and a lead, +// THE table with the device's column washed, a note, one filled main button, +// an optional second choice as a text link, and "Go back". The callers differ +// only in words, so there is one sheet. +import 'package:flutter/material.dart'; + +import '../../ble/slot_comparison.dart'; +import '../ui2.dart'; +import 'pair_widgets.dart'; +import 'slot_comparison_view.dart'; + +/// What the user picked on a [PairChoiceSheet]. +enum PairChoice { primary, secondary, back } + +class PairChoiceSheet extends StatelessWidget { + /// The device's tile and its name ("WHOOP 5 · 7C21", "Oura Ring"). + final IconData deviceIcon; + final String deviceName; + + final String title; + final String? lead; + final SlotComparison comparison; + + /// The column the device goes into; washed on the table. + final DeviceSlot? focus; + + /// Icon-and-caption notes under the table. + final List notes; + + /// "Put it in pairing mode", above the main button; null shows none. + final Widget? pairingMode; + + final String primaryLabel; + final IconData? primaryIcon; + + /// The second choice, a text link under the button. Null hides it. + final String? secondaryLabel; + final String backLabel; + + const PairChoiceSheet({ + super.key, + required this.deviceIcon, + required this.deviceName, + required this.title, + this.lead, + required this.comparison, + this.focus, + this.notes = const [], + this.pairingMode, + required this.primaryLabel, + this.primaryIcon, + this.secondaryLabel, + required this.backLabel, + }); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + void pop(PairChoice v) => Navigator.of(c).pop(v); + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row(children: [ + Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: + BoxDecoration(color: p.card2, borderRadius: R.rSm), + child: Icon(deviceIcon, size: 16, color: p.ink2), + ), + const SizedBox(width: S.x2), + Expanded( + child: Text(deviceName, + key: const ValueKey('choice-device'), + style: F.cap.copyWith( + color: p.ink2, fontWeight: FontWeight.w600)), + ), + ]), + const SizedBox(height: S.x3), + Text(title, + key: const ValueKey('choice-title'), + style: F.t1.copyWith(color: p.ink)), + if (lead != null) ...[ + const SizedBox(height: S.x2), + Text(lead!, style: F.body.copyWith(color: p.ink2)), + ], + const SizedBox(height: S.x4), + SlotTable(comparison: comparison, focus: focus), + ...notes, + if (pairingMode != null) ...[ + const SizedBox(height: S.x5), + pairingMode!, + ], + const SizedBox(height: S.x5), + BigButton(primaryLabel, + key: const ValueKey('choice-primary'), + icon: primaryIcon, + onTap: () => pop(PairChoice.primary)), + if (secondaryLabel != null) + TextLinkButton(secondaryLabel!, + onTap: () => pop(PairChoice.secondary)), + TextLinkButton(backLabel, onTap: () => pop(PairChoice.back)), + ]), + ), + ); + } +} + +/// Shows [sheet] from the bottom; null when dismissed without a choice. +Future showPairChoiceSheet(BuildContext c, PairChoiceSheet sheet) { + final p = P.of(c); + return showModalBottomSheet( + context: c, + backgroundColor: p.card, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => sheet, + ); +} diff --git a/lib/ui2/pairing/pair_sheets.dart b/lib/ui2/pairing/pair_sheets.dart new file mode 100644 index 000000000..457064687 --- /dev/null +++ b/lib/ui2/pairing/pair_sheets.dart @@ -0,0 +1,188 @@ +// The confirm sheets of the "Add a device" flow, all built on one +// PairChoiceSheet with THE table: +// - a WHOOP: "Pair it as my band" (or, with a band already paired, "Unpair +// and pair this one" and its one confirm), and "Use it for workouts +// only"; +// - a ring or a watch: "Every day" washed, the experimental note, "Pair (it)"; +// - a heart-rate strap with no band paired: "Workouts only" washed. +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../ble/slot_comparison.dart'; +import '../../l10n/display_text.dart'; +import '../ui2.dart'; +import 'pair_choice_sheet.dart'; +import 'pair_text.dart'; +import 'pair_widgets.dart'; +import 'reset_steps_view.dart' show PairingModeSection; + +/// What the user picked on the band-found sheet. Going back is null. +enum BandSheetChoice { + /// Pair it as the main band (no band paired yet). + asBand, + + /// Pair the replacement before retiring the current main band. Only + /// offered with a band paired; the caller confirms once before acting. + replaceBand, + + /// Pair it as itself, not as the main band: a WHOOP kept for workouts only + /// (read as a heart-rate sensor), or a ring or a watch. + asSensor, +} + +/// "This is a WHOOP band" (or a ring, or a watch). [afterConnect] is the same +/// sheet after the connect found the band (nothing was saved), with the +/// sentence that says so. [modelKnown] false: only the name matched, so the +/// sheet names no model. [deviceName] is the advertised name, when the scan +/// heard one. [mainBandName] names the band paired now ([hasMainBand]). +Future showBandFoundSheet( + BuildContext c, { + required BandEntry band, + required bool hasMainBand, + String? mainBandName, + bool afterConnect = false, + bool modelKnown = true, + String? deviceName, +}) async { + final l = pairL10n(c); + final label = modelKnown ? uiText(c, band.label) : 'WHOOP'; + final cmp = slotComparisonFor(band, modelKnown: modelKnown); + final icon = deviceKindIcon(band.kind); + final name = deviceName ?? label; + if (!band.canBeMainDevice) { + // A ring or a watch: it fills every day as the active wearable, behind + // its own switch; a Coros watch fits neither column. + final wearable = cmp.slot == DeviceSlot.everyDay; + final choice = await showPairChoiceSheet( + c, + PairChoiceSheet( + deviceIcon: icon, + deviceName: name, + title: deviceSheetTitle(l, band), + lead: afterConnect + ? l.pairIdentifiedBody(label) + : wearable + ? band.id == kPebble.id + ? l.pairWearableLeadPebble + : l.pairWearableLead + : null, + comparison: cmp, + focus: cmp.slot, + notes: [ + wearable + ? PairNote(LucideIcons.flaskConical, l.pairWearableNote, + color: C.orange) + : PairNote(LucideIcons.info, l.pairSensorExplainerCoros), + ], + pairingMode: PairingModeSection(entry: band), + primaryLabel: l.pairDeviceNamed(label), + primaryIcon: LucideIcons.bluetooth, + backLabel: l.pairGoBack, + ), + ); + return choice == PairChoice.primary ? BandSheetChoice.asSensor : null; + } + final current = mainBandName ?? l.pairCurrentBandFallback; + final choice = await showPairChoiceSheet( + c, + PairChoiceSheet( + deviceIcon: icon, + deviceName: name, + title: l.pairThisIsWhoop, + lead: afterConnect + ? l.pairIdentifiedBody(label) + : hasMainBand + ? l.pairBandFoundBodyHasBand + : l.pairBandFoundBody, + comparison: cmp, + focus: DeviceSlot.everyDay, + notes: [ + if (hasMainBand) PairNote(LucideIcons.watch, l.pairBandNow(current)), + ], + // A WHOOP heard by name only gets no model's steps. + pairingMode: modelKnown ? PairingModeSection(entry: band) : null, + primaryLabel: + hasMainBand ? l.pairReplaceBand(current) : l.pairAsMyBand, + primaryIcon: + hasMainBand ? LucideIcons.arrowLeftRight : LucideIcons.watch, + secondaryLabel: l.pairUseForWorkouts, + backLabel: l.pairGoBack, + ), + ); + return switch (choice) { + PairChoice.primary => + hasMainBand ? BandSheetChoice.replaceBand : BandSheetChoice.asBand, + PairChoice.secondary => BandSheetChoice.asSensor, + _ => null, + }; +} + +/// Confirm replacement while preserving the old pairing on failed setup. +/// True only on the explicit yes. +Future showReplaceBandConfirm(BuildContext c, {String? mainBandName}) async { + final l = pairL10n(c); + final name = mainBandName ?? l.pairCurrentBandFallback; + final ok = await showDialog( + context: c, + builder: (d) { + final p = P.of(d); + return Dialog( + backgroundColor: p.card, + insetPadding: const EdgeInsets.symmetric(horizontal: S.x6), + shape: RoundedRectangleBorder( + borderRadius: R.rXl, side: BorderSide(color: p.line)), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(S.x5, S.x5, S.x5, S.x2), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(LucideIcons.arrowLeftRight, size: 24, color: p.ink2), + const SizedBox(height: S.x3), + Text(l.pairReplaceConfirmTitle(name), + key: const ValueKey('replace-confirm-title'), + style: F.t2.copyWith(color: p.ink)), + const SizedBox(height: S.x2), + Text(l.pairReplaceConfirmBody, + style: F.body.copyWith(color: p.ink2)), + const SizedBox(height: S.x5), + BigButton(l.pairReplaceConfirmYes, + key: const ValueKey('replace-confirm-yes'), + onTap: () => Navigator.of(d).pop(true)), + TextLinkButton(l.actionCancel, + onTap: () => Navigator.of(d).pop(false)), + ]), + ), + ); + }, + ); + return ok == true; +} + +/// "This is a heart-rate strap": "Workouts only" washed, the switch note and +/// "Pair (it)". True when the user chose to go ahead. [deviceName] is the +/// advertised name, when the scan heard one. +Future showStrapSheet(BuildContext c, + {required BandEntry sensor, String? deviceName}) async { + final l = pairL10n(c); + final label = uiText(c, sensor.label); + final cmp = slotComparisonFor(sensor); + final choice = await showPairChoiceSheet( + c, + PairChoiceSheet( + deviceIcon: deviceKindIcon(sensor.kind), + deviceName: deviceName ?? label, + title: deviceSheetTitle(l, sensor), + lead: l.pairStrapLead, + comparison: cmp, + focus: DeviceSlot.workoutsOnly, + notes: [PairNote(LucideIcons.info, l.pairStrapNote)], + pairingMode: PairingModeSection(entry: sensor), + primaryLabel: l.pairDeviceNamed(label), + primaryIcon: LucideIcons.bluetooth, + backLabel: l.pairGoBack, + ), + ); + return choice == PairChoice.primary; +} diff --git a/lib/ui2/pairing/pair_text.dart b/lib/ui2/pairing/pair_text.dart index 1718af4a0..efa7b6bcd 100644 --- a/lib/ui2/pairing/pair_text.dart +++ b/lib/ui2/pairing/pair_text.dart @@ -5,13 +5,92 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../ble/adapters/_registry.dart'; +import '../../ble/slot_comparison.dart'; import '../../l10n/app_localizations.dart'; +import '../ui2.dart'; /// Localizations for [c]; English when none are mounted (a bare test widget), /// the same fallback the rest of the pairing copy has. AppLocalizations pairL10n(BuildContext c) => AppLocalizations.of(c) ?? lookupAppLocalizations(const Locale('en')); +String capabilityLabel(AppLocalizations l, DeviceCapability c) => switch (c) { + DeviceCapability.sleep => l.capLabelSleep, + DeviceCapability.recovery => l.capLabelRecovery, + DeviceCapability.strain => l.capLabelStrain, + DeviceCapability.heartRate => l.capLabelHeartRate, + DeviceCapability.hrv => l.capLabelHrv, + DeviceCapability.respiration => l.capLabelRespiration, + DeviceCapability.skinTemperature => l.capLabelSkinTemperature, + DeviceCapability.steps => l.capLabelSteps, + DeviceCapability.bloodOxygen => l.capLabelBloodOxygen, + DeviceCapability.workoutHeartRate => l.capLabelWorkoutHeartRate, + DeviceCapability.workoutBeatToBeat => l.capLabelWorkoutBeatToBeat, +}; + +/// The one-line caption under a capability, or null when it has none. +String? capabilityReason(AppLocalizations l, CapabilityStatus s) => + switch (s.kind) { + CapabilityKind.native => null, + CapabilityKind.vendorReported => l.capVendorReported, + CapabilityKind.conditional => switch (s.reasonKey) { + kReasonSkinTempNights => l.capReasonSkinTempNights, + kReasonSkinTempProvisional => l.capReasonSkinTempProvisional, + kReasonEstimated => l.capReasonEstimated, + kReasonEarly => l.capReasonEarly, + kReasonFirstNights => l.capReasonFirstNights, + kReasonOwnStagesRing => l.capReasonOwnStagesRing, + kReasonOwnStages => l.capReasonOwnStages, + kReasonLiveSessions => l.capReasonLiveSessions, + kReasonUnconfirmed => l.capReasonUnconfirmed, + kReasonPebble2Only => l.capReasonPebble2Only, + kReasonPebble2OnlyEstimated => l.capReasonPebble2OnlyEstimated, + _ => null, + }, + }; + +/// The column's name: "Every day" or "Workouts only". +String slotLabel(AppLocalizations l, DeviceSlot s) => switch (s) { + DeviceSlot.everyDay => l.slotEveryDay, + DeviceSlot.workoutsOnly => l.slotWorkoutsOnly, +}; + +/// Each column's colour and icon, the same on the table, the chips and the +/// device card. +Color slotColor(DeviceSlot s) => switch (s) { + DeviceSlot.everyDay => C.teal, + DeviceSlot.workoutsOnly => C.purple, +}; + +IconData slotIcon(DeviceSlot s) => switch (s) { + DeviceSlot.everyDay => LucideIcons.sun, + DeviceSlot.workoutsOnly => LucideIcons.dumbbell, +}; + +/// The chip that says where a device fits. +Widget slotPill(AppLocalizations l, DeviceSlot s, {Key? key}) => + Pill(slotLabel(l, s), slotColor(s), icon: slotIcon(s), key: key); + +/// The filter pills' groups: what the user would call the device. +/// A WHOOP is its own group; a Mi Band sits with the watches. Scales and +/// thermometers have no group and show under All only. +enum PairFilter { whoop, rings, watches, straps } + +PairFilter? pairFilterOf(DeviceKind k) => switch (k) { + DeviceKind.strap => PairFilter.whoop, + DeviceKind.ring => PairFilter.rings, + DeviceKind.watch || DeviceKind.band => PairFilter.watches, + DeviceKind.chestStrap || DeviceKind.armband => PairFilter.straps, + DeviceKind.scale || DeviceKind.thermometer || DeviceKind.other => null, +}; + +String pairFilterLabel(AppLocalizations l, PairFilter f) => switch (f) { + PairFilter.whoop => 'WHOOP', + PairFilter.rings => l.pairKindRing, + PairFilter.watches => l.pairKindWatch, + PairFilter.straps => l.pairKindStrap, +}; + /// The sheet title for [e]: "This is a WHOOP band", "This is a ring", …. String deviceSheetTitle(AppLocalizations l, BandEntry e) => switch (e.kind) { _ when e.canBeMainDevice => l.pairThisIsWhoop, diff --git a/lib/ui2/pairing/pair_widgets.dart b/lib/ui2/pairing/pair_widgets.dart index 9833758f2..9488cc315 100644 --- a/lib/ui2/pairing/pair_widgets.dart +++ b/lib/ui2/pairing/pair_widgets.dart @@ -115,3 +115,28 @@ class ChoiceRow extends StatelessWidget { ); } } + +/// A caption with its icon, under pairing content: grey by default, or in [color]'s +/// ink (the orange "Experimental" note). +class PairNote extends StatelessWidget { + final IconData icon; + final String text; + final Color? color; + const PairNote(this.icon, this.text, {super.key, this.color}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final ink = color == null ? p.ink2 : p.on(color!); + return Padding( + padding: const EdgeInsets.only(top: S.x3), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(icon, size: 15, color: ink)), + const SizedBox(width: S.x2), + Expanded(child: Text(text, style: F.cap.copyWith(color: ink))), + ]), + ); + } +} diff --git a/lib/ui2/pairing/slot_comparison_view.dart b/lib/ui2/pairing/slot_comparison_view.dart new file mode 100644 index 000000000..f16088d77 --- /dev/null +++ b/lib/ui2/pairing/slot_comparison_view.dart @@ -0,0 +1,174 @@ +// THE table of what a device gives "Every day" and "Workouts only": a row +// per capability, a check on a tint where it sends, a muted cross where it +// does not (never a dash). The pairing sheets wash the column the device goes +// into; the device page fades the column it is not in. One widget, one file, +// so the comparison style can be swapped without touching a screen. The facts +// come from `slotComparisonFor`; this only draws. +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/slot_comparison.dart'; +import '../ui2.dart'; +import 'pair_text.dart'; + +/// One table row: null in a column means that column does not send it. +typedef SlotRow = ({ + DeviceCapability capability, + CapabilityStatus? everyDay, + CapabilityStatus? workouts, +}); + +/// The rows of [c]: every row of the table ([kTableRows]), in order. +List slotRowsFor(SlotComparison c) => [ + for (final k in kTableRows) + ( + capability: k, + everyDay: c.everyDay.sends[k], + workouts: c.workoutsOnly.sends[k], + ), +]; + +class SlotTable extends StatelessWidget { + final SlotComparison comparison; + + /// The column the device goes into, washed in its colour (the sheets). + final DeviceSlot? focus; + + /// The column the device is not in, faded (the device page). + final DeviceSlot? dim; + + const SlotTable({super.key, required this.comparison, this.focus, this.dim}); + + static const double colWidth = 104; + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + Widget cell(DeviceSlot s, Widget child) => Container( + key: ValueKey('slot-cell-${s.name}'), + width: colWidth, + alignment: Alignment.center, + color: focus == s ? p.wash(slotColor(s), strength: .55) : null, + child: Opacity(opacity: dim == s ? .28 : 1, child: child), + ); + Widget line(Widget label, Widget a, Widget b, + {Key? key, bool top = true, double v = S.x3}) => + Container( + key: key, + decoration: BoxDecoration( + border: top ? Border(top: BorderSide(color: p.line)) : null), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB(S.x4, v, S.x2, v), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: label), + ), + ), + cell(DeviceSlot.everyDay, a), + cell(DeviceSlot.workoutsOnly, b), + ]), + ), + ); + Widget head(DeviceSlot s) => Padding( + // Room above the icons, so they do not sit on the table's edge. + padding: const EdgeInsets.fromLTRB(S.x1, S.x3, S.x1, S.x2), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(slotIcon(s), size: 16, color: p.on(slotColor(s))), + const SizedBox(height: 3), + Text(slotLabel(l, s), + key: ValueKey('slot-head-${s.name}'), + textAlign: TextAlign.center, + style: F.cap.copyWith(color: p.ink, fontWeight: FontWeight.w700)), + ]), + ); + Widget row(SlotRow r) { + final k = r.capability.name; + final reasons = []; + for (final (slot, status) in [ + (DeviceSlot.everyDay, r.everyDay), + (DeviceSlot.workoutsOnly, r.workouts), + ]) { + final reason = status == null ? null : capabilityReason(l, status); + if (reason != null) { + reasons.add(l.slotColumnReason(slotLabel(l, slot), reason)); + } + } + return line( + key: ValueKey('slot-row-$k'), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(capabilityLabel(l, r.capability), + style: F.body.copyWith(color: p.ink)), + for (final reason in reasons) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(reason, style: F.cap.copyWith(color: p.ink3)), + ), + ]), + SlotMark(r.everyDay != null, capability: r.capability, + slot: DeviceSlot.everyDay, reason: r.everyDay?.kind == CapabilityKind.conditional + ? capabilityReason(l, r.everyDay!) : null, key: ValueKey('slot-everyDay-$k')), + SlotMark(r.workouts != null, capability: r.capability, + slot: DeviceSlot.workoutsOnly, reason: r.workouts?.kind == CapabilityKind.conditional + ? capabilityReason(l, r.workouts!) : null, key: ValueKey('slot-workoutsOnly-$k')), + ); + } + + final rows = slotRowsFor(comparison); + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: p.card2, borderRadius: R.rLg, border: Border.all(color: p.line)), + child: Column(children: [ + line(const SizedBox.shrink(), head(DeviceSlot.everyDay), + head(DeviceSlot.workoutsOnly), + top: false, v: S.x2), + // The workout rows name themselves ("… during workouts"); no group + // row. + for (final r in rows) row(r), + ]), + ); + } +} + +/// A check on a green tint where the column sends the row, a muted cross where +/// it does not. Announces the capability, column and status to a screen reader. +class SlotMark extends StatelessWidget { + final bool sends; + final DeviceCapability capability; + final DeviceSlot slot; + final String? reason; + const SlotMark(this.sends, + {super.key, required this.capability, required this.slot, this.reason}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final status = sends ? l.slotSendsHeading : l.slotDoesntSendHeading; + return Semantics( + label: l.slotCapabilityStatus(capabilityLabel(l, capability), + slotLabel(l, slot), reason == null ? status : '$status. $reason'), + excludeSemantics: true, + child: sends + ? Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: + BoxDecoration(color: p.wash(C.green), shape: BoxShape.circle), + child: Icon(LucideIcons.check, size: 15, color: p.on(C.green)), + ) + : SizedBox( + width: 24, + height: 24, + child: Icon(LucideIcons.x, size: 16, color: p.ink3)), + ); + } +} + diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 7f7c173d7..beb0fbd82 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -85,6 +85,10 @@ import '../../sync/paired_device.dart' show cleanDeviceLabel; import '../../state/app_state.dart'; import '../../state/clock_format.dart' show formatClockOf; import '../pairing/device_picker.dart' show DevicePickerScreen; +import '../pairing/pair_text.dart' show pairL10n, slotPill; +import '../../ble/slot_comparison.dart' show DeviceSlot; +import '../pairing/device_slot_card.dart' + show DeviceSlotCard, pairedDeviceSlot; import 'firmware_flash_screen.dart' show FirmwareFlashScreen; import '../onboarding/profile_setup.dart' show formatDay; import '../ui2.dart'; @@ -1132,8 +1136,9 @@ class MyDevices extends StatelessWidget { } Widget _build(BuildContext c, AppState app, Set liveAdapterIds) { + final sources = rankSources(liveSources(app, liveAdapterIds: liveAdapterIds)); return MyDevicesView( - sources: rankSources(liveSources(app, liveAdapterIds: liveAdapterIds)), + sources: sources, // The band row's dot says connected or not. That covers six different // problems with six different fixes, and a user cannot fix a problem the // app will not name — so the engine's own verdict rides alongside it. @@ -1147,6 +1152,10 @@ class MyDevices extends StatelessWidget { // way back to pairing. So push it. onPair: () => goto(c, const RePair()), onAddSensor: () => addSensor(c), + onUnpair: () { + final band = sources.where((s) => s.isBand).firstOrNull; + _confirmForget(c, app, band?.displayName(c) ?? ''); + }, contendedSignals: contendedSignals(app), onSignalPriority: () => goto(c, const SignalPriorityScreen()), ); @@ -1470,6 +1479,9 @@ class MyDevicesView extends StatelessWidget { final List sources; final VoidCallback? onPair, onAddSensor; + /// "Unpair band" on the already-paired card; null hides the button. + final VoidCallback? onUnpair; + /// The band's own state, from `bandStatusFor`. Null when nothing is paired. final BandStatus? status; @@ -1484,6 +1496,7 @@ class MyDevicesView extends StatelessWidget { this.sources = const [], this.onPair, this.onAddSensor, + this.onUnpair, this.status, this.contendedSignals = const [], this.onSignalPriority, @@ -1506,7 +1519,6 @@ class MyDevicesView extends StatelessWidget { // the toggle sits on while a denied READ permission returns nothing. So // "a source exists" was the wrong test for this card: it told a user // whose phone was measuring nothing that "the phone counts steps". - final phoneCounting = sources.any((s) => !s.isBand && s.connected); final l = AppLocalizations.of(c); return Scaffold( backgroundColor: p.bg, @@ -1520,31 +1532,10 @@ class MyDevicesView extends StatelessWidget { child: ListView( padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10), children: [ - // FIRST, in the tier-2 slot the missing band would occupy — - // not appended under the phone. Someone who has just forgotten - // a band is here to add one, and the row they can see is not - // the one they came for. - if (!hasBand) ...[ - StatusCard( - phoneCounting - ? (l?.devicesNoBandPaired ?? 'No band is paired') - : (l?.devicesNothingMeasuringYet ?? - 'Nothing is measuring yet'), - phoneCounting - ? (l?.devicesPhoneCountingBody ?? - 'The phone counts steps and nothing else. Heart ' - 'rate, sleep, recovery and temperature all abstain ' - 'until a band is paired.') - : (l?.devicesNothingMeasuringBody ?? - 'No band is paired and no phone steps are arriving, ' - 'so every metric in the app will abstain rather ' - 'than estimate.'), - fix: l?.devicesPairABand ?? 'Pair a band', - icon: LucideIcons.watch, - onFix: onPair, - ), - if (sources.isNotEmpty) const SizedBox(height: S.x3), - ], + // "Pair a device": the band first (or who it is, when one is + // paired), then "Add a device" for anything else. + ..._pairSection(c, hasBand), + if (sources.isNotEmpty) const SizedBox(height: S.x3), // LIVE — the first of the two buckets. Only drawn when there is // something in it: a heading over nothing is the empty rung // problem again, one level up. @@ -1562,19 +1553,6 @@ class MyDevicesView extends StatelessWidget { ], const SizedBox(height: S.x3), ], - // At the foot of LIVE, because that is what it adds to. Not - // gated on having a band: a sensor is a second source, and - // someone with no band at all is exactly who benefits most - // from being able to pair one. - if (onAddSensor != null) - Surface( - pad: const EdgeInsets.symmetric(horizontal: S.x4), - child: SetRow(LucideIcons.plus, C.green, - l?.devicesAddASensor ?? 'Add a sensor', - sub: l?.devicesAddASensorSub ?? - 'A heart-rate strap or a ring, alongside the band', - onTap: onAddSensor), - ), // ONLY when something can actually contend. A reorder screen // over one device is a control with nothing to order, which is // the empty-rung problem this screen already refuses (§6.5). @@ -1617,6 +1595,153 @@ class MyDevicesView extends StatelessWidget { } } +extension _PairSection on MyDevicesView { + /// The "Pair a device" section. Empty when there is nothing to show (a + /// fixture with no callbacks and no band). + List _pairSection(BuildContext c, bool hasBand) { + final l = pairL10n(c); + final band = sources.where((s) => s.isBand).firstOrNull; + // What having no band costs. The phone row is listed whenever its toggle + // is on, so "a source exists" is not the test: only steps actually being + // banked is (see the note above `hasBand`). + final phoneCounting = sources.any((s) => !s.isBand && s.connected); + final al = AppLocalizations.of(c); + final children = [ + // ONE card for the band, paired or not, so the section keeps its + // height when the state flips: the status and the action in one box. + if ((!hasBand && onPair != null) || (hasBand && band != null)) + _YourBandCard( + bandName: hasBand ? band?.displayName(c) : null, + bandState: hasBand && band != null + ? _localizedSourceState(c, band) + : null, + onPair: onPair, + onUnpair: onUnpair, + consequence: hasBand + ? null + : phoneCounting + ? (al?.devicesPhoneCountingBody ?? + 'The phone counts steps and nothing else. Heart ' + 'rate, sleep, recovery and temperature all abstain ' + 'until a band is paired.') + : (al?.devicesNothingMeasuringBody ?? + 'No band is paired and no phone steps are arriving, ' + 'so every metric in the app will abstain rather ' + 'than estimate.'), + ), + if (onAddSensor != null) + Surface( + pad: const EdgeInsets.symmetric(horizontal: S.x4), + child: SetRow(LucideIcons.plus, C.green, l.pairAddDevice, + sub: l.pairAddDeviceSub, onTap: onAddSensor), + ), + ]; + if (children.isEmpty) return const []; + return [ + Section(l.pairSectionTitle, Column(children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: S.x3), + children[i], + ], + ])), + const SizedBox(height: S.x3), + ]; + } +} + +/// A paired device that fits one of the two columns: a ring or a watch +/// (every day, as the active wearable) or a workout sensor such as a +/// heart-rate strap (workouts only). Null for the band, the phone, a scale, a +/// thermometer or a Coros watch. A WHOOP the user kept for workouts is stored +/// under the heart-rate-sensor adapter, so it is a workout sensor too. +({BandEntry entry, DeviceSlot slot})? slotDeviceOf(HealthSource s) { + if (s.isBand || s.deviceId == null) return null; + final e = kBandRegistry.where((e) => e.id == s.family).firstOrNull; + final slot = e == null ? null : pairedDeviceSlot(e); + return slot == null ? null : (entry: e!, slot: slot); +} + +/// The column chip a source row carries: the band is every day; a paired +/// device its own column. +DeviceSlot? sourceSlot(HealthSource s) => + s.isBand ? DeviceSlot.everyDay : slotDeviceOf(s)?.slot; + +/// "Your band": no band yet ("No band is paired", the WHOOP-only line and +/// "Pair your band"), or the paired band (its name, its state and "Unpair +/// band"). Both states keep the same shape: a heading row, two lines, one +/// button. +class _YourBandCard extends StatelessWidget { + /// The paired band's name; null when no band is paired. + final String? bandName; + final String? bandState; + final VoidCallback? onPair, onUnpair; + + /// Unpaired only: what the app can and cannot measure without a band, in + /// the same card so the section says it once. + final String? consequence; + const _YourBandCard( + {this.bandName, + this.bandState, + this.onPair, + this.onUnpair, + this.consequence}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final paired = bandName != null; + return Surface( + key: ValueKey(paired ? 'band-paired-card' : 'pair-band-card'), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: S.x2, + runSpacing: S.x2, + children: [ + Text(l.devicesYourBand, style: F.head.copyWith(color: p.ink)), + slotPill(l, DeviceSlot.everyDay), + ], + ), + const SizedBox(height: S.x2), + Text(paired ? bandName! : l.devicesNoBandPaired, + key: const ValueKey('your-band-status'), + style: F.body.copyWith(color: p.ink, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + paired + ? Text(bandState ?? '', style: F.cap.copyWith(color: p.ink2)) + : Text(l.pairCardBandBody, + key: const ValueKey('main-band-whoop-only-note'), + style: F.cap.copyWith(color: p.ink2)), + if (!paired && consequence != null) ...[ + const SizedBox(height: S.x2), + Text(consequence!, + key: const ValueKey('no-band-consequence'), + style: F.cap.copyWith(color: p.ink2)), + ], + if (paired ? onUnpair != null : onPair != null) ...[ + const SizedBox(height: S.x4), + paired + ? BigButton(l.pairUnpairBand, + icon: LucideIcons.unlink, + color: C.red, + soft: true, + onTap: onUnpair) + : BigButton(l.pairBandTitle, + icon: LucideIcons.bluetooth, onTap: onPair), + ], + ]), + ); + } +} + +@visibleForTesting +Future debugConfirmForgetBand( + BuildContext c, AppState app, String name) => + _confirmForget(c, app, name); + + /// The one line under a source's name. /// /// "Not connected" is a statement about a radio link, and the phone has none: @@ -1631,7 +1756,10 @@ String sourceState(HealthSource s) { // resting state and not a fault — and a sensor whose tier could not be named // falls back to the phone's rung, where this function would otherwise have // told the user their chest strap was reporting steps. - if (s.deviceId != null) { + // A ring or watch fills whole days, so a workout never arms it: it reads + // as a band does, by sync and link, below. + final wearable = slotDeviceOf(s)?.slot == DeviceSlot.everyDay; + if (s.deviceId != null && !wearable) { if (s.connected) return 'Streaming beats'; // A source with no rung supplies no signal, so there is nothing for a // workout to arm and nothing being derived. Say that, rather than promise @@ -1647,7 +1775,7 @@ String sourceState(HealthSource s) { String _localizedSourceState(BuildContext c, HealthSource s) { final l = AppLocalizations.of(c); - if (s.deviceId != null) { + if (s.deviceId != null && slotDeviceOf(s)?.slot != DeviceSlot.everyDay) { if (s.connected) return l?.devicesStreamingBeats ?? 'Streaming beats'; return s.tier == null ? (l?.devicesStoringWhatItSends ?? 'Paired · storing what it sends') @@ -1675,7 +1803,11 @@ class SourceRow extends StatelessWidget { final p = P.of(c); final battery = s.batteryPct; final stacked = bigText(c); - final tierBadge = s.tier == null + // A secondary sensor is named by its role on its own page; a rung and an + // "Experimental" word on the row said nothing about what it sends. + final slot = sourceSlot(s); + final secondary = slotDeviceOf(s) != null; + final tierBadge = s.tier == null || secondary ? null : Pill( AppLocalizations.of(c)?.devicesTierRank(s.tier!.rank) ?? @@ -1746,11 +1878,16 @@ class SourceRow extends StatelessWidget { // IN THE WRAP, not a second trailing pill: this line already // wraps at accessibility sizes, and a pill column beside it does // not. The band's own page carries the explanation. - if (s.experimental) + if (s.experimental && !secondary) Text(AppLocalizations.of(c)?.devicesExperimental ?? 'Experimental', style: F.over.copyWith(color: p.on(C.orange))), ], ), + if (slot != null) ...[ + const SizedBox(height: S.x2), + slotPill(pairL10n(c), slot, + key: ValueKey('source-slot-${slot.name}')), + ], if (stacked && tierBadge != null) ...[ const SizedBox(height: S.x1), tierBadge, @@ -2436,7 +2573,12 @@ class DeviceDetailView extends StatelessWidget { fix: fault.fix ?? '', icon: LucideIcons.bluetoothOff), const SizedBox(height: S.x5), ], - if (s.tier case final t?) ...[ + if (slotDeviceOf(s) case final d?) ...[ + DeviceSlotCard(entry: d.entry, slot: d.slot), + const SizedBox(height: S.x4), + // The switch the card's note names, right under it. + DeviceUseSwitch(deviceId: s.deviceId!, adapterId: d.entry.id), + ] else if (s.tier case final t?) ...[ TierRow(t, filled: true), const SizedBox(height: S.x5), ], @@ -2452,23 +2594,28 @@ class DeviceDetailView extends StatelessWidget { Surface( pad: const EdgeInsets.symmetric(horizontal: S.x4), child: Column(children: [ - SetRow(LucideIcons.flaskConical, C.orange, - l?.devicesSupport ?? 'Support', - value: l?.devicesExperimental ?? 'Experimental', - sub: l?.devicesSensorExperimentalSub ?? - 'Decoded from the protocol, never checked ' - 'against the hardware — nobody here owns one.', - chevron: false), - Divider(color: p.line, height: 1), - SetRow(LucideIcons.database, C.teal, - l?.devicesWhatItDoes ?? 'What it does', - sub: s.tier == null - ? (l?.devicesWhatItDoesStored ?? - 'Everything it sends is saved under its name. Scores from it are experimental and off until it’s been checked against the hardware.') - : (l?.devicesWhatItDoesBeats ?? - 'Heartbeat timing is saved under its name, during workouts and a few minutes after. Scoring workouts from it is experimental and off for now.'), - chevron: false), - Divider(color: p.line, height: 1), + if (slotDeviceOf(s) == null) ...[ + SetRow(LucideIcons.flaskConical, C.orange, + l?.devicesSupport ?? 'Support', + value: l?.devicesExperimental ?? 'Experimental', + sub: l?.devicesSensorExperimentalSub ?? + 'Decoded from the protocol, never checked ' + 'against the hardware — nobody here owns one.', + chevron: false), + Divider(color: p.line, height: 1), + ], + // The every-day card above already describes wearables. + if (slotDeviceOf(s)?.slot != DeviceSlot.everyDay) ...[ + SetRow(LucideIcons.database, C.teal, + l?.devicesWhatItDoes ?? 'What it does', + sub: s.tier == null + ? (l?.devicesWhatItDoesStored ?? + 'Everything it sends is saved under its name. Scores from it are experimental and off until it’s been checked against the hardware.') + : (l?.devicesWhatItDoesBeats ?? + 'Heartbeat timing is saved under its name, during workouts and a few minutes after. Scoring workouts from it is experimental and off for now.'), + chevron: false), + Divider(color: p.line, height: 1), + ], SetRow(LucideIcons.refreshCw, C.purple, l?.devicesLastData ?? 'Last data', value: last == null ? '' : formatDayTime(last, l), @@ -2521,7 +2668,8 @@ class DeviceDetailView extends StatelessWidget { ), ), const SizedBox(height: S.x5), - if (s.family case final family?) + if (s.family case final family? + when slotDeviceOf(s) == null) DeviceUseSwitch( deviceId: s.deviceId!, adapterId: family), ], diff --git a/lib/ui2/profile/pair_sensor.dart b/lib/ui2/profile/pair_sensor.dart index 09b437de2..ab7cfa8ac 100644 --- a/lib/ui2/profile/pair_sensor.dart +++ b/lib/ui2/profile/pair_sensor.dart @@ -57,17 +57,21 @@ import 'package:provider/provider.dart'; import '../../ble/accessory_setup.dart'; import '../../ble/adapters/_registry.dart'; import '../../ble/band_status_l10n.dart' show localizedBandStatus; +import '../../ble/band_identity.dart' + show BandIdentity, PairConfirmation, PairOutcome; import '../../ble/ble_state.dart' show BleUnavailableException, bandStatusFor, classifyBleBlocker; -import '../../ble/band_identity.dart' show BandIdentity; import '../../ble/hrs_link.dart'; -import '../pairing/pair_outcome_text.dart'; import '../../ble/ios_ble_restore.dart'; +import '../../ble/oura_link.dart' show pairOuraRingWithTypedKey; import '../../data/db.dart' show LocalDb; import '../../l10n/app_localizations.dart'; +import '../onboarding/pairing.dart' show PairingScreen; +import '../pairing/add_device_flow.dart'; +import '../pairing/pair_outcome_text.dart'; import '../../state/app_state.dart'; import '../ui2.dart'; -import 'devices.dart' show refuseIfSensorCapReached; +import 'devices.dart' show kPairableSensors, refuseIfSensorCapReached; import 'profile.dart'; import 'wearable_numbers.dart' show WearableUnlocks; @@ -100,19 +104,25 @@ class PairSensorScreen extends StatefulWidget { @visibleForTesting final List? debugFound; + /// Substitute only the radio's pairing result in screen regression tests. + @visibleForTesting + final Future Function(BluetoothDevice)? debugPair; + const PairSensorScreen({ super.key, required this.entry, this.onPicked, this.onPickedWithKey, this.debugFound, + this.debugPair, }); @override State createState() => _PairSensorScreenState(); } -class _PairSensorScreenState extends State { +class _PairSensorScreenState extends State + implements AddDeviceIo { List _found = const []; bool _scanning = false; @@ -281,9 +291,11 @@ class _PairSensorScreenState extends State { } } - Future _pick(BandCandidate c) => _pickDevice(c.device, label: c.label, earlierBand: c.alsoBand); + Future _pick(BandCandidate c) => + _pickDevice(c.device, label: c.label, earlierBand: c.alsoBand); - Future _pickDevice(BluetoothDevice device, {required String? label, BandIdentity? earlierBand}) async { + Future _pickDevice(BluetoothDevice device, + {required String? label, BandIdentity? earlierBand}) async { // THE exact cap check. This screen stays open after a pair and offers // "Pair another", and every route into it (Add sensor, onboarding, // re-pair) only checked on the way in. The minted id is the row only @@ -322,12 +334,7 @@ class _PairSensorScreenState extends State { ? await widget.onPickedWithKey!(device, typedKey) : widget.onPicked != null ? await widget.onPicked!(device) - : pairOutcomeText(l, await HrsLink.pairNotifySensor( - widget.entry, - device, - label: label, - earlierBand: earlierBand, - )); + : await _pairPlain(device, label, earlierBand); } catch (e) { failure = l?.pairSensorCouldNotPair(e.toString()) ?? 'Could not pair that device: $e'; @@ -346,6 +353,90 @@ class _PairSensorScreenState extends State { if (failure == null) await _load(); } + /// The plain notify-class pairing. A connect that finds the device is a band + /// (nothing was saved) goes through the same confirmation a nearby row in + /// "Add a device" gets, so no path pairs a band by accident. + Future _pairPlain( + BluetoothDevice device, String? label, BandIdentity? earlierBand) async { + final l = AppLocalizations.of(context); + final outcome = widget.debugPair != null + ? await widget.debugPair!(device) + : await HrsLink.pairNotifySensor(widget.entry, device, + label: label, earlierBand: earlierBand); + if (outcome.isBand == null || !mounted) return pairOutcomeText(l, outcome); + final cand = ( + device: device, + label: label, + rssi: null, + entryId: widget.entry.id, + alsoBand: earlierBand, + ); + final previousMain = context.read().paired?.remoteId; + final r = await AddDeviceFlow(this) + .afterConnect(context, cand, widget.entry, outcome); + if (!mounted) return null; + return afterBandFoundText(l, outcome, r, + bandPairedNow: context.read().paired?.remoteId != null && + context.read().paired?.remoteId != previousMain); + } + + @override + bool get hasMainBand => context.read().isPaired; + + @override + String? get mainBandRemoteId => context.read().paired?.remoteId; + + @override + String? get mainBandName => context.read().strapName; + + + @override + Future pair(BandCandidate cand, BandEntry entry, + PairConfirmation? confirmation) async { + try { + return await HrsLink.pairNotifySensor(entry, cand.device, + label: cand.label, + earlierBand: cand.alsoBand, + confirmation: confirmation); + } catch (e) { + return PairOutcome.failed(AppLocalizations.of(context) + ?.pairSensorCouldNotPair(e.toString()) ?? + 'Could not pair that device: $e'); + } + } + + @override + Future stopScan() => HrsLink.stopScanIfRunning(this); + + @override + Future mainBandBlockedReason() => HrsLink.scanHeldBackReason(); + + @override + Future openBandFlow(BandEntry band, + {required String remoteId, bool modelKnown = true, bool replacing = false}) async { + if (!mounted) return; + if (!band.isFramed) { + // A ring pairs only on its own screen, with its key (the same screen + // "Add a device" opens for it). + final sensor = + kPairableSensors.where((s) => s.entry.id == band.id).firstOrNull; + if (sensor == null) return; + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PairSensorScreen( + entry: band, + onPicked: sensor.pick, + onPickedWithKey: + band.id == kOura.id ? pairOuraRingWithTypedKey : null, + ), + )); + return; + } + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => + PairingScreen(entry: modelKnown ? band : null, remoteId: remoteId, replacing: replacing), + )); + } + Future _forget(String id) async { await HrsLink.forgetDevice(id); if (!mounted) return; @@ -704,3 +795,28 @@ class PairSensorView extends StatelessWidget { ); } } + +/// What this screen says after a connect found a band and the user chose +/// what to do ([AddDeviceFlow.afterConnect]); null says nothing went wrong. +/// The main-band flow that opened may have paired the band: then +/// "Nothing was saved" would be false. A ring's own screen reported its own +/// outcome. [bandPairedNow] says whether this flow changed the main band; +/// the system picker can select a different peripheral from the scan. +@visibleForTesting +String? afterBandFoundText( + AppLocalizations? l, + PairOutcome outcome, + AddDeviceResult r, { + required bool bandPairedNow, +}) => + switch (r.outcome) { + AddDeviceOutcome.pairedSecondary || AddDeviceOutcome.pairedOther => null, + AddDeviceOutcome.failed || AddDeviceOutcome.bandFlowHeldBack => r.message, + AddDeviceOutcome.openedBandFlow + when bandPairedNow || outcome.isBand?.isFramed == false => + null, + // Backed out, or the band flow did not pair it: nothing was saved. + AddDeviceOutcome.cancelled || + AddDeviceOutcome.openedBandFlow => + pairOutcomeText(l, outcome), + }; diff --git a/test/app_state_pairing_confirmation_test.dart b/test/app_state_pairing_confirmation_test.dart index d016eac9c..d381f7ac2 100644 --- a/test/app_state_pairing_confirmation_test.dart +++ b/test/app_state_pairing_confirmation_test.dart @@ -1,12 +1,19 @@ // Selecting a band in the system picker is not a completed BLE bootstrap. // Pair through the real AppState entry point with only the radio replaced, // so a failed first connect cannot persist a pairing or advance onboarding. +import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:openstrap_edge/sync/reset_gate.dart'; +import 'package:openstrap_edge/data/models.dart' show DeviceState; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/ios_ble_restore.dart'; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/notify/notification_service.dart'; +import 'package:openstrap_edge/state/app_state.dart' show SameBandSelectedException; +import 'package:openstrap_edge/state/prefs.dart' show Prefs; import 'package:openstrap_edge/sync/band_ownership.dart'; import 'package:openstrap_edge/sync/paired_device.dart'; // ignore: depend_on_referenced_packages @@ -246,4 +253,334 @@ void main() { expect(await _history(), before); }, paired: false), ); + group('replacement keeps the old pairing until READY', () { + const replacementId = 'AA:BB:CC:DD:EE:99'; + + // WHY: a lost band's retry must finish before candidate bootstrap starts. + // catches: rejecting replacement because the old loop is reconnecting, + // or clearing its flag without waiting for its in-flight connect. + for (final success in [true, false]) { + test('replacement retires an in-flight reconnect (success=$success)', + syncCase((rig, timers) async { + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + await rig.openAndSettle(); + rig.engine.events.clear(); + final oldConnect = rig.holdConnect(); + rig.engine.drop(); + await rig.waitFor(() => rig.engine.count('connect') == 1); + var completed = false; + rig.engine.connectScript.addAll([true, success, false, true]); + final replacement = rig.app.replaceWith(BluetoothDevice.fromId(replacementId)); + final observed = success + ? replacement.then((_) => completed = true) + : expectLater(replacement, throwsA(isA())).then((_) => completed = true); + await settleMs(40); + expect(completed, isFalse, reason: 'old connect still owns the radio'); + expect(rig.engine.only('connect'), ['connect:$kRemoteId:gen4']); + oldConnect.complete(); + await observed; + expect(rig.engine.only('connect').take(2), [ + 'connect:$kRemoteId:gen4', 'connect:$replacementId:-', + ]); + expect((await PairedDevice.load())?.remoteId, success ? replacementId : kRemoteId); + expect(timers.activePeriodic(kSuperviseEvery), hasLength(1)); + if (!success) { + // Recovery is supervised even if its first attempt cannot reach the old band. + timers.activePeriodic(kSuperviseEvery).single.fire(); + await rig.waitFor(() => rig.engine.only('connect').length >= 4); + } + })); + } + + // WHY: replacement suppresses the old disconnect callback, including its + // pedometer finalization. A 20-second tail is still recorded walking data. + // catches: resetting the pedometer at new-session start without banking it. + test('replacement banks the old band partial gait minute', + syncCase((rig, timers) async { + const startMs = 1786233600000; + for (var frame = 0; frame < 100; frame++) { + rig.app.debugFeedLiveAccel([ + for (var i = 0; i < 20; i++) + 1 + 0.45 * math.sin(2 * math.pi * 2 * (frame * 20 + i) / 100), + ], atMs: startMs + frame * 200, recTs: startMs ~/ 1000); + } + await rig.app.replaceWith(BluetoothDevice.fromId(replacementId)); + final rows = await (await LocalDb.instance).query('live_coverage'); + expect(rows, hasLength(1)); + expect(rows.single['start_ts'], startMs ~/ 1000); + // The last frame arrives at 19.8 s; coverage stores whole epoch seconds. + expect(rows.single['end_ts'], startMs ~/ 1000 + 19); + expect(rows.single['steps'], greaterThan(0)); + })); + + // WHY: a calibration read can resume after Delete everything has wiped. + // catches: checking reset only before the read, or outside the commit gate. + test('pre-reset live finalization cannot restore deleted step rows', + syncCase((rig, timers) async { + const startMs = 1786233600000; + for (var frame = 0; frame < 100; frame++) { + rig.app.debugFeedLiveAccel([ + for (var i = 0; i < 20; i++) + 1 + 0.45 * math.sin(2 * math.pi * 2 * (frame * 20 + i) / 100), + ], atMs: startMs + frame * 200, recTs: startMs ~/ 1000); + } + final db = await LocalDb.instance; + final entered = Completer(); + final release = Completer(); + rig.app.debugReadLiveStepCalibration = () async { + final rows = await LocalDb.liveStepCalibrationRuns(); + entered.complete(); + await release.future; + return rows; + }; + final finalizing = rig.app.debugFinalizeLivePedometer(); + await entered.future; + ResetGate.enter(); + await LocalDb.wipeAll(); + ResetGate.leave(); + release.complete(); + await finalizing; + expect(await db.query('live_coverage'), isEmpty); + // Positive control: a new session still banks real measured steps. + await LocalDb.addLiveCoverage(1786233600, 1786233660, 80, '2026-08-09'); + expect(await LocalDb.liveStepsForDay('2026-08-09'), 80); + })); + + // WHY: replacement suppresses disconnect callbacks, but traces are per band. + // catches: preserving primary samples at the identity reset boundary. + test('replacement clears only the old primary live HR trace', + syncCase((rig, timers) async { + rig.app.debugFeedEngineState('', DeviceState() + ..connection = 'connected' ..liveHr = 61 ..liveHrAt = 1786233600000); + rig.app.debugFeedEngineState('ring', DeviceState() + ..connection = 'connected' ..liveHr = 72 ..liveHrAt = 1786233600000); + expect(rig.app.liveHrTrace(''), [61]); + expect(rig.app.liveHrTrace('ring'), [72]); + await rig.app.replaceWith(BluetoothDevice.fromId(replacementId)); + expect(rig.app.liveHrTrace(''), isEmpty); + expect(rig.app.liveHrTrace('ring'), [72]); + rig.app.debugFeedEngineState('', rig.engine.state + ..liveHr = 84 ..liveHrAt = 1786233601000); + expect(rig.app.liveHrTrace(''), [84]); + })); + + // WHY: MG's saved ECG remains available while its radio is absent. + // catches: resetting pairedIsMaverick on a failed replacement rollback. + test('failed replacement restores the absent old MG identity', + syncCase((rig, timers) async { + rig.app.pairedIsMaverick = true; + rig.engine.connectScript.addAll([false, false]); + await expectLater(rig.app.replaceWith(BluetoothDevice.fromId(replacementId)), + throwsA(isA())); + expect(rig.app.paired?.remoteId, kRemoteId); + expect(rig.app.pairedIsMaverick, isTrue); + })); + + for (final success in [true, false]) { + // WHY: the epoch and confirmation describe a physical band's arm; + // a new band must receive its own write, and failure keeps the old arm. + // catches: clearing only RAM, or clearing before replacement commits. + test('replacement alarm ownership follows commit (success=$success)', + syncCase((rig, timers) async { + await rig.openAndSettle(); + final tomorrow = DateTime.now().add(const Duration(days: 1)); + await rig.app.setScheduleDay(weekday: tomorrow.weekday, + hour: 7, minute: 0, enabled: true); + final epoch = rig.app.alarmEpoch; + expect(epoch, isNotNull); + expect(rig.app.alarmConfirmed, isTrue); + final schedule = await LocalDb.alarmScheduleRows(); + rig.engine.events.clear(); + rig.engine.connectScript.addAll(success ? [true] : [false, false]); + if (success) { + await rig.app.replaceWith(BluetoothDevice.fromId(replacementId)); + expect(rig.engine.count('armAlarm'), greaterThanOrEqualTo(1), + reason: 'the same next occurrence must be written to the new band'); + } else { + await expectLater(rig.app.replaceWith(BluetoothDevice.fromId(replacementId)), + throwsA(isA())); + expect(rig.app.alarmEpoch, epoch); + expect(rig.app.alarmConfirmed, isTrue); + expect((await SharedPreferences.getInstance()).getInt('alarm_epoch'), epoch); + } + expect(await LocalDb.alarmScheduleRows(), schedule); + })); + } + + test('held bootstrap keeps old identity; READY switches without losing history', + syncCase((rig, timers) async { + // WHY: discovering a replacement must not forget a working band. + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + final before = await _history(); + final old = await PairedDevice.load(); + final gate = rig.holdConnect(); + final replacing = rig.app.replaceWith(BluetoothDevice.fromId(replacementId)); + await rig.waitFor(() => rig.engine.count('connect') == 1); + rig.engine.state.serial = 'NEW12345'; + rig.engine.state.generation = 'gen5'; + rig.app.debugFeedEngineState('', rig.engine.state); + expect((await PairedDevice.load())?.remoteId, old!.remoteId); + expect((await PairedDevice.load())?.serial, old.serial); + expect(rig.app.isPaired, isTrue); + gate.complete(); + await replacing; + expect((await PairedDevice.load())?.remoteId, replacementId); + expect((await PairedDevice.load())?.serial, 'NEW12345'); + expect((await PairedDevice.load())?.generation, 'gen5'); + expect(await _history(), before); + })); + + for (final error in [false, StateError('bootstrap refused')]) { + test('failed replacement ($error) preserves the old pairing', + syncCase((rig, timers) async { + // WHY: a failed new band must leave a saved old band to reconnect. + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + final before = await _history(); + rig.engine.connectScript.add(error); + await expectLater(rig.app.replaceWith(BluetoothDevice.fromId(replacementId)), + throwsA(isA())); + expect((await PairedDevice.load())?.remoteId, kRemoteId); + expect(rig.app.paired?.remoteId, kRemoteId); + expect(rig.app.isPaired, isTrue); + expect(rig.app.busy, isFalse); + expect(rig.engine.events.where((e) => e.startsWith('connect:')), [ + 'connect:$replacementId:-', 'connect:$kRemoteId:gen4', + ]); + expect(await _history(), before); + })); + } + + test('a replacement that fails keeps the old band\'s saved name', + syncCase((rig, timers) async { + // WHY: the name is what the old band is shown under until a + // replacement commits; wiping it first left the band nameless. + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + await Prefs.ensureLoaded(); + Prefs.setString('band.strap_name', 'Old Strap'); + rig.engine.connectScript.add(StateError('bootstrap refused')); + await expectLater(rig.app.replaceWith(BluetoothDevice.fromId(replacementId)), + throwsA(isA())); + expect(Prefs.getString('band.strap_name', ''), 'Old Strap'); + })); + + test('a committed replacement clears the old band\'s saved name', + syncCase((rig, timers) async { + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + await Prefs.ensureLoaded(); + Prefs.setString('band.strap_name', 'Old Strap'); + await rig.app.replaceWith(BluetoothDevice.fromId(replacementId)); + expect((await PairedDevice.load())?.remoteId, replacementId); + expect(Prefs.getString('band.strap_name', ''), ''); + })); + + test('replacing a band with itself says so and changes nothing', + syncCase((rig, timers) async { + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + await Prefs.ensureLoaded(); + Prefs.setString('band.strap_name', 'Old Strap'); + await expectLater(rig.app.replaceWith(BluetoothDevice.fromId(kRemoteId)), + throwsA(isA())); + expect((await PairedDevice.load())?.remoteId, kRemoteId); + expect(Prefs.getString('band.strap_name', ''), 'Old Strap'); + expect(rig.engine.events.where((e) => e.startsWith('connect:')), isEmpty); + })); + + test('failed replacement save rolls back to the old pairing', + syncCase((rig, timers) async { + // WHY: READY alone cannot publish a primary the database did not save. + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + final before = await _history(); + final store = SharedPreferencesStorePlatform.instance; + SharedPreferencesStorePlatform.instance = FailingPreferencesStore( + store, key: 'paired_serial', throws: true); + addTearDown(() => SharedPreferencesStorePlatform.instance = store); + await expectLater(rig.app.replaceWith(BluetoothDevice.fromId(replacementId)), + throwsStateError); + expect((await PairedDevice.load())?.remoteId, kRemoteId); + expect(rig.app.paired?.remoteId, kRemoteId); + expect(await _history(), before); + })); + + test('forget wins while replacement waits', + syncCase((rig, timers) async { + // WHY: cancelling the old pairing must not resurrect it on rollback. + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + final gate = rig.holdConnect(); + final replacing = rig.app.replaceWith(BluetoothDevice.fromId(replacementId)); + final failed = expectLater(replacing, throwsA(isA())); + await rig.waitFor(() => rig.engine.count('connect') == 1); + await rig.app.unpair(); + gate.complete(); + await failed; + expect(await PairedDevice.load(), isNull); + expect(rig.app.isPaired, isFalse); + })); + test('a cancelled system picker keeps the old pairing and requests addAnother', + syncCase((rig, timers) async { + // WHY: an already-provisioned old band must not silently win the picker. + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + const channel = MethodChannel('openstrap/accessory_setup'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + Object? argument; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'showPicker') { + argument = call.arguments; + throw PlatformException(code: 'cancelled'); + } + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + await expectLater(rig.app.replaceViaAccessorySetup(), throwsA(isA())); + expect(argument, isTrue); + expect((await PairedDevice.load())?.remoteId, kRemoteId); + expect(rig.app.isPaired, isTrue); + })); + + test('forget during the replacement save does not recreate a pairing', + syncCase((rig, timers) async { + // WHY: unpair must win both before READY and while persistence awaits. + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + final store = SharedPreferencesStorePlatform.instance; + final held = _HeldPreferences(store); + SharedPreferencesStorePlatform.instance = held; + addTearDown(() => SharedPreferencesStorePlatform.instance = store); + final replacing = rig.app.replaceWith(BluetoothDevice.fromId(replacementId)); + final failed = expectLater(replacing, throwsA(isA())); + await held.entered.future; + final forgetting = rig.app.unpair(); + held.release.complete(); + await forgetting; + await failed; + expect(await PairedDevice.load(), isNull); + expect(rig.app.isPaired, isFalse); + })); + + + }); +} + +// Hold the actual preference write, keeping PairedDevice's real transaction. +class _HeldPreferences extends SharedPreferencesStorePlatform { + _HeldPreferences(this.delegate); + final SharedPreferencesStorePlatform delegate; + final entered = Completer(); + final release = Completer(); + bool held = false; + Future write(String key, Future Function() action) async { + if (!held && key == 'flutter.paired_serial') { + held = true; + entered.complete(); + await release.future; + } + return action(); + } + @override + Future> getAll() => delegate.getAll(); + @override + Future clear() => delegate.clear(); + @override + Future remove(String key) => write(key, () => delegate.remove(key)); + @override + Future setValue(String type, String key, Object value) => + write(key, () => delegate.setValue(type, key, value)); } diff --git a/test/app_state_sync_commit_test.dart b/test/app_state_sync_commit_test.dart index c6bff1221..9dc1cdfa9 100644 --- a/test/app_state_sync_commit_test.dart +++ b/test/app_state_sync_commit_test.dart @@ -11,6 +11,7 @@ import 'dart:async'; // ignore: depend_on_referenced_packages import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' show BluetoothDevice; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/data/models.dart'; import 'package:openstrap_edge/state/app_state.dart'; @@ -25,6 +26,9 @@ import 'support/app_state_sync_harness.dart'; const _db = 'app_state_sync_commit.db'; base class _AdapterOn extends FlutterBluePlusPlatform { + final connections = StreamController.broadcast(); + @override + Stream get onConnectionStateChanged => connections.stream; @override Future getAdapterState( BmBluetoothAdapterStateRequest request, @@ -141,6 +145,24 @@ void main() { }); }); + // WHY: candidate history belongs to the band only after replacement commits. + // catches: ACKing a candidate batch into the old primary store. + test('replacement refuses candidate commits and leaves the cursor unchanged', () async { + final app = await _realApp(); + addTearDown(app.dispose); + await PairedDevice.save(kRemoteId, kSerial, generation: 'gen4'); + app.paired = await PairedDevice.load(); + final before = await (await LocalDb.instance).query('decoded_onehz'); + final replacement = app.replaceWith(BluetoothDevice.fromId('candidate')); + final failed = expectLater(replacement, throwsA(isA())); + await expectLater(app.engine.onCommitBatch!( + [_raw(1750000100)], [null], '0011223344556677'), throwsStateError); + await failed; + expect(await (await LocalDb.instance).query('decoded_onehz'), before); + expect(await LocalDb.getCursorInt('rec_ts_hw'), isNull); + expect((await PairedDevice.load())?.remoteId, kRemoteId); + }); + group('the stored-data callback', () { test('lights syncingNow at once, arms one quiet timer that notifies when ' 'the window closes, and refreshes the data edge from the cursor', diff --git a/test/app_state_sync_reconnect_test.dart b/test/app_state_sync_reconnect_test.dart index fab4ec944..79abdf989 100644 --- a/test/app_state_sync_reconnect_test.dart +++ b/test/app_state_sync_reconnect_test.dart @@ -366,10 +366,9 @@ void main() { expect(BandOwnership.foregroundIntent, isFalse); })); - test('a connect that was in flight when endSession retired the loop and ' - 'then answers true still runs the post-connect setup and a drain (the ' - 'generation is only checked at the loop head and in the finally), but ' - 'arms no backfill timer and leaves no intent or lease behind', + // WHY: a retired loop no longer owns the link. A late successful connect + // must not configure or drain whichever band is now being paired. + test('a retired connect that answers true skips post-connect work', syncCase((rig, timers) async { await _open(rig); final gate = rig.holdConnect(); @@ -379,16 +378,16 @@ void main() { rig.engine.events.clear(); gate.complete(); await rig.waitFor(() => _logged(rig, 'was superseded')); - await rig.waitFor(() => rig.engine.count('runSync') == 1); await rig.quiesce(); - expect(rig.engine.count('getBattery'), 1); - expect(rig.engine.count('reconcile'), 1); + expect(rig.engine.count('runSync'), 0); + expect(rig.engine.count('getBattery'), 0); + expect(rig.engine.count('reconcile'), 0); expect(rig.engine.count('requestHistorySync'), 0); expect(timers.activePeriodic(kBackfillEvery), isEmpty); expect(BandOwnership.foregroundIntent, isFalse); expect(BandOwnership.owner, isNull); expect(rig.engine.count('disconnect'), 0, - reason: 'nothing drops the stray link the retired connect won'); + reason: 'the retiring owner handles teardown; the old loop cannot touch a successor'); })); test('endSession then openSession while the old attempt is parked: the ' diff --git a/test/band_identity_test.dart b/test/band_identity_test.dart index 53ea64c85..4cc332f1e 100644 --- a/test/band_identity_test.dart +++ b/test/band_identity_test.dart @@ -8,6 +8,7 @@ import 'package:openstrap_edge/ble/band_identity.dart'; import 'package:openstrap_edge/ble/hrs_link.dart'; import 'package:openstrap_edge/ui2/pairing/pair_outcome_text.dart'; import 'package:openstrap_edge/sync/paired_device.dart' show kBandSourceTier; +import 'package:openstrap_edge/ui2/pairing/add_device_flow.dart' show addDeviceScanEntries; import 'package:openstrap_protocol/openstrap_protocol.dart'; BandEntry? _id(List svc, [String? name]) => @@ -135,6 +136,30 @@ void main() { rssi: -55, ); + test('the Add a device scan covers the framed WHOOP entries: a WHOOP-only advertisement is a candidate', () { + // WHY: a WHOOP that advertises only its own band service matched no + // sensor entry and was dropped, so the band could never be discovered + // from "Add a device". + final entries = addDeviceScanEntries(); + expect(entries.map((e) => e.id), containsAll(['gen4', 'gen5', 'ble_hrs', 'oura'])); + expect(entries.map((e) => e.id).toSet().length, kBandRegistry.length); + // (A by-name entry makes the OS filter empty: every advertisement is heard.) + final filter = HrsLink.scanServiceFilter(entries); + expect(filter.isEmpty || filter.contains(Guid(kWhoopGen5.service)), isTrue); + final c = HrsLink.candidateFromAd( + entries: entries, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:21'), + serviceUuids: [Guid(kWhoopGen5.service)], + advName: 'WHOOP 5A1B', + platformName: '', + rssi: -50, + ); + expect(c, isNotNull); + expect(c!.entryId, 'gen5'); + // Control: the same advertisement against the sensor list alone is dropped. + expect(cand([kWhoopGen5.service], 'WHOOP 5A1B'), isNull); + }); + test('a WHOOP advertising 0x180D is a ble_hrs hit flagged alsoBand=gen4', () { // WHY: the picker needs both facts: it matched the HR scan AND is a band. final c = cand([kHeartRateServiceUuid, kWhoopGen4.service], 'WHOOP 4C01'); diff --git a/test/device_sources_test.dart b/test/device_sources_test.dart index bf43a2aa4..980184bd2 100644 --- a/test/device_sources_test.dart +++ b/test/device_sources_test.dart @@ -79,7 +79,7 @@ void main() { home: MyDevicesView(sources: const [], onAddSensor: () {}), )); await t.pumpAndSettle(); - expect(find.text('Add a sensor'), findsOneWidget); + expect(find.text('Add a device'), findsOneWidget); const t1 = SourceTier.beatToBeat; final rung = find.text('Tier ${t1.rank} · ${t1.label}'); await t.scrollUntilVisible(rung, 300, scrollable: find.byType(Scrollable).first); @@ -95,7 +95,7 @@ void main() { )); await t.pumpAndSettle(); // The card that offers to pair a BAND is still there, because one is not. - expect(find.text('Pair a band'), findsOneWidget); + expect(find.byKey(const ValueKey('pair-band-card')), findsOneWidget); expect(find.text('Polar H10'), findsOneWidget); }); diff --git a/test/guards/pairing_reset_steps_guard_test.dart b/test/guards/pairing_reset_steps_guard_test.dart index 7dc5f97d6..a41242258 100644 --- a/test/guards/pairing_reset_steps_guard_test.dart +++ b/test/guards/pairing_reset_steps_guard_test.dart @@ -65,6 +65,12 @@ void main() { } }); + test('Hindi physical strain is distinct from stress', () { + // WHY: the comparison table must not label physical strain as stress. + final hi = _arb(File('lib/l10n/app_hi.arb')); + expect(hi['capLabelStrain'], 'शारीरिक परिश्रम'); + }); + test('every registry entry declares reset steps', () { // WHY: an undeclared device would show a user stuck at pairing nothing. final missing = [ @@ -110,7 +116,7 @@ void main() { group('unverified', () { test('an unverified entry carries an officialUrl on its pinned host', () { // WHY: unsourced steps point at the vendor's own help page, never a - // search engine or a look-alike host. DT8: zero unverified entries is + // search engine or a look-alike host. zero unverified entries is // allowed (the goal), so the registry loop may be empty; the fixture // controls below prove the check can bite. for (final e in _of(ResetKind.unverified)) { @@ -119,7 +125,7 @@ void main() { }); test('control: the unverified check passes a good fixture and fails bad ones', () { - // WHY (DT8): a fixture, not a real registry entry, is the positive control. + // WHY: a fixture, not a real registry entry, is the positive control. const good = ResetSteps.unverified(officialUrl: 'https://support.whoop.com/x'); const search = ResetSteps.unverified( officialUrl: 'https://www.google.com/search?q=whoop'); @@ -170,7 +176,7 @@ void main() { final actual = {for (final e in _of(ResetKind.noOfficialSite)) e.id}; expect(expected, {'thermometer'}, reason: 'only the thermometer is a registry entry on this branch'); - // DT11: the generic HR profile has no maker either (no research row). + // the generic HR profile has no maker either (no research row). expect(actual, {...expected, 'ble_hrs'}); expect(kBleHrs.resetSteps?.reason, 'Generic Bluetooth heart-rate profile; maker unknown'); }); diff --git a/test/no_gen4_default_test.dart b/test/no_gen4_default_test.dart index d73415a5c..6e66f35f2 100644 --- a/test/no_gen4_default_test.dart +++ b/test/no_gen4_default_test.dart @@ -18,6 +18,8 @@ const _bandProfileGen4Defaults = 6; final _pureComment = RegExp(r'^\s*(///|//|\*|/\*)'); +RegExp _assignmentOf(String symbol) => RegExp('(?])=\\s*${RegExp.escape(symbol)}\\b'); + int _countMatches(RegExp pattern) { var total = 0; for (final f in Directory('lib') @@ -33,10 +35,24 @@ int _countMatches(RegExp pattern) { } void main() { + // WHY: a model comparison added by the pairing screen is not a fallback. + // catches: counting the second '=' in == or the '=' in !=/<=/>=. + test('the default counter distinguishes assignment from comparisons', () { + for (final symbol in ['kWhoopGen4', 'BandProfile.gen4']) { + final assignment = _assignmentOf(symbol); + expect(assignment.hasMatch('BandEntry entry = $symbol;'), isTrue); + for (final operator in ['==', '!=', '<=', '>=']) { + expect(assignment.hasMatch('entry $operator $symbol.id'), isFalse, + reason: '$operator is a comparison, not an assignment'); + } + } + }); + + test('kWhoopGen4 default-fallback count only goes down', () { // `?? kWhoopGen4` sites plus the one field default (`= kWhoopGen4`). final count = _countMatches(RegExp(r'\?\?\s*kWhoopGen4')) + - _countMatches(RegExp(r'=\s*kWhoopGen4\b')); + _countMatches(_assignmentOf('kWhoopGen4')); expect(count, _kWhoopGen4Defaults, reason: 'kWhoopGen4-default count drifted from the tracked value. ' 'If a commit just retired a site, lower _kWhoopGen4Defaults in ' @@ -47,7 +63,7 @@ void main() { // `?? BandProfile.gen4` sites plus the one field/parameter default // (`= BandProfile.gen4`). final count = _countMatches(RegExp(r'\?\?\s*BandProfile\.gen4')) + - _countMatches(RegExp(r'=\s*BandProfile\.gen4\b')); + _countMatches(_assignmentOf('BandProfile.gen4')); expect(count, _bandProfileGen4Defaults, reason: 'BandProfile.gen4-default count drifted from the tracked ' 'value. If a commit just retired a site, lower ' diff --git a/test/pairing_stack_review_test.dart b/test/pairing_stack_review_test.dart new file mode 100644 index 000000000..c71ab4e1f --- /dev/null +++ b/test/pairing_stack_review_test.dart @@ -0,0 +1,368 @@ +// Behavioral regressions for the pairing stack review. No real Bluetooth. +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' show BluetoothDevice; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/hrs_link.dart'; +import 'package:openstrap_edge/ble/band_identity.dart' show PairOutcome; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/sync/paired_device.dart'; +import 'package:openstrap_edge/ui2/onboarding/pairing.dart'; +import 'package:openstrap_edge/ui2/pairing/add_device_flow.dart'; +import 'package:openstrap_edge/ui2/pairing/device_picker.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart'; +import 'package:openstrap_edge/ui2/profile/pair_sensor.dart'; + +import 'support/app_state_derive_harness.dart' + show deriveDbSetUp, deriveDbTearDown; +import 'support/pair_ui_support.dart'; + +class _App extends AppState { + _App() : super.forTesting() { + paired = PairedDevice('old', 'old'); + } + Completer? supported; + Completer? scanned; + String? scannedGeneration; + String? scannedRemoteId; + var replacements = 0; + var pickers = 0; + @override + Future accessorySetupSupported() async => + supported == null ? false : await supported!.future; + @override + Future scanForBand({ + String? remoteId, + String? generation, + }) async { + scannedGeneration = generation; + scannedRemoteId = remoteId; + return scanned == null ? null : await scanned!.future; + } + + @override + Future replaceWith(BluetoothDevice device) async => replacements++; + @override + Future replaceViaAccessorySetup() async => pickers++; + @override + Future refreshSensors() async {} +} + +Future _screen(WidgetTester t, _App app, Widget screen) => pumpPair( + t, + screen, + wrap: (child) => + ChangeNotifierProvider.value(value: app, child: child), + size: const Size(412, 2400), +); + +Future _ioUntil(WidgetTester t, bool Function() ready) async { + for (var i = 0; i < 100 && !ready(); i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await t.pump(); + } + expect(ready(), isTrue, reason: 'screen did not finish its database read'); +} + +void main() { + setUp(() async { + await deriveDbSetUp('pairing_stack_review.db'); + await LocalDb.instance; + }); + tearDown(() => deriveDbTearDown('pairing_stack_review.db')); + + for (final found in [false, true]) { + // WHY: the default picker deliberately skips automatic scanning, and must + // offer an explicit scan both before and after results arrive. + // catches: exposing onScan only inside the held-back warning card. + testWidgets('scan remains reachable (has results=$found)', (t) async { + final query = TextEditingController(); + addTearDown(query.dispose); + var scans = 0; + await pumpPair( + t, + DevicePickerView( + query: query, + title: 'Devices', + subtitle: 'Nearby', + found: found + ? [ + ( + device: BluetoothDevice.fromId('ring'), + label: 'Ring', + rssi: -50, + entryId: 'colmi', + alsoBand: null, + ), + ] + : [], + onScan: () => scans++, + ), + size: const Size(412, 2400), + ); + // Exercise the rendered scan control, not just the stored callback. + expect(find.text('Search for devices'), findsOneWidget); + await t.tap(find.text('Search for devices')); + await t.pump(); + expect(scans, 1); + }); + } + + // WHY: a hidden Rings selection must not hide an ungrouped scale. + // catches: applying a selected filter when only one pill group remains. + testWidgets('hidden filter pills leave rings and ungrouped devices visible', ( + t, + ) async { + final query = TextEditingController(); + final selection = ValueNotifier(PairFilter.rings); + addTearDown(query.dispose); + addTearDown(selection.dispose); + await pumpPair( + t, + DevicePickerView( + query: query, + title: 'Devices', + subtitle: 'Nearby', + kindSelection: selection, + categories: [ + (entry: kColmi, blurb: 'Ring', icon: LucideIcons.circleDot), + ( + entry: kThermometer, + blurb: 'Temperature', + icon: LucideIcons.thermometer, + ), + ], + ), + size: const Size(412, 2400), + ); + expect(find.text('Colmi ring'), findsOneWidget); + expect(find.text('Bluetooth thermometer'), findsOneWidget); + }); + + // WHY: first-run callbacks may already be queued before navigation covers + // the picker. Returning from the route must allow the next deliberate tap. + testWidgets('first-run taps stay locked until navigation returns', (t) async { + final app = _App(); + addTearDown(app.dispose); + await _screen(t, app, const DevicePickerScreen()); + final dynamic host = t.state(find.byType(DevicePickerScreen)); + host.pickBand(kWhoopGen4); + host.pickBand(kWhoopGen4); + host.strapOnlyConfirmed(); + await t.pumpAndSettle(); + expect(find.byType(PairingScreen, skipOffstage: false), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + Navigator.of(t.element(find.byType(PairingScreen))).pop(); + await t.pumpAndSettle(); + host.pickBand(kWhoopGen4); + await t.pumpAndSettle(); + expect( + find.byType(PairingScreen), + findsOneWidget, + reason: 'next deliberate tap is accepted', + ); + }); + + // WHY: the host callbacks used by first-run must obey the same lock as + // category and nearby taps, without losing the selected WHOOP model. + // catches: pickBand forwarding straight to _openEntry without the guard. + testWidgets('first-run host callbacks share the navigation lock', (t) async { + final app = _App(); + addTearDown(app.dispose); + await _screen(t, app, const DevicePickerScreen()); + final dynamic host = t.state(find.byType(DevicePickerScreen)); + host.pickBand(kWhoopGen5); + host.pickBand(kWhoopGen4); + host.strapOnlyConfirmed(); + await t.pumpAndSettle(); + expect(find.byType(PairingScreen, skipOffstage: false), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + final pairing = t.widget(find.byType(PairingScreen)); + expect(pairing.entry, kWhoopGen5); + await t.tap(find.text('Find my band')); + await t.pumpAndSettle(); + expect(app.scannedGeneration, 'gen5'); + }); + + // WHY: a name-only candidate establishes the remote id, but not a model. + // The representative entry supplies safe help without filtering out gen5. + testWidgets('name-only WHOOP handoff keeps help and the exact candidate', ( + t, + ) async { + final app = _App(); + addTearDown(app.dispose); + await _screen(t, app, const DevicePickerScreen()); + final io = t.state(find.byType(DevicePickerScreen)) as AddDeviceIo; + final opened = io.openBandFlow( + kWhoopGen4, + remoteId: 'picked', + modelKnown: false, + ); + await t.pumpAndSettle(); + expect( + t.widget(find.byType(PairingScreen)).entry, + kWhoopGen4, + ); + await t.tap(find.text('Find my band')); + await t.pumpAndSettle(); + expect(app.scannedGeneration, isNull); + expect(app.scannedRemoteId, 'picked'); + Navigator.of(t.element(find.byType(PairingScreen))).pop(); + await t.pumpAndSettle(); + await opened; + }); + + for (final waitingOnSupport in [false, true]) { + // WHY: leaving while the scan/support probe waits cancels the intent to + // replace. Checking mounted after replacement is already too late. + // catches: starting replaceWith/replaceViaAccessorySetup after disposal. + testWidgets( + 'closing pairing cancels pending work (support=$waitingOnSupport)', + (t) async { + final app = _App(); + addTearDown(app.dispose); + app.supported = Completer(); + app.scanned = Completer(); + await _screen(t, app, const PairingScreen(replacing: true)); + await t.tap(find.text('Find my band')); + await t.pump(); + if (!waitingOnSupport) { + app.supported!.complete(false); + await t.pump(); + } + await t.pumpWidget(const MaterialApp(home: SizedBox())); + if (waitingOnSupport) { + app.supported!.complete(true); + } else { + app.scanned!.complete(BluetoothDevice.fromId('new')); + } + await t.pumpAndSettle(); + expect(app.replacements, 0); + expect(app.pickers, 0); + expect(t.takeException(), isNull); + }, + ); + } + + // WHY: a category tap owns the radio even while the earlier iOS probe waits. + // catches: _start checking the navigation lock only before its await. + testWidgets('pending scan probe does not start a scan behind pairing', (t) async { + final app = _App(); + addTearDown(app.dispose); + final probe = Completer(); + await _screen(t, app, DevicePickerScreen(includeBand: false, debugScanHeldBackReason: () => probe.future)); + final view = t.widget(find.byType(DevicePickerView)); + view.onScan!(); + view.onOpenEntry!(kWhoopGen4); + await t.pumpAndSettle(); + probe.complete(null); + await t.pumpAndSettle(); + final parent = t.widget(find.byType(DevicePickerView, skipOffstage: false)); + expect(parent.scanning, isFalse); + expect(parent.problem, isNull, reason: 'no late parent scan may touch Bluetooth'); + // The navigation lock still belongs to the child route. + expect(parent.locked, isTrue); + Navigator.of(t.element(find.byType(PairingScreen))).pop(); + await t.pumpAndSettle(); + expect(t.widget(find.byType(DevicePickerView)).locked, isFalse); + }); + + // WHY: a representative gen4 entry is not evidence of the band's model. + // catches: forwarding widget.entry to failure help despite modelKnown=false. + testWidgets('unknown WHOOP failure keeps recovery guidance model-neutral', (t) async { + final app = _App(); + addTearDown(app.dispose); + await _screen(t, app, const PairingScreen(entry: kWhoopGen4, modelKnown: false)); + await t.tap(find.text('Find my band')); + await t.pumpAndSettle(); + expect(t.widget(find.byType(PairingView)).phase, PairPhase.notFound); + expect(t.widget(find.byType(PairingView)).resetEntry, isNull); + expect(find.text('WHOOP 4'), findsNothing); + }); + + // WHY: an old primary still existing does not prove a replacement succeeded. + // catches: using AppState.isPaired instead of comparing the selected identity. + for (final systemPickerSaved in [false, true]) { + testWidgets( + 'band flow reports actual replacement (system picker=$systemPickerSaved)', + (t) async { + final app = _App(); + addTearDown(app.dispose); + final candidate = ( + device: BluetoothDevice.fromId('new'), + label: 'Candidate', + rssi: -50, + entryId: 'ble_hrs', + alsoBand: null, + ); + final entered = Completer(); + await _screen( + t, + app, + PairSensorScreen( + entry: kBleHrs, + debugFound: [candidate], + debugPair: (_) async { + entered.complete(); + return PairOutcome.band(kWhoopGen5); + }, + ), + ); + await t.runAsync( + () async => Future.delayed(const Duration(milliseconds: 50)), + ); + await t.pumpAndSettle(); + var done = false; + final pick = + t.widget(find.byType(PairSensorView)).onPick! + as Future Function(BandCandidate); + final pairing = pick(candidate).whenComplete(() => done = true); + await _ioUntil(t, () => entered.isCompleted); + await t.pumpAndSettle(); + await t.tap(find.textContaining('Pair this band, then disconnect')); + await t.pumpAndSettle(); + await t.tap(find.byKey(const ValueKey('replace-confirm-yes'))); + await t.pumpAndSettle(); + expect(find.byType(PairingScreen), findsOneWidget); + if (systemPickerSaved) app.paired = PairedDevice('system-picked', 'new'); + Navigator.of(t.element(find.byType(PairingScreen))).pop(); + await t.pumpAndSettle(); + await t.runAsync( + () async => Future.delayed(const Duration(milliseconds: 50)), + ); + await t.pumpAndSettle(); + await _ioUntil(t, () => done); + await pairing; + await t.pumpAndSettle(); + expect(app.paired?.remoteId, systemPickerSaved ? 'system-picked' : 'old'); + expect(find.textContaining('Nothing was saved'), systemPickerSaved ? findsNothing : findsOneWidget); + if (!systemPickerSaved) expect(find.text('Candidate'), findsOneWidget); + }, + ); + + } + + // WHY: AddDeviceFlow can finish stopping a scan after its owner is disposed. + // catches: looking up either navigator on a disposed PairSensorScreen. + testWidgets('disposed sensor screen ignores band handoff', (t) async { + final app = _App(); + addTearDown(app.dispose); + await _screen(t, app, PairSensorScreen(entry: kBleHrs)); + final io = t.state(find.byType(PairSensorScreen)) as AddDeviceIo; + await t.runAsync( + () async => Future.delayed(const Duration(milliseconds: 50)), + ); + await t.pumpWidget(const MaterialApp(home: SizedBox())); + for (final band in [kWhoopGen5, kOura]) { + await io.openBandFlow(band, remoteId: 'new'); + expect(t.takeException(), isNull); + } + }); +} diff --git a/test/reset_quiesces_ingest_test.dart b/test/reset_quiesces_ingest_test.dart index af959a5fd..bc4c61e63 100644 --- a/test/reset_quiesces_ingest_test.dart +++ b/test/reset_quiesces_ingest_test.dart @@ -34,10 +34,13 @@ void main() { final src = File('lib/state/app_state.dart').readAsStringSync(); final bg = File('lib/sync/background_sync.dart').readAsStringSync(); - test('both ingest callbacks refuse while a reset is in flight', () { + // WHY: adding replacement isolation must retain the reset gate as well. + // Both flags are required on every non-ACK ingest path below. The actual + // ACK callback's replacement refusal is exercised by app_state_sync_commit_test. + test('both ingest callbacks refuse during reset or band replacement', () { // The singles path. final onRecord = RegExp( - r'Future _onRecord\([^)]*\) async \{\s*\n\s*if \(_resetting\) return;', + r'Future _onRecord\([^)]*\) async \{\s*\n\s*if \(_resetting \|\| _replacingBand\) return;', ); expect(onRecord.hasMatch(src), isTrue, reason: '_onRecord must refuse before it writes'); @@ -45,7 +48,7 @@ void main() { // The drain path, which is wired straight to LocalDb and so bypasses every // check AppState makes unless the closure carries one itself. final batch = RegExp( - r'onRecordsBatch: \([^)]*\) async \{\s*\n\s*if \(_resetting\) return;', + r'onRecordsBatch: \([^)]*\) async \{\s*\n\s*if \(_resetting \|\| _replacingBand\) return;', ); expect(batch.hasMatch(src), isTrue, reason: 'onRecordsBatch must refuse before it writes'); @@ -58,7 +61,7 @@ void main() { // event notification landing after ResetGate.enter() but before the // band disconnects would resurrect deleted data. final onLiveEvent = RegExp( - r'void _onLiveEvent\([^)]*\) \{\s*\n\s*if \(_resetting\) return;', + r'void _onLiveEvent\([^)]*\) \{\s*\n\s*if \(_resetting \|\| _replacingBand\) return;', ); expect(onLiveEvent.hasMatch(src), isTrue, reason: '_onLiveEvent must refuse before it writes'); @@ -71,7 +74,7 @@ void main() { // notification landing after ResetGate.enter() but before the band // disconnects would resurrect a deleted device/band_battery row. final onEngineState = RegExp( - r'void _onEngineState\([^)]*\) \{\s*\n\s*if \(_resetting\) return;', + r'void _onEngineState\([^)]*\) \{\s*\n\s*if \(_resetting \|\| _replacingBand\) return;', ); expect(onEngineState.hasMatch(src), isTrue, reason: '_onEngineState must refuse before it writes'); diff --git a/test/russian_review_findings_test.dart b/test/russian_review_findings_test.dart index 717c56566..1d261d120 100644 --- a/test/russian_review_findings_test.dart +++ b/test/russian_review_findings_test.dart @@ -327,11 +327,7 @@ void main() { ); testWidgets('category search accepts original and Russian blurb', (t) async { - // The firmware warning banner sits above the list; a tall surface keeps - // the filtered row built instead of lazily off-screen. - await t.binding.setSurfaceSize(const Size(800, 3000)); - addTearDown(() => t.binding.setSurfaceSize(null)); - await t.pumpWidget(page(const DevicePickerScreen())); + await t.pumpWidget(page(const DevicePickerScreen(includeBand: false))); await t.pumpAndSettle(); final entry = kBleHrs; for (final query in [ diff --git a/test/slot_comparison_test.dart b/test/slot_comparison_test.dart new file mode 100644 index 000000000..a4018aa1d --- /dev/null +++ b/test/slot_comparison_test.dart @@ -0,0 +1,324 @@ +// Slot comparison: what a device gives "Every day" (the main band, or the +// active wearable) and "Workouts only" (a workout-armed sensor). Capabilities +// come from the compute layer's eligibility rules: the metric x device +// columns (canonical.dart columnFor/capabilities), the native WHOOP pipeline, +// and the workout-armed sensor set. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/slot_comparison.dart'; +import 'package:openstrap_edge/compute/inputs/canonical.dart' + show MetricClass, capabilities, columnFor, kWorkoutArmedSensors; + +typedef C = DeviceCapability; +typedef K = CapabilityKind; + +// The table's rows, in the order the sheets draw them (owner-approved r4 +// mockup): the whole-day rows, then the workout rows. Never blood oxygen. +const _dayRows = [ + C.sleep, + C.recovery, + C.strain, + C.heartRate, + C.hrv, + C.respiration, + C.skinTemperature, + C.steps, +]; +const _workoutRows = [C.workoutHeartRate, C.workoutBeatToBeat]; + +Set _keys(SlotSide s) => s.sends.keys.toSet(); + +List _columnEntries() => + [for (final e in kBandRegistry) if (columnFor(e.id) != null) e]; + +void main() { + test('the table rows are the eight whole-day rows, then the two workout rows', () { + // WHY: every sheet and the device card draw the same rows in the same + // order; a drifting list changes every table at once. + expect(kDayCapabilities, _dayRows); + expect(kSessionCapabilities.toList(), _workoutRows); + expect(kTableRows, [..._dayRows, ..._workoutRows]); + expect(kTableRows, isNot(contains(C.bloodOxygen))); + }); + + group('WHOOP', () { + for (final e in [kWhoopGen4, kWhoopGen5]) { + test('${e.id}: every day, as the main band, sends every row', () { + // WHY: the native pipeline (onehz_pipeline.dart) computes sleep, + // recovery, strain, resting HR, HRV, respiration and skin temperature, + // and the band records workouts. + final cmp = slotComparisonFor(e); + expect(cmp.slot, DeviceSlot.everyDay); + final d = cmp.everyDay.sends; + expect(d.keys.toSet(), {..._dayRows, ..._workoutRows}); + for (final c in [C.sleep, C.recovery, C.strain, C.heartRate, C.hrv, + C.respiration, C.workoutHeartRate, C.workoutBeatToBeat]) { + expect(d[c]?.kind, K.native, reason: '${e.id} $c'); + } + // a nightly mean, >= 3 prior nights, baseline spread >= 1. + expect(d[C.skinTemperature]?.kind, K.conditional); + expect(d[C.skinTemperature]?.reasonKey, kReasonSkinTempNights); + }); + + test('${e.id}: workouts only, read as a heart-rate sensor', () { + // WHY: a WHOOP kept for workouts is paired under ble_hrs, so it sends + // heart rate, and beat timing only if the band sends RR intervals. + final w = slotComparisonFor(e).workoutsOnly.sends; + expect(w.keys.toSet(), _workoutRows.toSet()); + expect(w[C.workoutHeartRate]?.kind, K.native); + expect(w[C.workoutBeatToBeat]?.kind, K.conditional); + expect(w[C.workoutBeatToBeat]?.reasonKey, kReasonUnconfirmed); + }); + } + + test('steps: WHOOP 4 only during live sessions, WHOOP 5 from its own counter', () { + // WHY: gen4 has no on-chip counter (derivation_engine.dart + // _stepCounterModulus), so its steps come only from the strap's 100 Hz + // pedometer over a live link (live_step_runs.dart). gen5's cumulative + // counter rides every synced row (decoded_onehz.step_count). + final g4 = slotComparisonFor(kWhoopGen4).everyDay.sends[C.steps]; + expect(g4?.kind, K.conditional); + expect(g4?.reasonKey, kReasonLiveSessions); + expect(slotComparisonFor(kWhoopGen5).everyDay.sends[C.steps]?.kind, + K.native); + }); + }); + + group('wearables fill every day (the active wearable)', () { + test('the six columned entries are found (control for the loops below)', () { + expect({for (final e in _columnEntries()) e.id}, + {'garmin', 'pebble', 'miband234', 'ultrahuman', 'colmi', 'oura'}); + }); + + test('Oura: the rows read off kOuraColumn, with their captions', () { + // WHY: oura_inputs.dart: sleep off the ring's own stages, readiness + // provisional until 14 nights of baseline, skin temperature after a few + // nights, strain and steps not decoded; not workout-armed. + final cmp = slotComparisonFor(kOura); + expect(cmp.slot, DeviceSlot.everyDay); + final d = cmp.everyDay.sends; + expect(d.keys.toSet(), {C.sleep, C.recovery, C.heartRate, C.hrv, + C.respiration, C.skinTemperature}); + expect(d[C.sleep]?.reasonKey, kReasonOwnStagesRing); + expect(d[C.recovery]?.reasonKey, kReasonFirstNights); + expect(d[C.skinTemperature]?.reasonKey, kReasonSkinTempProvisional); + for (final c in [C.heartRate, C.hrv, C.respiration]) { + expect(d[c]?.kind, K.native, reason: '$c'); + } + expect(cmp.workoutsOnly.sends, isEmpty); + }); + + test('every columned wearable: every-day rows follow capabilities(column)', () { + // WHY: ours -> native (or a stated condition), device -> the device's own + // reading, estimated -> conditional, unavailable -> not sent. + for (final e in _columnEntries()) { + final col = columnFor(e.id)!; + final cls = capabilities(col); + final cmp = slotComparisonFor(e); + expect(cmp.slot, DeviceSlot.everyDay, reason: e.id); + expect(cmp.workoutsOnly.sends, isEmpty, reason: e.id); + final sends = cmp.everyDay.sends; + for (final m in kCapabilityRow.entries) { + final c = cls[m.value]; + final got = sends[m.key]; + final why = '${e.id} ${m.key} ($c)'; + switch (c) { + case null || MetricClass.unavailable: + expect(got, isNull, reason: why); + case MetricClass.device: + expect(got?.kind, K.vendorReported, reason: why); + case MetricClass.estimated: + expect(got?.kind, K.conditional, reason: why); + case MetricClass.ours: + expect(got, isNotNull, reason: why); + expect(got!.kind, isNot(K.vendorReported), reason: why); + } + if (got?.kind == K.conditional) { + expect(got!.reasonKey?.trim() ?? '', isNotEmpty, reason: why); + } + } + expect(sends.keys.toSet().intersection(_workoutRows.toSet()), isEmpty, + reason: '${e.id}: a wearable is no workout session source'); + } + }); + + test('Pebble HR rows are Pebble 2 only; SE keeps reported sleep and steps', () { + final cmp = slotComparisonFor(kPebble); + final d = cmp.everyDay.sends; + expect(d.keys.toSet(), {C.sleep, C.recovery, C.strain, C.heartRate, C.steps}); + for (final c in [C.recovery, C.strain, C.heartRate]) { + expect(d[c]?.kind, K.conditional, reason: 'SE has no HR sensor: $c'); + } + expect(d[C.strain]?.reasonKey, 'capReasonPebble2Only'); + expect(d[C.heartRate]?.reasonKey, 'capReasonPebble2Only'); + // WHY: Pebble 2's recovery is an estimate; the + // Pebble 2 only caption must not drop that. + expect(d[C.recovery]?.reasonKey, 'capReasonPebble2OnlyEstimated'); + expect(d[C.sleep]?.reasonKey, kReasonOwnStages); + expect(d[C.steps]?.kind, K.vendorReported); + expect(cmp.workoutsOnly.sends, isEmpty); + }); + + test('hand-read anchors: Garmin', () { + // garmin_inputs.dart: HRV the watch's own unless a strap gave beats, + // readiness an estimate without a strap, strain ours, no skin temp. + final g = slotComparisonFor(kGarmin).everyDay.sends; + expect(g.containsKey(C.skinTemperature), isFalse); + expect(g[C.hrv]?.kind, K.vendorReported); + expect(g[C.recovery]?.kind, K.conditional); + expect(g[C.strain]?.kind, K.native); + }); + }); + + group('workout sensors fill workouts only', () { + test('a chest strap: heart rate; beat timing if the strap sends it', () { + // WHY: kWorkoutArmedSensors arms ble_hrs; RR intervals are optional in + // the Heart Rate Service. + final cmp = slotComparisonFor(kBleHrs); + expect(cmp.slot, DeviceSlot.workoutsOnly); + expect(cmp.everyDay.sends, isEmpty); + final w = cmp.workoutsOnly.sends; + expect(w[C.workoutHeartRate]?.kind, K.native); + expect(w[C.workoutBeatToBeat]?.reasonKey, kReasonUnconfirmed); + expect(cmp.workoutsOnly.doesNotSend, _dayRows.toSet()); + }); + + test('Polar PMD: its PPI stream is beats, so no "not confirmed" caption', () { + final w = slotComparisonFor(kPolarPmd).workoutsOnly.sends; + expect(w[C.workoutBeatToBeat]?.kind, K.native); + expect(w[C.workoutHeartRate]?.reasonKey, isNull); + expect(slotComparisonFor(kPolarPmd).slot, DeviceSlot.workoutsOnly); + }); + + test('only the armed sensors and a WHOOP send anything for workouts', () { + expect(kWorkoutArmedSensors, {'ble_hrs', 'polar_pmd'}); + for (final e in kBandRegistry) { + final w = _keys(slotComparisonFor(e).workoutsOnly); + final armed = {'ble_hrs', 'polar_pmd', 'gen4', 'gen5'}.contains(e.id); + expect(w, armed ? _workoutRows.toSet() : isEmpty, reason: e.id); + } + }); + }); + + test('"not confirmed" only where the device may not send it: a generic strap, a WHOOP read as one', () { + // WHY: devices people use (Oura, Polar) carry only factual + // captions; the unconfirmed one is for RR from the generic profile. + final unconfirmed = { + for (final e in kBandRegistry) + for (final s in DeviceSlot.values) + for (final m in slotComparisonFor(e).side(s).sends.entries) + if (m.value.reasonKey == kReasonUnconfirmed) '${e.id}:${m.key.name}', + }; + expect(unconfirmed, { + 'ble_hrs:workoutBeatToBeat', + 'gen4:workoutBeatToBeat', + 'gen5:workoutBeatToBeat', + }); + }); + + test('Coros, scales and thermometers fit neither column', () { + // WHY: canonical.dart wearableEnabled() is false for Coros and it has no + // column; nothing arms it for workouts. + expect(declaredSignals('coros').map((s) => s.name), contains('rrIntervals'), + reason: 'positive control: Coros does declare beats'); + for (final e in [kCoros, kThermometer, kMiScale2, kMiScaleComposition]) { + final cmp = slotComparisonFor(e); + expect(cmp.slot, isNull, reason: e.id); + expect(cmp.everyDay.sends, isEmpty, reason: e.id); + expect(cmp.workoutsOnly.sends, isEmpty, reason: e.id); + } + }); + + test('no registry entry claims blood oxygen, in either column', () { + expect(capabilities(columnFor('oura')!)['spo2'], MetricClass.device, + reason: 'positive control: a column does offer a vendor spo2'); + for (final e in kBandRegistry) { + final cmp = slotComparisonFor(e); + for (final s in DeviceSlot.values) { + expect(cmp.side(s).sends.containsKey(C.bloodOxygen), isFalse, + reason: '${e.id} $s'); + expect(cmp.side(s).doesNotSend.contains(C.bloodOxygen), isFalse, + reason: '${e.id} $s'); + expect(capabilityTrace(e, C.bloodOxygen, slot: s), isNull, + reason: '${e.id} $s'); + } + } + }); + + test('doesNotSend is the table rows minus sends, for every entry and column', () { + for (final e in kBandRegistry) { + final cmp = slotComparisonFor(e); + for (final s in DeviceSlot.values) { + final side = cmp.side(s); + expect(side.doesNotSend, kTableRows.toSet().difference(_keys(side)), + reason: '${e.id} $s'); + } + } + }); + + group('nothing fabricated', () { + test('every claim traces to an eligibility rule in an existing file', () { + var claims = 0; + for (final e in kBandRegistry) { + final cmp = slotComparisonFor(e); + for (final s in DeviceSlot.values) { + for (final c in cmp.side(s).sends.keys) { + claims++; + final t = capabilityTrace(e, c, slot: s); + final why = '${e.id} $s $c'; + expect(t, isNotNull, reason: why); + expect(t!.rule.trim(), isNotEmpty, reason: why); + expect(File(t.source).existsSync(), isTrue, reason: '$why: ${t.source}'); + } + } + } + expect(claims, greaterThan(20), reason: 'vacuity control'); + }); + + test('an every-day claim needs a column or the native WHOOP pipeline', () { + for (final e in kBandRegistry) { + final eligible = + columnFor(e.id) != null || e.id == 'gen4' || e.id == 'gen5'; + if (!eligible) { + expect(slotComparisonFor(e).everyDay.sends, isEmpty, reason: e.id); + } + } + }); + + test('every whole-day capability has a column row', () { + final rows = {for (final e in _columnEntries()) ...columnFor(e.id)!.keys}; + for (final c in _dayRows) { + expect(kCapabilityRow[c], isNotNull, reason: '$c'); + expect(rows, contains(kCapabilityRow[c]), reason: '$c'); + } + }); + }); + + test('every label and reason key the table can show has text in every locale', () { + final keys = {for (final c in kTableRows) kCapabilityLabelKey[c]!}; + for (final e in kBandRegistry) { + final cmp = slotComparisonFor(e); + for (final s in DeviceSlot.values) { + for (final st in cmp.side(s).sends.values) { + if (st.reasonKey != null) keys.add(st.reasonKey!); + } + } + } + expect(keys.length, greaterThan(kTableRows.length + 4), + reason: 'vacuity control: some reason keys were found'); + final arbs = Directory('lib/l10n').listSync().whereType().where( + (f) => RegExp(r'app_\w+\.arb$').hasMatch(f.path)); + expect(arbs.length, 8); + for (final f in arbs) { + final arb = jsonDecode(f.readAsStringSync()) as Map; + final bad = [ + for (final k in keys) + if (arb[k] is! String || (arb[k] as String).trim().isEmpty) k, + ]; + expect(bad, isEmpty, reason: '${f.path}: $bad'); + } + }); +} diff --git a/test/support/app_state_sync_harness.dart b/test/support/app_state_sync_harness.dart index eb9592642..234cc435e 100644 --- a/test/support/app_state_sync_harness.dart +++ b/test/support/app_state_sync_harness.dart @@ -274,6 +274,13 @@ class SyncFakeEngine extends BleEngine { if (batteryThrows != null) throw batteryThrows!; } + @override + Future<({DateTime? armed, bool echoed})> armAlarm(DateTime when, + {int index = 0, List? haptics}) async { + events.add('armAlarm:${when.millisecondsSinceEpoch ~/ 1000}'); + return (armed: when, echoed: true); + } + @override Future getStrapName() async => events.add('getStrapName'); diff --git a/test/support/pair_ui_support.dart b/test/support/pair_ui_support.dart index 3014c3eb8..9f9cf27d3 100644 --- a/test/support/pair_ui_support.dart +++ b/test/support/pair_ui_support.dart @@ -14,11 +14,12 @@ Future pumpPair( double textScale = 1, Size size = const Size(412, 915), bool settle = true, + Widget Function(Widget)? wrap, }) async { t.view.physicalSize = size * 2; t.view.devicePixelRatio = 2; addTearDown(t.view.reset); - await t.pumpWidget(MaterialApp( + final app = MaterialApp( debugShowCheckedModeBanner: false, locale: locale, localizationsDelegates: const [ @@ -34,7 +35,8 @@ Future pumpPair( child: child!, ), home: home, - )); + ); + await t.pumpWidget(wrap?.call(app) ?? app); if (settle) { await t.pumpAndSettle(); } else { diff --git a/test/ui2_device_slot_card_test.dart b/test/ui2_device_slot_card_test.dart new file mode 100644 index 000000000..8efbaaaa5 --- /dev/null +++ b/test/ui2_device_slot_card_test.dart @@ -0,0 +1,249 @@ +// The device slot card and how a paired device reads on the sources list +// and its detail page. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/slot_comparison.dart'; +import 'package:openstrap_edge/ui2/pairing/device_slot_card.dart'; +import 'package:openstrap_edge/ui2/profile/devices.dart'; +import 'package:openstrap_edge/ui2/profile/wearable_numbers.dart' show DeviceUseSwitch; + +import 'support/pair_ui_support.dart'; + +HealthSource _src(String family, {String name = 'Polar H10', bool isBand = false}) => + HealthSource( + name: name, + kind: 'Bluetooth heart rate sensor', + tier: SourceTier.beatToBeat, + icon: LucideIcons.heartPulse, + isBand: isBand, + deviceId: '$family-0a1b2c3d', + family: family, + ); + +Widget _card(BandEntry e, DeviceSlot slot) => Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: DeviceSlotCard(entry: e, slot: slot))); + +bool _icon(WidgetTester t, Finder within, IconData icon) => t + .widgetList(find.descendant(of: within, matching: find.byType(Icon))) + .any((i) => i.icon == icon); + +Finder _mark(String side, DeviceCapability c) => + find.byKey(ValueKey('slot-$side-${c.name}')); + +double _opacityOf(WidgetTester t, String side) => t + .widget(find.descendant( + of: find.byKey(ValueKey('slot-cell-$side')).first, + matching: find.byType(Opacity)) + .first) + .opacity; + +void main() { + test('a ring reads as a band does, not as a sensor waiting for a workout', () { + expect(sourceState(_src('oura', name: 'Oura Ring')), 'Not connected'); + expect(sourceState(_src('ble_hrs')), 'Waiting for a workout'); + }); + + group('which sources get the card', () { + test('a strap, a Polar sensor and a ring, not a band, a scale or a Coros watch', () { + expect(slotDeviceOf(_src('ble_hrs'))?.entry.id, 'ble_hrs'); + expect(slotDeviceOf(_src('ble_hrs'))?.slot, DeviceSlot.workoutsOnly); + expect(slotDeviceOf(_src('polar_pmd'))?.entry.id, 'polar_pmd'); + expect(slotDeviceOf(_src('polar_pmd'))?.slot, DeviceSlot.workoutsOnly); + // A columned wearable fills every day. + expect(slotDeviceOf(_src('oura'))?.slot, DeviceSlot.everyDay); + expect(slotDeviceOf(_src('colmi'))?.slot, DeviceSlot.everyDay); + expect(slotDeviceOf(_src('thermometer')), isNull); + expect(slotDeviceOf(_src('miscale2')), isNull); + expect(slotDeviceOf(_src('coros')), isNull); + // The primary band is never a slot device (it is every day by itself). + expect(slotDeviceOf(_src('gen5', isBand: true)), isNull); + expect(sourceSlot(_src('gen5', isBand: true)), DeviceSlot.everyDay); + // The phone has no device row. + const phone = HealthSource( + name: 'This phone', + kind: 'Motion coprocessor', + tier: SourceTier.phone, + icon: Icons.phone_android); + expect(slotDeviceOf(phone), isNull); + expect(sourceSlot(phone), isNull); + }); + }); + + group('DeviceSlotCard', () { + testWidgets('a strap: Workouts only chip, the Every day column faded, the other not', (t) async { + await pumpPair(t, _card(kBleHrs, DeviceSlot.workoutsOnly), size: const Size(412, 1100)); + expect(find.text('What it sends'), findsOneWidget); + final pill = find.byKey(const ValueKey('device-slot-pill')); + expect(find.descendant(of: pill, matching: find.text('Workouts only')), findsOneWidget); + expect(find.descendant(of: pill, matching: find.text('Every day')), findsNothing); + expect(_opacityOf(t, 'everyDay'), 0.28); + expect(_opacityOf(t, 'workoutsOnly'), 1); + }); + + testWidgets('a strap: heart rate and beat timing are ✓ under Workouts only, sleep ✗ in both', (t) async { + await pumpPair(t, _card(kBleHrs, DeviceSlot.workoutsOnly), size: const Size(412, 1100)); + final cmp = slotComparisonFor(kBleHrs); + expect(cmp.workoutsOnly.sends, isNotEmpty); + for (final k in cmp.workoutsOnly.sends.keys) { + expect(_icon(t, _mark('workoutsOnly', k), LucideIcons.check), isTrue, reason: k.name); + } + expect(_icon(t, _mark('workoutsOnly', DeviceCapability.workoutHeartRate), LucideIcons.check), isTrue); + expect(_icon(t, _mark('workoutsOnly', DeviceCapability.workoutBeatToBeat), LucideIcons.check), isTrue); + for (final side in ['everyDay', 'workoutsOnly']) { + expect(_icon(t, _mark(side, DeviceCapability.sleep), LucideIcons.x), isTrue, reason: side); + expect(_icon(t, _mark(side, DeviceCapability.sleep), LucideIcons.check), isFalse, reason: side); + } + expect(find.byIcon(LucideIcons.minus), findsNothing); + }); + + testWidgets('a strap’s note: both need “Use during workouts” on; no Experimental word', (t) async { + // WHY: a workout sensor records nothing until the switch is on (default off). + await pumpPair(t, _card(kBleHrs, DeviceSlot.workoutsOnly), size: const Size(412, 1100)); + expect(find.text('Both need “Use during workouts” on.'), findsOneWidget); + expect(find.textContaining('Experimental'), findsNothing); + expect(find.textContaining('Tier'), findsNothing); + }); + + testWidgets('a strap’s beat timing is conditional on the strap', (t) async { + await pumpPair(t, _card(kBleHrs, DeviceSlot.workoutsOnly), size: const Size(412, 1100)); + expect(find.text('Workouts only: If this device sends it, not confirmed'), findsOneWidget); + }); + + testWidgets('a ring: Every day chip, Workouts only faded, the orange experimental note', (t) async { + await pumpPair(t, _card(kOura, DeviceSlot.everyDay), size: const Size(412, 1400)); + final pill = find.byKey(const ValueKey('device-slot-pill')); + expect(find.descendant(of: pill, matching: find.text('Every day')), findsOneWidget); + expect(_opacityOf(t, 'workoutsOnly'), 0.28); + expect(_opacityOf(t, 'everyDay'), 1); + const note = 'Fills days without your WHOOP. Experimental, so switch it on below.'; + expect(find.text(note), findsOneWidget); + expect(find.text('Both need “Use during workouts” on.'), findsNothing); + // Orange, not the grey of the strap's note. + final grey = t.widget(find.text(note)).style!.color; + await pumpPair(t, _card(kBleHrs, DeviceSlot.workoutsOnly), size: const Size(412, 1100)); + final strapNote = t.widget(find.text('Both need “Use during workouts” on.')).style!.color; + expect(grey, isNot(strapNote)); + }); + + testWidgets('a ring sends in the Every day column, nothing in Workouts only', (t) async { + await pumpPair(t, _card(kOura, DeviceSlot.everyDay), size: const Size(412, 1400)); + expect(_icon(t, _mark('everyDay', DeviceCapability.sleep), LucideIcons.check), isTrue); + expect(_icon(t, _mark('workoutsOnly', DeviceCapability.sleep), LucideIcons.x), isTrue); + expect(_icon(t, _mark('workoutsOnly', DeviceCapability.workoutHeartRate), LucideIcons.x), isTrue); + }); + + for (final (e, s) in [ + (kOura, DeviceSlot.everyDay), + (kPolarPmd, DeviceSlot.workoutsOnly), + ]) { + testWidgets('German at 2x text does not overflow (${e.id})', (t) async { + await pumpPair(t, _card(e, s), + locale: const Locale('de'), textScale: 2, size: const Size(360, 2000)); + expect(t.takeException(), isNull); + expect(find.text('Was es sendet'), findsOneWidget); + }); + } + }); + + group('on the sources list', () { + testWidgets('a strap’s row shows its Workouts only chip, no Tier pill, no Experimental word', (t) async { + await pumpPair(t, Scaffold(body: SourceRow(_src('ble_hrs'))), size: const Size(412, 600)); + expect(find.byKey(const ValueKey('source-slot-workoutsOnly')), findsOneWidget); + expect(find.byKey(const ValueKey('source-slot-everyDay')), findsNothing); + expect(find.textContaining('Tier'), findsNothing); + expect(find.text('Experimental'), findsNothing); + expect(find.text('Waiting for a workout'), findsOneWidget); + }); + + testWidgets('a ring’s row shows an Every day chip and no Tier pill', (t) async { + await pumpPair( + t, + Scaffold( + body: SourceRow(HealthSource( + name: 'Oura Ring', + kind: 'Ring', + tier: SourceTier.wristOptical, + icon: Icons.circle, + deviceId: 'oura-1', + family: 'oura'))), + size: const Size(412, 600)); + expect(find.byKey(const ValueKey('source-slot-everyDay')), findsOneWidget); + expect(find.textContaining('Tier'), findsNothing); + expect(find.text('Experimental'), findsNothing); + }); + + testWidgets('the band’s row says Every day', (t) async { + await pumpPair( + t, + const Scaffold( + body: SourceRow(HealthSource( + name: 'WHOOP 5', + kind: 'WHOOP', + tier: SourceTier.wristOptical, + icon: Icons.watch, + isBand: true))), + size: const Size(412, 600)); + expect(find.byKey(const ValueKey('source-slot-everyDay')), findsOneWidget); + }); + + testWidgets('a device with no column (Coros) keeps its Tier pill and has no slot chip', (t) async { + await pumpPair( + t, + const Scaffold( + body: SourceRow(HealthSource( + name: 'Coros', + kind: 'Watch', + tier: SourceTier.wristOptical, + icon: Icons.watch, + deviceId: 'coros-1', + family: 'coros'))), + size: const Size(412, 600)); + expect(find.textContaining('Tier'), findsOneWidget); + expect(find.byKey(const ValueKey('source-slot-everyDay')), findsNothing); + expect(find.byKey(const ValueKey('source-slot-workoutsOnly')), findsNothing); + }); + }); + + group('on its detail page', () { + testWidgets('the card replaces the tier row and the Support row, and the use switch follows it', (t) async { + await pumpPair(t, DeviceDetailView(_src('ble_hrs')), size: const Size(412, 2000)); + expect(find.byKey(const ValueKey('device-slot-card')), findsOneWidget); + expect(find.textContaining('Tier'), findsNothing); + // No Support: Experimental row either. + expect(find.text('Support'), findsNothing); + expect(find.text('Experimental'), findsNothing); + // The switch widget sits right after the card in the page (it draws + // nothing here: no device is registered with WearableDisplay). + final items = t + .widgetList(find.descendant( + of: find.byType(DeviceDetailView), matching: find.byWidgetPredicate((w) => w is DeviceSlotCard || w is DeviceUseSwitch))) + .toList(); + expect(items.map((w) => w.runtimeType), [DeviceSlotCard, DeviceUseSwitch]); + }); + + testWidgets('a ring’s page gets the card and the switch too', (t) async { + await pumpPair(t, DeviceDetailView(_src('oura', name: 'Oura Ring')), size: const Size(412, 2400)); + expect(find.byKey(const ValueKey('device-slot-card')), findsOneWidget); + expect(find.byType(DeviceUseSwitch), findsOneWidget); + expect(find.textContaining('Tier'), findsNothing); + }); + + testWidgets('a band’s page does not get the card', (t) async { + await pumpPair( + t, + DeviceDetailView(const HealthSource( + name: 'WHOOP 5', + kind: 'WHOOP', + tier: SourceTier.wristOptical, + icon: Icons.watch, + isBand: true)), + size: const Size(412, 2000)); + expect(find.byKey(const ValueKey('device-slot-card')), findsNothing); + expect(find.byType(DeviceUseSwitch), findsNothing); + }); + }); +} diff --git a/test/ui2_pair_band_view_test.dart b/test/ui2_pair_band_view_test.dart new file mode 100644 index 000000000..8fd88fd13 --- /dev/null +++ b/test/ui2_pair_band_view_test.dart @@ -0,0 +1,267 @@ +// First-run "Pair your band": the list, the filter pills, the strap-only +// sheet, the always-open Trouble connecting card and the way out. Rendered with fakes. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ui2/community_links.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_band_view.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_help.dart' show PairLinkOpener; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart' show PairFilter; + +import 'support/pair_ui_support.dart'; + +class _Links implements PairLinkOpener { + final opened = []; + @override + Future open(String url) async { + opened.add(url); + return true; + } +} + +class _Host implements PairBandHost { + final void Function(BandEntry) onPick; + final VoidCallback onStrap; + _Host(this.onPick, this.onStrap); + @override + void pickBand(BandEntry entry) => onPick(entry); + @override + void strapOnlyConfirmed() => onStrap(); +} + +Finder _row(BandEntry e) => find.byKey(ValueKey('band-row-${e.id}')); + +void main() { + group('which devices the first run offers', () { + test('only the WHOOP bands', () { + final ids = [for (final e in firstRunBandEntries()) e.id]; + expect(ids, ['gen4', 'gen5']); + // Other bands pair as additional devices from "Add a device"; sensors, + // scales and the thermometer are never a main device. + for (final id in ['oura', 'colmi', 'miband234', 'ultrahuman', 'pebble', 'coros', 'garmin', + 'ble_hrs', 'polar_pmd', 'thermometer', 'miscale_bc', 'miscale2']) { + expect(ids, isNot(contains(id)), reason: id); + } + }); + }); + + group('PairBandView', () { + Widget view({ + List? entries, + ValueChanged? onPick, + VoidCallback? onStrap, + VoidCallback? onSkip, + VoidCallback? onBack, + String? problem, + _Links? links, + }) => + PairBandView( + entries: entries ?? firstRunBandEntries(), + host: _Host(onPick ?? (_) {}, onStrap ?? () {}), + onSkip: onSkip, + onBack: onBack, + problem: problem, + openLink: links ?? _Links(), + ); + + testWidgets('title, subtitle, and one row per device with what it sends', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + expect(find.text('Set up'), findsOneWidget); + expect(find.byKey(const ValueKey('pair-band-title')), findsOneWidget); + expect(find.text('Pair your band'), findsOneWidget); + expect(find.text('Pick the one you wear. We will look for it nearby.'), findsOneWidget); + for (final e in firstRunBandEntries()) { + expect(_row(e), findsOneWidget, reason: e.id); + expect( + find.descendant( + of: _row(e), + matching: find.text('Sleep, recovery, strain, heart rate and more')), + findsOneWidget, + reason: e.id); + } + }); + + + + testWidgets('the WHOOP description is not shown under other devices', (t) async { + await pumpPair(t, view(entries: [kWhoopGen5, kOura, kColmi]), + size: const Size(412, 2400)); + const line = 'Sleep, recovery, strain, heart rate and more'; + expect(find.descendant(of: _row(kWhoopGen5), matching: find.text(line)), + findsOneWidget); + for (final e in [kOura, kColmi]) { + expect(_row(e), findsOneWidget, reason: e.id); + expect(find.descendant(of: _row(e), matching: find.text(line)), + findsNothing, + reason: e.id); + } + }); + + testWidgets('says plainly that only WHOOP is the main band', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + expect(find.byKey(const ValueKey('main-band-whoop-only-note')), findsOneWidget); + expect(find.text('Main band: WHOOP 4.0 or WHOOP 5.0/MG only. Other devices can be added as additional devices.'), findsOneWidget); + }); + + testWidgets('each row carries the Every day chip', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + for (final e in firstRunBandEntries()) { + expect(find.descendant(of: _row(e), matching: find.text('Every day')), findsOneWidget, + reason: e.id); + expect(find.descendant(of: _row(e), matching: find.text('Workouts only')), findsNothing, + reason: e.id); + } + }); + + testWidgets('never says what a band would NOT send, nor primary/secondary/tier', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + for (final w in ['Doesn’t send', 'primary', 'Primary', 'secondary', 'Secondary', 'Tier', 'Experimental']) { + expect(find.textContaining(w), findsNothing, reason: w); + } + }); + + testWidgets('pills show only the groups in the list; Rings narrows it', (t) async { + // The view works for any list; the first run itself offers WHOOP only. + final all = [kWhoopGen5, kOura, kColmi, kUltrahuman, kMiBand234, kPebble]; + await pumpPair(t, view(entries: all), size: const Size(412, 2400)); + for (final k in ['All', 'WHOOP', 'Rings', 'Watches']) { + expect(find.text(k), findsOneWidget, reason: k); + } + expect(find.text('Chest straps & armbands'), findsNothing); + expect(find.text('Bands'), findsNothing); + expect(find.text('Scales'), findsNothing); + await t.tap(find.text('Rings')); + await t.pumpAndSettle(); + expect(_row(kOura), findsOneWidget); + expect(_row(kColmi), findsOneWidget); + expect(_row(kUltrahuman), findsOneWidget); + expect(_row(kWhoopGen5), findsNothing); + expect(_row(kMiBand234), findsNothing); + // The Mi Band is a watch, with the other watches. + await t.tap(find.text('Watches')); + await t.pumpAndSettle(); + expect(_row(kMiBand234), findsOneWidget); + expect(_row(kPebble), findsOneWidget); + expect(_row(kOura), findsNothing); + await t.tap(find.text('All')); + await t.pumpAndSettle(); + expect(_row(kWhoopGen5), findsOneWidget); + }); + + test('the pills’ groups are PairFilter values (WHOOP, rings, watches, straps)', () { + expect(PairFilter.values.map((f) => f.name), ['whoop', 'rings', 'watches', 'straps']); + }); + + testWidgets('tapping a row picks that device', (t) async { + final picked = []; + await pumpPair(t, view(onPick: (e) => picked.add(e.id)), size: const Size(412, 2400)); + await t.tap(_row(kWhoopGen5)); + expect(picked, ['gen5']); + }); + + testWidgets('the strap-only link opens the strap sheet', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + await t.tap(find.text('I only have a heart-rate strap')); + await t.pumpAndSettle(); + expect(find.text('This is a heart-rate sensor'), findsOneWidget); + expect(find.text('Pair Bluetooth heart rate sensor'), findsOneWidget); + expect(find.text('Go back'), findsOneWidget); + expect(find.text('Sleep'), findsWidgets); + }); + + testWidgets('Go back closes the sheet and pairs nothing', (t) async { + var confirmed = 0; + await pumpPair(t, view(onStrap: () => confirmed++), size: const Size(412, 2400)); + await t.tap(find.text('I only have a heart-rate strap')); + await t.pumpAndSettle(); + await t.tap(find.text('Go back')); + await t.pumpAndSettle(); + expect(find.text('This is a heart-rate sensor'), findsNothing); + expect(confirmed, 0); + }); + + testWidgets('Pair closes the sheet and carries on (strapOnlyConfirmed)', (t) async { + var confirmed = 0; + await pumpPair(t, view(onStrap: () => confirmed++), size: const Size(412, 2400)); + await t.tap(find.text('I only have a heart-rate strap')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair Bluetooth heart rate sensor')); + await t.pumpAndSettle(); + expect(find.text('This is a heart-rate sensor'), findsNothing); + expect(confirmed, 1); + }); + + testWidgets('there is no “I can’t pair my band” button, and no dialog anywhere', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + expect(find.text('I can’t pair my band'), findsNothing); + await t.tap(_row(kWhoopGen5)); + await t.pumpAndSettle(); + expect(find.byType(Dialog), findsNothing); + expect(find.text('Need a hand pairing?'), findsNothing); + }); + + testWidgets('the Trouble connecting card is always there, with a link per WHOOP and Discord', (t) async { + final links = _Links(); + await pumpPair(t, view(links: links), size: const Size(412, 2400)); + expect(find.byKey(const ValueKey('trouble-connecting')), findsOneWidget); + expect(find.text('Trouble connecting?'), findsOneWidget); + for (final e in firstRunBandEntries()) { + expect(find.byKey(ValueKey('trouble-link-${e.id}')), findsOneWidget, reason: e.id); + } + await t.tap(find.byKey(const ValueKey('trouble-link-discord'))); + expect(links.opened, [kDiscordUrl]); + }); + + testWidgets('the card stays, unchanged, after trying a device; its links open each model’s page', (t) async { + final links = _Links(); + await pumpPair(t, view(links: links), size: const Size(412, 2400)); + await t.tap(_row(kWhoopGen5)); + await t.pumpAndSettle(); + expect(find.byKey(const ValueKey('trouble-connecting')), findsOneWidget); + await t.tap(find.byKey(ValueKey('trouble-link-${kWhoopGen5.id}'))); + expect(links.opened, [kWhoopGen5.resetSteps!.officialUrl]); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('Skip for now shows only when there is something to skip', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + expect(find.text('Skip for now'), findsNothing); + var skipped = 0; + await pumpPair(t, view(onSkip: () => skipped++), size: const Size(412, 2400)); + // The first-run help and comparison chips can put Skip below the fold. + await t.ensureVisible(find.text('Skip for now')); + await t.pumpAndSettle(); + await t.tap(find.text('Skip for now')); + expect(skipped, 1); + }); + + testWidgets('the back chevron goes where the first run says', (t) async { + var back = 0; + await pumpPair(t, view(onBack: () => back++), size: const Size(412, 2400)); + await t.tap(find.byIcon(LucideIcons.chevronLeft)); + expect(back, 1); + }); + + testWidgets('a problem is said in a card', (t) async { + await pumpPair(t, view(problem: 'Bluetooth is off.'), size: const Size(412, 2400)); + expect(find.text('Bluetooth is off.'), findsOneWidget); + // The card is under it, not instead of it. + expect(find.byKey(const ValueKey('trouble-connecting')), findsOneWidget); + }); + + testWidgets('German at 2x text on a small phone does not overflow', (t) async { + await pumpPair(t, view(onSkip: () {}), + locale: const Locale('de'), textScale: 2, size: const Size(320, 700)); + expect(t.takeException(), isNull); + // The retained firmware warning adds substantial height at 2x text. + await t.scrollUntilVisible(find.text('Ich habe nur einen Herzfrequenzgurt'), 200, maxScrolls: 100); + await t.ensureVisible(find.text('Ich habe nur einen Herzfrequenzgurt')); + await t.pumpAndSettle(); + await t.tap(find.text('Ich habe nur einen Herzfrequenzgurt')); + await t.pumpAndSettle(); + expect(find.text('Das ist ein Herzfrequenzsensor'), findsOneWidget); + expect(t.takeException(), isNull); + }); + }); +} diff --git a/test/ui2_pair_device_settings_test.dart b/test/ui2_pair_device_settings_test.dart new file mode 100644 index 000000000..a485ad7e2 --- /dev/null +++ b/test/ui2_pair_device_settings_test.dart @@ -0,0 +1,372 @@ +// Settings > My sources > "Pair a device": the one "Your band" card (no band, +// a band paired), and what the "Add a device" screen adds (pills, slot chips, +// the paired notice). +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' show BluetoothDevice; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/hrs_link.dart' show BandCandidate; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/pairing/device_picker.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart' show PairFilter; +import 'package:openstrap_edge/ui2/profile/devices.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +import 'support/pair_ui_support.dart'; + +HealthSource _band([String name = 'Tyler’s WHOOP 5.0 / MG']) => HealthSource( + name: name, + kind: 'WHOOP', + tier: SourceTier.wristOptical, + icon: Icons.watch, + connected: true, + isBand: true, + ); + +HealthSource _strap() => const HealthSource( + name: 'Polar H10', + kind: 'Bluetooth heart rate sensor', + tier: SourceTier.beatToBeat, + icon: Icons.favorite, + isBand: false, + deviceId: 'ble_hrs-0a1b2c3d', + family: 'ble_hrs', + ); + +BandCandidate _cand(String entryId, + {String id = 'AA:BB:CC:DD:EE:01', String? label, BandEntry? alsoBand}) => + ( + device: BluetoothDevice.fromId(id), + label: label, + rssi: -60, + entryId: entryId, + alsoBand: alsoBand == null ? null : (entry: alsoBand, modelCertain: true), + ); + +void main() { + group('My sources: Pair a device', () { + Widget view(List sources, + {VoidCallback? onPair, VoidCallback? onAdd, VoidCallback? onUnpair}) => + MyDevicesView( + sources: sources, + onPair: onPair, + onAddSensor: onAdd, + onUnpair: onUnpair); + + Finder inCard(String key, Finder f) => + find.descendant(of: find.byKey(ValueKey(key)), matching: f); + + testWidgets('no band: ONE Your band card says so, says WHOOP only, and Pair your band opens the band flow', (t) async { + var pair = 0; + await pumpPair(t, view(const [], onPair: () => pair++, onAdd: () {}), + size: const Size(412, 1400)); + expect(find.text('Pair a device'), findsOneWidget); + expect(find.byKey(const ValueKey('pair-band-card')), findsOneWidget); + expect(find.byKey(const ValueKey('band-paired-card')), findsNothing); + expect(inCard('pair-band-card', find.text('Your band')), findsOneWidget); + expect(inCard('pair-band-card', find.text('Every day')), findsOneWidget); + expect(inCard('pair-band-card', find.byKey(const ValueKey('your-band-status'))), + findsOneWidget); + expect(inCard('pair-band-card', find.text('No band is paired')), findsOneWidget); + expect( + inCard('pair-band-card', + find.text('WHOOP 4.0 or WHOOP 5.0/MG only. Sleep, recovery and strain, around the clock.')), + findsOneWidget); + expect(find.byKey(const ValueKey('main-band-whoop-only-note')), findsOneWidget); + final button = inCard('pair-band-card', find.byType(BigButton)); + expect(button, findsOneWidget); + expect(find.descendant(of: button, matching: find.text('Pair your band')), findsOneWidget); + await t.tap(button); + expect(pair, 1); + expect(find.text('Unpair band'), findsNothing); + }); + + testWidgets('no band: no separate “No band is paired” status card above the Your band card', (t) async { + // WHY: the owner's rule is ONE card; a second one said the same thing twice. + await pumpPair(t, view(const [], onPair: () {}, onAdd: () {}), size: const Size(412, 1400)); + expect(find.text('No band is paired'), findsOneWidget); + expect(find.text('Nothing is measuring yet'), findsNothing); + expect(find.byType(StatusCard), findsNothing); + expect(find.text('Pair a band'), findsNothing); + // One button for the band; the Add a device row is not a BigButton. + expect(find.byType(BigButton), findsOneWidget); + }); + + testWidgets('no band: the same card says what that costs, once', (t) async { + // WHY: one card, but it must still say that every metric abstains; the + // WHOOP-only line alone never told a person their data was empty. + const phone = HealthSource( + name: 'This phone', + kind: 'Motion coprocessor', + tier: SourceTier.phone, + icon: Icons.smartphone, + connected: false, + ); + await pumpPair(t, view(const [phone], onPair: () {}, onAdd: () {}), + size: const Size(412, 1400)); + expect( + inCard('pair-band-card', find.byKey(const ValueKey('no-band-consequence'))), + findsOneWidget); + expect(find.textContaining('every metric in the app will abstain'), findsOneWidget); + expect(find.textContaining('The phone counts steps and nothing else'), findsNothing); + }); + + testWidgets('no band, phone steps arriving: says only the phone counts steps', (t) async { + const phone = HealthSource( + name: 'This phone', + kind: 'Motion coprocessor', + tier: SourceTier.phone, + icon: Icons.smartphone, + connected: true, + ); + await pumpPair(t, view(const [phone], onPair: () {}, onAdd: () {}), + size: const Size(412, 1400)); + expect(find.textContaining('The phone counts steps and nothing else'), findsOneWidget); + expect(find.textContaining('every metric in the app will abstain'), findsNothing); + }); + + testWidgets('a paired band: no consequence line', (t) async { + await pumpPair(t, view([_band()], onUnpair: () {}), size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('no-band-consequence')), findsNothing); + }); + + testWidgets('“Add a device” is its own row, below the band card', (t) async { + var add = 0; + await pumpPair(t, view(const [], onPair: () {}, onAdd: () => add++), size: const Size(412, 1400)); + expect(find.descendant(of: find.byKey(const ValueKey('pair-band-card')), matching: find.text('Add a device')), + findsNothing); + expect(find.text('Rings, watches and heart-rate straps'), findsOneWidget); + expect(t.getTopLeft(find.text('Add a device')).dy, + greaterThan(t.getBottomLeft(find.byKey(const ValueKey('pair-band-card'))).dy)); + await t.tap(find.text('Add a device')); + expect(add, 1); + }); + + testWidgets('the WHOOP-only line is in the card with no band; a paired band has no such line', (t) async { + await pumpPair(t, view(const [], onPair: () {}, onAdd: () {}), size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('main-band-whoop-only-note')), findsOneWidget); + await pumpPair(t, view([_band()], onUnpair: () {}, onAdd: () {}), size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('main-band-whoop-only-note')), findsNothing); + }); + + testWidgets('the old “Add a sensor” row is gone', (t) async { + await pumpPair(t, view(const [], onPair: () {}, onAdd: () {}), size: const Size(412, 1400)); + expect(find.text('Add a sensor'), findsNothing); + }); + + testWidgets('a paired strap is not a band: still the Your band card, no band', (t) async { + await pumpPair(t, view([_strap()], onPair: () {}, onAdd: () {}), size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('pair-band-card')), findsOneWidget); + expect(find.byKey(const ValueKey('band-paired-card')), findsNothing); + }); + + testWidgets('a band paired: the same card names it, shows its state, offers Unpair band; Add a device stays', (t) async { + var unpair = 0, add = 0; + await pumpPair(t, view([_band()], onPair: () {}, onAdd: () => add++, onUnpair: () => unpair++), + size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('pair-band-card')), findsNothing); + final card = find.byKey(const ValueKey('band-paired-card')); + expect(card, findsOneWidget); + expect(inCard('band-paired-card', find.text('Your band')), findsOneWidget); + expect(inCard('band-paired-card', find.text('Every day')), findsOneWidget); + expect( + t.widget(inCard('band-paired-card', find.byKey(const ValueKey('your-band-status')))).data, + 'Tyler’s WHOOP 5.0 / MG'); + expect(inCard('band-paired-card', find.text('Connected')), findsOneWidget); + // None of the old copy. + expect(find.text('Paired'), findsNothing); + expect(find.textContaining('You already have a band paired'), findsNothing); + expect(find.text('No band is paired'), findsNothing); + expect(find.byKey(const ValueKey('main-band-whoop-only-note')), findsNothing); + expect(find.text('Pair your band'), findsNothing); + await t.tap(inCard('band-paired-card', find.text('Unpair band'))); + expect(unpair, 1); + await t.tap(find.text('Add a device')); + expect(add, 1); + }); + + testWidgets('Unpair band is tinted red, soft, not a filled button', (t) async { + await pumpPair(t, view([_band()], onUnpair: () {}), size: const Size(412, 1400)); + final button = t.widget(find.byType(BigButton)); + expect(button.color, C.red); + expect(button.soft, isTrue); + expect(find.text('Unpair band'), findsOneWidget); + }); + + testWidgets('Pair your band is the filled one', (t) async { + await pumpPair(t, view(const [], onPair: () {}), size: const Size(412, 1400)); + expect(t.widget(find.byType(BigButton)).soft, isFalse); + }); + + testWidgets('no callbacks, no band: no Pair a device section at all', (t) async { + await pumpPair(t, const MyDevicesView(), size: const Size(412, 1400)); + expect(find.text('Pair a device'), findsNothing); + expect(find.byKey(const ValueKey('pair-band-card')), findsNothing); + }); + + testWidgets('the forget flow asks first, and Keep it paired changes nothing', (t) async { + final app = AppState(); + addTearDown(app.dispose); + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => debugConfirmForgetBand(c, app, 'Tyler’s WHOOP'), + child: const Text('go')))), + size: const Size(412, 900)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + expect(find.text('Forget Tyler’s WHOOP?'), findsOneWidget); + await t.tap(find.text('Keep it paired')); + await t.pumpAndSettle(); + expect(find.text('Forget Tyler’s WHOOP?'), findsNothing); + }); + + testWidgets('German at 2x text does not overflow, in either state', (t) async { + for (final (sources, card) in [ + (const [], 'pair-band-card'), + ([_band()], 'band-paired-card'), + ]) { + await pumpPair(t, view(sources, onPair: () {}, onAdd: () {}, onUnpair: () {}), + locale: const Locale('de'), textScale: 2, size: const Size(360, 1800)); + final ex = t.takeException(); + expect(ex, isNull, reason: ex is FlutterError ? ex.toStringDeep() : '$ex'); + expect(find.byKey(ValueKey(card)), findsOneWidget); + } + }); + }); + + group('Add a device (the unified scan screen)', () { + DevicePickerView view({ + List found = const [], + List<({BandEntry entry, String blurb, IconData icon})> categories = const [], + ValueNotifier? kindSelection, + String? notice, + ValueChanged? onPick, + }) => + DevicePickerView( + query: TextEditingController(), + title: 'Add a device', + subtitle: 'Scan once.', + found: found, + categories: categories, + kindSelection: kindSelection ?? ValueNotifier(null), + notice: notice, + onPickNearby: onPick, + ); + + testWidgets('a WHOOP heard as a strap gets the Every day chip, read off the band; a plain strap Workouts only', (t) async { + await pumpPair( + t, + view(found: [ + _cand('ble_hrs', label: 'Jordan’s WHOOP', alsoBand: kWhoopGen5, id: 'AA:01'), + _cand('ble_hrs', label: 'Polar H10', id: 'AA:02'), + ]), + size: const Size(412, 1400)); + expect(find.text('This is a band'), findsNothing); + // Both candidates are ble_hrs rows; the chip under each is its own. + final chips = find.byKey(const ValueKey('row-slot-ble_hrs')); + expect(find.byKey(ValueKey('row-slot-${kWhoopGen5.id}')), findsOneWidget); + expect(find.descendant(of: find.byKey(ValueKey('row-slot-${kWhoopGen5.id}')), matching: find.text('Every day')), + findsOneWidget); + expect(chips, findsOneWidget); + expect(find.descendant(of: chips, matching: find.text('Workouts only')), findsOneWidget); + }); + + testWidgets('a ring that is nearby gets the Every day chip', (t) async { + await pumpPair(t, view(found: [_cand('oura', label: 'Oura Ring')]), size: const Size(412, 1400)); + expect(find.descendant(of: find.byKey(const ValueKey('row-slot-oura')), matching: find.text('Every day')), + findsOneWidget); + expect(find.text('This is a band'), findsNothing); + }); + + testWidgets('a device that fits neither column (Coros) gets no chip', (t) async { + await pumpPair(t, view(found: [_cand('coros', label: 'Coros')]), size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('row-slot-coros')), findsNothing); + }); + + testWidgets('Browse rows carry the chip too', (t) async { + await pumpPair( + t, + view(categories: [ + (entry: kOura, blurb: 'ring blurb', icon: Icons.circle), + (entry: kBleHrs, blurb: 'strap blurb', icon: Icons.favorite), + ]), + size: const Size(412, 1400)); + expect(find.descendant(of: find.byKey(const ValueKey('row-slot-oura')), matching: find.text('Every day')), + findsOneWidget); + expect(find.descendant(of: find.byKey(const ValueKey('row-slot-ble_hrs')), matching: find.text('Workouts only')), + findsOneWidget); + }); + + testWidgets('pills cover the found devices and the browse list; Rings narrows both', (t) async { + final kind = ValueNotifier(null); + Widget harness() => ValueListenableBuilder( + valueListenable: kind, + builder: (_, _, _) => view( + kindSelection: kind, + found: [ + _cand('oura', label: 'Oura Ring', id: 'AA:01'), + _cand('ble_hrs', label: 'Polar H10', id: 'AA:02'), + _cand('polar_pmd', label: 'Polar Verity Sense', id: 'AA:03'), + ], + categories: [ + (entry: kOura, blurb: 'ring blurb', icon: Icons.circle), + (entry: kBleHrs, blurb: 'strap blurb', icon: Icons.favorite), + (entry: kPolarPmd, blurb: 'armband blurb', icon: Icons.favorite), + ])); + await pumpPair(t, harness(), size: const Size(412, 1400)); + expect(find.text('Rings'), findsOneWidget); + expect(find.text('Chest straps & armbands'), findsOneWidget); + await t.tap(find.text('Rings')); + await t.pumpAndSettle(); + expect(kind.value, PairFilter.rings); + expect(find.text('Polar H10'), findsNothing); + expect(find.text('Polar Verity Sense'), findsNothing); + expect(find.text('armband blurb'), findsNothing); + expect(find.text('Oura Ring'), findsWidgets); + expect(find.text('strap blurb'), findsNothing); + expect(find.text('ring blurb'), findsOneWidget); + }); + + testWidgets('(CR r1) a kind the search emptied out filters nothing', (t) async { + // WHY: with one kind left the pills hide, "All" with them; a stale + // selection then hid every row with no control to clear it. + final kind = ValueNotifier(PairFilter.rings); + await pumpPair( + t, + view(kindSelection: kind, found: [ + _cand('ble_hrs', label: 'Polar H10', id: 'AA:02'), + ]), + size: const Size(412, 1400)); + expect(find.text('All'), findsNothing); + expect(find.text('Polar H10'), findsWidgets); + }); + + testWidgets('no pills when onKind is not supplied (the old screen is unchanged)', (t) async { + await pumpPair( + t, + DevicePickerView( + query: TextEditingController(), + title: 't', + subtitle: 's', + found: [_cand('oura', label: 'Oura Ring'), _cand('ble_hrs', label: 'Polar H10', id: 'AA:02')]), + size: const Size(412, 1400)); + expect(find.text('All'), findsNothing); + }); + + testWidgets('after a workouts-only device pairs: “Paired for workouts only.”', (t) async { + await pumpPair(t, view(notice: 'Paired for workouts only.'), size: const Size(412, 1400)); + expect(find.text('Paired for workouts only.'), findsOneWidget); + }); + + testWidgets('tapping a nearby row hands the candidate over', (t) async { + final got = []; + await pumpPair(t, view(found: [_cand('ble_hrs', label: 'Polar H10')], onPick: (c) => got.add(c.entryId)), + size: const Size(412, 1400)); + await t.tap(find.text('Polar H10')); + expect(got, ['ble_hrs']); + }); + }); +} diff --git a/test/ui2_pair_sheets_test.dart b/test/ui2_pair_sheets_test.dart new file mode 100644 index 000000000..ff6455b6d --- /dev/null +++ b/test/ui2_pair_sheets_test.dart @@ -0,0 +1,1077 @@ +// The "this is a band" / "sensor without a band" sheets, and the tap flow that +// decides which one a scanned device gets. Rendered, with a fake pairing +// operation: no BLE. +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' show BluetoothDevice; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/band_identity.dart'; +import 'package:openstrap_edge/ble/hrs_link.dart' show BandCandidate; +import 'package:openstrap_edge/ui2/pairing/add_device_flow.dart'; +import 'package:openstrap_edge/ui2/profile/devices.dart' show kPairableSensors; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/pairing/device_picker.dart' show DevicePickerView; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart' show PairFilter; +import 'package:openstrap_edge/ui2/profile/pair_sensor.dart' show afterBandFoundText; +import 'package:openstrap_edge/ui2/pairing/pair_sheets.dart'; + +import 'support/pair_ui_support.dart'; +import 'package:openstrap_edge/ui2/pairing/slot_comparison_view.dart'; + +BandCandidate _cand(String entryId, + {String id = 'AA:BB:CC:DD:EE:01', String? label, BandEntry? alsoBand}) => + ( + device: BluetoothDevice.fromId(id), + label: label, + rssi: -60, + entryId: entryId, + alsoBand: alsoBand == null ? null : (entry: alsoBand, modelCertain: true), + ); + +/// A screen with one button that runs [flow] on [cand] and prints the result. +class _Harness extends StatefulWidget { + final AddDeviceFlow flow; + final BandCandidate cand; + const _Harness(this.flow, this.cand); + @override + State<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends State<_Harness> { + String result = 'none'; + String second = 'none'; + @override + Widget build(BuildContext c) => Scaffold( + body: Column(children: [ + TextButton( + onPressed: () async { + final r = await widget.flow.onTap(c, widget.cand); + setState(() => result = '${r.outcome.name}|${r.message ?? ''}'); + }, + child: const Text('tap')), + // A competing tap on the same flow, as a second row would make. + TextButton( + onPressed: () async { + final r = await widget.flow.onTap(c, widget.cand); + setState(() => second = '${r.outcome.name}|${r.message ?? ''}'); + }, + child: const Text('tap again')), + Text(result, key: const ValueKey('result')), + Text(second, key: const ValueKey('second')), + ]), + ); +} + +class _Calls implements AddDeviceIo { + final pairs = <(String entryId, PairConfirmation? confirmation)>[]; + final opened = []; + final events = []; + String? heldBack; + final List outcomes; + Object? pairError; + _Calls([this.outcomes = const [PairOutcome.saved()]]); + int _i = 0; + bool _hasMain = false; + @override + bool get hasMainBand => _hasMain; + + String primaryId = 'primary'; + @override + String? get mainBandRemoteId => _hasMain ? primaryId : null; + + String? bandName = 'Jordan’s WHOOP'; + @override + String? get mainBandName => _hasMain ? bandName : null; + + Completer? openGate; + final replacementFlags = []; + @override + Future pair(BandCandidate cand, BandEntry entry, + PairConfirmation? confirmation) async { + if (pairError != null) throw pairError!; + events.add('pair:${entry.id}'); + pairs.add((entry.id, confirmation)); + return outcomes[_i < outcomes.length ? _i++ : outcomes.length - 1]; + } + + final openedWith = <(String remoteId, bool modelKnown)>[]; + @override + Future openBandFlow(BandEntry band, + {required String remoteId, bool modelKnown = true, bool replacing = false}) async { + events.add('open:${band.id}'); + replacementFlags.add(replacing); + await openGate?.future; + opened.add(band.id); + openedWith.add((remoteId, modelKnown)); + } + + @override + Future stopScan() async => events.add('stop'); + + @override + Future mainBandBlockedReason() async => heldBack; + + late AddDeviceFlow flowFor; + AddDeviceFlow flow({bool hasMain = false}) { + _hasMain = hasMain; + return flowFor = AddDeviceFlow(this); + } +} + +String _result(WidgetTester t) => + (t.widget(find.byKey(const ValueKey('result'))) as Text).data!; + +Future _tap(WidgetTester t, _Calls calls, BandCandidate cand, + {bool hasMain = false}) async { + await pumpPair(t, _Harness(calls.flow(hasMain: hasMain), cand), + size: const Size(412, 2000)); + await t.tap(find.text('tap')); + await t.pumpAndSettle(); +} + +void main() { + group('the band-found sheet', () { + Future open(WidgetTester t, BandEntry band, + {required bool hasMain, bool afterConnect = false}) async { + BandSheetChoice? got; + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => got = await showBandFoundSheet(c, + band: band, + hasMainBand: hasMain, + afterConnect: afterConnect), + child: const Text('open')))), + size: const Size(412, 2000)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + return got; + } + + testWidgets('pairing mode is up front: WHOOP 5’s steps, above the main button', (t) async { + await open(t, kWhoopGen5, hasMain: false); + expect(find.text('Put it in pairing mode'), findsOneWidget); + final step = t.getTopLeft(find.byKey(const ValueKey('pairing-mode-step-gen5-1'))).dy; + expect(step, lessThan(t.getTopLeft(find.text('Pair it as my band')).dy)); + expect(step, greaterThan(t.getTopLeft(find.byType(SlotTable)).dy)); + }); + + testWidgets('the table: every day sends sleep, workouts only does not; no dashes', (t) async { + await open(t, kWhoopGen5, hasMain: false); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-sleep'))).label, 'Sleep, Every day: Sends'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-workoutsOnly-sleep'))).label, 'Sleep, Workouts only: Doesn’t send'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-workoutsOnly-workoutHeartRate'))).label, 'Heart rate during workouts, Workouts only: Sends'); + expect(find.byIcon(LucideIcons.minus), findsNothing); + expect(find.text('Workouts only: If this device sends it, not confirmed'), findsOneWidget); + expect(find.text('Blood oxygen'), findsNothing); + }); + + testWidgets('the sheet washes the Every day column, not Workouts only', (t) async { + await open(t, kWhoopGen5, hasMain: false); + Color? colour(String slot) => (t + .widget(find + .descendant( + of: find.byKey(const ValueKey('slot-row-sleep')), + matching: find.byKey(ValueKey('slot-cell-$slot'))) + .first) + .color); + expect(colour('everyDay'), isNotNull); + expect(colour('workoutsOnly'), isNull); + }); + + testWidgets('a ring fills every day: the ring’s rows with their captions, and the experimental note', (t) async { + await open(t, kOura, hasMain: false); + expect(find.text('This is a ring'), findsOneWidget); + expect(find.text('It can fill your days with sleep, recovery and heart rate.'), findsOneWidget); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-sleep'))).label, 'Sleep, Every day: Sends. From the ring’s own sleep stages'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-workoutsOnly-sleep'))).label, 'Sleep, Workouts only: Doesn’t send'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-strain'))).label, 'Strain, Every day: Doesn’t send'); + expect(find.text('Every day: From the ring’s own sleep stages'), findsOneWidget); + expect(find.text('Every day: Estimated for the first 14 nights'), findsOneWidget); + expect(find.text('Every day: Provisional estimate; needs a few nights'), findsOneWidget); + expect(find.text('Fills days without your WHOOP. Experimental, so switch it on in Devices.'), findsOneWidget); + expect(find.text('Pair Oura Ring'), findsOneWidget); + expect(find.text('Pair it as my band'), findsNothing); + }); + + testWidgets('a heart-rate strap: workouts only, the switch note, and Pair', (t) async { + await pumpPair(t, Builder(builder: (c) => Scaffold(body: TextButton( + onPressed: () => showStrapSheet(c, sensor: kBleHrs), + child: const Text('open')))), size: const Size(412, 2000)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(find.text('This is a heart-rate sensor'), findsOneWidget); + expect(find.text('It adds heart rate while you work out. It does not record sleep, recovery or daily strain.'), findsOneWidget); + expect(find.text('Both need “Use during workouts” on.'), findsOneWidget); + expect(find.text('Workouts only: If this device sends it, not confirmed'), findsOneWidget); + expect(find.text('Pair Bluetooth heart rate sensor'), findsOneWidget); + expect(find.text('Go back'), findsOneWidget); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-sleep'))).label, 'Sleep, Every day: Doesn’t send'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-workoutHeartRate'))).label, 'Heart rate during workouts, Every day: Doesn’t send'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-workoutsOnly-workoutHeartRate'))).label, 'Heart rate during workouts, Workouts only: Sends'); + }); + + testWidgets('the confirm fits in German at 2x text', (t) async { + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => showReplaceBandConfirm(c, + mainBandName: 'Jordans WHOOP'), + child: const Text('open')))), + locale: const Locale('de'), textScale: 2, size: const Size(360, 800)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(find.byType(SlotTable), findsNothing); + }); + testWidgets('no main band yet: pair it as my band, or use it for workouts only', (t) async { + await open(t, kWhoopGen5, hasMain: false); + expect(find.text('This is a WHOOP band'), findsOneWidget); + expect(find.text('WHOOP 5'), findsOneWidget, reason: 'the device line'); + expect(find.text('It does much more every day than during workouts only. Which do you want?'), findsOneWidget); + expect(find.text('Pair it as my band'), findsOneWidget); + expect(find.text('Use it for workouts only'), findsOneWidget); + expect(find.text('Go back'), findsOneWidget); + }); + + + + testWidgets('a ring and a strap get their own pairing-mode section', (t) async { + await open(t, kOura, hasMain: false); + expect(find.byKey(const ValueKey('pairing-mode-oura')), findsOneWidget); + expect(find.byKey(const ValueKey('pairing-mode-step-oura-1')), findsOneWidget); + }); + + + + + + testWidgets('the choices come back to the caller', (t) async { + BandSheetChoice? got; + Future run(String label) async { + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => got = await showBandFoundSheet(c, + band: kWhoopGen5, hasMainBand: false), + child: const Text('open')))), + size: const Size(412, 2000)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + await t.tap(find.text(label)); + await t.pumpAndSettle(); + } + + await run('Pair it as my band'); + expect(got, BandSheetChoice.asBand); + got = null; + await run('Use it for workouts only'); + expect(got, BandSheetChoice.asSensor); + got = BandSheetChoice.asBand; + await run('Go back'); + expect(got, isNull); + }); + + testWidgets('a main band already exists: unpair it and pair this one, or workouts only', (t) async { + BandSheetChoice? got; + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => got = await showBandFoundSheet(c, + band: kWhoopGen5, + hasMainBand: true, + mainBandName: 'Jordan’s WHOOP'), + child: const Text('open')))), + size: const Size(412, 2000)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(find.text('You already have a band. Only one can be your band at a time.'), findsOneWidget); + expect(find.text('Your band now: Jordan’s WHOOP. Your data stays when you switch.'), findsOneWidget); + expect(find.text('Pair it as my band'), findsNothing); + expect(find.text('Use it for workouts only'), findsOneWidget); + await t.tap(find.text('Pair this band, then disconnect Jordan’s WHOOP')); + await t.pumpAndSettle(); + expect(got, BandSheetChoice.replaceBand); + }); + + testWidgets('a paired band with no name is “your band”', (t) async { + await open(t, kWhoopGen5, hasMain: true); + expect(find.text('Pair this band, then disconnect your band'), findsOneWidget); + }); + + testWidgets('Coros fits neither column: a watch, all crosses, one Pair button', (t) async { + await open(t, kCoros, hasMain: false); + expect(find.text('This is a watch'), findsOneWidget); + expect(find.text('Pair Coros watch'), findsOneWidget); + expect(find.text('Pair it as my band'), findsNothing); + expect(find.byIcon(LucideIcons.check), findsNothing); + expect(find.textContaining('its readings never feed a score'), findsOneWidget); + }); + + + + testWidgets('after connecting: says it identified itself, and that nothing was saved', (t) async { + await open(t, kWhoopGen5, hasMain: false, afterConnect: true); + expect( + find.text('It looked like a heart-rate strap, but when we connected it identified itself as WHOOP 5. Nothing was saved.'), + findsOneWidget); + expect(find.text('Pair it as my band'), findsOneWidget); + }); + + testWidgets('German at 2x text does not overflow', (t) async { + BandSheetChoice? ignore; + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => ignore = await showBandFoundSheet(c, + band: kWhoopGen5, hasMainBand: false), + child: const Text('open')))), + locale: const Locale('de'), textScale: 2, size: const Size(360, 900)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(ignore, isNull); + }); + }); + + group('the strap sheet', () { + Future open(WidgetTester t, BandEntry sensor, void Function(bool?) got) async { + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => + got(await showStrapSheet(c, sensor: sensor)), + child: const Text('open')))), + size: const Size(412, 2000)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + } + + + + testWidgets('a generic strap says there are no maker’s steps', (t) async { + await open(t, kBleHrs, (_) {}); + expect(find.byKey(const ValueKey('pairing-mode-no-site-ble_hrs')), findsOneWidget); + }); + + testWidgets('another kind of sensor is a heart-rate sensor', (t) async { + await open(t, kPolarPmd, (_) {}); + expect(find.text('This is a heart-rate sensor'), findsOneWidget); + expect(find.text('This is a heart-rate strap'), findsNothing); + }); + + testWidgets('Pair returns true, Go back false', (t) async { + bool? got; + await open(t, kBleHrs, (v) => got = v); + await t.tap(find.text('Pair Bluetooth heart rate sensor')); + await t.pumpAndSettle(); + expect(got, isTrue); + await open(t, kBleHrs, (v) => got = v); + await t.tap(find.text('Go back')); + await t.pumpAndSettle(); + expect(got, isFalse); + }); + + testWidgets('German at 2x text does not overflow', (t) async { + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => showStrapSheet(c, sensor: kBleHrs), + child: const Text('open')))), + locale: const Locale('de'), textScale: 2, size: const Size(360, 900)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + }); + }); + + group('replacing the band', () { + final whoop = _cand('gen5', id: 'AA:00:00:00:00:77', label: 'WHOOP 5A1B'); + + testWidgets('confirm opens replacement pairing without forgetting the current band', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoop, hasMain: true); + await t.tap(find.text('Pair this band, then disconnect Jordan’s WHOOP')); + await t.pumpAndSettle(); + expect(find.text('Replace Jordan’s WHOOP after this band connects?'), findsOneWidget); + expect(find.text('Your current band stays paired if setup fails or you cancel. Your data stays.'), findsOneWidget); + expect(calls.events, isEmpty, reason: 'nothing happens before the one confirm'); + await t.tap(find.text('Pair replacement')); + await t.pumpAndSettle(); + expect(calls.events, ['stop', 'open:gen5']); + expect(calls.openedWith, [('AA:00:00:00:00:77', true)]); + expect(calls.replacementFlags, [true]); + expect(calls.pairs, isEmpty); + expect(_result(t), 'openedBandFlow|'); + }); + + testWidgets('cancel does nothing: no unpair, no pairing, no band flow', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoop, hasMain: true); + await t.tap(find.text('Pair this band, then disconnect Jordan’s WHOOP')); + await t.pumpAndSettle(); + await t.tap(find.text('Cancel')); + await t.pumpAndSettle(); + expect(calls.events, isEmpty); + expect(calls.pairs, isEmpty); + expect(calls.opened, isEmpty); + expect(_result(t), 'cancelled|'); + }); + + + + testWidgets('a second tap while replacement pairing is running starts nothing', (t) async { + final calls = _Calls()..openGate = Completer(); + await _tap(t, calls, whoop, hasMain: true); + await t.tap(find.text('Pair this band, then disconnect Jordan’s WHOOP')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair replacement')); + await t.pump(); + expect(calls.events, ['stop', 'open:gen5']); + // Replacement pairing is still awaiting; a competing tap is refused at once. + await t.tap(find.text('tap again')); + await t.pumpAndSettle(); + expect(find.text('This is a WHOOP band'), findsNothing, + reason: 'no second sheet opened'); + expect((t.widget(find.byKey(const ValueKey('second'))) as Text).data, + 'cancelled|'); + calls.openGate!.complete(); + await t.pumpAndSettle(); + expect(calls.events, ['stop', 'open:gen5']); + expect(calls.opened, ['gen5']); + expect(_result(t), 'openedBandFlow|'); + // Once it ended, the flow takes a tap again. + expect(calls.flowFor.running, isFalse); + }); + + testWidgets('while a flow runs the Add a device list takes no taps', (t) async { + var taps = 0; + await pumpPair( + t, + DevicePickerView( + query: TextEditingController(), + title: 'Add a device', + subtitle: '', + found: [whoop], + locked: true, + onPickNearby: (_) => taps++, + ), + size: const Size(412, 2000)); + await t.tap(find.text('WHOOP 5A1B'), warnIfMissed: false); + await t.pumpAndSettle(); + expect(taps, 0); + await pumpPair( + t, + DevicePickerView( + query: TextEditingController(), + title: 'Add a device', + subtitle: '', + found: [whoop], + onPickNearby: (_) => taps++, + ), + size: const Size(412, 2000)); + await t.tap(find.text('WHOOP 5A1B')); + await t.pumpAndSettle(); + expect(taps, 1, reason: 'positive control: unlocked, the row takes a tap'); + }); + + testWidgets('a WHOOP heard as a strap is replaced the same way, bound to that device', (t) async { + final calls = _Calls(); + await _tap(t, calls, + _cand('ble_hrs', id: 'AA:00:00:00:00:78', label: 'Jordan’s other WHOOP', alsoBand: kWhoopGen4), + hasMain: true); + await t.tap(find.text('Pair this band, then disconnect Jordan’s WHOOP')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair replacement')); + await t.pumpAndSettle(); + expect(calls.events, ['stop', 'open:gen4']); + expect(calls.openedWith, [('AA:00:00:00:00:78', true)]); + }); + + testWidgets('after a connect found a band: the same replace, one confirm', (t) async { + final calls = _Calls([PairOutcome.band(kWhoopGen5)]); + await _tap(t, calls, _cand('ble_hrs', label: 'Polar H10'), hasMain: true); + await t.tap(find.text('Pair this band, then disconnect Jordan’s WHOOP')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair replacement')); + await t.pumpAndSettle(); + expect(calls.events, ['pair:ble_hrs', 'stop', 'open:gen5']); + }); + + + }); + + // WHY: the workout cleanup must never own the main peripheral's link. + // catches: letting the primary's remote id reach the secondary pair call. + for (final discoveredAfterConnect in [false, true]) { + testWidgets('current main band cannot become a sensor (after connect=$discoveredAfterConnect)', (t) async { + final calls = _Calls(discoveredAfterConnect + ? [PairOutcome.band(kWhoopGen5), const PairOutcome.saved()] + : [const PairOutcome.saved()]); + await _tap(t, calls, _cand('ble_hrs', id: 'primary', + alsoBand: discoveredAfterConnect ? null : kWhoopGen5), hasMain: true); + final choice = find.text('Use it for workouts only'); + if (choice.evaluate().isNotEmpty) { + await t.tap(choice); + await t.pumpAndSettle(); + } + expect(calls.pairs, isEmpty, + reason: 'reject before connecting to the saved primary'); + expect(_result(t), startsWith('failed|')); + }); + } + + // WHY: another flow can commit a primary while this confirmation is open. + // catches: checking primary identity only at the original tap. + testWidgets('workouts choice rechecks the primary identity before connecting', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('gen5', id: 'new-main'), hasMain: true); + calls.primaryId = 'new-main'; + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(_result(t), startsWith('failed|')); + }); + + // WHY: post-connect WHOOP identification supersedes a Polar-looking advert. + // catches: retrying WHOOP under Polar PMD's required characteristics. + testWidgets('WHOOP discovered during Polar pairing retries as BLE HRS', (t) async { + final calls = _Calls([PairOutcome.band(kWhoopGen5), const PairOutcome.saved()]); + await _tap(t, calls, _cand('polar_pmd'), hasMain: true); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.map((p) => p.$1), ['polar_pmd', 'ble_hrs']); + expect(calls.pairs.last.$2?.bandEntryId, 'gen5'); + expect(_result(t), 'pairedSecondary|'); + }); + + group('tap flow', () { + final whoopAsStrap = _cand('ble_hrs', label: 'Jordan’s WHOOP', alsoBand: kWhoopGen5); + + testWidgets('a WHOOP heard as a strap, no band yet: sheet, then “secondary anyway” pairs with a confirmation bound to that device and entry', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoopAsStrap); + expect(find.text('This is a WHOOP band'), findsOneWidget); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.length, 1); + expect(calls.pairs.single.$1, 'ble_hrs'); + expect(calls.pairs.single.$2!.remoteId, 'AA:BB:CC:DD:EE:01'); + expect(calls.pairs.single.$2!.bandEntryId, 'gen5'); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('“Pair it as my band” goes to the main band flow and pairs nothing here', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoopAsStrap); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(calls.opened, ['gen5']); + expect(_result(t), 'openedBandFlow|'); + }); + + testWidgets('(CR r1) the band flow is bound to the tapped device, not the first WHOOP in range', (t) async { + // WHY: handing over only the entry let the main-band scan pair any + // nearby WHOOP of that kind. + final calls = _Calls(); + await _tap(t, calls, _cand('ble_hrs', id: 'AA:00:00:00:00:42', alsoBand: kWhoopGen5)); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.openedWith, [('AA:00:00:00:00:42', true)]); + }); + + testWidgets('(CR r1) a WHOOP known only by its name is not named a model', (t) async { + // WHY: the name cannot tell WHOOP 4 from 5; the name-matched entry is + // gen4, which must not become "This is WHOOP 4" or a WHOOP 4 pairing. + final calls = _Calls(); + final byName = ( + device: BluetoothDevice.fromId('AA:00:00:00:00:43'), + label: 'WHOOP 4C0FFEE', + rssi: -60, + entryId: 'gen4', + alsoBand: (entry: kWhoopGen4, modelCertain: false), + ); + await _tap(t, calls, byName); + expect(find.text('This is a WHOOP band'), findsOneWidget); + expect(find.text('WHOOP 4'), findsNothing, reason: 'no model named'); + expect(find.text('WHOOP 4C0FFEE'), findsOneWidget, reason: 'its own name'); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.openedWith, [('AA:00:00:00:00:43', false)]); + }); + + testWidgets('Go back pairs nothing and opens nothing', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoopAsStrap); + await t.tap(find.text('Go back')); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(calls.opened, isEmpty); + expect(_result(t), 'cancelled|'); + }); + + testWidgets('a band already paired: workouts only pairs with the confirmation', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoopAsStrap, hasMain: true); + expect(find.text('Pair it as my band'), findsNothing); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.single.$2!.bandEntryId, 'gen5'); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('a plain strap and no band: the strap sheet, then pairs without a confirmation', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('ble_hrs', label: 'Polar H10')); + expect(find.text('This is a heart-rate sensor'), findsOneWidget); + expect(find.text('Polar H10'), findsOneWidget, reason: 'its own name'); + await t.tap(find.text('Pair Bluetooth heart rate sensor')); + await t.pumpAndSettle(); + expect(calls.pairs.single.$1, 'ble_hrs'); + expect(calls.pairs.single.$2, isNull); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('a plain strap, declined: nothing pairs', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('ble_hrs')); + await t.tap(find.text('Go back')); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(_result(t), 'cancelled|'); + }); + + testWidgets('a plain strap with a band already paired pairs at once, no sheet', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('ble_hrs'), hasMain: true); + expect(find.text('This is a heart-rate strap'), findsNothing); + expect(calls.pairs.single.$1, 'ble_hrs'); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('a thermometer is not a secondary sensor: no sheet, and no secondary message', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('thermometer')); + expect(find.text('This is a heart-rate strap'), findsNothing); + expect(calls.pairs.single.$1, 'thermometer'); + expect(_result(t), 'pairedOther|'); + }); + + testWidgets('a connect that finds a band: the identified-itself sheet; confirming re-runs pairing with the confirmation', (t) async { + final calls = _Calls([PairOutcome.band(kWhoopGen5), const PairOutcome.saved()]); + await _tap(t, calls, _cand('ble_hrs', label: 'Polar H10'), hasMain: true); + expect(find.textContaining('identified itself as WHOOP 5. Nothing was saved.'), findsOneWidget); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.length, 2); + expect(calls.pairs[0].$2, isNull); + expect(calls.pairs[1].$2!.remoteId, 'AA:BB:CC:DD:EE:01'); + expect(calls.pairs[1].$2!.bandEntryId, 'gen5'); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('a connect that finds a band, no main band: “Pair it as my band” opens that flow', (t) async { + final calls = _Calls([PairOutcome.band(kWhoopGen5)]); + await _tap(t, calls, _cand('ble_hrs'), hasMain: false); + // The strap sheet first (nothing known yet), then the band sheet after connect. + await t.tap(find.text('Pair Bluetooth heart rate sensor')); + await t.pumpAndSettle(); + expect(find.textContaining('identified itself as WHOOP 5'), findsOneWidget); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.pairs.length, 1); + expect(calls.opened, ['gen5']); + expect(_result(t), 'openedBandFlow|'); + }); + + testWidgets('a connect that finds a band, then Go back: pairing ran once and nothing was saved', (t) async { + final calls = _Calls([PairOutcome.band(kWhoopGen5)]); + await _tap(t, calls, _cand('ble_hrs'), hasMain: true); + await t.ensureVisible(find.text('Go back')); + await t.pumpAndSettle(); + await t.tap(find.text('Go back')); + await t.pumpAndSettle(); + expect(calls.pairs.length, 1); + expect(_result(t), 'cancelled|'); + }); + + // WHY: database/device errors must reach the screen and leave taps retryable. + // catches: an exception escaping onTap before its caller can show a result. + testWidgets('a thrown pairing error becomes a visible failure and allows retry', (t) async { + final calls = _Calls()..pairError = StateError('device rows unavailable'); + await _tap(t, calls, _cand('ble_hrs'), hasMain: true); + expect(_result(t), contains('Could not pair that device: Bad state: device rows unavailable')); + expect(calls.flowFor.running, isFalse); + calls.pairError = null; + await t.tap(find.text('tap')); + await t.pumpAndSettle(); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('a failure comes back as its sentence', (t) async { + final calls = _Calls([const PairOutcome.failed('The sensor did not answer.')]); + await _tap(t, calls, _cand('ble_hrs'), hasMain: true); + expect(_result(t), 'failed|The sensor did not answer.'); + }); + + testWidgets('a ring (a band in its own right) gets the band sheet, and its own screen does the pairing', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('oura', label: 'Oura Ring')); + expect(find.text('This is a ring'), findsOneWidget); + // Not a WHOOP, so there is no main-band choice: one button. + expect(find.text('Pair it as my band'), findsNothing); + // The comparison table and unverified guidance can put this below the fold. + await t.ensureVisible(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + // The ring needs its own key screen, so it never pairs from here. + expect(calls.pairs, isEmpty); + expect(calls.opened, ['oura']); + }); + }); + + group('(CR r2) a ring heard as a heart-rate strap', () { + final ouraAsStrap = + _cand('ble_hrs', id: 'AA:00:00:00:00:91', label: 'Oura', alsoBand: kOura); + + testWidgets('the tap opens the ring’s own screen, never a plain strap pairing', (t) async { + final calls = _Calls(); + await _tap(t, calls, ouraAsStrap); + expect(find.text('This is a ring'), findsOneWidget); + // The comparison table and unverified guidance can put this below the fold. + await t.ensureVisible(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(calls.opened, ['oura']); + expect(_result(t), 'openedBandFlow|'); + }); + + testWidgets('a connect that finds the ring does the same', (t) async { + final calls = _Calls([PairOutcome.band(kOura)]); + await _tap(t, calls, _cand('ble_hrs', label: 'HRM'), hasMain: true); + await t.ensureVisible(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + // The comparison table and unverified guidance can put this below the fold. + await t.ensureVisible(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + expect(calls.pairs.length, 1, reason: 'only the first connect'); + expect(calls.opened, ['oura']); + expect(_result(t), 'openedBandFlow|'); + }); + }); + + group('(CR r2) a WHOOP heard as a strap is filtered as a WHOOP', () { + testWidgets('the WHOOP pill shows it, the Straps pill does not', (t) async { + final group = ValueNotifier(null); + await pumpPair( + t, + // The screen that owns the selection rebuilds on it, as the picker + // does. + ValueListenableBuilder( + valueListenable: group, + builder: (_, _, _) => DevicePickerView( + query: TextEditingController(), + title: 'Add a device', + subtitle: '', + found: [ + _cand('ble_hrs', id: 'AA:00:00:00:00:A1', label: 'Jordan’s WHOOP', alsoBand: kWhoopGen5), + _cand('ble_hrs', id: 'AA:00:00:00:00:A2', label: 'Polar H10'), + // This Polar fixture is an armband; HRS alone proves no placement. + _cand('polar_pmd', id: 'AA:00:00:00:00:A3', label: 'Polar Verity Sense'), + ], + kindSelection: group, + ), + ), + size: const Size(412, 2000)); + expect(find.byKey(const ValueKey('pill-whoop')), findsOneWidget); + group.value = PairFilter.whoop; + await t.pumpAndSettle(); + expect(find.text('Jordan’s WHOOP'), findsOneWidget); + expect(find.text('Polar H10'), findsNothing); + group.value = PairFilter.straps; + await t.pumpAndSettle(); + expect(find.text('Jordan’s WHOOP'), findsNothing); + expect(find.text('Polar H10'), findsNothing); + expect(find.text('Polar Verity Sense'), findsOneWidget); + group.value = null; + await t.pumpAndSettle(); + expect(find.text('Polar H10'), findsOneWidget); + expect(find.text('Jordan’s WHOOP'), findsOneWidget); + }); + }); + + group('(CR r2) after the band flow, the sensor screen tells the truth', () { + final l = lookupAppLocalizations(const Locale('en')); + final found = PairOutcome.band(kWhoopGen5); + const opened = (outcome: AddDeviceOutcome.openedBandFlow, message: null); + test('the band got paired: no "Nothing was saved"', () { + expect(afterBandFoundText(l, found, opened, bandPairedNow: true), isNull); + }); + test('the band flow closed without pairing: nothing was saved, said so', () { + expect(afterBandFoundText(l, found, opened, bandPairedNow: false), + contains('Nothing was saved')); + }); + test('a ring’s own screen reported its own outcome', () { + expect( + afterBandFoundText(l, PairOutcome.band(kOura), opened, + bandPairedNow: false), + isNull); + }); + test('backed out, or failed, as before', () { + expect( + afterBandFoundText( + l, found, (outcome: AddDeviceOutcome.cancelled, message: null), + bandPairedNow: false), + contains('Nothing was saved')); + expect( + afterBandFoundText( + l, found, (outcome: AddDeviceOutcome.failed, message: 'x'), + bandPairedNow: true), + 'x'); + }); + }); + + group('browsing goes through the same confirmations', () { + // A button that runs confirmBrowse and prints the answer. + Future browse(WidgetTester t, _Calls calls, BandEntry e, + {required bool hasMain, String? tapLabel}) async { + bool? got; + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => + got = await calls.flow(hasMain: hasMain).confirmBrowse(c, e), + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + if (tapLabel != null) { + await t.tap(find.text(tapLabel)); + await t.pumpAndSettle(); + } + return got; + } + + testWidgets('the generic heart-rate sensor with no main band asks first; Go back says no', (t) async { + final calls = _Calls(); + final got = await browse(t, calls, kBleHrs, hasMain: false, tapLabel: 'Go back'); + expect(got, isFalse); + }); + + testWidgets('... and “Pair” says yes', (t) async { + final calls = _Calls(); + expect(await browse(t, calls, kBleHrs, hasMain: false, tapLabel: 'Pair Bluetooth heart rate sensor'), isTrue); + }); + + testWidgets('Polar PMD is a workout sensor too: same sheet', (t) async { + final calls = _Calls(); + await browse(t, calls, kPolarPmd, hasMain: false); + expect(find.text('This is a heart-rate sensor'), findsOneWidget); + }); + + testWidgets('with a main band, or for a device that is its own entry, no sheet', (t) async { + final calls = _Calls(); + expect(await browse(t, calls, kBleHrs, hasMain: true), isTrue); + expect(find.text('This is a heart-rate strap'), findsNothing); + expect(await browse(t, calls, kColmi, hasMain: false), isTrue); + expect(find.text('This is a heart-rate strap'), findsNothing); + }); + + testWidgets('a connect that finds a band (category path): band-found sheet, then re-pair with the confirmation', (t) async { + final calls = _Calls([const PairOutcome.saved()]); + bool? ok; + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async { + final r = await calls.flow(hasMain: true).afterConnect( + c, _cand('ble_hrs'), kBleHrs, PairOutcome.band(kWhoopGen5)); + ok = r.outcome == AddDeviceOutcome.pairedSecondary; + }, + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + expect(find.textContaining('identified itself as WHOOP 5. Nothing was saved.'), findsOneWidget); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.single.$2!.bandEntryId, 'gen5'); + expect(ok, isTrue); + }); + }); + + testWidgets('(CR 4500501772) a name-only WHOOP found on connect keeps its unknown model', (t) async { + // WHY: the connect can only confirm what the name said (gen4 is the + // name-matched entry); a WHOOP 5 heard by name must not enter the band + // flow as a WHOOP 4. + final calls = _Calls(); + final byName = ( + device: BluetoothDevice.fromId('AA:00:00:00:00:44'), + label: 'WHOOP 4C0FFEE', + rssi: -60, + entryId: 'ble_hrs', + alsoBand: (entry: kWhoopGen4, modelCertain: false), + ); + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => calls.flow(hasMain: false).afterConnect( + c, byName, kBleHrs, + const PairOutcome.band(kWhoopGen4, modelCertain: false)), + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + expect(find.textContaining('WHOOP 4.'), findsNothing, reason: 'no model named'); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.openedWith, [('AA:00:00:00:00:44', false)]); + }); + + testWidgets('(CR 4500539072) a model GATT confirmed is named, even when the name guessed it', (t) async { + final calls = _Calls(); + final byName = ( + device: BluetoothDevice.fromId('AA:00:00:00:00:45'), + label: 'WHOOP 4C0FFEE', + rssi: -60, + entryId: 'ble_hrs', + alsoBand: (entry: kWhoopGen4, modelCertain: false), + ); + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => calls.flow(hasMain: false).afterConnect( + c, byName, kBleHrs, const PairOutcome.band(kWhoopGen4)), + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.openedWith, [('AA:00:00:00:00:45', true)]); + }); + + testWidgets('(CR 4500539075) a ring found on connect pairs under its own entry', (t) async { + // WHY: kept under ble_hrs, a Colmi would be a workout-only strap with no + // ring history sync (and fail outright without 0x2A37). + final calls = _Calls([const PairOutcome.saved()]); + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => calls.flow(hasMain: true).afterConnect( + c, _cand('ble_hrs', label: 'R02_1A2B'), kBleHrs, + const PairOutcome.band(kColmi)), + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + await t.ensureVisible(find.text('Pair Colmi ring')); + await t.tap(find.text('Pair Colmi ring')); + await t.pumpAndSettle(); + expect(calls.pairs.map((p) => p.$1), ['colmi']); + }); + + testWidgets('(CR 4500539075) a ring heard as a heart-rate sensor pairs under its own entry from the list', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('ble_hrs', label: 'R02_1A2B', alsoBand: kColmi)); + await t.ensureVisible(find.text('Pair Colmi ring')); + await t.tap(find.text('Pair Colmi ring')); + await t.pumpAndSettle(); + expect(calls.pairs.map((p) => p.$1), ['colmi']); + }); + + for (final sensor in kPairableSensors.where((s) => s.pick != null)) { + testWidgets('(CR 4500569334) ${sensor.entry.id} found on connect goes to its own screen, never a plain save', (t) async { + // WHY: its pairing installs a key (its `pick`). A plain notify pair + // would save a row sync cannot use. + final calls = _Calls([const PairOutcome.saved()]); + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => calls.flow(hasMain: true).afterConnect( + c, _cand('ble_hrs'), kBleHrs, + PairOutcome.band(sensor.entry)), + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + final pair = find.textContaining('Pair ').last; + await t.ensureVisible(pair); + await t.tap(pair); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(calls.opened, [sensor.entry.id]); + }); + } + + group('the handoff to the main-band flow', () { + final whoopOnly = _cand('gen5', label: 'WHOOP 5A1B'); + + testWidgets('the scan is stopped BEFORE the main-band flow opens', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoopOnly); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.events, ['stop', 'open:gen5']); + expect(_result(t), 'openedBandFlow|'); + }); + + testWidgets('where the system pairing sheet cannot open (iOS, no provisioned band) restart guidance replaces it', (t) async { + final calls = _Calls()..heldBack = 'Restart the app before you pair it.'; + await _tap(t, calls, whoopOnly); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.opened, isEmpty); + expect(calls.events, ['stop']); + expect(_result(t), 'bandFlowHeldBack|Restart the app before you pair it.'); + }); + }); + + group('a WHOOP-only advertisement', () { + testWidgets('the band-choice flow runs for a scanned framed entry; a secondary choice pairs under ble_hrs with the confirmation', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('gen5', label: 'WHOOP 5A1B'), hasMain: true); + expect(find.text('This is a WHOOP band'), findsOneWidget); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.single.$1, 'ble_hrs'); + expect(calls.pairs.single.$2!.bandEntryId, 'gen5'); + expect(_result(t), 'pairedSecondary|'); + }); + }); +} diff --git a/test/ui2_pairing_blocks_test.dart b/test/ui2_pairing_blocks_test.dart new file mode 100644 index 000000000..d9cfbfd91 --- /dev/null +++ b/test/ui2_pairing_blocks_test.dart @@ -0,0 +1,422 @@ +// The two swappable pairing building blocks: the capability matrix and +// the category filter pills, plus the registry field the pills read. +// Rendered, not read: both are layout. +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' show Tristate; + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart' show SemanticsNode; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/slot_comparison.dart'; +import 'package:openstrap_edge/ui2/pairing/category_filter_pills.dart'; +import 'package:openstrap_edge/ui2/ui2.dart' show S; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart'; +import 'package:openstrap_edge/ui2/pairing/slot_comparison_view.dart'; + +import 'support/pair_ui_support.dart'; + +Finder _mark(String side, DeviceCapability c) => + find.byKey(ValueKey('slot-$side-${c.name}')); + +bool _hasIcon(WidgetTester t, Finder mark, IconData icon) => t + .widgetList(find.descendant(of: mark, matching: find.byType(Icon))) + .any((i) => i.icon == icon); + +bool _sel(SemanticsNode n) => n.getSemanticsData().flagsCollection.isSelected == Tristate.isTrue; + +void main() { + group('BandEntry.kind', () { + test('only generic HRS leaves physical placement unknown', () { + // WHY: HRS can be a watch, armband or strap; named devices retain their kinds. + final other = [ + for (final e in kBandRegistry) + if (e.kind == DeviceKind.other) e.id + ]; + expect(other, ['ble_hrs']); + }); + + test('the kinds the owner named', () { + expect(kWhoopGen4.kind, DeviceKind.strap); + expect(kWhoopGen5.kind, DeviceKind.strap); + expect(kOura.kind, DeviceKind.ring); + expect(kColmi.kind, DeviceKind.ring); + expect(kUltrahuman.kind, DeviceKind.ring); + expect(kGarmin.kind, DeviceKind.watch); + expect(kMiBand234.kind, DeviceKind.band); + expect(kBleHrs.kind, DeviceKind.other); + expect(kThermometer.kind, DeviceKind.thermometer); + expect(kMiScale2.kind, DeviceKind.scale); + }); + }); + + group('CategoryFilterPills', () { + Widget pills(List kinds, + {PairFilter? selected, ValueNotifier? selection}) => + Scaffold( + body: CategoryFilterPills( + kinds: kinds, + selection: selection ?? ValueNotifier(selected)), + ); + + testWidgets('all five filters keep reading order when the strap label wraps', + (t) async { + await pumpPair( + t, + pills([ + DeviceKind.chestStrap, + DeviceKind.watch, + DeviceKind.ring, + DeviceKind.strap, + ]), + size: const Size(600, 800)); + // The fuller strap label can wrap to the next line. Pin reading order + // in both dimensions, so wrapping does not masquerade as reordering. + final positions = [ + for (final k in ['All', 'WHOOP', 'Rings', 'Watches', 'Chest straps & armbands']) + t.getTopLeft(find.text(k)), + ]; + final readingOrder = [...positions]..sort((a, b) { + final row = a.dy.compareTo(b.dy); + return row == 0 ? a.dx.compareTo(b.dx) : row; + }); + expect(positions, readingOrder); + expect(positions.toSet().length, 5); + }); + + testWidgets('only the groups that have entries get a pill', (t) async { + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.strap, DeviceKind.ring])); + expect(find.text('All'), findsOneWidget); + expect(find.text('WHOOP'), findsOneWidget); + expect(find.text('Rings'), findsOneWidget); + expect(find.text('Watches'), findsNothing); + expect(find.text('Chest straps & armbands'), findsNothing); + }); + + testWidgets('a Mi Band (DeviceKind.band) is under Watches, not a pill of its own', + (t) async { + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.band])); + expect(find.text('Watches'), findsOneWidget); + expect(find.text('Bands'), findsNothing); + expect(pairFilterOf(DeviceKind.band), PairFilter.watches); + expect(pairFilterOf(DeviceKind.watch), PairFilter.watches); + }); + + testWidgets('a scale shows under All only: it earns no pill', (t) async { + await pumpPair(t, pills([DeviceKind.scale, DeviceKind.thermometer])); + expect(find.text('All'), findsNothing); + expect(pairFilterOf(DeviceKind.scale), isNull); + expect(pairFilterOf(DeviceKind.thermometer), isNull); + // With a ring beside it the scale still adds no pill. + await pumpPair(t, pills([DeviceKind.scale, DeviceKind.ring, DeviceKind.watch])); + expect(find.text('All'), findsOneWidget); + expect(find.text('Rings'), findsOneWidget); + expect(find.text('Watches'), findsOneWidget); + expect(find.byType(CategoryFilterPills), findsOneWidget); + }); + + test('every kind maps to its group', () { + expect(pairFilterOf(DeviceKind.strap), PairFilter.whoop); + expect(pairFilterOf(DeviceKind.ring), PairFilter.rings); + expect(pairFilterOf(DeviceKind.chestStrap), PairFilter.straps); + expect(pairFilterOf(DeviceKind.armband), PairFilter.straps); + expect(pairFilterOf(DeviceKind.other), isNull); + }); + + testWidgets('a list with a single group shows no pills at all', (t) async { + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.ring])); + expect(find.text('All'), findsNothing); + expect(find.text('Rings'), findsNothing); + }); + + testWidgets('tapping a pill reports its group; All reports null', (t) async { + final got = []; + final sel = ValueNotifier(PairFilter.rings); + sel.addListener(() => got.add(sel.value)); + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.watch], selection: sel)); + await t.tap(find.text('Watches')); + await t.tap(find.text('All')); + expect(got, [PairFilter.watches, null]); + }); + + testWidgets('the selected pill is marked selected for screen readers, the rest are not', + (t) async { + await pumpPair( + t, pills([DeviceKind.ring, DeviceKind.watch], selected: PairFilter.rings)); + final s = t.getSemantics(find.byKey(const ValueKey('pill-rings'))); + expect(_sel(s), isTrue); + final o = t.getSemantics(find.byKey(const ValueKey('pill-watches'))); + expect(_sel(o), isFalse); + }); + + testWidgets('nothing selected means All is the selected pill', (t) async { + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.watch])); + expect(_sel(t.getSemantics(find.byKey(const ValueKey('pill-all')))), isTrue); + }); + + testWidgets('labels follow the locale', (t) async { + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.watch]), + locale: const Locale('de')); + expect(find.text('Alle'), findsOneWidget); + expect(find.text('Ringe'), findsOneWidget); + expect(find.text('Uhren'), findsOneWidget); + }); + + testWidgets('five groups wrap instead of overflowing at 2x text on a narrow phone', + (t) async { + await pumpPair( + t, + pills([ + DeviceKind.strap, + DeviceKind.ring, + DeviceKind.watch, + DeviceKind.chestStrap + ]), + textScale: 2, + size: const Size(320, 700)); + expect(t.takeException(), isNull); + }); + + test('filterByGroup keeps only that group, null keeps all', () { + final all = [kWhoopGen5, kOura, kColmi, kMiBand234, kBleHrs, kPolarPmd, kMiScale2]; + expect(filterByGroup(all, null), all); + expect(filterByGroup(all, PairFilter.rings), [kOura, kColmi]); + expect(filterByGroup(all, PairFilter.whoop), [kWhoopGen5]); + expect(filterByGroup(all, PairFilter.watches), [kMiBand234]); + expect(filterByGroup(all, PairFilter.straps), [kPolarPmd]); + }); + }); + + group('slot rows (pure)', () { + final whoop = slotComparisonFor(kWhoopGen5); + final strap = slotComparisonFor(kBleHrs); + + test('every comparison lists the whole table, in order, and never blood oxygen', () { + for (final c in [whoop, strap, slotComparisonFor(kOura), slotComparisonFor(kCoros)]) { + expect([for (final r in slotRowsFor(c)) r.capability], [ + DeviceCapability.sleep, + DeviceCapability.recovery, + DeviceCapability.strain, + DeviceCapability.heartRate, + DeviceCapability.hrv, + DeviceCapability.respiration, + DeviceCapability.skinTemperature, + DeviceCapability.steps, + DeviceCapability.workoutHeartRate, + DeviceCapability.workoutBeatToBeat, + ]); + } + }); + + test('a WHOOP sends every day and, read as a strap, during workouts', () { + final rows = {for (final r in slotRowsFor(whoop)) r.capability: r}; + expect(rows[DeviceCapability.sleep]!.everyDay, isNotNull); + expect(rows[DeviceCapability.sleep]!.workouts, isNull); + expect(rows[DeviceCapability.workoutHeartRate]!.everyDay, isNotNull); + expect(rows[DeviceCapability.workoutHeartRate]!.workouts, isNotNull); + }); + + test('skin temperature keeps its condition', () { + final r = slotRowsFor(whoop) + .firstWhere((r) => r.capability == DeviceCapability.skinTemperature); + expect(r.everyDay!.kind, CapabilityKind.conditional); + expect(r.everyDay!.reasonKey, kReasonSkinTempNights); + }); + + test('a strap alone sends nothing every day: its rows are its workout lines plus the gaps', () { + final rows = {for (final r in slotRowsFor(strap)) r.capability: r}; + // WHOOP: "Doesn't send" must stay visible for what a workout sensor lacks. + expect(rows[DeviceCapability.sleep]!.workouts, isNull); + expect(rows[DeviceCapability.sleep]!.everyDay, isNull); + expect(rows[DeviceCapability.workoutHeartRate]!.workouts, isNotNull); + expect(rows[DeviceCapability.workoutHeartRate]!.everyDay, isNull); + }); + }); + + group('SlotTable', () { + Widget table(SlotComparison c, {DeviceSlot? focus, DeviceSlot? dim}) => + Scaffold( + body: SingleChildScrollView( + child: SlotTable(comparison: c, focus: focus, dim: dim))); + + const rowNames = [ + 'Sleep', + 'Recovery', + 'Strain', + 'Heart rate and resting heart rate', + 'Heart-rate variability', + 'Respiration', + 'Skin temperature', + 'Steps', + 'Heart rate during workouts', + 'Beat-to-beat timing during workouts', + ]; + + testWidgets('every row is drawn, under the two column heads', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + expect(find.text('Every day'), findsOneWidget); + expect(find.text('Workouts only'), findsOneWidget); + expect(find.byKey(const ValueKey('slot-head-everyDay')), findsOneWidget); + expect(find.byKey(const ValueKey('slot-head-workoutsOnly')), findsOneWidget); + for (final c in kTableRows) { + expect(find.byKey(ValueKey('slot-row-${c.name}')), findsOneWidget, reason: c.name); + } + for (final n in rowNames) { + expect(find.text(n), findsOneWidget, reason: n); + } + // The workout rows name themselves; there is no group row. + expect(find.text('DURING WORKOUTS'), findsNothing); + expect(find.text('During workouts'), findsNothing); + }); + + testWidgets('the header leaves room above the column icons', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + final top = t.getTopLeft(find.byType(SlotTable)).dy; + final icon = t.getTopLeft(find.descendant( + of: find.byKey(const ValueKey('slot-cell-everyDay')).first, + matching: find.byType(Icon))) + .dy; + // Was 1 px (the table's border) before the owner asked for room. + expect(icon - top, greaterThanOrEqualTo(S.x3)); + }); + + testWidgets('blood oxygen is never a row', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + expect(find.byKey(const ValueKey('slot-row-bloodOxygen')), findsNothing); + expect(find.text('Blood oxygen'), findsNothing); + }); + + testWidgets('the workout rows come last, after Steps', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + final steps = t.getTopLeft(find.text('Steps')).dy; + expect(t.getTopLeft(find.text('Heart rate during workouts')).dy, greaterThan(steps)); + expect(t.getTopLeft(find.text('Beat-to-beat timing during workouts')).dy, + greaterThan(t.getTopLeft(find.text('Heart rate during workouts')).dy)); + }); + + testWidgets('a check in the column that sends, a cross in the one that does not', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + expect(_hasIcon(t, _mark('everyDay', DeviceCapability.sleep), LucideIcons.check), isTrue); + expect(_hasIcon(t, _mark('workoutsOnly', DeviceCapability.sleep), LucideIcons.x), isTrue); + expect(_hasIcon(t, _mark('workoutsOnly', DeviceCapability.sleep), LucideIcons.check), isFalse); + expect(_hasIcon(t, _mark('everyDay', DeviceCapability.sleep), LucideIcons.x), isFalse); + // A WHOOP kept for workouts sends heart rate in both columns. + expect(_hasIcon(t, _mark('workoutsOnly', DeviceCapability.workoutHeartRate), LucideIcons.check), isTrue); + }); + + testWidgets('a heart-rate strap crosses the whole-day column out', (t) async { + await pumpPair(t, table(slotComparisonFor(kBleHrs))); + for (final c in kDayCapabilities) { + expect(_hasIcon(t, _mark('everyDay', c), LucideIcons.x), isTrue, reason: c.name); + expect(_hasIcon(t, _mark('workoutsOnly', c), LucideIcons.x), isTrue, reason: c.name); + } + expect(_hasIcon(t, _mark('workoutsOnly', DeviceCapability.workoutHeartRate), LucideIcons.check), isTrue); + expect(_hasIcon(t, _mark('everyDay', DeviceCapability.workoutHeartRate), LucideIcons.x), isTrue); + }); + + testWidgets('no dash anywhere: a "no" is a cross', (t) async { + for (final e in [kWhoopGen5, kBleHrs, kOura, kCoros]) { + await pumpPair(t, table(slotComparisonFor(e))); + expect(find.byIcon(LucideIcons.minus), findsNothing, reason: e.id); + } + }); + + testWidgets('marks name the capability, column and sends status to a screen reader', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + expect(t.getSemantics(_mark('everyDay', DeviceCapability.sleep)).label, 'Sleep, Every day: Sends'); + expect(t.getSemantics(_mark('workoutsOnly', DeviceCapability.sleep)).label, 'Sleep, Workouts only: Doesn’t send'); + }); + + testWidgets('a conditional capability shows its caption under the label', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + expect(find.text('Every day: After a few nights'), findsOneWidget); + // Read as a workout sensor, a WHOOP's beat timing is conditional too. + expect(find.text('Workouts only: If this device sends it, not confirmed'), findsOneWidget); + // WHOOP 5 counts steps natively: no live-session caption. + expect(find.text('Every day: During live sessions'), findsNothing); + // The caption sits under its label, in the same row. + final row = find.byKey(const ValueKey('slot-row-skinTemperature')); + expect(find.descendant(of: row, matching: find.text('Skin temperature')), findsOneWidget); + expect(find.descendant(of: row, matching: find.text('Every day: After a few nights')), findsOneWidget); + expect(t.getTopLeft(find.text('Every day: After a few nights')).dy, + greaterThan(t.getTopLeft(find.text('Skin temperature')).dy)); + }); + + testWidgets('a strap says its beat timing is only if it sends it', (t) async { + await pumpPair(t, table(slotComparisonFor(kBleHrs))); + expect(find.text('Workouts only: If this device sends it, not confirmed'), findsOneWidget); + }); + + testWidgets('a ring’s captions: its own sleep stages and the first 14 nights', (t) async { + await pumpPair(t, table(slotComparisonFor(kOura))); + expect(find.text('Every day: From the ring’s own sleep stages'), findsOneWidget); + expect(find.text('Every day: Estimated for the first 14 nights'), findsOneWidget); + }); + + testWidgets('a vendor-reported value says so instead of looking native', (t) async { + await pumpPair( + t, + const Scaffold( + body: SingleChildScrollView( + child: SlotTable( + comparison: SlotComparison( + everyDay: SlotSide(sends: { + DeviceCapability.sleep: + CapabilityStatus(CapabilityKind.vendorReported) + }, doesNotSend: {}), + workoutsOnly: SlotSide(sends: {}, doesNotSend: {DeviceCapability.sleep})))))); + expect(find.text('Every day: The device’s own reading'), findsOneWidget); + }); + + testWidgets('focus washes only that column', (t) async { + Color? colorOf(String s) => t + .widget(find.byKey(ValueKey('slot-cell-$s')).first) + .color; + await pumpPair(t, table(slotComparisonFor(kBleHrs), focus: DeviceSlot.workoutsOnly)); + expect(colorOf('workoutsOnly'), isNotNull); + expect(colorOf('everyDay'), isNull); + await pumpPair(t, table(slotComparisonFor(kOura), focus: DeviceSlot.everyDay)); + expect(colorOf('everyDay'), isNotNull); + expect(colorOf('workoutsOnly'), isNull); + await pumpPair(t, table(slotComparisonFor(kOura))); + expect(colorOf('everyDay'), isNull); + expect(colorOf('workoutsOnly'), isNull); + }); + + testWidgets('dim fades only the other column (Opacity 0.28)', (t) async { + double opacityIn(String s) => t + .widget(find.descendant( + of: find.byKey(ValueKey('slot-cell-$s')).first, + matching: find.byType(Opacity)) + .first) + .opacity; + await pumpPair(t, table(slotComparisonFor(kBleHrs), dim: DeviceSlot.everyDay)); + expect(opacityIn('everyDay'), 0.28); + expect(opacityIn('workoutsOnly'), 1); + await pumpPair(t, table(slotComparisonFor(kOura), dim: DeviceSlot.workoutsOnly)); + expect(opacityIn('workoutsOnly'), 0.28); + expect(opacityIn('everyDay'), 1); + }); + + test('the WHOOP-only strings exist, non-blank, in every locale', () { + for (final f in Directory('lib/l10n').listSync().whereType().where((f) => RegExp(r'app_\w+\.arb$').hasMatch(f.path))) { + final arb = jsonDecode(f.readAsStringSync()) as Map; + for (final k in ['pairMainBandWhoopOnly']) { + expect(arb[k], isA(), reason: '${f.path} $k'); + expect((arb[k] as String).trim(), isNotEmpty, reason: '${f.path} $k'); + } + } + }); + + testWidgets('German heads and rows render without overflow at 2x text', (t) async { + for (final e in [kWhoopGen5, kBleHrs, kOura]) { + await pumpPair(t, table(slotComparisonFor(e), focus: DeviceSlot.everyDay, dim: DeviceSlot.workoutsOnly), + locale: const Locale('de'), textScale: 2, size: const Size(360, 900)); + expect(t.takeException(), isNull, reason: e.id); + expect(find.text('Schlaf'), findsOneWidget); + } + }); + }); +} diff --git a/test/ui2_pairing_review_test.dart b/test/ui2_pairing_review_test.dart new file mode 100644 index 000000000..aa74020dc --- /dev/null +++ b/test/ui2_pairing_review_test.dart @@ -0,0 +1,296 @@ +// PR #669 review regressions: exercise the rendered sheets, recovery screens, +// table and device details, rather than just their copy helpers. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/slot_comparison.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/onboarding/pairing.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_help.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_sheets.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart'; +import 'package:openstrap_edge/ui2/pairing/slot_comparison_view.dart'; +import 'package:openstrap_edge/ui2/profile/devices.dart'; + +import 'support/pair_ui_support.dart'; + +const _ouraKey = 'Already use the Oura app? Keep the ring in that app and pair with its existing key. Skip the reset steps below. Only reset it if you want a new key.'; +const _pebbleLead = 'Pebble 2 can fill your days with sleep, recovery and heart rate. Pebble 2 SE shows the watch’s own sleep and steps. It has no heart-rate sensor, so this app cannot calculate daily strain or estimate recovery from it.'; +const _strapLead = 'It adds heart rate while you work out. It does not record sleep, recovery or daily strain.'; + +Future _openBand(WidgetTester t, BandEntry entry) async { + await pumpPair(t, Builder(builder: (c) => Scaffold(body: TextButton( + onPressed: () => showBandFoundSheet(c, band: entry, hasMainBand: false), + child: const Text('open')))), size: const Size(412, 2400)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); +} + +void _expectKeyBeforeReset(WidgetTester t) { + final guidance = find.text(_ouraKey); + expect(guidance, findsOneWidget); + final reset = find.text('Open the maker’s app and remove this device from it.'); + expect(reset, findsOneWidget, reason: 'new-key reset path stays available'); + expect(t.getTopLeft(guidance).dy, lessThan(t.getTopLeft(reset).dy)); +} + +void main() { + // WHY: synthetic-night calibration stays provisional after the minimum nights. + // catches: replacing the provisional warning with only a waiting period. + for (final ring in [kOura, kColmi, kUltrahuman]) { + testWidgets('${ring.id} skin temperature retains its provisional warning', (t) async { + await _openBand(t, ring); + final row = find.byKey(const ValueKey('slot-row-skinTemperature')); + expect(find.descendant(of: row, matching: find.text( + 'Every day: Provisional estimate; needs a few nights')), findsOneWidget); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-skinTemperature'))).label, + contains('Provisional estimate; needs a few nights')); + }); + } + + // WHY: a name-only WHOOP could have either generation's step acquisition. + // catches: using the representative's live-only or stored-counter claim. + for (final representative in [kWhoopGen4, kWhoopGen5]) { + testWidgets('unknown WHOOP steps stay unconfirmed (${representative.id})', (t) async { + await pumpPair(t, Builder(builder: (c) => Scaffold(body: TextButton( + onPressed: () => showBandFoundSheet(c, band: representative, + hasMainBand: false, modelKnown: false), + child: const Text('open')))), size: const Size(412, 2400)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + final l = lookupAppLocalizations(const Locale('en')); + final row = find.byKey(const ValueKey('slot-row-steps')); + expect(find.descendant(of: row, matching: find.text( + l.slotColumnReason(l.slotEveryDay, l.capReasonUnconfirmed))), findsOneWidget); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-steps'))).label, + contains(l.capReasonUnconfirmed)); + expect(find.text('WHOOP 4'), findsNothing); + expect(find.text('WHOOP 5'), findsNothing); + }); + } + + testWidgets('Oura sheet preserves the existing key before reset steps', (t) async { + await _openBand(t, kOura); + _expectKeyBeforeReset(t); + }); + + testWidgets('Oura trouble card preserves the existing key before reset steps', (t) async { + await pumpPair(t, Scaffold(body: SingleChildScrollView( + child: TroubleConnectingCard(entries: [kOura]))), + size: const Size(412, 2400)); + _expectKeyBeforeReset(t); + }); + + testWidgets('Oura bond-refused screen preserves the existing key before reset steps', (t) async { + await pumpPair(t, PairingView(phase: PairPhase.bondRefused, + onPair: () {}, resetEntry: kOura), size: const Size(412, 2400)); + _expectKeyBeforeReset(t); + expect(find.text('In your phone’s Bluetooth settings, find the device and choose Forget.'), + findsOneWidget); + }); + + testWidgets('Pebble lead keeps HR for Pebble 2 and excludes it for SE', (t) async { + await _openBand(t, kPebble); + expect(find.text(_pebbleLead), findsOneWidget); + expect(find.text('It can fill your days with sleep, recovery and heart rate.'), findsNothing); + }); + + testWidgets('Pebble HR captions qualify every affected row and its accessible mark', (t) async { + await _openBand(t, kPebble); + const only = 'Pebble 2 only. The 2 SE has no heart-rate sensor.'; + for (final (cap, reason) in [ + (DeviceCapability.recovery, 'Pebble 2 only, and an estimate, not a ' + 'direct reading. The 2 SE has no heart-rate sensor.'), + (DeviceCapability.strain, only), + (DeviceCapability.heartRate, only), + ]) { + final row = find.byKey(ValueKey('slot-row-${cap.name}')); + expect(find.descendant(of: row, matching: find.text('Every day: $reason')), + findsOneWidget, reason: '${cap.name} must exclude SE'); + final l = lookupAppLocalizations(const Locale('en')); + expect(t.getSemantics(find.byKey(ValueKey('slot-everyDay-${cap.name}'))).label, + '${capabilityLabel(l, cap)}, Every day: Sends. $reason'); + } + final sleep = find.byKey(const ValueKey('slot-row-sleep')); + expect(find.descendant(of: sleep, matching: find.text( + 'Every day: From the device’s own sleep stages')), findsOneWidget); + final steps = find.byKey(const ValueKey('slot-row-steps')); + expect(find.descendant(of: steps, matching: find.text( + 'Every day: The device’s own reading')), findsOneWidget); + }); + + testWidgets('strap lead excludes daily strain precisely', (t) async { + await pumpPair(t, Builder(builder: (c) => Scaffold(body: TextButton( + onPressed: () => showStrapSheet(c, sensor: kBleHrs), + child: const Text('open')))), size: const Size(412, 2400)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(find.text(_strapLead), findsOneWidget); + }); + + test('Russian replacement says connect first and retain the old pairing on cancellation', () { + // WHY: the old copy described forgetting first, which could strand the user. + final l = lookupAppLocalizations(const Locale('ru')); + expect(l.pairReplaceConfirmTitle('WHOOP').toLowerCase(), contains('после подключения')); + expect(l.pairReplaceBand('WHOOP').toLowerCase(), contains('затем отключить')); + expect(l.pairReplaceConfirmBody.toLowerCase(), contains('останется сопряжённым')); + expect(l.pairReplaceConfirmBody.toLowerCase(), contains('отмените')); + }); + + test('Russian unpair explicitly removes the pairing', () { + // WHY: disconnecting a radio link does not remove its saved pairing. + final l = lookupAppLocalizations(const Locale('ru')); + expect(l.pairUnpairBand.toLowerCase(), contains('сопряжени')); + expect(l.pairUnpairBand.toLowerCase(), isNot(contains('отключ'))); + }); + + test('Thai strap filter names chest straps and armbands', () { + final l = lookupAppLocalizations(const Locale('th')); + expect(l.pairKindStrap, contains('คาดอก')); + expect(l.pairKindStrap, contains('ต้นแขน')); + }); + + testWidgets('WHOOP unconfirmed beat timing caption names only Workouts only', (t) async { + await pumpPair(t, Scaffold(body: SingleChildScrollView( + child: SlotTable(comparison: slotComparisonFor(kWhoopGen5)))), + size: const Size(412, 2000)); + final row = find.byKey(const ValueKey('slot-row-workoutBeatToBeat')); + expect(find.descendant(of: row, matching: find.text( + 'Workouts only: If this device sends it, not confirmed')), findsOneWidget); + expect(find.descendant(of: row, matching: find.text( + 'Every day: If this device sends it, not confirmed')), findsNothing); + }); + + testWidgets('different reasons in both columns remain associated with each column', (t) async { + await pumpPair(t, const Scaffold(body: SingleChildScrollView( + child: SlotTable(comparison: SlotComparison( + everyDay: SlotSide(sends: {DeviceCapability.sleep: + CapabilityStatus(CapabilityKind.vendorReported)}, doesNotSend: {}), + workoutsOnly: SlotSide(sends: {DeviceCapability.sleep: + CapabilityStatus(CapabilityKind.conditional, reasonKey: kReasonUnconfirmed)}, + doesNotSend: {}), + )))), size: const Size(412, 2000)); + final row = find.byKey(const ValueKey('slot-row-sleep')); + expect(find.descendant(of: row, matching: find.text( + 'Every day: The device’s own reading')), findsOneWidget); + expect(find.descendant(of: row, matching: find.text( + 'Workouts only: If this device sends it, not confirmed')), findsOneWidget); + }); + + testWidgets('each table mark announces its capability, column and status', (t) async { + await pumpPair(t, Scaffold(body: SingleChildScrollView( + child: SlotTable(comparison: slotComparisonFor(kWhoopGen5)))), + size: const Size(412, 2000)); + for (final (key, label) in [ + ('slot-everyDay-sleep', 'Sleep, Every day: Sends'), + ('slot-workoutsOnly-sleep', 'Sleep, Workouts only: Doesn’t send'), + ('slot-everyDay-workoutBeatToBeat', 'Beat-to-beat timing during workouts, Every day: Sends'), + ('slot-workoutsOnly-workoutBeatToBeat', 'Beat-to-beat timing during workouts, Workouts only: Sends. If this device sends it, not confirmed'), + ]) { + expect(t.getSemantics(find.byKey(ValueKey(key))).label, label, reason: key); + } + }); + + for (final family in ['colmi', 'oura']) { + testWidgets('$family detail uses its every-day description without workout-only copy', (t) async { + final l = lookupAppLocalizations(const Locale('en')); + await pumpPair(t, DeviceDetailView(HealthSource(name: family, kind: 'Ring', + tier: SourceTier.beatToBeat, icon: Icons.circle, isBand: false, + deviceId: '$family-test', family: family)), size: const Size(412, 2400)); + expect(find.text(l.pairWearableCardNote), findsOneWidget, + reason: 'the every-day card explains its role'); + expect(find.text(l.devicesWhatItDoesBeats), findsNothing); + }); + } + + testWidgets('workout strap detail retains the workout description', (t) async { + final l = lookupAppLocalizations(const Locale('en')); + await pumpPair(t, const DeviceDetailView(HealthSource(name: 'Strap', kind: 'Strap', + tier: SourceTier.beatToBeat, icon: Icons.favorite, isBand: false, + deviceId: 'ble_hrs-test', family: 'ble_hrs')), size: const Size(412, 2400)); + expect(find.text(l.devicesWhatItDoesBeats), findsOneWidget); + }); + + test('every Pebble locale names the SE limits and watch-reported readings', () { + const copy = { + 'en': ('Pebble 2 only. The 2 SE has no heart-rate sensor.', + 'Pebble 2 SE shows the watch’s own sleep and steps. It has no heart-rate sensor, so this app cannot calculate daily strain or estimate recovery from it.'), + 'de': ('Nur Pebble 2. Die 2 SE hat keinen Herzfrequenzsensor.', + 'Pebble 2 SE zeigt die eigenen Schlaf- und Schrittdaten der Uhr. Sie hat keinen Herzfrequenzsensor, daher kann diese App daraus weder die tägliche Belastung berechnen noch die Erholung schätzen.'), + 'es': ('Solo Pebble 2. El 2 SE no tiene sensor de frecuencia cardiaca.', + 'Pebble 2 SE muestra el sueño y los pasos registrados por el propio reloj. No tiene sensor de frecuencia cardiaca, por lo que esta app no puede calcular la carga diaria ni estimar la recuperación a partir de sus datos.'), + 'fr': ('Pebble 2 uniquement. La 2 SE n’a pas de capteur de fréquence cardiaque.', + 'Pebble 2 SE affiche le sommeil et les pas enregistrés par la montre. Elle n’a pas de capteur de fréquence cardiaque, donc cette app ne peut pas calculer la charge quotidienne ni estimer la récupération à partir de ses données.'), + 'hi': ('केवल Pebble 2। 2 SE में हृदय गति सेंसर नहीं है।', + 'Pebble 2 SE घड़ी की अपनी नींद और कदमों की रीडिंग दिखाता है। उसमें हृदय गति सेंसर नहीं है, इसलिए यह ऐप उसके डेटा से रोज़ का शारीरिक भार नहीं निकाल सकता और न ही रिकवरी का अनुमान लगा सकता है।'), + 'ru': ('Только Pebble 2. У 2 SE нет датчика пульса.', + 'Pebble 2 SE показывает сон и шаги по данным самих часов. У него нет датчика пульса, поэтому приложение не может рассчитать дневную нагрузку или оценить восстановление по его данным.'), + 'th': ('เฉพาะ Pebble 2 รุ่น 2 SE ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ', + 'Pebble 2 SE แสดงข้อมูลการนอนและจำนวนก้าวที่นาฬิกาบันทึกเอง รุ่นนี้ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ แอปจึงคำนวณภาระร่างกายรายวันหรือประเมินการฟื้นตัวจากข้อมูลของรุ่นนี้ไม่ได้'), + 'zh': ('仅限 Pebble 2。2 SE 没有心率传感器。', + 'Pebble 2 SE 显示手表自身记录的睡眠和步数。它没有心率传感器,因此本应用无法根据它的数据计算每日身体负荷或估算恢复。'), + }; + expect(AppLocalizations.supportedLocales.map((l) => l.languageCode).toSet(), + copy.keys.toSet()); + for (final entry in copy.entries) { + final l = lookupAppLocalizations(Locale(entry.key)); + expect(capabilityReason(l, const CapabilityStatus(CapabilityKind.conditional, + reasonKey: 'capReasonPebble2Only')), entry.value.$1, reason: entry.key); + expect(l.pairWearableLeadPebble, endsWith(entry.value.$2), reason: entry.key); + // Recovery is also an estimate on Pebble 2: its caption keeps both. + final both = capabilityReason(l, const CapabilityStatus( + CapabilityKind.conditional, reasonKey: kReasonPebble2OnlyEstimated))!; + expect(both.toLowerCase(), contains(l.capReasonEstimated.toLowerCase()), + reason: entry.key); + expect(both, contains('2 SE'), reason: entry.key); + } + }); + + for (final locale in AppLocalizations.supportedLocales) { + testWidgets('Pebble conditions render and are announced in $locale at 2x text', (t) async { + final l = lookupAppLocalizations(locale); + String reason(String key) => capabilityReason(l, + CapabilityStatus(CapabilityKind.conditional, reasonKey: key))!; + await pumpPair(t, Scaffold(body: SingleChildScrollView( + child: SlotTable(comparison: slotComparisonFor(kPebble)))), + locale: locale, textScale: 2, size: const Size(360, 900)); + for (final (cap, key) in [ + (DeviceCapability.recovery, kReasonPebble2OnlyEstimated), + (DeviceCapability.strain, kReasonPebble2Only), + (DeviceCapability.heartRate, kReasonPebble2Only), + ]) { + final row = find.byKey(ValueKey('slot-row-${cap.name}')); + expect(find.descendant(of: row, matching: find.text( + l.slotColumnReason(l.slotEveryDay, reason(key)))), findsOneWidget); + final mark = find.byKey(ValueKey('slot-everyDay-${cap.name}')); + await t.ensureVisible(mark); + await t.pumpAndSettle(); + expect(t.getSemantics(mark).label, contains(reason(key))); + } + expect(t.takeException(), isNull); + }); + } + + test('every conditional mark in every locale has its caption', () { + // A check whose condition has no caption would claim more than the device + // gives. Every registry entry, both columns, every shipped locale. + var seen = 0; + for (final locale in AppLocalizations.supportedLocales) { + final l = lookupAppLocalizations(locale); + for (final e in kBandRegistry) { + final cmp = slotComparisonFor(e); + for (final side in [cmp.everyDay, cmp.workoutsOnly]) { + for (final MapEntry(key: cap, value: st) in side.sends.entries) { + if (st.kind != CapabilityKind.conditional) continue; + seen++; + final caption = capabilityReason(l, st); + expect(caption, isNotNull, + reason: '${e.id} $cap ${st.reasonKey} in $locale has no caption'); + expect(caption!.trim(), isNotEmpty); + } + } + } + } + expect(seen, greaterThan(0), reason: 'the registry has conditional marks'); + }); +} diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart index 9f3ad78be..0a382ba8d 100644 --- a/test/ui2_router_test.dart +++ b/test/ui2_router_test.dart @@ -519,8 +519,15 @@ void main() { sources: [band(connected: false)], status: bandStatusFor(connection: 'disconnected')), )); - // Exactly one "Not connected": the row. No card repeating it. - expect(find.text('Not connected'), findsOneWidget); + // No failure card. "Not connected" is said by the row and by the + // "Your band" card's state line, never by a StatusCard. + expect(find.byType(StatusCard), findsNothing); + expect(find.text('Not connected'), findsNWidgets(2)); + expect( + find.descendant( + of: find.byKey(const ValueKey('band-paired-card')), + matching: find.text('Not connected')), + findsOneWidget); }); }); @@ -549,8 +556,8 @@ void main() { child: MaterialApp( theme: buildTheme(Brightness.light), home: const MyDevices()), )); - expect(find.text('Your band'), findsOneWidget); - expect(find.text('Pair a band'), findsNothing); + expect(find.byKey(const ValueKey('band-paired-card')), findsOneWidget); + expect(find.text('Pair your band'), findsNothing); // Forget. `unpair()` itself is platform-bound (ASK, the engine, the // foreground service); what it leaves behind for this screen is this. @@ -558,10 +565,15 @@ void main() { app.notifyListeners(); await tester.pump(); - expect(find.text('Your band'), findsNothing); + // The band's row is gone; the one "Your band" left is the card's + // heading, now saying so and offering the way back. + expect(find.byKey(const ValueKey('band-paired-card')), findsNothing); + expect(find.text('Your band'), findsOneWidget); + expect(find.text('No band is paired'), findsOneWidget); + expect(find.widgetWithText(BigButton, 'Pair your band'), findsOneWidget); expect(find.text('This phone'), findsOneWidget, reason: 'the phone row is what used to swallow the empty state'); - expect(find.text('Pair a band'), findsOneWidget); + expect(find.byKey(const ValueKey('pair-band-card')), findsOneWidget); }); testWidgets('and for someone who only ever had the phone', (tester) async { @@ -581,7 +593,7 @@ void main() { onPair: () => pairs++, ), )); - await tester.tap(find.text('Pair a band')); + await tester.tap(find.widgetWithText(BigButton, 'Pair your band')); expect(pairs, 1); }); @@ -622,7 +634,7 @@ void main() { isBand: true), ], onPair: () {}), )); - expect(find.text('Pair a band'), findsNothing); + expect(find.text('Pair your band'), findsNothing); }); }); diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index 5651c963e..308423c6c 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -275,8 +275,18 @@ const _notComponents = { // `start_session_card_test.dart`, which renders it at a real phone width and // asserts the copy is not truncated and nothing overflows. 'StartCard', - // Pairing pieces are exercised on routes by the pairing widget tests. - 'TroubleConnectingCard', 'ResetStepsView', 'PairingModeSection', 'PairStepRow', 'TextLinkButton', 'ChoiceRow', + // The pairing screens' building blocks (lib/ui2/pairing): each is one + // swappable piece of a pairing screen, driven by registry data and pumped, + // state by state, by ui2_pairing_blocks_test.dart, + // ui2_pairing_help_reset_test.dart, ui2_pair_band_view_test.dart and + // ui2_device_slot_card_test.dart. A + // gallery cell is too narrow for the slot table and the sheets, and the + // sheets open over a route. + 'SlotTable', 'SlotMark', 'PairNote', 'CategoryFilterPills', + 'TroubleConnectingCard', 'ResetStepsView', 'PairingModeSection', + 'PairBandView', + 'PairChoiceSheet', 'PairStepRow', 'TextLinkButton', 'ChoiceRow', + 'DeviceSlotCard', // tabs and drill-downs 'HomeScreen', 'HealthScreen', 'WorkoutScreen', 'NutritionScreen', 'WellnessScreen', 'CycleTab', 'MetricDetail', 'ReadinessDetail',