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/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..44089b20c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3613,6 +3613,33 @@ "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.", "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 +3712,7 @@ "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." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b43507676..4baea7d24 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16765,6 +16765,139 @@ "@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." + }, "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 +16938,7 @@ "@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." } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2cf903bc1..47ce82168 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3063,6 +3063,33 @@ "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.", "alarmNextLabel": "SIGUIENTE", "alarmScheduleGroup": "Horario semanal", "alarmWakeTimeRowTitle": "Hora de despertar", @@ -3385,5 +3412,7 @@ "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." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 112586647..3b19017c1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3383,7 +3383,36 @@ "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.", "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." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9ed1242ae..f3a0b7640 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -3034,9 +3034,38 @@ "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 घड़ी की अपनी नींद और कदमों की रीडिंग दिखाता है। उसमें हृदय गति सेंसर नहीं है, इसलिए यह ऐप उसके डेटा से रोज़ का शारीरिक भार नहीं निकाल सकता और न ही रिकवरी का अनुमान लगा सकता है।", "whoopFirmwareWarningTitle": "WHOOP उपयोगकर्ता: WHOOP ऐप से दोबारा कनेक्ट न करें", "whoopFirmwareWarningAction": "अगर आप OpenStrap या कोई दूसरा ओपन-सोर्स ऐप इस्तेमाल करते हैं, या बाद में किसी पर जाने की सोच रहे हैं, तो अपना बैंड आधिकारिक WHOOP ऐप से दोबारा कनेक्ट न करें। वह फ़र्मवेयर अपडेट अपने-आप इंस्टॉल कर सकता है। WHOOP ऐप को अनइंस्टॉल या अक्षम कर दें, ताकि वह आपके बैंड को अपडेट न कर सके।", "whoopFirmwareWarningDetails": "WHOOP 5.0 / MG के फ़र्मवेयर 50.43.1.0 और 50.43.2.0 के बारे में रिपोर्टें बताती हैं कि डेटा तक पहुँच रोक दी जाती है। ब्लूटूथ फिर भी कनेक्ट हो सकता है। प्रभावित बैंड को अनलॉक करने के लिए WHOOP द्वारा साइन किया गया मेंबरशिप टोकन चाहिए, जिसे OpenStrap जारी नहीं कर सकता। हम समाधान पर काम कर रहे हैं, लेकिन अभी कोई उपाय उपलब्ध नहीं है।", + "pairSearchDevices": "डिवाइस खोजें", + "pairPrimaryCannotBeSensor": "यह पहले से आपका मुख्य बैंड है। इसे वर्कआउट सेंसर के रूप में भी पेयर नहीं किया जा सकता।", "alarmNextLabel": "अगला", "alarmScheduleGroup": "साप्ताहिक शेड्यूल", "alarmWakeTimeRowTitle": "जागने का समय", @@ -3337,7 +3366,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..688d716e3 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -8159,5 +8159,69 @@ "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 показывает сон и шаги по данным самих часов. У него нет датчика пульса, поэтому приложение не может рассчитать дневную нагрузку или оценить восстановление по его данным.", + "pairSearchDevices": "Найти устройства", + "pairPrimaryCannotBeSensor": "Это уже ваш основной браслет. Его нельзя также подключить как датчик для тренировок." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 227ddbff6..9a1373d73 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16627,5 +16627,137 @@ "@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 แสดงข้อมูลการนอนและจำนวนก้าวที่นาฬิกาบันทึกเอง รุ่นนี้ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ แอปจึงคำนวณภาระร่างกายรายวันหรือประเมินการฟื้นตัวจากข้อมูลของรุ่นนี้ไม่ได้", + "pairSearchDevices": "ค้นหาอุปกรณ์", + "pairPrimaryCannotBeSensor": "นี่คือสายรัดหลักของคุณอยู่แล้ว จึงไม่สามารถจับคู่เป็นเซ็นเซอร์ออกกำลังกายด้วยได้" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 52eae2f7c..9aaace4bc 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -3034,9 +3034,37 @@ "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 显示手表自身记录的睡眠和步数。它没有心率传感器,因此本应用无法根据它的数据计算每日身体负荷或估算恢复。", "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 +3413,6 @@ "stepCalRoutineTooShort": "这项测试在计时结束前就停止了。请坚持到它完成。", "stepCalSaveFailed": "无法保存此次校准。", "stepCalDoneGait": "已保存。你数了 {count} 步,腕带记录了 {band} 步。今后这类走路将按 {pct}% 进行校正。", - "stepCalDoneHousehold": "已保存。在腕带记录的 {band} 步中,你的手机记录了 {count} 步。居家活动现在将按 {gain} 进行缩放。" + "stepCalDoneHousehold": "已保存。在腕带记录的 {band} 步中,你的手机记录了 {count} 步。居家活动现在将按 {gain} 进行缩放。", + "pairPrimaryCannotBeSensor": "这已经是你的主手环,不能同时配对为运动传感器。" } 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..eaff7a790 --- /dev/null +++ b/lib/ui2/pairing/category_filter_pills.dart @@ -0,0 +1,86 @@ +// K3: 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..a335f6bd2 100644 --- a/lib/ui2/pairing/device_picker.dart +++ b/lib/ui2/pairing/device_picker.dart @@ -38,19 +38,32 @@ 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 'category_filter_pills.dart'; +import 'pair_text.dart' + show PairFilter, pairFilterOf; import 'whoop_firmware_warning.dart'; 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 +84,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 { final _query = TextEditingController(); String _q = ''; @@ -87,9 +108,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 +132,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 +153,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 +173,7 @@ class _DevicePickerScreenState extends State { }); try { await HrsLink.scanForAny( - _notifyEntries, + addDeviceScanEntries(), owner: this, onResults: (c) { if (mounted) setState(() => _found = c); @@ -155,64 +194,153 @@ 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); + } + } + + void pickBand(BandEntry entry) => unawaited(_browseEntry(entry, modelKnown: true)); + + 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 +349,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 +411,19 @@ class _DevicePickerScreenState extends State { @override Widget build(BuildContext c) { final l = AppLocalizations.of(c); - final entries = [ - if (widget.includeBand) kWhoopGen4, - ..._notifyEntries, - ]; + final entries = [if (widget.includeBand) kWhoopGen4, ..._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 +442,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 +477,18 @@ class DevicePickerView extends StatelessWidget { final VoidCallback? onSkip; final VoidCallback? onBack; + /// The filter pills over the results (K3): 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 +506,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 +568,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 +590,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( @@ -581,7 +776,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 +797,13 @@ class _NearbySection extends StatelessWidget { chevron: !busy, onTap: busy || onTap == null ? null : () => onTap!(cand), ); + return row; } } +/// The "Every day" / "Workouts only" chip under a device row, or null when +/// the device fits neither column. + /// 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/pair_choice_sheet.dart b/lib/ui2/pairing/pair_choice_sheet.dart new file mode 100644 index 000000000..7d921d9c8 --- /dev/null +++ b/lib/ui2/pairing/pair_choice_sheet.dart @@ -0,0 +1,115 @@ +// 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 '../ui2.dart'; +import 'pair_widgets.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; + + + /// 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, + 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), + ...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..1b3278047 --- /dev/null +++ b/lib/ui2/pairing/pair_sheets.dart @@ -0,0 +1,179 @@ +// 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 '../../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, + + /// Unpair the band paired now, then pair this one as the 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 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 = band.category == DeviceCategory.wearable; + 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, + 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, + 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, + }; +} + +/// The one confirm before replacing the band: "Unpair (your band) and pair this +/// band instead? Your data stays." 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 choice = await showPairChoiceSheet( + c, + PairChoiceSheet( + deviceIcon: deviceKindIcon(sensor.kind), + deviceName: deviceName ?? label, + title: deviceSheetTitle(l, sensor), + lead: l.pairStrapLead, + 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..1952ceec7 100644 --- a/lib/ui2/pairing/pair_text.dart +++ b/lib/ui2/pairing/pair_text.dart @@ -12,6 +12,26 @@ import '../../l10n/app_localizations.dart'; AppLocalizations pairL10n(BuildContext c) => AppLocalizations.of(c) ?? lookupAppLocalizations(const Locale('en')); +/// The filter pills' groups (K3): 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/profile/devices.dart b/lib/ui2/profile/devices.dart index 7f7c173d7..d9b575095 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -1570,8 +1570,8 @@ class MyDevicesView extends StatelessWidget { Surface( pad: const EdgeInsets.symmetric(horizontal: S.x4), child: SetRow(LucideIcons.plus, C.green, - l?.devicesAddASensor ?? 'Add a sensor', - sub: l?.devicesAddASensorSub ?? + l?.pairAddDevice ?? 'Add a device', + sub: l?.pairAddDeviceSub ?? 'A heart-rate strap or a ring, alongside the band', onTap: onAddSensor), ), 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/device_sources_test.dart b/test/device_sources_test.dart index bf43a2aa4..a2e33f480 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); diff --git a/test/pairing_stack_review_test.dart b/test/pairing_stack_review_test.dart new file mode 100644 index 000000000..029ed30c3 --- /dev/null +++ b/test/pairing_stack_review_test.dart @@ -0,0 +1,373 @@ +// 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; + 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; + 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: callbacks may already be queued before the disabled rows rebuild. + // catches: guarding only _flow.running, which category navigation never sets. + testWidgets( + 'category and nearby taps share a lock until navigation returns', + (t) async { + final app = _App(); + addTearDown(app.dispose); + await _screen(t, app, const DevicePickerScreen()); + final view = t.widget(find.byType(DevicePickerView)); + view.onOpenEntry!(kWhoopGen4); + view.onOpenEntry!(kWhoopGen4); + view.onPickNearby!(( + device: BluetoothDevice.fromId('new'), + label: 'WHOOP', + rssi: -50, + entryId: 'gen5', + alsoBand: null, + )); + 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(); + final unlocked = t.widget( + find.byType(DevicePickerView), + ); + expect(unlocked.locked, isFalse); + unlocked.onOpenEntry!(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: the single WHOOP category stands for gen4 and gen5 while still + // carrying WHOOP reset help. Only a scanned candidate establishes its model. + // catches: forwarding the category representative gen4 as a scan filter. + testWidgets('WHOOP category keeps help while scanning across generations', ( + t, + ) async { + final app = _App(); + addTearDown(app.dispose); + await _screen(t, app, const DevicePickerScreen()); + t.widget(find.byType(DevicePickerView)).onOpenEntry!( + kWhoopGen4, + ); + 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); + }); + + 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(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/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_pair_sheets_test.dart b/test/ui2_pair_sheets_test.dart new file mode 100644 index 000000000..b5fc1a94b --- /dev/null +++ b/test/ui2_pair_sheets_test.dart @@ -0,0 +1,989 @@ +// 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'; + +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('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 (DT12): one button. + expect(find.text('Pair it as my band'), findsNothing); + 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); + 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(); + 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('G3 r1 S2: 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('G3 r1 S3: 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('G3 r1 S4: 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_tokens_test.dart b/test/ui2_tokens_test.dart index 5651c963e..1a39100ca 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -276,6 +276,8 @@ const _notComponents = { // asserts the copy is not truncated and nothing overflows. 'StartCard', // Pairing pieces are exercised on routes by the pairing widget tests. + 'PairNote', 'CategoryFilterPills', 'PairChoiceSheet', + // Pairing pieces are exercised on routes by the pairing widget tests. 'TroubleConnectingCard', 'ResetStepsView', 'PairingModeSection', 'PairStepRow', 'TextLinkButton', 'ChoiceRow', // tabs and drill-downs 'HomeScreen', 'HealthScreen', 'WorkoutScreen', 'NutritionScreen',