diff --git a/lib/ble/adapters/_registry.dart b/lib/ble/adapters/_registry.dart index c67e7ecd5..a79da89c0 100644 --- a/lib/ble/adapters/_registry.dart +++ b/lib/ble/adapters/_registry.dart @@ -328,6 +328,47 @@ enum DeviceKind { other, } +/// Which of the three reset-help states an entry is in. +enum ResetKind { specific, unverified, noOfficialSite } + +/// What to tell a user whose device will not finish pairing. THREE states: +/// - specific: l10n keys of device-specific steps (>= 1), optional official URL; +/// - unverified: no sourced procedure; safe guidance plus an official URL that +/// is pinned to the vendor's host (follow-up list); +/// - noOfficialSite: the vendor has no official site (generic family, no-name +/// brand); generic steps and a nonblank [reason] (follow-up list). +class ResetSteps { + final ResetKind kind; + + /// Device-specific steps, or a safe subset of guidance for an unverified device. + final List? keys; + + /// Official https help page. Optional for specific, required for unverified, + /// null for noOfficialSite. + final String? officialUrl; + + /// Why there is no official site. Non-null only for noOfficialSite. + final String? reason; + const ResetSteps.specific(List this.keys, {this.officialUrl}) + : kind = ResetKind.specific, + reason = null; + const ResetSteps.unverified({required String this.officialUrl, this.keys}) + : kind = ResetKind.unverified, + reason = null; + const ResetSteps.noOfficialSite({required String this.reason}) + : kind = ResetKind.noOfficialSite, + keys = null, + officialUrl = null; +} + +/// l10n keys of the generic reset template, shown for unverified and +/// no-official-site entries. +const List kGenericResetStepKeys = [ + 'pairResetGenericStep1', + 'pairResetGenericStep2', + 'pairResetGenericStep3', +]; + /// One band the app can discover and connect to. /// /// The wire format itself stays in `protocol` ([BandProfile] = header length, @@ -467,6 +508,9 @@ class BandEntry { /// sensor). Required so a new entry cannot silently bypass recognition. final DeviceRole role; + /// What to tell a user whose device will not finish pairing (every entry sets it). + final ResetSteps? resetSteps; + /// What the device physically is; drives the pairing lists' filter pills. final DeviceKind kind; @@ -499,6 +543,7 @@ class BandEntry { this.logsConsoleOutput = false, this.nameMatcher, required this.role, + this.resetSteps, this.kind = DeviceKind.other, bool canBeMainDevice = true, }) : _canBeMainDevice = canBeMainDevice, @@ -534,6 +579,7 @@ class BandEntry { this.scanCompanyIds = const [], this.scanServiceData = const [], required this.role, + this.resetSteps, this.kind = DeviceKind.other, bool canBeMainDevice = true, }) : _canBeMainDevice = canBeMainDevice, @@ -586,6 +632,16 @@ const BandEntry kWhoopGen4 = BandEntry.framed( id: 'gen4', role: DeviceRole.band, kind: DeviceKind.strap, + resetSteps: ResetSteps.unverified( + // Opening the official app may update the band; use only app-free guidance. + keys: [ + 'pairResetWhoop5Step3', + 'pairResetGenericStep2', + 'pairResetWhoop5Step4', + ], + officialUrl: + 'https://support.whoop.com/s/article/WHOOP-4-0-iOS-Connectivity-and-Pairing?language=en_US', + ), label: 'WHOOP 4', gatt: GattProfile.gen4, wire: BandProfile.gen4, @@ -630,6 +686,15 @@ const BandEntry kWhoopGen5 = BandEntry.framed( setClockDriftGated: true, burstCountGateEnforced: true, logsConsoleOutput: true, + resetSteps: ResetSteps.specific([ + 'pairResetWhoop5Step1', + 'pairResetWhoop5Step2', + 'pairResetWhoop5Step3', + 'pairResetWhoop5Step4', + ], + officialUrl: + 'https://www.community.whoop.com/t/problem-with-pairing-mode/10783/2', + ), commands: BandWireCommands( hello: Cmd.getHello, helloBody: [0x01], @@ -662,6 +727,9 @@ const BandEntry kBleHrs = BandEntry.notify( role: DeviceRole.sensor, // HRS alone identifies no body position: watches and armbands use it too. kind: DeviceKind.other, + resetSteps: ResetSteps.noOfficialSite( + reason: 'Generic Bluetooth heart-rate profile; maker unknown', + ), category: DeviceCategory.workoutSensor, label: 'Bluetooth heart rate sensor', service: kHeartRateServiceUuid, @@ -696,6 +764,10 @@ const BandEntry kPolarPmd = BandEntry.notify( id: 'polar_pmd', role: DeviceRole.sensor, kind: DeviceKind.armband, + resetSteps: ResetSteps.unverified( + officialUrl: + 'https://support.polar.com/en/how-can-i-reset-my-sensor', + ), category: DeviceCategory.workoutSensor, label: 'Polar sensor', service: kPolarPmdService, @@ -737,6 +809,10 @@ const BandEntry kOura = BandEntry.notify( id: 'oura', role: DeviceRole.band, kind: DeviceKind.ring, + resetSteps: ResetSteps.unverified( + officialUrl: + 'https://support.ouraring.com/hc/en-us/articles/360025446854-Factory-Reset-an-Oura-Product', + ), category: DeviceCategory.wearable, label: 'Oura Ring', service: kOuraService, @@ -781,6 +857,10 @@ const BandEntry kCoros = BandEntry.notify( id: 'coros', role: DeviceRole.band, kind: DeviceKind.watch, + resetSteps: ResetSteps.unverified( + officialUrl: + 'https://support.coros.com/hc/en-us/articles/360046709991-How-to-remove-your-COROS-watch-properly-from-paired-smart-device', + ), canBeMainDevice: false, category: DeviceCategory.workoutSensor, label: 'Coros watch', @@ -829,6 +909,10 @@ const BandEntry kGarmin = BandEntry.notify( id: 'garmin', role: DeviceRole.band, kind: DeviceKind.watch, + resetSteps: ResetSteps.unverified( + officialUrl: + 'https://support.garmin.com/', + ), canBeMainDevice: false, category: DeviceCategory.wearable, label: 'Garmin watch', @@ -877,6 +961,10 @@ const BandEntry kUltrahuman = BandEntry.notify( id: 'ultrahuman', role: DeviceRole.band, kind: DeviceKind.ring, + resetSteps: ResetSteps.unverified( + officialUrl: + 'https://www.ultrahuman.com/blog/how-to-reset-ultrahuman-ring-air/', + ), category: DeviceCategory.wearable, label: 'Ultrahuman Ring Air', service: kUltrahumanCommandService, @@ -918,6 +1006,10 @@ const BandEntry kMiBand234 = BandEntry.notify( id: 'miband234', role: DeviceRole.band, kind: DeviceKind.band, + resetSteps: ResetSteps.unverified( + officialUrl: + 'https://www.mi.com/global/support/faq/details/KA-515630/', + ), category: DeviceCategory.wearable, label: 'Mi Band 2/3', service: kHuami234Service, @@ -949,6 +1041,10 @@ const BandEntry kPebble = BandEntry.notify( id: 'pebble', role: DeviceRole.band, kind: DeviceKind.watch, + resetSteps: ResetSteps.unverified( + officialUrl: + 'https://help.repebble.com/en/articles/15403048-restarting-resetting-your-watch', + ), category: DeviceCategory.wearable, label: 'Pebble', service: kPebbleServiceUuid, @@ -1011,6 +1107,10 @@ const BandEntry kColmi = BandEntry.notify( id: 'colmi', role: DeviceRole.band, kind: DeviceKind.ring, + resetSteps: ResetSteps.unverified( + officialUrl: + 'https://www.colmi.com/', + ), category: DeviceCategory.wearable, label: 'Colmi ring', service: kColmiService, @@ -1030,6 +1130,9 @@ const BandEntry kThermometer = BandEntry.notify( id: 'thermometer', role: DeviceRole.sensor, kind: DeviceKind.thermometer, + resetSteps: ResetSteps.noOfficialSite( + reason: 'Generic Bluetooth thermometer; no single maker', + ), category: DeviceCategory.healthMeasurement, label: 'Bluetooth thermometer', service: kHtpService, @@ -1058,6 +1161,10 @@ const BandEntry kMiScaleComposition = BandEntry.notify( id: 'miscale_bc', role: DeviceRole.sensor, kind: DeviceKind.scale, + resetSteps: ResetSteps.unverified( + officialUrl: + 'https://www.mi.com/global/support/', + ), category: DeviceCategory.healthMeasurement, label: 'Mi Body Composition Scale', service: kMiScaleBodyCompositionService, @@ -1082,6 +1189,10 @@ const BandEntry kMiScale2 = BandEntry.notify( id: 'miscale2', role: DeviceRole.sensor, kind: DeviceKind.scale, + resetSteps: ResetSteps.unverified( + officialUrl: + 'https://www.mi.com/ph/support/article/KA-12895', + ), category: DeviceCategory.healthMeasurement, label: 'Mi Smart Scale 2', service: kMiScaleWeightService, diff --git a/lib/ble/reset_url.dart b/lib/ble/reset_url.dart new file mode 100644 index 000000000..78c6f9f9c --- /dev/null +++ b/lib/ble/reset_url.dart @@ -0,0 +1,16 @@ +// Check of a vendor help URL against the entry's pinned official host. + +/// Null when [url] is acceptable for an entry whose reviewed host is +/// [pinnedHost]; otherwise a short reason. Acceptable: https, parseable, host +/// equal to [pinnedHost] (case-insensitive), no userinfo. +String? resetUrlProblem(String url, {required String pinnedHost}) { + final u = Uri.tryParse(url.trim()); + if (u == null || !u.hasScheme) return 'not a URL'; + if (u.scheme != 'https') return 'not https'; + if (u.host.isEmpty) return 'no host'; + if (u.userInfo.isNotEmpty) return 'has userinfo'; + if (u.host.toLowerCase() != pinnedHost.toLowerCase()) { + return 'host ${u.host} is not the pinned $pinnedHost'; + } + return null; +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bb0aeda51..d207b3df7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3510,6 +3510,37 @@ } }, "pairBandNotSensor": "Dieses Gerät ist ein {label}, also ein Band und kein einfacher Sensor. Es wurde nichts gespeichert.", + "pairResetWhoop5Step1": "Nimm das Band vom Handgelenk und warte, bis die grünen Lichter ausgehen.", + "pairResetWhoop5Step2": "Halte das Band an den Seiten und tippe mit zwei Fingern fest auf die Oberseite, bis nur noch Blau blinkt. Wenn Blau und Weiß zusammen leuchten, tippe weiter, bis nur noch Blau übrig ist.", + "pairResetWhoop5Step3": "Wenn die WHOOP-App auf diesem Telefon ist, beende sie zwangsweise.", + "pairResetWhoop5Step4": "Komm hierher zurück und versuche es noch einmal.", + "pairResetGenericStep1": "Öffne die App des Herstellers und entferne dieses Gerät dort.", + "pairResetGenericStep2": "Suche das Gerät in den Bluetooth-Einstellungen deines Telefons und wähle „Ignorieren“ bzw. „Vergessen“.", + "pairResetGenericStep3": "Starte das Gerät neu, halte es nah an dieses Telefon und versuche es noch einmal.", + "pairResetGenericNote": "Das sind allgemeine Tipps. Die genauen Schritte für dieses Gerät haben wir noch nicht geprüft.", + "pairResetOfficialLink": "Hilfeseite des Herstellers öffnen", + "pairResetTitleBand": "Dein Band ist woanders gekoppelt", + "pairResetTitleRing": "Dein Ring ist woanders gekoppelt", + "pairResetBody": "Wir konnten {name} nicht fertig koppeln. Vermutlich ist es noch mit einem anderen Handy oder einer anderen App verbunden.", + "pairResetHeading": "So setzt du es zurück", + "pairResetWhoopTakeover": "Wenn du es hier koppelst, wird es von der WHOOP-App getrennt.", + "pairResetOfficialSub": "{host} · öffnet sich im Browser", + "pairLinkOpenFailed": "Der Link ließ sich nicht öffnen. Die Adresse lautet {url}", + "pairPickDifferentBand": "Anderes Band wählen", + "pairThisIsWhoop": "Das ist ein WHOOP-Band", + "pairThisIsRing": "Das ist ein Ring", + "pairThisIsWatch": "Das ist eine Uhr", + "pairThisIsFitnessBand": "Das ist ein Fitnessarmband", + "pairThisIsStrap": "Das ist ein Herzfrequenzgurt", + "pairThisIsSensor": "Das ist ein Herzfrequenzsensor", + "pairTroubleTitle": "Probleme beim Verbinden?", + "pairTroubleBody": "Versetze das Band in den Kopplungsmodus und versuche es erneut. Klappt es immer noch nicht? Frag uns.", + "pairTroubleModeLink": "Kopplungsmodus von {device}", + "pairTroubleDiscord": "Auf Discord fragen", + "pairTroubleDiscordSub": "Die OpenStrap-Community", + "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.", "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": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7dc473837..480426fdf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16537,6 +16537,150 @@ } } }, + "pairResetWhoop5Step1": "Take the band off your wrist and wait for the green lights to go out.", + "@pairResetWhoop5Step1": { + "description": "Reset help for WHOOP 5.0 / MG, step 1." + }, + "pairResetWhoop5Step2": "Hold the band by its sides and tap the top firmly with two fingers until only blue flashes. If blue and white show together, keep tapping until only blue is left.", + "@pairResetWhoop5Step2": { + "description": "Reset help for WHOOP 5.0 / MG, step 2: the tap procedure." + }, + "pairResetWhoop5Step3": "If the WHOOP app is on this phone, force-quit it.", + "@pairResetWhoop5Step3": { + "description": "Reset help for WHOOP 5.0 / MG, step 3." + }, + "pairResetWhoop5Step4": "Come back here and try again.", + "@pairResetWhoop5Step4": { + "description": "Reset help for WHOOP 5.0 / MG, step 4." + }, + "pairResetGenericStep1": "Open the maker’s app and remove this device from it.", + "@pairResetGenericStep1": { + "description": "General reset help for a device, step 1." + }, + "pairResetGenericStep2": "In your phone’s Bluetooth settings, find the device and choose Forget.", + "@pairResetGenericStep2": { + "description": "General reset help for a device, step 2." + }, + "pairResetGenericStep3": "Restart the device, hold it close to this phone and try again.", + "@pairResetGenericStep3": { + "description": "General reset help for a device, step 3." + }, + "pairResetGenericNote": "These are general tips. We haven’t checked the exact steps for this device yet.", + "@pairResetGenericNote": { + "description": "Shown above the general reset steps for a device whose own steps have not been checked yet." + }, + "pairResetOfficialLink": "Open the maker’s help page", + "@pairResetOfficialLink": { + "description": "Link text opening the maker’s own help page in the browser." + }, + "pairResetTitleBand": "Your band is paired somewhere else", + "@pairResetTitleBand": { + "description": "Title of the screen shown when pairing a band did not finish: it is probably still paired with another phone or app." + }, + "pairResetTitleRing": "Your ring is paired somewhere else", + "@pairResetTitleRing": { + "description": "Same screen as the band title, for a ring." + }, + "pairResetBody": "We couldn’t finish pairing {name}. It is probably still connected to another phone or app.", + "@pairResetBody": { + "description": "Body under the pairing-did-not-finish title. {name} is the device’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairResetHeading": "To reset it", + "@pairResetHeading": { + "description": "Heading above the numbered reset steps." + }, + "pairResetWhoopTakeover": "Pairing here disconnects it from the WHOOP app.", + "@pairResetWhoopTakeover": { + "description": "Shown for WHOOP devices: a WHOOP keeps one connection, so pairing here takes it from the WHOOP app." + }, + "pairResetOfficialSub": "{host} · opens in your browser", + "@pairResetOfficialSub": { + "description": "Second line of the maker’s-help-page row. {host} is the website’s host name.", + "placeholders": { + "host": { + "type": "String" + } + } + }, + "pairLinkOpenFailed": "Could not open the link. The address is {url}", + "@pairLinkOpenFailed": { + "description": "Snackbar when a help link could not be opened. {url} is the address, so the user can open it by hand.", + "placeholders": { + "url": { + "type": "String" + } + } + }, + "pairPickDifferentBand": "Pick a different band", + "@pairPickDifferentBand": { + "description": "Button that goes back to the list of bands." + }, + "pairThisIsWhoop": "This is a WHOOP band", + "@pairThisIsWhoop": { + "description": "Sheet title for a WHOOP found by the scan." + }, + "pairThisIsRing": "This is a ring", + "@pairThisIsRing": { + "description": "Sheet title for a ring." + }, + "pairThisIsWatch": "This is a watch", + "@pairThisIsWatch": { + "description": "Sheet title for a watch." + }, + "pairThisIsFitnessBand": "This is a fitness band", + "@pairThisIsFitnessBand": { + "description": "Sheet title for a fitness band (Mi Band)." + }, + "pairThisIsStrap": "This is a heart-rate strap", + "@pairThisIsStrap": { + "description": "Sheet title for a heart-rate chest strap." + }, + "pairThisIsSensor": "This is a heart-rate sensor", + "@pairThisIsSensor": { + "description": "Sheet title for a heart-rate armband or other heart-rate sensor." + }, + "pairTroubleTitle": "Trouble connecting?", + "@pairTroubleTitle": { + "description": "Heading of the always-open help card on the pairing screen." + }, + "pairTroubleBody": "Put the band in pairing mode, then try again. Still stuck? Ask us.", + "@pairTroubleBody": { + "description": "Body of the help card." + }, + "pairTroubleModeLink": "{device} pairing mode", + "@pairTroubleModeLink": { + "description": "Link to the maker’s page on putting {device} in pairing mode.", + "placeholders": { + "device": { + "type": "String" + } + } + }, + "pairTroubleDiscord": "Ask on Discord", + "@pairTroubleDiscord": { + "description": "Link to the project’s Discord." + }, + "pairTroubleDiscordSub": "The OpenStrap community", + "@pairTroubleDiscordSub": { + "description": "Line under the Discord link." + }, + "pairModeTitle": "Put it in pairing mode", + "@pairModeTitle": { + "description": "Heading of the pairing-mode steps on a device sheet." + }, + "pairModeNoSite": "A generic device with no single maker, so there are no maker’s steps to follow.", + "@pairModeNoSite": { + "description": "Shown instead of steps for a generic device with no maker’s page." + }, + "pairOuraExistingKeyGuidance": "Already use the Oura app? Keep the ring in that app and pair with its existing key. Skip the reset steps below. Only reset it if you want a new key.", + "@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." + }, "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": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index bd23b7c57..1683d6f5e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2959,6 +2959,37 @@ "napAutoLogSub": "Una siesta detectada con indicios sólidos se añade sin preguntar. Puedes editarla o eliminarla después", "napAutoLogSaveFailed": "No se pudo guardar el ajuste. Inténtalo de nuevo.", "pairBandNotSensor": "Ese dispositivo es una pulsera {label}, no un sensor sencillo. No se guardó nada.", + "pairResetWhoop5Step1": "Quítate la pulsera de la muñeca y espera a que se apaguen las luces verdes.", + "pairResetWhoop5Step2": "Sujeta la pulsera por los lados y toca con firmeza la parte superior con dos dedos hasta que solo parpadee el azul. Si salen el azul y el blanco a la vez, sigue tocando hasta que quede solo el azul.", + "pairResetWhoop5Step3": "Si la app de WHOOP está en este teléfono, ciérrala por completo con «forzar cierre».", + "pairResetWhoop5Step4": "Vuelve aquí e inténtalo de nuevo.", + "pairResetGenericStep1": "Abre la app del fabricante y quita este dispositivo de ella.", + "pairResetGenericStep2": "En los ajustes de Bluetooth del teléfono, busca el dispositivo y elige «Olvidar».", + "pairResetGenericStep3": "Reinicia el dispositivo, acércalo a este teléfono e inténtalo de nuevo.", + "pairResetGenericNote": "Son consejos generales. Aún no hemos comprobado los pasos exactos para este dispositivo.", + "pairResetOfficialLink": "Abrir la página de ayuda del fabricante", + "pairResetTitleBand": "Tu pulsera está emparejada en otro sitio", + "pairResetTitleRing": "Tu anillo está emparejado en otro sitio", + "pairResetBody": "No pudimos terminar de emparejar {name}. Probablemente sigue conectado a otro teléfono o app.", + "pairResetHeading": "Para restablecerlo", + "pairResetWhoopTakeover": "Al emparejarla aquí se desconecta de la app de WHOOP.", + "pairResetOfficialSub": "{host} · se abre en tu navegador", + "pairLinkOpenFailed": "No se pudo abrir el enlace. La dirección es {url}", + "pairPickDifferentBand": "Elegir otra pulsera", + "pairThisIsWhoop": "Es una pulsera WHOOP", + "pairThisIsRing": "Es un anillo", + "pairThisIsWatch": "Es un reloj", + "pairThisIsFitnessBand": "Es una pulsera de actividad", + "pairThisIsStrap": "Es una banda de frecuencia cardiaca", + "pairThisIsSensor": "Es un sensor de frecuencia cardiaca", + "pairTroubleTitle": "¿Problemas para conectar?", + "pairTroubleBody": "Pon la pulsera en modo de emparejamiento e inténtalo de nuevo. ¿Sigue sin funcionar? Pregúntanos.", + "pairTroubleModeLink": "Modo de emparejamiento de {device}", + "pairTroubleDiscord": "Preguntar en Discord", + "pairTroubleDiscordSub": "La comunidad de OpenStrap", + "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.", "alarmNextLabel": "SIGUIENTE", "alarmScheduleGroup": "Horario semanal", "alarmWakeTimeRowTitle": "Hora de despertar", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 274104dc5..caf1d1e46 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3280,6 +3280,37 @@ "napAutoLogSub": "Une sieste détectée avec des indices solides est ajoutée sans demander. Vous pouvez toujours la modifier ou la supprimer", "napAutoLogSaveFailed": "Le réglage n’a pas pu être enregistré. Réessayez.", "pairBandNotSensor": "Cet appareil est un {label}, c’est-à-dire un bracelet, pas un simple capteur. Rien n’a été enregistré.", + "pairResetWhoop5Step1": "Retire le bracelet de votre poignet et attendez que les voyants verts s’éteignent.", + "pairResetWhoop5Step2": "Tenez le bracelet par les côtés et tapotez fermement le dessus avec deux doigts jusqu’à ce que seul le bleu clignote. Si le bleu et le blanc s’affichent ensemble, continuez à tapoter jusqu’à ce qu’il ne reste que le bleu.", + "pairResetWhoop5Step3": "Si l’application WHOOP est sur ce téléphone, forcez-la à se fermer.", + "pairResetWhoop5Step4": "Revenez ici et réessayez.", + "pairResetGenericStep1": "Ouvrez l’application du fabricant et retirez-y cet appareil.", + "pairResetGenericStep2": "Dans les réglages Bluetooth du téléphone, repérez l’appareil et choisissez « Oublier ».", + "pairResetGenericStep3": "Redémarrez l’appareil, approchez-le de ce téléphone et réessayez.", + "pairResetGenericNote": "Ce sont des conseils généraux. Nous n’avons pas encore vérifié les étapes exactes pour cet appareil.", + "pairResetOfficialLink": "Ouvrir la page d’aide du fabricant", + "pairResetTitleBand": "Votre bracelet est associé ailleurs", + "pairResetTitleRing": "Votre bague est associée ailleurs", + "pairResetBody": "Nous n’avons pas pu terminer l’association de {name}. Il est probablement encore connecté à un autre téléphone ou une autre appli.", + "pairResetHeading": "Pour le réinitialiser", + "pairResetWhoopTakeover": "L’associer ici la déconnecte de l’appli WHOOP.", + "pairResetOfficialSub": "{host} · s’ouvre dans votre navigateur", + "pairLinkOpenFailed": "Impossible d’ouvrir le lien. L’adresse est {url}", + "pairPickDifferentBand": "Choisir un autre bracelet", + "pairThisIsWhoop": "C’est un bracelet WHOOP", + "pairThisIsRing": "C’est une bague", + "pairThisIsWatch": "C’est une montre", + "pairThisIsFitnessBand": "C’est un bracelet d’activité", + "pairThisIsStrap": "C’est une ceinture cardio", + "pairThisIsSensor": "C’est un capteur cardio", + "pairTroubleTitle": "Un problème de connexion ?", + "pairTroubleBody": "Mettez le bracelet en mode association, puis réessayez. Toujours bloqué ? Demandez-nous.", + "pairTroubleModeLink": "Mode association du {device}", + "pairTroubleDiscord": "Demander sur Discord", + "pairTroubleDiscordSub": "La communauté OpenStrap", + "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é.", "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." diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 507d558d6..77314226a 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2930,6 +2930,37 @@ "napAutoLogSub": "मज़बूत संकेतों वाली पहचानी गई झपकी बिना पूछे जोड़ दी जाती है। आप इसे बाद में भी बदल या हटा सकते हैं", "napAutoLogSaveFailed": "यह सेटिंग सहेजी नहीं जा सकी। फिर से कोशिश करें।", "pairBandNotSensor": "यह डिवाइस {label} है, यानी एक बैंड, साधारण सेंसर नहीं। कुछ भी सहेजा नहीं गया।", + "pairResetWhoop5Step1": "बैंड को कलाई से उतारें और हरी लाइटें बंद होने तक रुकें।", + "pairResetWhoop5Step2": "बैंड को किनारों से पकड़ें और ऊपर की सतह पर दो उंगलियों से ज़ोर से थपथपाएं, जब तक सिर्फ़ नीली लाइट न झपके। अगर नीली और सफ़ेद लाइट साथ दिखें, तो तब तक थपथपाते रहें जब तक सिर्फ़ नीली न बचे।", + "pairResetWhoop5Step3": "अगर WHOOP ऐप इस फ़ोन पर है, तो उसे ज़बरदस्ती बंद करें।", + "pairResetWhoop5Step4": "यहां वापस आएं और फिर से कोशिश करें।", + "pairResetGenericStep1": "निर्माता का ऐप खोलें और उसमें से इस डिवाइस को हटाएं।", + "pairResetGenericStep2": "फ़ोन की ब्लूटूथ सेटिंग में डिवाइस ढूंढें और “भूलें” चुनें।", + "pairResetGenericStep3": "डिवाइस को फिर से चालू करें, उसे इस फ़ोन के पास रखें और दोबारा कोशिश करें।", + "pairResetGenericNote": "ये सामान्य सुझाव हैं। हमने इस डिवाइस के सटीक चरणों की अभी जांच नहीं की है।", + "pairResetOfficialLink": "निर्माता का सहायता पृष्ठ खोलें", + "pairResetTitleBand": "आपका बैंड कहीं और पेयर है", + "pairResetTitleRing": "आपकी रिंग कहीं और पेयर है", + "pairResetBody": "हम {name} को पेयर करना पूरा नहीं कर सके। शायद वह अभी भी किसी दूसरे फ़ोन या ऐप से जुड़ा है।", + "pairResetHeading": "इसे रीसेट करने के लिए", + "pairResetWhoopTakeover": "यहाँ पेयर करने पर यह WHOOP ऐप से कट जाता है।", + "pairResetOfficialSub": "{host} · आपके ब्राउज़र में खुलता है", + "pairLinkOpenFailed": "लिंक नहीं खुल सका। पता है {url}", + "pairPickDifferentBand": "कोई दूसरा बैंड चुनें", + "pairThisIsWhoop": "यह एक WHOOP बैंड है", + "pairThisIsRing": "यह एक रिंग है", + "pairThisIsWatch": "यह एक घड़ी है", + "pairThisIsFitnessBand": "यह एक फ़िटनेस बैंड है", + "pairThisIsStrap": "यह एक हार्ट-रेट स्ट्रैप है", + "pairThisIsSensor": "यह एक हार्ट-रेट सेंसर है", + "pairTroubleTitle": "कनेक्ट करने में दिक्कत?", + "pairTroubleBody": "बैंड को पेयरिंग मोड में डालें, फिर दोबारा कोशिश करें। फिर भी नहीं हो रहा? हमसे पूछें।", + "pairTroubleModeLink": "{device} पेयरिंग मोड", + "pairTroubleDiscord": "Discord पर पूछें", + "pairTroubleDiscordSub": "OpenStrap समुदाय", + "pairModeTitle": "इसे पेयरिंग मोड में डालें", + "pairModeNoSite": "यह किसी एक निर्माता का नहीं, एक सामान्य डिवाइस है, इसलिए निर्माता के कोई चरण नहीं हैं।", + "pairOuraExistingKeyGuidance": "क्या आप पहले से Oura ऐप इस्तेमाल करते हैं? रिंग को उस ऐप में पेयर रहने दें और उसकी मौजूदा कुंजी से पेयर करें। नीचे दिए रीसेट के चरण छोड़ दें। केवल नई कुंजी चाहिए तो ही रीसेट करें।", "whoopFirmwareWarningTitle": "WHOOP उपयोगकर्ता: WHOOP ऐप से दोबारा कनेक्ट न करें", "whoopFirmwareWarningAction": "अगर आप OpenStrap या कोई दूसरा ओपन-सोर्स ऐप इस्तेमाल करते हैं, या बाद में किसी पर जाने की सोच रहे हैं, तो अपना बैंड आधिकारिक WHOOP ऐप से दोबारा कनेक्ट न करें। वह फ़र्मवेयर अपडेट अपने-आप इंस्टॉल कर सकता है। WHOOP ऐप को अनइंस्टॉल या अक्षम कर दें, ताकि वह आपके बैंड को अपडेट न कर सके।", "whoopFirmwareWarningDetails": "WHOOP 5.0 / MG के फ़र्मवेयर 50.43.1.0 और 50.43.2.0 के बारे में रिपोर्टें बताती हैं कि डेटा तक पहुँच रोक दी जाती है। ब्लूटूथ फिर भी कनेक्ट हो सकता है। प्रभावित बैंड को अनलॉक करने के लिए WHOOP द्वारा साइन किया गया मेंबरशिप टोकन चाहिए, जिसे OpenStrap जारी नहीं कर सकता। हम समाधान पर काम कर रहे हैं, लेकिन अभी कोई उपाय उपलब्ध नहीं है।" diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e8c327518..ca30fccb6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -8030,5 +8030,64 @@ "type": "String" } } - } + }, + "pairResetWhoop5Step1": "Снимите браслет с руки и подождите, пока погаснут зелёные огоньки.", + "pairResetWhoop5Step2": "Держите браслет за боковые стороны и сильно постукивайте двумя пальцами по верхней части, пока не будет мигать только синий. Если горят синий и белый вместе, продолжайте стучать, пока не останется только синий.", + "pairResetWhoop5Step3": "Если приложение WHOOP установлено на этом телефоне, принудительно закройте его.", + "pairResetWhoop5Step4": "Вернитесь сюда и попробуйте ещё раз.", + "pairResetGenericStep1": "Откройте приложение производителя и удалите из него это устройство.", + "pairResetGenericStep2": "В настройках Bluetooth телефона найдите устройство и выберите «Забыть».", + "pairResetGenericStep3": "Перезапустите устройство, поднесите его ближе к этому телефону и попробуйте ещё раз.", + "pairResetGenericNote": "Это общие советы. Точные шаги для этого устройства мы пока не проверяли.", + "pairResetOfficialLink": "Открыть страницу помощи производителя", + "pairResetTitleBand": "Ваш браслет подключён к другому устройству", + "pairResetTitleRing": "Ваше кольцо подключено к другому устройству", + "pairResetBody": "Не удалось завершить подключение {name}. Скорее всего, устройство всё ещё подключено к другому телефону или приложению.", + "@pairResetBody": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairResetHeading": "Как сбросить", + "pairResetWhoopTakeover": "Подключение здесь отключает его от приложения WHOOP.", + "pairResetOfficialSub": "{host} · откроется в браузере", + "@pairResetOfficialSub": { + "placeholders": { + "host": { + "type": "String" + } + } + }, + "pairLinkOpenFailed": "Не удалось открыть ссылку. Адрес: {url}", + "@pairLinkOpenFailed": { + "placeholders": { + "url": { + "type": "String" + } + } + }, + "pairPickDifferentBand": "Выбрать другой браслет", + "pairThisIsWhoop": "Это браслет WHOOP", + "pairThisIsRing": "Это кольцо", + "pairThisIsWatch": "Это часы", + "pairThisIsFitnessBand": "Это фитнес-браслет", + "pairThisIsStrap": "Это нагрудный пульсометр", + "pairThisIsSensor": "Это датчик пульса", + "pairTroubleTitle": "Не получается подключить?", + "pairTroubleBody": "Переведите браслет в режим сопряжения и попробуйте снова. Не помогло? Спросите нас.", + "pairTroubleModeLink": "Режим сопряжения {device}", + "@pairTroubleModeLink": { + "placeholders": { + "device": { + "type": "String" + } + } + }, + "pairTroubleDiscord": "Спросить в Discord", + "pairTroubleDiscordSub": "Сообщество OpenStrap", + "pairModeTitle": "Переведите в режим сопряжения", + "pairModeNoSite": "Это универсальное устройство без единого производителя, поэтому инструкции производителя нет.", + "pairOuraExistingKeyGuidance": "Уже пользуетесь приложением Oura? Оставьте кольцо сопряжённым в этом приложении и выполните сопряжение с его текущим ключом. Пропустите шаги сброса ниже. Сбрасывайте кольцо только для получения нового ключа." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 98cdfc54f..5a1cf316f 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16402,5 +16402,146 @@ "type": "String" } } - } + }, + "pairResetWhoop5Step1": "ถอดสายรัดออกจากข้อมือ แล้วรอจนไฟสีเขียวดับ", + "@pairResetWhoop5Step1": { + "description": "Reset help for WHOOP 5.0 / MG, step 1." + }, + "pairResetWhoop5Step2": "จับสายรัดที่ด้านข้าง แล้วใช้สองนิ้วแตะด้านบนแรงๆ จนเหลือแต่ไฟสีน้ำเงินกะพริบ ถ้าไฟสีน้ำเงินและสีขาวขึ้นพร้อมกัน ให้แตะต่อจนเหลือแต่สีน้ำเงิน", + "@pairResetWhoop5Step2": { + "description": "Reset help for WHOOP 5.0 / MG, step 2: the tap procedure." + }, + "pairResetWhoop5Step3": "ถ้ามีแอป WHOOP ในโทรศัพท์เครื่องนี้ ให้บังคับปิดแอป", + "@pairResetWhoop5Step3": { + "description": "Reset help for WHOOP 5.0 / MG, step 3." + }, + "pairResetWhoop5Step4": "กลับมาที่นี่แล้วลองอีกครั้ง", + "@pairResetWhoop5Step4": { + "description": "Reset help for WHOOP 5.0 / MG, step 4." + }, + "pairResetGenericStep1": "เปิดแอปของผู้ผลิต แล้วลบอุปกรณ์นี้ออกจากแอป", + "@pairResetGenericStep1": { + "description": "General reset help for a device, step 1." + }, + "pairResetGenericStep2": "ในการตั้งค่า Bluetooth ของโทรศัพท์ หาอุปกรณ์นี้แล้วเลือกลืม", + "@pairResetGenericStep2": { + "description": "General reset help for a device, step 2." + }, + "pairResetGenericStep3": "รีสตาร์ทอุปกรณ์ ถือไว้ใกล้โทรศัพท์เครื่องนี้ แล้วลองอีกครั้ง", + "@pairResetGenericStep3": { + "description": "General reset help for a device, step 3." + }, + "pairResetGenericNote": "นี่เป็นคำแนะนำทั่วไป เรายังไม่ได้ตรวจสอบขั้นตอนที่ถูกต้องสำหรับอุปกรณ์นี้", + "@pairResetGenericNote": { + "description": "Shown above the general reset steps for a device whose own steps have not been checked yet." + }, + "pairResetOfficialLink": "เปิดหน้าช่วยเหลือของผู้ผลิต", + "@pairResetOfficialLink": { + "description": "Link text opening the maker’s own help page in the browser." + }, + "pairResetTitleBand": "สายรัดของคุณจับคู่อยู่กับที่อื่น", + "@pairResetTitleBand": { + "description": "Title of the screen shown when pairing a band did not finish: it is probably still paired with another phone or app." + }, + "pairResetTitleRing": "แหวนของคุณจับคู่อยู่กับที่อื่น", + "@pairResetTitleRing": { + "description": "Same screen as the band title, for a ring." + }, + "pairResetBody": "จับคู่ {name} ไม่สำเร็จ อุปกรณ์อาจยังเชื่อมต่ออยู่กับโทรศัพท์หรือแอปอื่น", + "@pairResetBody": { + "description": "Body under the pairing-did-not-finish title. {name} is the device’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairResetHeading": "วิธีรีเซ็ต", + "@pairResetHeading": { + "description": "Heading above the numbered reset steps." + }, + "pairResetWhoopTakeover": "การจับคู่ที่นี่จะตัดการเชื่อมต่อจากแอป WHOOP", + "@pairResetWhoopTakeover": { + "description": "Shown for WHOOP devices: a WHOOP keeps one connection, so pairing here takes it from the WHOOP app." + }, + "pairResetOfficialSub": "{host} · เปิดในเบราว์เซอร์", + "@pairResetOfficialSub": { + "description": "Second line of the maker’s-help-page row. {host} is the website’s host name.", + "placeholders": { + "host": { + "type": "String" + } + } + }, + "pairLinkOpenFailed": "เปิดลิงก์ไม่ได้ ที่อยู่คือ {url}", + "@pairLinkOpenFailed": { + "description": "Snackbar when a help link could not be opened. {url} is the address, so the user can open it by hand.", + "placeholders": { + "url": { + "type": "String" + } + } + }, + "pairPickDifferentBand": "เลือกสายรัดอื่น", + "@pairPickDifferentBand": { + "description": "Button that goes back to the list of bands." + }, + "pairThisIsWhoop": "นี่คือสายรัด WHOOP", + "@pairThisIsWhoop": { + "description": "Sheet title for a WHOOP found by the scan." + }, + "pairThisIsRing": "นี่คือแหวน", + "@pairThisIsRing": { + "description": "Sheet title for a ring." + }, + "pairThisIsWatch": "นี่คือนาฬิกา", + "@pairThisIsWatch": { + "description": "Sheet title for a watch." + }, + "pairThisIsFitnessBand": "นี่คือสายรัดฟิตเนส", + "@pairThisIsFitnessBand": { + "description": "Sheet title for a fitness band (Mi Band)." + }, + "pairThisIsStrap": "นี่คือสายวัดชีพจร", + "@pairThisIsStrap": { + "description": "Sheet title for a heart-rate chest strap." + }, + "pairThisIsSensor": "นี่คือเซนเซอร์วัดชีพจร", + "@pairThisIsSensor": { + "description": "Sheet title for a heart-rate armband or other heart-rate sensor." + }, + "pairTroubleTitle": "เชื่อมต่อไม่ได้ใช่ไหม", + "@pairTroubleTitle": { + "description": "Heading of the always-open help card on the pairing screen." + }, + "pairTroubleBody": "ตั้งสายรัดเป็นโหมดจับคู่ แล้วลองอีกครั้ง ยังไม่ได้อยู่ใช่ไหม ถามเราได้", + "@pairTroubleBody": { + "description": "Body of the help card." + }, + "pairTroubleModeLink": "โหมดจับคู่ของ {device}", + "@pairTroubleModeLink": { + "description": "Link to the maker’s page on putting {device} in pairing mode.", + "placeholders": { + "device": { + "type": "String" + } + } + }, + "pairTroubleDiscord": "ถามใน Discord", + "@pairTroubleDiscord": { + "description": "Link to the project’s Discord." + }, + "pairTroubleDiscordSub": "ชุมชน OpenStrap", + "@pairTroubleDiscordSub": { + "description": "Line under the Discord link." + }, + "pairModeTitle": "ตั้งให้อยู่ในโหมดจับคู่", + "@pairModeTitle": { + "description": "Heading of the pairing-mode steps on a device sheet." + }, + "pairModeNoSite": "อุปกรณ์ทั่วไปที่ไม่มีผู้ผลิตรายเดียว จึงไม่มีขั้นตอนจากผู้ผลิต", + "@pairModeNoSite": { + "description": "Shown instead of steps for a generic device with no maker’s page." + }, + "pairOuraExistingKeyGuidance": "ใช้แอป Oura อยู่แล้วใช่ไหม คงการจับคู่แหวนในแอปนั้นไว้ แล้วจับคู่ด้วยคีย์เดิมของแหวน ข้ามขั้นตอนรีเซ็ตด้านล่าง รีเซ็ตเฉพาะเมื่อต้องการคีย์ใหม่เท่านั้น" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9490b7bfe..ac2228fb2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2930,6 +2930,37 @@ "napAutoLogSub": "证据充分的小睡会直接记录,不再询问。之后仍可编辑或删除", "napAutoLogSaveFailed": "无法保存该设置,请重试。", "pairBandNotSensor": "这台设备是 {label},是手环,不是普通传感器。没有保存任何内容。", + "pairResetWhoop5Step1": "把手环从手腕上取下,等绿灯熄灭。", + "pairResetWhoop5Step2": "握住手环两侧,用两根手指用力连续轻敲顶部,直到只有蓝灯闪烁。如果蓝灯和白灯同时出现,请继续敲,直到只剩蓝灯。", + "pairResetWhoop5Step3": "如果这部手机上装有 WHOOP 应用,请将其强制退出。", + "pairResetWhoop5Step4": "回到这里再试一次。", + "pairResetGenericStep1": "打开厂商的应用,把这台设备从里面移除。", + "pairResetGenericStep2": "在手机的蓝牙设置里找到这台设备,选择“忽略”或“取消配对”。", + "pairResetGenericStep3": "重启设备,把它靠近这部手机,再试一次。", + "pairResetGenericNote": "这些是通用建议。我们还没有核实这台设备的具体步骤。", + "pairResetOfficialLink": "打开厂商的帮助页面", + "pairResetTitleBand": "你的手环已和别处配对", + "pairResetTitleRing": "你的戒指已和别处配对", + "pairResetBody": "我们没能完成 {name} 的配对。它很可能仍连着另一部手机或另一个应用。", + "pairResetHeading": "如何重置", + "pairResetWhoopTakeover": "在这里配对会使它与 WHOOP 应用断开。", + "pairResetOfficialSub": "{host} · 在浏览器中打开", + "pairLinkOpenFailed": "无法打开链接。地址是 {url}", + "pairPickDifferentBand": "换一个手环", + "pairThisIsWhoop": "这是 WHOOP 手环", + "pairThisIsRing": "这是一枚戒指", + "pairThisIsWatch": "这是一块手表", + "pairThisIsFitnessBand": "这是一款运动手环", + "pairThisIsStrap": "这是一条心率带", + "pairThisIsSensor": "这是一个心率传感器", + "pairTroubleTitle": "连接遇到问题?", + "pairTroubleBody": "让手环进入配对模式,然后重试。还是不行?来问我们。", + "pairTroubleModeLink": "{device} 配对模式", + "pairTroubleDiscord": "在 Discord 提问", + "pairTroubleDiscordSub": "OpenStrap 社区", + "pairModeTitle": "让它进入配对模式", + "pairModeNoSite": "这是没有单一厂商的通用设备,因此没有厂商步骤可循。", + "pairOuraExistingKeyGuidance": "已经在用 Oura 应用?请保留戒指在该应用中的配对,并使用戒指现有的密钥配对。跳过下面的重置步骤。只有想使用新密钥时才重置戒指。", "whoopFirmwareWarningTitle": "WHOOP 用户:请勿重新连接 WHOOP 应用", "whoopFirmwareWarningAction": "如果你在使用 OpenStrap 或其他开源应用,或打算以后改用,请不要再把手环连接到 WHOOP 官方应用。它可能会自动安装固件更新。请卸载或停用 WHOOP 应用,以免它更新你的手环。", "whoopFirmwareWarningDetails": "有报告称,WHOOP 5.0 / MG 固件 50.43.1.0 和 50.43.2.0 会阻止访问数据。蓝牙可能仍能连接。解锁受影响的手环需要由 WHOOP 签名的会员令牌,OpenStrap 无法签发。我们正在寻找解决办法,但目前还没有可用的修复。", diff --git a/lib/ui2/onboarding/pairing.dart b/lib/ui2/onboarding/pairing.dart index acc9aed3e..05d168e3d 100644 --- a/lib/ui2/onboarding/pairing.dart +++ b/lib/ui2/onboarding/pairing.dart @@ -15,12 +15,17 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; -import '../../ble/adapters/_registry.dart' show BandEntry; +import '../../ble/adapters/_registry.dart' show BandEntry, ResetKind; import '../../ble/band_status_l10n.dart' show localizedBandStatus; import '../../ble/ble_state.dart'; import '../../l10n/app_localizations.dart'; +import '../../l10n/display_text.dart' show localizedText; import '../../state/app_state.dart'; import '../../state/prefs.dart'; +import '../pairing/pair_help.dart'; +import '../pairing/pair_text.dart'; +import '../pairing/pair_widgets.dart'; +import '../pairing/reset_steps_view.dart'; import '../ui2.dart'; import '../pairing/whoop_firmware_warning.dart'; @@ -111,9 +116,14 @@ class PairingScreen extends StatefulWidget { /// Walk past pairing and open the app anyway. Supplied by the router. final VoidCallback? onSkip; - /// Android and older iOS bind scans to this choice. On iOS 18+ the - /// AccessorySetupKit sheet chooses; no post-connect choice check runs. + /// 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. final BandEntry? entry; + + /// 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}); @@ -173,6 +183,10 @@ class _PairingScreenState extends State { blocker: _blocker, onPair: _pair, onSkip: widget.onSkip, + resetEntry: widget.entry, + onPickDifferent: Navigator.of(c).canPop() + ? () => Navigator.of(c).maybePop() + : null, ); } @@ -185,6 +199,17 @@ class PairingView extends StatelessWidget { /// Which phone-side blocker, when [phase] is `bluetoothBlocked`. final BleBlocker? blocker; + /// The band being paired. When set, a pairing that could not finish + /// (not found, refused, failed) shows this device's reset steps and a way + /// to ask for help instead of the generic advice card. + final BandEntry? resetEntry; + + /// "Pick a different band". Null hides the link. + final VoidCallback? onPickDifferent; + + /// How the "Trouble connecting?" card and the maker's-page row open a link. + final PairLinkOpener openLink; + const PairingView({ super.key, required this.phase, @@ -192,10 +217,100 @@ class PairingView extends StatelessWidget { this.detail = '', this.blocker, this.onSkip, + this.resetEntry, + this.onPickDifferent, + this.openLink = const SystemLinkOpener(), }); + static const _resetPhases = { + PairPhase.notFound, + PairPhase.bondRefused, + PairPhase.failed, + }; + + /// The failure screen with a device's reset steps. + Widget _resetScaffold(BuildContext c, BandEntry entry) { + final p = P.of(c); + final l = pairL10n(c); + final label = localizedText(AppLocalizations.of(c), entry.label); + return Scaffold( + backgroundColor: p.bg, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(S.x4, S.x6, S.x4, S.x8), + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Container( + width: 56, + height: 56, + alignment: Alignment.center, + decoration: BoxDecoration( + color: p.wash(C.orange), borderRadius: R.rMd), + child: Icon(LucideIcons.triangleAlert, + size: 28, color: p.on(C.orange)), + ), + ), + const SizedBox(height: S.x4), + Text(resetTitle(l, entry), style: F.t1.copyWith(color: p.ink)), + const SizedBox(height: S.x2), + Text(l.pairResetBody(label), style: F.body.copyWith(color: p.ink2)), + const SizedBox(height: S.x4), + ResetStepsView( + entry: entry, + openLink: openLink, + bondRefused: phase == PairPhase.bondRefused), + if (phase == PairPhase.failed && detail.isNotEmpty) ...[ + const SizedBox(height: S.x3), + _Detail(detail), + ], + const SizedBox(height: S.x4), + BigButton(_cta(c, phase), + icon: LucideIcons.refreshCw, color: C.green, onTap: onPair), + const SizedBox(height: S.x4), + // The maker's page sits in the steps above when they are general + // ones; the card does not offer it twice. + TroubleConnectingCard( + entries: [ + if (entry.resetSteps?.kind != ResetKind.unverified) entry, + ], + openLink: openLink, + showSteps: false), + if (onPickDifferent != null) + TextLinkButton(l.pairPickDifferentBand, onTap: onPickDifferent), + ..._skip(c), + ], + ), + ), + ); + } + + /// "Skip for now" and its note; nothing when there is no way to skip. + List _skip(BuildContext c) { + final p = P.of(c); + if (onSkip == null || phase == PairPhase.paired) return const []; + return [ + const SizedBox(height: S.x3), + // Never disabled, not even mid-scan: waiting out a scan you + // already know will fail is exactly the trap this exists for. + BigButton(AppLocalizations.of(c)?.pairingSkipForNow ?? 'Skip for now', + color: C.blue, soft: true, onTap: onSkip), + const SizedBox(height: S.x2), + Text( + AppLocalizations.of(c)?.pairingSkipNote ?? + 'The app opens without a band. Nothing is measured until one is ' + 'paired.', + style: F.cap.copyWith(color: p.ink3), + ), + ]; + } + @override Widget build(BuildContext c) { + final reset = resetEntry; + if (reset != null && _resetPhases.contains(phase)) { + return _resetScaffold(c, reset); + } final p = P.of(c); final busy = phase == PairPhase.scanning; // The copy for this one lives in the BLE layer, so this screen and the @@ -238,20 +353,7 @@ class PairingView extends StatelessWidget { icon: LucideIcons.radio, color: C.blue, onTap: busy ? null : onPair), - if (onSkip != null && phase != PairPhase.paired) ...[ - const SizedBox(height: S.x3), - // Never disabled, not even mid-scan: waiting out a scan you - // already know will fail is exactly the trap this exists for. - BigButton(AppLocalizations.of(c)?.pairingSkipForNow ?? 'Skip for now', - color: C.blue, soft: true, onTap: onSkip), - const SizedBox(height: S.x2), - Text( - AppLocalizations.of(c)?.pairingSkipNote ?? - 'The app opens without a band. Nothing is measured until one ' - 'is paired.', - style: F.cap.copyWith(color: p.ink3), - ), - ], + ..._skip(c), ], ), ), diff --git a/lib/ui2/pairing/pair_help.dart b/lib/ui2/pairing/pair_help.dart new file mode 100644 index 000000000..ebf451ffe --- /dev/null +++ b/lib/ui2/pairing/pair_help.dart @@ -0,0 +1,156 @@ +// "Trouble connecting?", an always-open card where the pairing screens +// list their ways out: each band's official pairing-mode page and the +// project's Discord. Nothing expands, so nothing shifts. Links open in the +// user's own browser, never inside the app. +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../l10n/display_text.dart' show uiText; +import '../ui2.dart'; +import 'pair_text.dart'; +import 'reset_steps_view.dart' show PairingModeSection; + +/// Opens a link outside the app; true when something took it. An interface +/// (not a function type) so tests pass a fake without an unresolved call. +abstract interface class PairLinkOpener { + Future open(String url); +} + +/// The real one: the user's own browser or app, never a WebView. +class SystemLinkOpener implements PairLinkOpener { + const SystemLinkOpener(); + @override + Future open(String url) => open3rdPartyLink(url); +} + +/// Opens [url] with [opener]; when nothing took it, a snackbar says so and +/// shows the address, so the user can still get there by hand. +Future openOrSay(BuildContext c, PairLinkOpener opener, String url) async { + final messenger = ScaffoldMessenger.maybeOf(c); + final l = pairL10n(c); + if (await opener.open(url)) return; + messenger?.showSnackBar(SnackBar(content: Text(l.pairLinkOpenFailed(url)))); +} + +/// The maker's official help page for [e], or null when the entry has none +/// (a no-official-site device, or nothing chosen yet). +String? officialHelpUrl(BandEntry? e) => e?.resetSteps?.officialUrl; + +class TroubleConnectingCard extends StatelessWidget { + /// The devices whose maker pages to offer, in order; an entry with no + /// official page is left out. Empty offers Discord only. + final List entries; + final PairLinkOpener openLink; + + /// Each model's pairing-mode steps, inline under its link. Off where the + /// steps are already on the screen (the failure screen). + final bool showSteps; + + const TroubleConnectingCard({ + super.key, + required this.entries, + this.openLink = const SystemLinkOpener(), + this.showSteps = true, + }); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final links = [ + for (final e in entries) + if (officialHelpUrl(e) case final url?) (entry: e, url: url), + ]; + return Surface( + key: const ValueKey('trouble-connecting'), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Row(children: [ + Icon(LucideIcons.lifeBuoy, size: 18, color: p.ink2), + const SizedBox(width: S.x2), + Expanded( + child: Text(l.pairTroubleTitle, + style: F.head.copyWith(color: p.ink)), + ), + ]), + const SizedBox(height: S.x2), + Text(l.pairTroubleBody, style: F.cap.copyWith(color: p.ink2)), + const SizedBox(height: S.x2), + for (final k in links) ...[ + _LinkRow( + key: ValueKey('trouble-link-${k.entry.id}'), + icon: deviceKindIcon(k.entry.kind), + title: l.pairTroubleModeLink(uiText(c, k.entry.label)), + sub: Uri.parse(k.url).host, + onTap: () => openOrSay(c, openLink, k.url), + ), + if (showSteps) + Padding( + padding: const EdgeInsets.only(left: 48, bottom: S.x3), + child: PairingModeSection( + entry: k.entry, heading: false, link: false), + ), + ], + _LinkRow( + key: const ValueKey('trouble-link-discord'), + icon: LucideIcons.messagesSquare, + title: l.pairTroubleDiscord, + sub: l.pairTroubleDiscordSub, + onTap: () => openOrSay(c, openLink, kDiscordUrl), + ), + ]), + ); + } +} + +/// A link row inside the card: icon tile, title, one line, the leave-the-app +/// mark. +class _LinkRow extends StatelessWidget { + final IconData icon; + final String title, sub; + final VoidCallback onTap; + const _LinkRow({ + super.key, + required this.icon, + required this.title, + required this.sub, + required this.onTap, + }); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + return Pressable( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: S.tap), + padding: const EdgeInsets.symmetric(vertical: S.x3), + decoration: + BoxDecoration(border: Border(top: BorderSide(color: p.line))), + child: Row(children: [ + Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: + BoxDecoration(color: p.wash(C.blue), borderRadius: R.rMd), + child: Icon(icon, size: 18, color: p.on(C.blue)), + ), + const SizedBox(width: S.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: F.body + .copyWith(color: p.ink, fontWeight: FontWeight.w600)), + Text(sub, style: F.cap.copyWith(color: p.ink3)), + ]), + ), + const SizedBox(width: S.x2), + Icon(LucideIcons.externalLink, size: 16, color: p.ink3), + ]), + ), + ); + } +} diff --git a/lib/ui2/pairing/pair_text.dart b/lib/ui2/pairing/pair_text.dart new file mode 100644 index 000000000..1718af4a0 --- /dev/null +++ b/lib/ui2/pairing/pair_text.dart @@ -0,0 +1,37 @@ +// The words the pairing screens print for registry and slot-comparison data, +// in the user's language. Registry and compute code store l10n KEYS; this is +// the one place a key becomes text (a generated class has no lookup by name). +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../l10n/app_localizations.dart'; + +/// Localizations for [c]; English when none are mounted (a bare test widget), +/// the same fallback the rest of the pairing copy has. +AppLocalizations pairL10n(BuildContext c) => + AppLocalizations.of(c) ?? lookupAppLocalizations(const Locale('en')); + +/// 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, + DeviceKind.strap => l.pairThisIsWhoop, + DeviceKind.ring => l.pairThisIsRing, + DeviceKind.watch => l.pairThisIsWatch, + DeviceKind.band => l.pairThisIsFitnessBand, + DeviceKind.chestStrap => l.pairThisIsStrap, + DeviceKind.armband || + DeviceKind.scale || + DeviceKind.thermometer || + DeviceKind.other => + l.pairThisIsSensor, +}; + +IconData deviceKindIcon(DeviceKind k) => switch (k) { + DeviceKind.ring => LucideIcons.circleDot, + DeviceKind.strap || DeviceKind.watch || DeviceKind.band => LucideIcons.watch, + DeviceKind.scale => LucideIcons.scale, + DeviceKind.thermometer => LucideIcons.thermometer, + DeviceKind.chestStrap || DeviceKind.armband || DeviceKind.other => + LucideIcons.heartPulse, +}; diff --git a/lib/ui2/pairing/pair_widgets.dart b/lib/ui2/pairing/pair_widgets.dart new file mode 100644 index 000000000..9833758f2 --- /dev/null +++ b/lib/ui2/pairing/pair_widgets.dart @@ -0,0 +1,117 @@ +// Small pieces the pairing screens share: a numbered step, a text link and a +// tappable icon row. Kept in one file so the screens read +// alike; none of them knows anything about pairing itself. +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../ui2.dart'; + +/// A numbered step: a round badge and the instruction beside it. +class PairStepRow extends StatelessWidget { + final int number; + final String text; + + /// The small one for sheets and cards: a smaller badge, caption text. + final bool compact; + const PairStepRow(this.number, this.text, {super.key, this.compact = false}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final badge = compact ? 22.0 : 28.0; + return Padding( + padding: EdgeInsets.symmetric(vertical: compact ? S.x1 : S.x2), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + width: badge, + height: badge, + alignment: Alignment.center, + decoration: + BoxDecoration(color: p.wash(C.blue), shape: BoxShape.circle), + child: Text('$number', + style: F.cap + .copyWith(color: p.on(C.blue), fontWeight: FontWeight.w700)), + ), + SizedBox(width: compact ? S.x2 : S.x3), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: compact ? 2 : 3), + child: Text(text, + style: (compact ? F.cap.copyWith(color: p.ink2) : F.body) + .copyWith(color: compact ? p.ink2 : p.ink)), + ), + ), + ]), + ); + } +} + +/// Centred blue text that acts as a link. +class TextLinkButton extends StatelessWidget { + final String text; + final VoidCallback? onTap; + const TextLinkButton(this.text, {super.key, this.onTap}); + + @override + Widget build(BuildContext c) => Pressable( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: S.x3), + child: Center( + child: Text(text, + textAlign: TextAlign.center, + style: F.body.copyWith( + color: P.of(c).on(C.blue), fontWeight: FontWeight.w600)), + ), + ), + ); +} + +/// A card row: an icon tile, a title with one line beneath, a trailing icon. +class ChoiceRow extends StatelessWidget { + final IconData icon, trailing; + final String title, sub; + final VoidCallback? onTap; + const ChoiceRow(this.icon, this.title, this.sub, + {super.key, this.trailing = LucideIcons.externalLink, this.onTap}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + return Pressable( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(S.x3), + decoration: BoxDecoration( + color: p.card2, + borderRadius: R.rLg, + border: Border.all(color: p.line)), + child: Row(children: [ + Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: + BoxDecoration(color: p.wash(C.blue), borderRadius: R.rMd), + child: Icon(icon, size: 22, color: p.on(C.blue)), + ), + const SizedBox(width: S.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: F.body + .copyWith(color: p.ink, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text(sub, style: F.cap.copyWith(color: p.ink2)), + ]), + ), + const SizedBox(width: S.x2), + Icon(trailing, size: 18, color: p.ink3), + ]), + ), + ); + } +} diff --git a/lib/ui2/pairing/reset_steps_view.dart b/lib/ui2/pairing/reset_steps_view.dart new file mode 100644 index 000000000..6ded992ba --- /dev/null +++ b/lib/ui2/pairing/reset_steps_view.dart @@ -0,0 +1,224 @@ +// What to do when a device will not finish pairing: the steps for this device, +// or general steps clearly marked as general. Three states (ResetKind): +// - specific: sourced steps for this device; +// - unverified: general steps, an orange "we haven't checked" note and a row +// that opens the maker's own help page; +// - noOfficialSite: general steps and no link (the maker has no site). +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../l10n/app_localizations.dart'; +import '../ui2.dart'; +import 'pair_help.dart' show PairLinkOpener, SystemLinkOpener, openOrSay; +import 'pair_text.dart'; +import 'pair_widgets.dart'; + +bool isWhoopEntry(BandEntry e) => + kWhoopGen4.id == e.id || kWhoopGen5.id == e.id; + +/// The l10n key of every step to show for [e] (its own, else the general ones). +/// [bondRefused]: the phone still holds an old pairing record the device +/// refused, which only "Forget" in Bluetooth settings clears, so that step is +/// added first when the device's own steps lack it: their last step is to +/// retry, which fails until the old record is gone. +List resetStepKeysFor(BandEntry e, {bool bondRefused = false}) { + final r = e.resetSteps; + final keys = r?.keys?.isNotEmpty == true + ? r!.keys! + : kGenericResetStepKeys; + const forget = 'pairResetGenericStep2'; + return bondRefused && !keys.contains(forget) ? [forget, ...keys] : keys; +} + +String resetStepText(AppLocalizations l, String key) => switch (key) { + 'pairResetWhoop5Step1' => l.pairResetWhoop5Step1, + 'pairResetWhoop5Step2' => l.pairResetWhoop5Step2, + 'pairResetWhoop5Step3' => l.pairResetWhoop5Step3, + 'pairResetWhoop5Step4' => l.pairResetWhoop5Step4, + 'pairResetGenericStep1' => l.pairResetGenericStep1, + 'pairResetGenericStep2' => l.pairResetGenericStep2, + 'pairResetGenericStep3' => l.pairResetGenericStep3, + _ => key, +}; + +/// "Your ring is paired somewhere else" for a ring, the band wording for the rest. +String resetTitle(AppLocalizations l, BandEntry e) => + e.kind == DeviceKind.ring ? l.pairResetTitleRing : l.pairResetTitleBand; + +class ResetStepsView extends StatelessWidget { + final BandEntry entry; + final PairLinkOpener openLink; + + /// The device refused the bond: the Bluetooth-settings "Forget" step is + /// always listed ([resetStepKeysFor]). + final bool bondRefused; + const ResetStepsView( + {super.key, + required this.entry, + this.openLink = const SystemLinkOpener(), + this.bondRefused = false}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final r = entry.resetSteps; + final kind = r?.kind ?? ResetKind.noOfficialSite; + final general = kind != ResetKind.specific; + final url = kind == ResetKind.unverified ? r?.officialUrl : null; + final keys = resetStepKeysFor(entry, bondRefused: bondRefused); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (entry.id == kOura.id) const _OuraKeyGuidance(), + if (general) ...[ + // The orange note says so loudly for an unchecked device; a device + // with no maker site gets the same sentence, muted. + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + kind == ResetKind.unverified + ? LucideIcons.circleAlert + : LucideIcons.info, + size: 14, + color: kind == ResetKind.unverified + ? p.on(C.orange) + : p.ink3), + ), + const SizedBox(width: S.x2), + Expanded( + child: Text(l.pairResetGenericNote, + key: const ValueKey('reset-general-note'), + style: F.cap.copyWith( + color: kind == ResetKind.unverified + ? p.on(C.orange) + : p.ink3)), + ), + ]), + const SizedBox(height: S.x3), + ], + Surface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l.pairResetHeading, + style: F.head.copyWith(color: p.ink)), + const SizedBox(height: S.x2), + for (var i = 0; i < keys.length; i++) + PairStepRow(i + 1, resetStepText(l, keys[i])), + ]), + ), + if (isWhoopEntry(entry)) ...[ + const SizedBox(height: S.x3), + Text(l.pairResetWhoopTakeover, + key: const ValueKey('reset-whoop-takeover'), + style: F.cap.copyWith(color: p.ink2)), + ], + if (url != null) ...[ + const SizedBox(height: S.x3), + ChoiceRow( + LucideIcons.externalLink, + l.pairResetOfficialLink, + l.pairResetOfficialSub(Uri.parse(url).host), + trailing: LucideIcons.chevronRight, + onTap: () => openOrSay(c, openLink, url), + ), + ], + ]); + } +} + +/// "Put it in pairing mode", up front on a device's sheet (and, without its +/// heading and link, under each model in "Trouble connecting?"). From the +/// entry's researched steps (ResetSteps): +/// - specific: its numbered steps; +/// - unverified: the general steps and the maker's page; +/// - noOfficialSite: one line saying there is no maker's page. +/// Always open, so nothing shifts. +class PairingModeSection extends StatelessWidget { + final BandEntry entry; + final PairLinkOpener openLink; + + /// The "Put it in pairing mode" heading. + final bool heading; + + /// The maker's page row (unverified only). + final bool link; + + const PairingModeSection({ + super.key, + required this.entry, + this.openLink = const SystemLinkOpener(), + this.heading = true, + this.link = true, + }); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final r = entry.resetSteps; + final kind = r?.kind ?? ResetKind.noOfficialSite; + final url = kind == ResetKind.unverified ? r?.officialUrl : null; + final keys = resetStepKeysFor(entry); + return Column( + key: ValueKey('pairing-mode-${entry.id}'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (entry.id == kOura.id) const _OuraKeyGuidance(), + if (heading) + Padding( + padding: const EdgeInsets.only(bottom: S.x2), + child: Text(l.pairModeTitle, style: F.head.copyWith(color: p.ink)), + ), + if (kind == ResetKind.unverified) + Padding( + padding: const EdgeInsets.only(bottom: S.x2), + child: Text(l.pairResetGenericNote, + key: ValueKey('pairing-mode-general-note-${entry.id}'), + style: F.cap.copyWith(color: p.on(C.orange))), + ), + if (kind == ResetKind.noOfficialSite) + Text(l.pairModeNoSite, + key: ValueKey('pairing-mode-no-site-${entry.id}'), + style: F.cap.copyWith(color: p.ink2)) + else + for (var i = 0; i < keys.length; i++) + PairStepRow(i + 1, resetStepText(l, keys[i]), + key: ValueKey('pairing-mode-step-${entry.id}-${i + 1}'), + compact: true), + if (link && url != null) + Pressable( + onTap: () => openOrSay(c, openLink, url), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: S.x2), + child: Row(children: [ + Icon(LucideIcons.externalLink, size: 14, color: p.on(C.blue)), + const SizedBox(width: S.x2), + Flexible( + child: Text( + '${l.pairResetOfficialLink} · ${Uri.parse(url).host}', + key: ValueKey('pairing-mode-link-${entry.id}'), + style: F.cap.copyWith( + color: p.on(C.blue), fontWeight: FontWeight.w600)), + ), + ]), + ), + ), + ], + ); + } +} + +/// Keep the existing-key path safe wherever Oura's reset steps are shown. +class _OuraKeyGuidance extends StatelessWidget { + const _OuraKeyGuidance(); + + @override + Widget build(BuildContext c) => Padding( + padding: const EdgeInsets.only(bottom: S.x3), + child: Text(pairL10n(c).pairOuraExistingKeyGuidance, + style: F.body.copyWith(color: P.of(c).ink))); +} diff --git a/test/guards/pairing_reset_steps_guard_test.dart b/test/guards/pairing_reset_steps_guard_test.dart new file mode 100644 index 000000000..7dc5f97d6 --- /dev/null +++ b/test/guards/pairing_reset_steps_guard_test.dart @@ -0,0 +1,241 @@ +// Reset-steps guard: a user whose device will not pair must always get steps, +// in their language, and a device without sourced steps must be visibly queued +// for a human. Three states: specific / unverified / noOfficialSite. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/reset_url.dart'; + +import 'support/pairing_reset_fixtures.dart'; + +Map _arb(File f) => + jsonDecode(f.readAsStringSync()) as Map; + +List _arbs() => Directory('lib/l10n') + .listSync() + .whereType() + .where((f) => RegExp(r'app_\w+\.arb$').hasMatch(f.path)) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + +/// Null when an unverified [r] for [id] is acceptable: a pinned host exists +/// for the id and the URL is on it. +String? _unverifiedProblem(String id, ResetSteps r) { + final host = kPinnedOfficialHost[id]; + if (host == null) return 'no pinned host'; + final url = r.officialUrl; + if (url == null) return 'unverified needs a URL'; + return resetUrlProblem(url, pinnedHost: host); +} + +Iterable _of(ResetKind k) => + kBandRegistry.where((e) => e.resetSteps?.kind == k); + +/// Every key in every locale must exist AND have non-blank text. +void _expectTexted(Iterable keys) { + for (final f in _arbs()) { + final arb = _arb(f); + final bad = [ + for (final k in keys) + if (arb[k] is! String || (arb[k] as String).trim().isEmpty) k, + ]; + expect(bad, isEmpty, reason: '${f.path} missing or blank: $bad'); + } +} + +void main() { + test('locale files are found (positive control for the loops below)', () { + // WHY: an empty locale list would make every key check vacuous. + final names = _arbs().map((f) => f.uri.pathSegments.last).toList(); + expect(names, containsAll([ + 'app_en.arb', 'app_de.arb', 'app_es.arb', 'app_fr.arb', + 'app_hi.arb', 'app_ru.arb', 'app_th.arb', 'app_zh.arb', + ])); + }); + + test('reviewed help links point to the right products', () { + // WHY: review found a Polar H9/H10 page behind an OH1/Verity Sense entry, a + // different Xiaomi product's FAQ behind the body-composition scale. + for (final m in kPinnedOfficialUrl.entries) { + final e = kBandRegistry.firstWhere((e) => e.id == m.key); + expect(e.resetSteps?.officialUrl, m.value, reason: m.key); + expect(Uri.parse(m.value).host, kPinnedOfficialHost[m.key], reason: m.key); + } + }); + + test('every registry entry declares reset steps', () { + // WHY: an undeclared device would show a user stuck at pairing nothing. + final missing = [ + for (final e in kBandRegistry) + if (e.resetSteps == null) e.id, + ]; + expect(missing, isEmpty, reason: 'entries without resetSteps: $missing'); + }); + + test('WHOOP 5 is device-specific, with step keys', () { + // WHY: owner-confirmed tap-until-blue reset is the only sourced procedure. + final r = kWhoopGen5.resetSteps; + expect(r, isNotNull); + expect(r!.kind, ResetKind.specific); + expect(r.keys, isNotEmpty); + }); + + test('a specific procedure has at least one step', () { + // WHY: a "specific" entry with zero steps shows the user an empty list. + final specific = _of(ResetKind.specific).toList(); + expect(specific, isNotEmpty, reason: 'positive control: gen5 is specific'); + for (final e in specific) { + expect(e.resetSteps!.keys, isNotNull, reason: e.id); + expect(e.resetSteps!.keys, isNotEmpty, reason: '${e.id}: zero steps'); + } + }); + + test('every specific step key has non-blank text in EVERY locale', () { + // WHY: a missing or "" value renders blank text for that language. + final keys = [ + for (final e in _of(ResetKind.specific)) ...?e.resetSteps!.keys, + ]; + expect(keys, isNotEmpty, reason: 'at least WHOOP 5 is specific'); + _expectTexted(keys); + }); + + test('generic fallback keys have non-blank text in EVERY locale', () { + // WHY: unverified / no-site devices rely on these; a gap strands that user. + expect(kGenericResetStepKeys, isNotEmpty); + _expectTexted(kGenericResetStepKeys); + }); + + group('unverified', () { + test('an unverified entry carries an officialUrl on its pinned host', () { + // WHY: unsourced steps point at the vendor's own help page, never a + // search engine or a look-alike host. DT8: zero unverified entries is + // allowed (the goal), so the registry loop may be empty; the fixture + // controls below prove the check can bite. + for (final e in _of(ResetKind.unverified)) { + expect(_unverifiedProblem(e.id, e.resetSteps!), isNull, reason: e.id); + } + }); + + test('control: the unverified check passes a good fixture and fails bad ones', () { + // WHY (DT8): a fixture, not a real registry entry, is the positive control. + const good = ResetSteps.unverified(officialUrl: 'https://support.whoop.com/x'); + const search = ResetSteps.unverified( + officialUrl: 'https://www.google.com/search?q=whoop'); + const other = ResetSteps.unverified(officialUrl: 'https://evil.example/x'); + expect(_unverifiedProblem('gen4', good), isNull); + expect(_unverifiedProblem('gen4', search), isNotNull); + expect(_unverifiedProblem('gen4', other), isNotNull); + expect(_unverifiedProblem('not_in_fixture', good), isNotNull, + reason: 'no pinned host for the id'); + }); + + test('a specific entry that has a URL has it on the pinned host', () { + // WHY: the optional link on a specific procedure is held to the same rule. + for (final e in _of(ResetKind.specific)) { + final url = e.resetSteps!.officialUrl; + if (url == null) continue; + expect(resetUrlProblem(url, pinnedHost: kPinnedOfficialHost[e.id] ?? ''), + isNull, reason: '${e.id}: $url'); + } + // Positive control that the loop above can bite: WHOOP 4 and 5 share a + // pinned host and at least the model declares one. + expect(kPinnedOfficialHost['gen5'], 'www.community.whoop.com'); + }); + }); + + group('noOfficialSite', () { + test('needs a non-blank reason and no URL', () { + // WHY: "no official site" without a why is a silent gap. + for (final e in _of(ResetKind.noOfficialSite)) { + expect(e.resetSteps!.reason?.trim() ?? '', isNotEmpty, reason: e.id); + expect(e.resetSteps!.officialUrl, isNull, reason: e.id); + } + expect(_of(ResetKind.noOfficialSite), isNotEmpty, + reason: 'positive control: the thermometer is NONE in the research'); + }); + + test('the research pins 13 NONE devices', () { + // WHY: vendor-urls.md lists 13; losing one from the fixture unpins it. + expect(kResearchNoneDevices, hasLength(13)); + }); + + test('the noOfficialSite entries are exactly the research NONE set', () { + // WHY: a device with a vendor site must not be filed as having none, and + // a NONE device must not be given an invented link. + final expected = { + for (final id in kResearchNoneDevices.values) ?id, + }; + final actual = {for (final e in _of(ResetKind.noOfficialSite)) e.id}; + expect(expected, {'thermometer'}, + reason: 'only the thermometer is a registry entry on this branch'); + // DT11: the generic HR profile has no maker either (no research row). + expect(actual, {...expected, 'ble_hrs'}); + expect(kBleHrs.resetSteps?.reason, 'Generic Bluetooth heart-rate profile; maker unknown'); + }); + }); + + test('fixtures cover the registry exactly (control)', () { + // WHY: a new registry entry must get a reviewed host or a NONE verdict + // before it ships; ble_hrs is the research gap, pinned by name. + final covered = { + ...kPinnedOfficialHost.keys, + for (final id in kResearchNoneDevices.values) ?id, + ...kResearchGapIds, + }; + expect(covered, {for (final e in kBandRegistry) e.id}); + expect(kPinnedOfficialHost.keys.toSet().intersection(kResearchGapIds), isEmpty); + }); + + group('resetUrlProblem (validator, direct)', () { + const host = 'support.whoop.com'; + test('accepts an https URL on the pinned host (any case, any path)', () { + // WHY: positive control; without it every rejection below could be an always-throw. + expect( + resetUrlProblem('https://support.whoop.com/hc/en-us/articles/1', + pinnedHost: host), + isNull); + expect(resetUrlProblem('https://SUPPORT.whoop.com/x?language=en_US', + pinnedHost: host), isNull); + }); + + for (final bad in { + 'search engine': 'https://www.google.com/search?q=whoop+pairing', + 'another engine': 'https://duckduckgo.com/?q=whoop', + 'unrelated https host': 'https://evil.example/whoop', + 'plain http': 'http://support.whoop.com/x', + 'pinned host as a subdomain prefix of another site': + 'https://support.whoop.com.evil.example/x', + 'pinned host as a suffix of another host': 'https://evilsupport.whoop.com/x', + 'userinfo trick': 'https://support.whoop.com@evil.example/x', + 'a sibling subdomain': 'https://community.whoop.com/x', + 'no host': 'https:///path', + 'not a URL': 'not a url', + 'empty': '', + }.entries) { + test('rejects ${bad.key}', () { + // WHY: only the reviewed official host may be linked. + expect(resetUrlProblem(bad.value, pinnedHost: host), isNotNull); + }); + } + }); + + group('follow-up list', () { + test('lists exactly the entries whose help is not device-specific', () { + // WHY: exact membership; an unverified/no-site device must be queued, a + // specific one must not be. Zero is allowed (no isNotEmpty on the list). + final ids = kBandRegistry.map((e) => e.id).toSet(); + expect(kPairingFollowUp.where((i) => !ids.contains(i)), isEmpty, + reason: 'listed ids not in the registry'); + final actual = { + for (final e in kBandRegistry) + if (e.resetSteps != null && e.resetSteps!.kind != ResetKind.specific) + e.id, + }; + expect(kBandRegistry.every((e) => e.resetSteps != null), isTrue, + reason: 'every entry must declare reset steps first'); + expect(actual, kPairingFollowUp); + }); + }); +} diff --git a/test/guards/support/pairing_reset_fixtures.dart b/test/guards/support/pairing_reset_fixtures.dart new file mode 100644 index 000000000..4c7eedcbb --- /dev/null +++ b/test/guards/support/pairing_reset_fixtures.dart @@ -0,0 +1,77 @@ +// Fixtures for the reset-steps guard. Taken from the research, not from the +// registry: edge.research/design/pairing-ux-research/vendor-urls.md (checked +// 2026-11-05). Changing a value here is a reviewed edit. + +/// The reviewed official host per registry entry id (the host of the URL in +/// vendor-urls.md). An entry's resetSteps.officialUrl must have exactly this +/// host. Entries absent here have no reviewed host: they are either in the NONE +/// set below or a research gap (see [kResearchGapIds]). +const Map kPinnedOfficialHost = { + 'gen4': 'support.whoop.com', // WHOOP 4.0 row + 'gen5': 'www.community.whoop.com', // WHOOP 5.0 / MG row + 'polar_pmd': 'support.polar.com', // OH1 / Verity Sense (the H-series is not this entry) + 'oura': 'support.ouraring.com', + 'ultrahuman': 'www.ultrahuman.com', + 'coros': 'support.coros.com', + 'miband234': 'www.mi.com', + 'pebble': 'help.repebble.com', + 'colmi': 'www.colmi.com', + 'garmin': 'support.garmin.com', + 'miscale_bc': 'www.mi.com', // Mi Body Composition Scale row + 'miscale2': 'www.mi.com', // Mi Smart Scale 2 row +}; + +/// Exact reviewed URLs where a wrong page was found in review. +/// The host pin above cannot tell a right page from a wrong one on the same host. +const Map kPinnedOfficialUrl = { + // OH1 / Verity Sense reset page. The old page was the H9/H10 one (battery + // removal), and this entry excludes the H-series. + 'polar_pmd': 'https://support.polar.com/en/how-can-i-reset-my-sensor', + // Xiaomi's general official support page. The old link was the S400 FAQ, a + // different product with model-specific reset-button steps. + 'miscale_bc': 'https://www.mi.com/global/support/', +}; + +/// The 13 devices vendor-urls.md marks NONE (no official site), by research +/// name, with the registry id where this registry has one (null: the device is +/// not a registry entry on this branch). +const Map kResearchNoneDevices = { + 'Smart ring R11M/R10M': null, + 'Lefun-protocol bands': null, + 'DaFit / MOYOUNG watches': null, + 'WearFit bands': null, + 'Jyou bands': null, + 'HPlus HR band': null, + 'ID115': null, + 'Makibes HR3': null, + 'TLW64 / NO1 F1': null, + 'Watch9': null, + 'XWatch': null, + 'DT78 / DT92 / DT66': null, + 'Generic "Bluetooth thermometer"': 'thermometer', +}; + +/// Registry ids vendor-urls.md has no row for. Not invented here; a human +/// decides (pinned host, or noOfficialSite with a reason, and the NONE set). +const Set kResearchGapIds = {'ble_hrs'}; + +/// Registry entries whose reset help is NOT device-specific (unverified or +/// noOfficialSite) and so need a human to source steps. SHRINK-ONLY: it is a +/// registered ratchet (test/guards/support/ratchets.dart, id +/// pairing_reset_followup, snapshot test/guards/ratchet_counts.json). Zero is +/// allowed and is the goal. Exact membership is checked against the registry. +const Set kPairingFollowUp = { + 'gen4', + 'ble_hrs', + 'oura', + 'polar_pmd', + 'coros', + 'ultrahuman', + 'miband234', + 'pebble', + 'colmi', + 'garmin', + 'thermometer', + 'miscale_bc', + 'miscale2', +}; diff --git a/test/support/pair_ui_support.dart b/test/support/pair_ui_support.dart new file mode 100644 index 000000000..3014c3eb8 --- /dev/null +++ b/test/support/pair_ui_support.dart @@ -0,0 +1,45 @@ +// Shared harness for the pairing screens' widget tests: real theme, real +// localizations, a phone-sized view, no BLE. +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Future pumpPair( + WidgetTester t, + Widget home, { + Locale locale = const Locale('en'), + Brightness brightness = Brightness.dark, + double textScale = 1, + Size size = const Size(412, 915), + bool settle = true, +}) async { + t.view.physicalSize = size * 2; + t.view.devicePixelRatio = 2; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + debugShowCheckedModeBanner: false, + locale: locale, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + theme: buildTheme(brightness), + builder: (c, child) => MediaQuery( + data: MediaQuery.of(c).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: home, + )); + if (settle) { + await t.pumpAndSettle(); + } else { + for (var i = 0; i < 5; i++) { + await t.pump(const Duration(milliseconds: 100)); + } + } +} diff --git a/test/ui2_pairing_help_reset_test.dart b/test/ui2_pairing_help_reset_test.dart new file mode 100644 index 000000000..4dc39e81e --- /dev/null +++ b/test/ui2_pairing_help_reset_test.dart @@ -0,0 +1,487 @@ +// ignore_for_file: depend_on_referenced_packages +// The "Trouble connecting?" card and the reset-steps view, plus the +// pairing screen's failure states that show them. Rendered, with fake link +// openers: no browser, no BLE. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/band_identity.dart' show PairOutcome; +import 'package:openstrap_edge/ble/ble_state.dart' show ScanAcceptPolicy; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/community_links.dart'; +import 'package:openstrap_edge/ui2/onboarding/pairing.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_help.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_outcome_text.dart'; +import 'package:openstrap_edge/ui2/pairing/reset_steps_view.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:url_launcher_platform_interface/link.dart' show LinkDelegate; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import 'support/pair_ui_support.dart'; + +class _Links implements PairLinkOpener { + final opened = []; + final bool ok; + _Links([this.ok = true]); + @override + Future open(String url) async { + opened.add(url); + return ok; + } +} + +class _FakeLauncher extends UrlLauncherPlatform with MockPlatformInterfaceMixin { + final calls = <(String, PreferredLaunchMode)>[]; + @override + LinkDelegate? get linkDelegate => null; + @override + Future canLaunch(String url) async => true; + @override + Future launchUrl(String url, LaunchOptions options) async { + calls.add((url, options.mode)); + return true; + } +} + +Widget _scroll(Widget w) => Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), child: w)); + +void main() { + group('(CR r1) the main-band scan takes only the band the user chose', () { + // WHY: PairingScreen paired whichever WHOOP the scan heard first, so a + // nearby WHOOP of the other generation, or not the one tapped, was saved. + const gen4 = '61080001-8d6d-82b8-614a-1c8cb0f8dcc6'; + const gen5 = 'fd4b0001-cce1-4033-93ce-002d5875f58a'; + test('the tapped remote id only', () { + expect(ScanAcceptPolicy.fitsChoice('AA:01', [gen5], wantRemoteId: 'AA:01'), isTrue); + expect(ScanAcceptPolicy.fitsChoice('AA:02', [gen5], wantRemoteId: 'AA:01'), isFalse); + }); + test('the chosen generation, or a result that names none', () { + expect(ScanAcceptPolicy.fitsChoice('AA:01', [gen4], wantGeneration: 'gen5'), isFalse); + expect(ScanAcceptPolicy.fitsChoice('AA:01', [gen5], wantGeneration: 'gen5'), isTrue); + // A name-only or 16-bit-only match names no generation: still a candidate. + expect(ScanAcceptPolicy.fitsChoice('AA:01', ['fd4b'], wantGeneration: 'gen4'), isTrue); + expect(ScanAcceptPolicy.fitsChoice('AA:01', const [], wantGeneration: 'gen4'), isTrue); + }); + test('nothing chosen: anything the scan matched', () { + expect(ScanAcceptPolicy.fitsChoice('AA:09', [gen4]), isTrue); + }); + }); + + test('(CR r1) the band-not-sensor sentence names the band in the user\'s language', () { + // WHY: the raw registry label ("Colmi ring") went into a German sentence. + final de = lookupAppLocalizations(const Locale('de')); + final text = pairOutcomeText(de, const PairOutcome.band(kColmi))!; + expect(text, contains(de.presentationColmiRing)); + expect(de.presentationColmiRing, isNot('Colmi ring'), reason: 'control'); + }); + + // WHY: WHOOP 4 must not send users into the app that may update their band. + // catches: restoring the generic app-opening fallback for gen4. + testWidgets('WHOOP 4 reset help never asks to open the maker app in any locale', (t) async { + for (final locale in AppLocalizations.supportedLocales) { + final l = lookupAppLocalizations(locale); + for (final view in [ + ResetStepsView(entry: kWhoopGen4), + PairingModeSection(entry: kWhoopGen4), + ]) { + await pumpPair(t, _scroll(view), locale: locale); + expect(find.text(l.pairResetGenericStep1), findsNothing, reason: locale.toString()); + expect(find.text(l.pairResetWhoop5Step3), findsOneWidget); + expect(find.text(l.pairResetGenericStep2), findsOneWidget); + expect(find.text(l.pairResetWhoop5Step4), findsOneWidget); + } + } + expect(kWhoopGen4.resetSteps!.kind, ResetKind.unverified); + expect(kWhoopGen4.resetSteps!.officialUrl, + 'https://support.whoop.com/s/article/WHOOP-4-0-iOS-Connectivity-and-Pairing?language=en_US'); + }); + + // WHY: compact help must disclose that these are unverified instructions. + // catches: hiding the note when the heading or help link is disabled. + testWidgets('unverified pairing sections always disclose general guidance', (t) async { + final l = lookupAppLocalizations(const Locale('en')); + for (final heading in [false, true]) { + for (final link in [false, true]) { + await pumpPair(t, _scroll(PairingModeSection( + entry: kColmi, heading: heading, link: link))); + expect(find.text(l.pairResetGenericNote), findsOneWidget); + } + } + await pumpPair(t, _scroll(PairingModeSection(entry: kWhoopGen5))); + expect(find.text(l.pairResetGenericNote), findsNothing); + }); + + group('Trouble connecting card', () { + Widget card(List entries, [PairLinkOpener? links]) => _scroll( + TroubleConnectingCard(entries: entries, openLink: links ?? _Links())); + + testWidgets('always open: title, body, a link per model, and Discord, with no tap', + (t) async { + await pumpPair(t, card([kWhoopGen4, kWhoopGen5])); + expect(find.byKey(const ValueKey('trouble-connecting')), findsOneWidget); + expect(find.text('Trouble connecting?'), findsOneWidget); + expect(find.text('Put the band in pairing mode, then try again. Still stuck? Ask us.'), + findsOneWidget); + expect(find.byKey(ValueKey('trouble-link-${kWhoopGen4.id}')), findsOneWidget); + expect(find.byKey(ValueKey('trouble-link-${kWhoopGen5.id}')), findsOneWidget); + expect(find.byKey(const ValueKey('trouble-link-discord')), findsOneWidget); + expect(find.text('Ask on Discord'), findsOneWidget); + expect(find.text('The OpenStrap community'), findsOneWidget); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('each link row names its model and shows the page’s host', (t) async { + await pumpPair(t, card([kWhoopGen5])); + expect(find.text('${kWhoopGen5.label} pairing mode'), findsOneWidget); + expect(find.text(Uri.parse(kWhoopGen5.resetSteps!.officialUrl!).host), findsOneWidget); + }); + + testWidgets('model links come in entry order, Discord last', (t) async { + await pumpPair(t, card([kWhoopGen5, kWhoopGen4])); + double y(String id) => t.getTopLeft(find.byKey(ValueKey('trouble-link-$id'))).dy; + expect(y(kWhoopGen5.id), lessThan(y(kWhoopGen4.id))); + expect(y(kWhoopGen4.id), lessThan(y('discord'))); + await pumpPair(t, card([kWhoopGen4, kWhoopGen5])); + expect(y(kWhoopGen4.id), lessThan(y(kWhoopGen5.id))); + }); + + testWidgets('each model link opens its own page', (t) async { + final links = _Links(); + await pumpPair(t, card([kWhoopGen4, kWhoopGen5], links)); + await t.tap(find.byKey(ValueKey('trouble-link-${kWhoopGen5.id}'))); + await t.tap(find.byKey(ValueKey('trouble-link-${kWhoopGen4.id}'))); + expect(links.opened, + [kWhoopGen5.resetSteps!.officialUrl, kWhoopGen4.resetSteps!.officialUrl]); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('an unverified device’s link opens its maker page too', (t) async { + final links = _Links(); + await pumpPair(t, card([kColmi], links)); + await t.tap(find.byKey(ValueKey('trouble-link-${kColmi.id}'))); + expect(links.opened, [kColmi.resetSteps!.officialUrl]); + }); + + testWidgets('the Discord link opens the community link', (t) async { + final links = _Links(); + await pumpPair(t, card([kWhoopGen5], links)); + await t.tap(find.byKey(const ValueKey('trouble-link-discord'))); + expect(links.opened, [kDiscordUrl]); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('(CR r1) a link nothing could open says so, with the address', (t) async { + // WHY: open3rdPartyLink returns false when no app takes the link; the + // tap did nothing visible and the user had no way to the page. + // Control first: an opened link says nothing. + await pumpPair(t, card([kWhoopGen5], _Links())); + await t.tap(find.byKey(const ValueKey('trouble-link-discord'))); + await t.pump(); + expect(find.textContaining('Could not open the link'), findsNothing); + await pumpPair(t, card([kWhoopGen5], _Links(false))); + await t.tap(find.byKey(const ValueKey('trouble-link-discord'))); + await t.pump(); + expect(find.text('Could not open the link. The address is $kDiscordUrl'), + findsOneWidget); + }); + + testWidgets('a model link that fails says it the same way, with its own address', (t) async { + await pumpPair(t, card([kWhoopGen5], _Links(false))); + await t.tap(find.byKey(ValueKey('trouble-link-${kWhoopGen5.id}'))); + await t.pump(); + expect( + find.text('Could not open the link. The address is ' + '${kWhoopGen5.resetSteps!.officialUrl}'), + findsOneWidget); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('a device with no official site is left out: Discord only', (t) async { + await pumpPair(t, card([kBleHrs])); + expect(find.byKey(ValueKey('trouble-link-${kBleHrs.id}')), findsNothing); + expect(find.byKey(const ValueKey('trouble-link-discord')), findsOneWidget); + }); + + testWidgets('no entries: Discord only', (t) async { + await pumpPair(t, card(const [])); + expect(find.text('Ask on Discord'), findsOneWidget); + expect(find.textContaining('pairing mode'), findsOneWidget); // the body line + }); + + testWidgets('by default links leave the app: external application mode', (t) async { + final fake = _FakeLauncher(); + final before = UrlLauncherPlatform.instance; + UrlLauncherPlatform.instance = fake; + addTearDown(() => UrlLauncherPlatform.instance = before); + await pumpPair(t, _scroll(TroubleConnectingCard(entries: [kWhoopGen5]))); + await t.tap(find.byKey(const ValueKey('trouble-link-discord'))); + await t.pump(); + await t.tap(find.byKey(ValueKey('trouble-link-${kWhoopGen5.id}'))); + await t.pump(); + expect(fake.calls, [ + (kDiscordUrl, PreferredLaunchMode.externalApplication), + (kWhoopGen5.resetSteps!.officialUrl!, PreferredLaunchMode.externalApplication), + ]); + }); + + testWidgets('German copy fits at 2x text', (t) async { + await pumpPair(t, card([kWhoopGen4, kWhoopGen5]), + locale: const Locale('de'), textScale: 2, size: const Size(360, 900)); + expect(t.takeException(), isNull); + expect(find.text('Probleme beim Verbinden?'), findsOneWidget); + expect(find.text('Auf Discord fragen'), findsOneWidget); + }); + }); + + group('pairing mode, up front (owner r5)', () { + Future section(WidgetTester t, BandEntry e, {_Links? links}) => pumpPair( + t, + Scaffold( + body: SingleChildScrollView( + child: PairingModeSection(entry: e, openLink: links ?? _Links()))), + size: const Size(412, 1000)); + + testWidgets('specific (WHOOP 5): its own numbered steps, no link', (t) async { + await section(t, kWhoopGen5); + expect(find.text('Put it in pairing mode'), findsOneWidget); + for (var i = 1; i <= 4; i++) { + expect(find.byKey(ValueKey('pairing-mode-step-gen5-$i')), findsOneWidget); + } + expect(find.textContaining('tap the top firmly'), findsOneWidget); + expect(find.byKey(const ValueKey('pairing-mode-link-gen5')), findsNothing); + }); + + testWidgets('unverified (Oura): the general steps and the maker page, no badge', (t) async { + final links = _Links(); + await section(t, kOura, links: links); + expect(find.text('Not confirmed yet'), findsNothing); + expect(find.byKey(const ValueKey('pairing-mode-step-oura-3')), findsOneWidget); + await t.tap(find.byKey(const ValueKey('pairing-mode-link-oura'))); + await t.pumpAndSettle(); + expect(links.opened, [kOura.resetSteps!.officialUrl]); + }); + + testWidgets('no official site (a generic strap): one line, no steps, no link', (t) async { + await section(t, kBleHrs); + expect(find.byKey(const ValueKey('pairing-mode-no-site-ble_hrs')), findsOneWidget); + expect(find.byKey(const ValueKey('pairing-mode-step-ble_hrs-1')), findsNothing); + }); + + testWidgets('the first-run card shows WHOOP 4 and WHOOP 5 steps with no tap', (t) async { + await pumpPair( + t, + Scaffold( + body: SingleChildScrollView( + child: TroubleConnectingCard(entries: [kWhoopGen4, kWhoopGen5]))), + size: const Size(412, 1600)); + expect(find.byKey(const ValueKey('pairing-mode-step-gen4-1')), findsOneWidget); + expect(find.byKey(const ValueKey('pairing-mode-step-gen5-4')), findsOneWidget); + // Each model's steps sit under its own link. + final link5 = t.getTopLeft(find.byKey(const ValueKey('trouble-link-gen5'))).dy; + expect(t.getTopLeft(find.byKey(const ValueKey('pairing-mode-step-gen4-1'))).dy, + lessThan(link5)); + expect(t.getTopLeft(find.byKey(const ValueKey('pairing-mode-step-gen5-1'))).dy, + greaterThan(link5)); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('every entry’s section fits in German at 2x text', (t) async { + for (final e in kBandRegistry) { + await pumpPair( + t, + Scaffold(body: SingleChildScrollView(child: PairingModeSection(entry: e))), + locale: const Locale('de'), + textScale: 2, + size: const Size(360, 2400)); + expect(t.takeException(), isNull, reason: e.id); + } + }); + }); + + group('reset steps view', () { + testWidgets('WHOOP 5 shows its own four steps, no “not checked” note, no maker row', + (t) async { + await pumpPair(t, _scroll(ResetStepsView(entry: kWhoopGen5, openLink: _Links()))); + expect(find.text('To reset it'), findsOneWidget); + expect(find.textContaining('Take the band off your wrist'), findsOneWidget); + expect(find.textContaining('tap the top firmly'), findsOneWidget); + expect(find.textContaining('force-quit it'), findsOneWidget); + expect(find.text('Come back here and try again.'), findsOneWidget); + expect(find.byKey(const ValueKey('reset-general-note')), findsNothing); + expect(find.text('Open the maker’s help page'), findsNothing); + }); + + testWidgets('a WHOOP says pairing here disconnects it from the WHOOP app', (t) async { + await pumpPair(t, _scroll(ResetStepsView(entry: kWhoopGen5))); + expect(find.text('Pairing here disconnects it from the WHOOP app.'), findsOneWidget); + await pumpPair(t, _scroll(ResetStepsView(entry: kWhoopGen4))); + expect(find.text('Pairing here disconnects it from the WHOOP app.'), findsOneWidget); + }); + + testWidgets('an unverified device: orange note, three general steps, maker link row', + (t) async { + final links = _Links(); + await pumpPair(t, _scroll(ResetStepsView(entry: kColmi, openLink: links))); + expect(find.byKey(const ValueKey('reset-general-note')), findsOneWidget); + expect(find.textContaining('We haven’t checked the exact steps'), findsOneWidget); + expect(find.text('Open the maker’s app and remove this device from it.'), findsOneWidget); + expect(find.textContaining('choose Forget'), findsOneWidget); + expect(find.textContaining('Restart the device'), findsOneWidget); + expect(find.byIcon(LucideIcons.circleAlert), findsOneWidget); + final host = Uri.parse(kColmi.resetSteps!.officialUrl!).host; + expect(find.text('$host · opens in your browser'), findsOneWidget); + await t.tap(find.text('Open the maker’s help page')); + expect(links.opened, [kColmi.resetSteps!.officialUrl]); + await pumpPair(t, _scroll(ResetStepsView(entry: kColmi, openLink: _Links(false)))); + await t.tap(find.text('Open the maker’s help page')); + await t.pump(); + expect( + find.text('Could not open the link. The address is ' + '${kColmi.resetSteps!.officialUrl}'), + findsOneWidget, + reason: 'CR r1: a failed open shows the address'); + // Not a WHOOP: no takeover line. + expect(find.byKey(const ValueKey('reset-whoop-takeover')), findsNothing); + }); + + testWidgets('WHOOP 4 is unverified: general steps, note, maker row, and the takeover line', + (t) async { + await pumpPair(t, _scroll(ResetStepsView(entry: kWhoopGen4))); + expect(find.byKey(const ValueKey('reset-general-note')), findsOneWidget); + expect(find.text('Open the maker’s help page'), findsOneWidget); + expect(find.byKey(const ValueKey('reset-whoop-takeover')), findsOneWidget); + }); + + testWidgets('a device with no official site: general steps, no maker row, no orange alert', + (t) async { + await pumpPair(t, _scroll(ResetStepsView(entry: kThermometer))); + expect(find.text('Open the maker’s app and remove this device from it.'), findsOneWidget); + expect(find.text('Open the maker’s help page'), findsNothing); + expect(find.byIcon(LucideIcons.circleAlert), findsNothing); + // Still says the steps are general. + expect(find.byKey(const ValueKey('reset-general-note')), findsOneWidget); + }); + + testWidgets('every registry entry renders, at 2x German text, without overflow', (t) async { + for (final e in kBandRegistry) { + await pumpPair(t, _scroll(ResetStepsView(entry: e)), + locale: const Locale('de'), textScale: 2, size: const Size(360, 900)); + expect(t.takeException(), isNull, reason: e.id); + expect(find.text('So setzt du es zurück'), findsOneWidget, reason: e.id); + } + }); + }); + + group('pairing screen: when pairing could not finish', () { + Widget view(PairPhase phase, + {BandEntry? entry, VoidCallback? onPair, VoidCallback? onPick, _Links? links}) => + PairingView( + phase: phase, + onPair: onPair ?? () {}, + resetEntry: entry, + onPickDifferent: onPick, + openLink: (links ?? _Links()), + ); + + for (final phase in [PairPhase.notFound, PairPhase.bondRefused, PairPhase.failed]) { + testWidgets('${phase.name}: the entry’s reset steps and the ways forward', (t) async { + var tried = 0, picked = 0; + await pumpPair( + t, + view(phase, + entry: kWhoopGen5, onPair: () => tried++, onPick: () => picked++), + size: const Size(412, 1800)); + expect(find.text('Your band is paired somewhere else'), findsOneWidget); + expect(find.textContaining('We couldn’t finish pairing WHOOP 5.'), findsOneWidget); + expect(find.textContaining('tap the top firmly'), findsOneWidget); + expect(find.text('Pairing here disconnects it from the WHOOP app.'), findsOneWidget); + await t.tap(find.text('Try again')); + expect(tried, 1); + await t.tap(find.text('Pick a different band')); + expect(picked, 1); + expect(find.text('I can’t pair my band'), findsNothing); + expect(find.byKey(const ValueKey('trouble-connecting')), findsOneWidget); + }); + } + + testWidgets('a refused bond lists the Bluetooth-settings Forget step first, even for WHOOP 5', (t) async { + // WHY: WHOOP 5's own four steps never say to forget the old pairing + // record, and Try again cannot clear a refused bond without it. + const forget = 'In your phone’s Bluetooth settings, find the device and choose Forget.'; + await pumpPair(t, view(PairPhase.bondRefused, entry: kWhoopGen5), + size: const Size(412, 1600)); + expect(find.text(forget), findsOneWidget); + expect(find.textContaining('tap the top firmly'), findsOneWidget, + reason: 'its own steps stay'); + await pumpPair(t, view(PairPhase.notFound, entry: kWhoopGen5), + size: const Size(412, 1600)); + expect(find.text(forget), findsNothing, + reason: 'not found is not a refused bond'); + // A device on the general steps already has it, once. + expect(resetStepKeysFor(kWhoopGen4, bondRefused: true) + .where((k) => k == 'pairResetGenericStep2'), hasLength(1)); + // WHOOP 5's last step is to retry, which fails while the refused + // record is still there, so Forget comes before its own steps. + expect(resetStepKeysFor(kWhoopGen5, bondRefused: true).first, + 'pairResetGenericStep2'); + }); + + testWidgets('the Trouble connecting card is there, with this device’s page, no tap needed', + (t) async { + final links = _Links(); + await pumpPair(t, view(PairPhase.notFound, entry: kWhoopGen5, links: links), + size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('trouble-connecting')), findsOneWidget); + expect(find.byKey(ValueKey('trouble-link-${kWhoopGen5.id}')), findsOneWidget); + expect(find.byKey(const ValueKey('trouble-link-discord')), findsOneWidget); + await t.tap(find.byKey(ValueKey('trouble-link-${kWhoopGen5.id}'))); + expect(links.opened, [kWhoopGen5.resetSteps!.officialUrl]); + await t.tap(find.byKey(const ValueKey('trouble-link-discord'))); + expect(links.opened.last, kDiscordUrl); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('unless the steps already show it: an unverified device’s card is Discord only', + (t) async { + await pumpPair(t, view(PairPhase.bondRefused, entry: kColmi), size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('trouble-connecting')), findsOneWidget); + expect(find.byKey(ValueKey('trouble-link-${kColmi.id}')), findsNothing); + expect(find.byKey(const ValueKey('trouble-link-discord')), findsOneWidget); + // The maker row in the steps is the one way to the page. + expect(find.text('Open the maker’s help page'), findsOneWidget); + }); + + testWidgets('an unverified ring: ring wording, orange note, maker row', (t) async { + await pumpPair(t, view(PairPhase.bondRefused, entry: kColmi), size: const Size(412, 1400)); + expect(find.text('Your ring is paired somewhere else'), findsOneWidget); + expect(find.byKey(const ValueKey('reset-general-note')), findsOneWidget); + expect(find.text('Open the maker’s help page'), findsOneWidget); + }); + + testWidgets('without an entry the screen is unchanged: no reset steps, no Trouble card', (t) async { + await pumpPair(t, view(PairPhase.notFound), size: const Size(412, 1400)); + expect(find.text('To reset it'), findsNothing); + expect(find.byKey(const ValueKey('trouble-connecting')), findsNothing); + expect(find.text('No band in range'), findsOneWidget); + }); + + for (final phase in [PairPhase.idle, PairPhase.cancelled, PairPhase.paired]) { + testWidgets('${phase.name} never shows reset steps', (t) async { + await pumpPair(t, view(phase, entry: kWhoopGen5), size: const Size(412, 1400)); + expect(find.text('To reset it'), findsNothing); + expect(find.byKey(const ValueKey('trouble-connecting')), findsNothing); + }); + } + + testWidgets('German at 2x text does not overflow', (t) async { + for (final e in [kColmi, kWhoopGen5]) { + await pumpPair(t, view(PairPhase.notFound, entry: e), + locale: const Locale('de'), textScale: 2, size: const Size(360, 2200)); + expect(t.takeException(), isNull, reason: e.id); + } + }); + }); +} diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index 0b37d136e..5651c963e 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -275,6 +275,8 @@ const _notComponents = { // `start_session_card_test.dart`, which renders it at a real phone width and // asserts the copy is not truncated and nothing overflows. 'StartCard', + // Pairing pieces are exercised on routes by the pairing widget tests. + 'TroubleConnectingCard', 'ResetStepsView', 'PairingModeSection', 'PairStepRow', 'TextLinkButton', 'ChoiceRow', // tabs and drill-downs 'HomeScreen', 'HealthScreen', 'WorkoutScreen', 'NutritionScreen', 'WellnessScreen', 'CycleTab', 'MetricDetail', 'ReadinessDetail',