diff --git a/lib/ble/adapters/_registry.dart b/lib/ble/adapters/_registry.dart index b7f7c3e29..e07316a52 100644 --- a/lib/ble/adapters/_registry.dart +++ b/lib/ble/adapters/_registry.dart @@ -310,6 +310,66 @@ class BandWireCommands { /// sessions only, a health measurement's own reading only. enum DeviceCategory { wearable, workoutSensor, healthMeasurement } +/// Whether a device can be the user's main device or only a workout add-on. +enum DeviceRole { band, sensor } + +/// What a device physically is, for the pairing lists' filter pills. Not +/// [DeviceCategory] (what the data is used for): a Colmi ring and an Oura ring +/// are both rings, a Polar armband and a chest strap are both workout sensors. +enum DeviceKind { + strap, + ring, + watch, + band, + chestStrap, + armband, + scale, + thermometer, + 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 steps; generic template 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; + + /// Non-null only for [ResetKind.specific]. + 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}) + : kind = ResetKind.unverified, + keys = null, + 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, @@ -421,7 +481,7 @@ class BandEntry { /// carried only a hint (and none of the entries' own services) is kept only /// when a [nameMatcher] claims it; a hint alone never confirms an entry, /// but this entry's name on an advertisement carrying its hint does, ahead - /// of another entry's service (`HrsLink.hintedNameMatch`). For + /// of another entry's service (`hintedNameMatch`). For /// a band whose advertisement carries a shared 16-bit UUID rather than the /// GATT service it is checked against after connect. final List scanHints; @@ -445,6 +505,27 @@ class BandEntry { /// What this device is for ([DeviceCategory]). A framed band is a wearable. final DeviceCategory category; + /// Whether the safeguard recognises this entry as a band (or a plain + /// sensor). Nullable only so a const constructor needs no default; every + /// registry entry sets it (pinned by pairing_device_role_test). + 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; + + final bool _canBeMainDevice; + + /// Whether the first-run list (W2) may offer this entry as the user's main + /// device. Separate from [role], which only says "recognised as a band" for + /// the safeguard (DT10): only a framed WHOOP band has a supported + /// main-device path (DT12). Every other band is recognised but pairs as an + /// additional device under its own adapter, and a sensor never is one. + bool get canBeMainDevice => + role == DeviceRole.band && isFramed && _canBeMainDevice; + /// A framed WHOOP-family band: an envelope, a command characteristic, and a /// flash the offload engine trims. const BandEntry.framed({ @@ -463,7 +544,12 @@ class BandEntry { this.burstCountGateEnforced = false, this.logsConsoleOutput = false, this.nameMatcher, - }) : _requiredCharacteristics = requiredCharacteristics, + this.role, + this.resetSteps, + this.kind = DeviceKind.other, + bool canBeMainDevice = true, + }) : _canBeMainDevice = canBeMainDevice, + _requiredCharacteristics = requiredCharacteristics, _commands = commands, scanByName = false, scanHints = const [], @@ -494,7 +580,12 @@ class BandEntry { this.scanHints = const [], this.scanCompanyIds = const [], this.scanServiceData = const [], - }) : _service = service, + this.role, + this.resetSteps, + this.kind = DeviceKind.other, + bool canBeMainDevice = true, + }) : _canBeMainDevice = canBeMainDevice, + _service = service, _requiredCharacteristics = characteristics, gatt = null, wire = null, @@ -541,6 +632,12 @@ bool _nameContainsWhoop(String lowercaseName) => lowercaseName.contains('whoop') /// that says nothing about a band is not evidence that the band does nothing. const BandEntry kWhoopGen4 = BandEntry.framed( id: 'gen4', + role: DeviceRole.band, + kind: DeviceKind.strap, + resetSteps: ResetSteps.unverified( + 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, @@ -572,6 +669,8 @@ const BandEntry kWhoopGen4 = BandEntry.framed( /// envelope differs, which is exactly what [BandProfile] models. const BandEntry kWhoopGen5 = BandEntry.framed( id: 'gen5', + role: DeviceRole.band, + kind: DeviceKind.strap, label: 'WHOOP 5', gatt: GattProfile.gen5, wire: BandProfile.gen5, @@ -583,6 +682,15 @@ const BandEntry kWhoopGen5 = BandEntry.framed( setClockDriftGated: true, burstCountGateEnforced: true, logsConsoleOutput: true, + resetSteps: ResetSteps.specific([ + 'pairResetWhoop5Step1', + 'pairResetWhoop5Step2', + 'pairResetWhoop5Step3', + 'pairResetWhoop5Step4', + ], + officialUrl: + 'https://support.whoop.com/hc/en-us/articles/360038582374-Putting-the-WHOOP-Strap-into-Pairing-Mode', + ), commands: BandWireCommands( hello: Cmd.getHello, helloBody: [0x01], @@ -612,6 +720,11 @@ const BandEntry kWhoopGen5 = BandEntry.framed( /// off, nothing derives from it. const BandEntry kBleHrs = BandEntry.notify( id: 'ble_hrs', + role: DeviceRole.sensor, + kind: DeviceKind.chestStrap, + resetSteps: ResetSteps.noOfficialSite( + reason: 'Generic Bluetooth heart-rate profile; maker unknown', + ), category: DeviceCategory.workoutSensor, label: 'Bluetooth heart rate sensor', service: kHeartRateServiceUuid, @@ -644,6 +757,12 @@ const BandEntry kBleHrs = BandEntry.notify( /// Pairing confirms the PMD characteristics and the PPI feature bit. 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, @@ -683,6 +802,12 @@ bool _looksLikePolarPpi(String lowercaseName) => /// proves nothing about decode correctness. 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, @@ -725,6 +850,13 @@ const BandEntry kOura = BandEntry.notify( /// session source either, flag or not (`kWorkoutArmedSensors`). 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', service: kCorosService, @@ -770,6 +902,13 @@ bool _looksLikeCoros(String lowercaseName) => /// wearable's substrate, while its flag is on (`compute/inputs/canonical.dart`). 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', service: kGarminService, @@ -815,6 +954,12 @@ const BandEntry kGarmin = BandEntry.notify( /// until a real ring's advertisement settles it. 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, @@ -854,6 +999,12 @@ bool _looksLikeUltrahuman(String lowercaseName) => /// `wearable_enabled:miband234` flag is on and it is the active wearable. 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, @@ -883,6 +1034,12 @@ bool _looksLikeMiBand23(String lowercaseName) => const { /// not a byte of this path has met hardware (ASSUMPTIONS R6). 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, @@ -943,6 +1100,12 @@ const String kColmiAdvertisedHint = '0000fee7-0000-1000-8000-00805f9b34fb'; /// found at all until a real ring's advertisement settles it. 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, @@ -960,6 +1123,11 @@ const BandEntry kColmi = BandEntry.notify( /// become attributed `body_temp` observations; nothing derives from them. 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, @@ -986,6 +1154,12 @@ bool _looksLikeThermometer(String lowercaseName) => /// stays a measurement only. 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, @@ -1008,6 +1182,12 @@ bool _looksLikeMiCompositionScale(String lowercaseName) => /// weight (`newestWeighing`). 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, @@ -1019,6 +1199,30 @@ const BandEntry kMiScale2 = BandEntry.notify( bool _looksLikeMiScale2(String lowercaseName) => lowercaseName == 'mi scale2'; +/// The entry whose [BandEntry.nameMatcher] claims [lowercaseName] while +/// [advertisedUuids] (canonical 128-bit lowercase) carries one of that same +/// entry's [BandEntry.scanHints], or null. Name and hint together name the +/// entry; either alone does not. +/// +/// [requireHint] false drops the hint condition: the plain "does this entry +/// claim this name" question, kept here so the name matcher is invoked from +/// ONE place (the heavy-calc baseline holds exactly this call). Lives with the +/// registry so identity code and the pairing link share it without importing +/// each other. +String? hintedNameMatch(List entries, + Iterable advertisedUuids, String lowercaseName, + {bool requireHint = true}) { + for (final e in entries) { + if ((e.nameMatcher?.call(lowercaseName) ?? false) && + (!requireHint || + e.scanHints + .any((h) => advertisedUuids.contains(h.toLowerCase())))) { + return e.id; + } + } + return null; +} + /// Every band this build can see. Order is match order during discovery. const List kBandRegistry = [ kWhoopGen4, diff --git a/lib/ble/band_identity.dart b/lib/ble/band_identity.dart new file mode 100644 index 000000000..e017762ad --- /dev/null +++ b/lib/ble/band_identity.dart @@ -0,0 +1,142 @@ +// Which main-device band (role == band) an advertisement or a GATT list belongs +// to, whatever scan found it, and what the pairing step reports. A WHOOP can +// advertise 0x180D under any name; filing it as a chest strap is the bug this +// guards against. Evidence order: a service beats a service-data UUID beats a +// name. A company id alone is no band evidence: it names the maker, not the +// product. +import 'adapters/_registry.dart'; + +/// [entry] is the band; [modelCertain] is false when only the NAME matched a +/// WHOOP (the name cannot tell gen4 from gen5), true when a service settled it. +typedef BandIdentity = ({BandEntry entry, bool modelCertain}); + +const String _sigBase = '-0000-1000-8000-00805f9b34fb'; + +/// A GATT UUID in canonical 128-bit lowercase form: '180D', '0000180D' and +/// '0000180d-0000-1000-8000-00805f9b34fb' all give the last one. +String normalizeGattUuid(String uuid) { + var u = uuid.trim().toLowerCase(); + if (u.startsWith('0x')) u = u.substring(2); + if (u.length == 4) return '0000$u$_sigBase'; + if (u.length == 8) return '$u$_sigBase'; + return u; +} + +Iterable get _bands => + kBandRegistry.where((e) => e.role == DeviceRole.band); + +/// Whether service [uuid] (any form) is [e]'s. A framed band is a family: +/// matched by its 32-bit service prefix. A notify-class band is matched by its +/// full UUID, because a shared prefix is a stranger (Nordic UART shares +/// Coros's 6e400001). +bool serviceIsEntry(BandEntry e, String uuid) { + final normalized = normalizeGattUuid(uuid); + return e.isFramed + ? normalized.startsWith(e.servicePrefix.toLowerCase()) + : normalized == normalizeGattUuid(e.service); +} + +BandEntry? _bandForService(Iterable uuids) { + for (final e in _bands) { + if (uuids.any((u) => serviceIsEntry(e, u))) return e; + } + return null; +} + +/// The band an advertisement belongs to, or null. A service match settles the +/// model; so does a service-data UUID an entry names as its own. A company id +/// does not count: Garmin's 0x0087 is on every Garmin product, so a Garmin +/// heart-rate strap would be refused as the watch. A name alone only proves +/// the family, so a framed band matched by name is +/// [BandIdentity.modelCertain] false. +BandIdentity? bandIdentityOf({ + required List advertisedServiceUuids, + required String? name, + List serviceDataUuids = const [], +}) { + final bySvc = _bandForService(advertisedServiceUuids); + if (bySvc != null) return (entry: bySvc, modelCertain: true); + final data = {for (final u in serviceDataUuids) normalizeGattUuid(u)}; + for (final e in _bands) { + if (e.scanServiceData.any((u) => data.contains(normalizeGattUuid(u)))) { + return (entry: e, modelCertain: true); + } + } + final lower = name?.toLowerCase() ?? ''; + if (lower.isEmpty) return null; + for (final e in _bands) { + if (hintedNameMatch([e], const [], lower, requireHint: false) != + null) { + return (entry: e, modelCertain: !e.isFramed); + } + } + return null; +} + +/// DEFINITIVE check after `discoverServices()`: the full GATT service list +/// against every role==band entry's service. +BandEntry? bandIdentityFromGatt(Iterable discoveredServiceUuids) => + _bandForService(discoveredServiceUuids); + +/// One discovered service and the characteristic UUIDs under it. +class DiscoveredService { + final String uuid; + final List characteristics; + const DiscoveredService(this.uuid, [this.characteristics = const []]); +} + +/// The user's explicit "yes, pair this band as a plain sensor": bound to ONE +/// physical device ([remoteId]) and ONE matched band entry ([bandEntryId]). +class PairConfirmation { + final String remoteId; + final String bandEntryId; + const PairConfirmation({required this.remoteId, required this.bandEntryId}); +} + +/// What the pairing operation asks its persistence callback to write: exactly +/// the arguments of `LocalDb.upsertDevice`. +typedef PairedDevice = ({ + String id, + String adapterId, + String remoteId, + String? label, + String? tier, +}); + +/// What `HrsLink.pairNotifySensor` reports: saved, failed (with a sentence), +/// or "this is a band" (nothing was saved). +class PairOutcome { + /// Non-null when the device turned out to be a band (nothing was saved). + final BandEntry? isBand; + + /// Non-null on a plain failure (nothing was saved). + final String? error; + + /// With [isBand]: false when only the device's name said which band it is + /// (a WHOOP name cannot tell 4 from 5); true when GATT showed it. + final bool modelCertain; + const PairOutcome._(this.isBand, this.error, {this.modelCertain = true}); + const PairOutcome.saved() : this._(null, null); + const PairOutcome.failed(String message) : this._(null, message); + const PairOutcome.band(BandEntry entry, {bool modelCertain = true}) + : this._(entry, null, modelCertain: modelCertain); + bool get saved => isBand == null && error == null; +} + +/// Everything [pairNotifyDevice] needs from the outside world once a device is +/// connected: GATT discovery, the Polar PMD feature read, the OS bond trigger +/// and persistence. One typed interface (production over BluetoothDevice and +/// LocalDb, a fake in tests) so the decision awaits plain methods. +abstract class PairingIo { + Future> discoverServices(); + + /// The PMD control characteristic's feature bytes, null when unreadable. + /// Only asked of a Polar PMD entry. + Future?> readPmdFeatures(); + + /// Writes the entry's bond-trigger characteristic; false when refused. Only + /// asked of an entry that names one ([BandEntry.bondTriggerCharacteristic]). + Future triggerBond(); + + Future persist(PairedDevice device); +} diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 6641f3284..8886f0196 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -1924,6 +1924,17 @@ class ScanAcceptPolicy { } return null; } + + /// Whether a scan result may be the band the user chose: the remote id + /// [wantRemoteId] when one was named, and no advertised generation other + /// than [wantGeneration]. A name-only or 16-bit-only match names no + /// generation, so it may still be either and passes. + static bool fitsChoice(String remoteId, Iterable serviceUuids, + {String? wantRemoteId, String? wantGeneration}) { + if (wantRemoteId != null && remoteId != wantRemoteId) return false; + final adv = accepts(serviceUuids); + return wantGeneration == null || adv == null || adv == wantGeneration; + } } /// Which connect order a link gets, decided BEFORE discovery has run. diff --git a/lib/ble/hrs_link.dart b/lib/ble/hrs_link.dart index b12174bed..dcb300e68 100644 --- a/lib/ble/hrs_link.dart +++ b/lib/ble/hrs_link.dart @@ -60,7 +60,7 @@ import 'package:openstrap_protocol/openstrap_protocol.dart' import '../compute/inputs/canonical.dart' show adoptReplacementWearable, pairedRowOf, wearableEnabled; import '../data/db.dart'; -import '../sync/paired_device.dart' show cleanDeviceLabel; +import '../sync/paired_device.dart' show cleanDeviceLabel, kBandSourceTier; import 'accessory_setup.dart'; import 'adapters/_registry.dart'; import 'adapters/adapter.dart'; @@ -68,6 +68,7 @@ import 'adapters/ble_hrs.dart'; import 'adapters/gatt_link.dart'; import '../sync/reset_gate.dart'; import 'adapters/host.dart' show BandHost, HrsReading; +import 'band_identity.dart'; import 'ble_state.dart' show BleBlocker, @@ -80,7 +81,7 @@ import 'colmi_link.dart' show ColmiLink; import 'session_link.dart' show SessionLink; import 'coros_link.dart' show CorosLink; import 'adapters/garmin.dart' show garminMlPair; -import 'garmin_link.dart' show GarminLink, garminMlCharsOf; +import 'garmin_link.dart' show GarminLink; import 'miband_link.dart' show MiBand234Link; import 'oura_link.dart' show OuraLink; import 'pebble_link.dart' show PebbleLink; @@ -107,6 +108,11 @@ typedef BandCandidate = ({ // scan (`scanFor`) always stamps its own id, so existing callers reading // `.label`/`.device`/`.rssi` are unaffected by this field's addition. String entryId, + // The role==band registry entry this advertisement ALSO belongs to (e.g. a + // WHOOP heard in a sensor scan because it advertises 0x180D), else null. + // Also set, to [entryId]'s own band with modelCertain false, when only the + // name matched a WHOOP: the name cannot tell WHOOP 4 from WHOOP 5. + BandIdentity? alsoBand, }); /// The live link to a paired heart-rate sensor. One instance; a second @@ -317,8 +323,8 @@ class HrsLink { Duration timeout = _scanWindow, }) { assert(entries.isNotEmpty, 'scanForAny needs at least one entry.'); - assert(entries.every((e) => !e.isFramed), - 'a framed band has no notify-class scan to join.'); + // Framed WHOOP entries may join: their band service is a plain scan + // filter, so a WHOOP advertising only that is still heard ("Add a device"). return withScanLock(() => _scanForEntries(entries, owner, onResults, timeout)); } @@ -345,65 +351,14 @@ class HrsLink { for (final e in entries) for (final h in e.scanHints) Guid(h), }..removeAll([for (final e in entries) Guid(e.service)]); - // Which entry a matched service belongs to, or NULL when this particular - // advertisement did not carry one of them. Services are each entry's own - // GATT identity, so a collision here would mean two registry rows sharing - // one service — a registry bug, not a runtime ambiguity to resolve softly. - // - // NULLABLE ON PURPOSE, and the whole point of the split with the caller - // below. `withServices` filters on the SCAN, not on the payload: an - // Android advertisement can match the filter and still arrive with an - // empty or truncated `serviceUuids` (the 31-byte advertising packet is - // full, and the rest is in a scan response that has not landed yet). A - // fallback returned from in here was indistinguishable from a real match, - // so the caller cached a guess and never looked again. - // [lowercaseName] is the belt-and-suspenders fallback: `BandEntry - // .nameMatcher` is the same per-entry escape hatch `transport.dart` uses - // for a framed band whose advertisement carries its name but not a - // matchable service UUID. It cannot rescue a device the OS-level - // `withServices` filter below already excluded from the scan entirely, - // which is why an entry that may not advertise its service at all sets - // `scanByName` and the filter is dropped. - // Split from the genuine service match on purpose: a name match is the - // same kind of guess the comment above already warns about, and caching - // it into `confirmed` would make it permanent the same way. Only - // [serviceMatchFor] is safe to lock in — a peripheral's GATT identity - // does not change mid-scan, but its advertised name matching a pattern - // is not proof of anything and gets re-tried every advertisement instead. - String? serviceMatchFor(List advertised) { - for (final g in advertised) { - for (final e in entries) { - if (g == Guid(e.service)) return e.id; - } - } - return null; - } - - // A company id or service-data UUID an entry names as its own - // ([BandEntry.scanCompanyIds], [BandEntry.scanServiceData]): an identity, - // as good as the service, so it confirms like one. - String? adMatchFor(AdvertisementData ad) { - for (final e in entries) { - if (e.scanCompanyIds.any(ad.manufacturerData.containsKey) || - e.scanServiceData.any((u) => ad.serviceData.containsKey(Guid(u)))) { - return e.id; - } - } - return null; - } - - String? nameMatchFor(String lowercaseName) { - for (final e in entries) { - if (e.nameMatcher?.call(lowercaseName) ?? false) return e.id; - } - return null; - } - // Remote ids whose entry is CONFIRMED by an advertisement that actually // carried the service. Everything else is shown under `entries.first` // until a real match lands — correct by construction for a single-entry // scan ([scanFor]), a placeholder that corrects itself for [scanForAny]. final confirmed = {}; + // Band evidence per remote id, kept for the whole scan: once an + // advertisement showed a band, a later bare 0x180D one does not erase it. + final bandSeen = {}; // Keyed by remote id: a scan re-reports the same peripheral several times // a second, and a list that grows a row per advertisement is not a picker. @@ -413,57 +368,25 @@ class HrsLink { for (final r in results) { final id = r.device.remoteId.str; final was = seen[id]; - // The ADVERTISED name first. `platformName` is the OS's cached name - // for a peripheral it has seen before, which can be stale or empty; - // the advertisement is what this sensor is saying now. Both go through - // `cleanDeviceLabel`, and null survives as null rather than becoming a - // placeholder. A name that drops out of a later advertisement keeps - // the one we already had — losing it mid-scan would make the row the - // user is reaching for change under their finger. - final label = cleanDeviceLabel(r.advertisementData.advName) ?? - cleanDeviceLabel(r.device.platformName) ?? - was?.label; - // Re-attempted on EVERY advertisement until one confirms, then fixed: - // a confirmed match cannot change (a peripheral does not swap GATT - // identity mid-scan) and re-reading it would only add work. - // A name an entry claims, on an advertisement carrying one of THAT - // entry's hints, outranks a plain service match: a Polar optical - // sensor advertises 0x180D, which is [kBleHrs]'s own service. Read - // off the sticky [label], so an advertisement whose name has not - // landed yet cannot flip the row back. - final svcMatch = hintedNameMatch(entries, - r.advertisementData.serviceUuids, label?.toLowerCase() ?? '') ?? - serviceMatchFor(r.advertisementData.serviceUuids) ?? - adMatchFor(r.advertisementData); - if (svcMatch != null) confirmed[id] = svcMatch; - final match = confirmed[id] ?? - svcMatch ?? - nameMatchFor((r.advertisementData.advName.isNotEmpty - ? r.advertisementData.advName - : r.device.platformName) - .toLowerCase()); - // Unfiltered scan: no OS filter vouched for this one, so a result - // with no match is some other peripheral, not a candidate. The same - // goes for one the filter let in on a shared hint alone - // ([BandEntry.scanHints]): every unrelated band carrying that UUID - // would otherwise show up as `entries.first`. - if (match == null && - (scanFilter.isEmpty || - r.advertisementData.serviceUuids.any(hints.contains))) { - continue; - } - final now = ( + // Everything per-advertisement lives in [candidateFromAd]. + final ad = r.advertisementData; + final now = candidateFromAd( + entries: entries, device: r.device, - label: label, + serviceUuids: ad.serviceUuids, + advName: ad.advName, + platformName: r.device.platformName, rssi: r.rssi, - entryId: match ?? entries.first.id, + manufacturerCompanyIds: ad.manufacturerData.keys.toList(), + serviceDataUuids: ad.serviceData.keys.toList(), + confirmed: confirmed, + bandSeen: bandSeen, + previousLabel: was?.label, + unfilteredScan: scanFilter.isEmpty, + hints: hints, ); - if (was == null || - was.rssi != now.rssi || - was.label != now.label || - was.entryId != now.entryId) { - changed = true; - } + if (now == null) continue; + if (candidateChanged(was, now)) changed = true; seen[id] = now; } if (changed) onResults(_ranked(seen)); @@ -496,6 +419,7 @@ class HrsLink { label: cleanDeviceLabel(d.platformName), rssi: null, entryId: e.id, + alsoBand: null, )); } } catch (err) { @@ -542,19 +466,127 @@ class HrsLink { onResults(_ranked(seen)); } - /// The entry whose [BandEntry.nameMatcher] claims [lowercaseName] while - /// [advertised] carries one of that same entry's [BandEntry.scanHints], or - /// null. Name and hint together name the entry; either alone does not. + /// Whether a re-heard [now] differs from the [was] the picker already has: + /// signal, label, entry, or the band evidence ([BandCandidate.alsoBand]). @visibleForTesting - static String? hintedNameMatch(List entries, - List advertised, String lowercaseName) { - for (final e in entries) { - if ((e.nameMatcher?.call(lowercaseName) ?? false) && - e.scanHints.any((h) => advertised.contains(Guid(h)))) { - return e.id; + static bool candidateChanged(BandCandidate? was, BandCandidate now) => + was == null || + was.rssi != now.rssi || + was.label != now.label || + was.entryId != now.entryId || + was.alsoBand != now.alsoBand; + + /// The per-advertisement step of the sensor scan: which of [entries] claims + /// this advertisement, and whether it ALSO belongs to a band + /// ([BandCandidate.alsoBand], from [bandIdentityOf]: a WHOOP advertises + /// 0x180D too). Null when no entry claims it. + /// + /// [confirmed] (remote id to entry id), [previousLabel], [unfilteredScan] + /// and [hints] carry the scan's per-peripheral state in and out; the + /// defaults describe a first, unfiltered sighting. + /// + /// A service (a framed band's by its prefix, as [serviceIsEntry]), company + /// id or service-data UUID that an entry names is its + /// identity and gets locked in [confirmed] (a peripheral does not swap GATT + /// identity mid-scan). A name match is only a guess and is + /// re-tried every advertisement. A name an entry claims on an advertisement + /// carrying one of THAT entry's hints outranks a plain service match: a + /// Polar optical sensor advertises 0x180D, which is [kBleHrs]'s own service. + /// + /// The label is the ADVERTISED name first, then [previousLabel] (a name that + /// drops out of a later advertisement keeps the row's label, so the row + /// under the user's finger does not change), then `platformName` (the OS's + /// cached one, possibly stale); names go through `cleanDeviceLabel`. + /// + /// Unfiltered scan, or one the filter let in on a shared hint alone + /// ([BandEntry.scanHints]): a result matching nothing is some other + /// peripheral, not a candidate. + @visibleForTesting + static BandCandidate? candidateFromAd({ + required List entries, + required BluetoothDevice device, + required List serviceUuids, + required String advName, + required String platformName, + required int rssi, + List manufacturerCompanyIds = const [], + List serviceDataUuids = const [], + Map? confirmed, + Map? bandSeen, + String? previousLabel, + bool unfilteredScan = true, + Set hints = const {}, + }) { + final key = device.remoteId.str; + final label = cleanDeviceLabel(advName) ?? + previousLabel ?? + cleanDeviceLabel(platformName); + // NULLABLE ON PURPOSE: `withServices` filters on the SCAN, not on the + // payload, so an advertisement can match the filter and still arrive with + // a truncated `serviceUuids`. A fallback returned from in here would be + // indistinguishable from a real match and the caller would cache a guess. + String? serviceMatch() { + for (final g in serviceUuids) { + for (final e in entries) { + if (serviceIsEntry(e, g.str128)) return e.id; + } } + return null; } - return null; + + String? adMatch() { + for (final e in entries) { + if (e.scanCompanyIds.any(manufacturerCompanyIds.contains) || + e.scanServiceData.any((u) => serviceDataUuids.contains(Guid(u)))) { + return e.id; + } + } + return null; + } + + final scanName = advName.isNotEmpty ? advName : platformName; + final adUuids = [for (final g in serviceUuids) g.str128]; + String? nameMatch() => hintedNameMatch( + entries, adUuids, scanName.toLowerCase(), + requireHint: false); + + final svcMatch = hintedNameMatch(entries, adUuids, + label?.toLowerCase() ?? '') ?? + serviceMatch() ?? + adMatch(); + if (svcMatch != null) confirmed?[key] = svcMatch; + final match = confirmed?[key] ?? svcMatch ?? nameMatch(); + final band = bandIdentityOf( + advertisedServiceUuids: adUuids, + name: scanName, + serviceDataUuids: [for (final g in serviceDataUuids) g.str128], + ); + // Recorded BEFORE the unmatched early return below: a band-only packet + // (no sensor match) must still leave its evidence for a later HR-only one. + // Evidence only accumulates: a later advertisement that shows a band + // replaces a weaker (name-only) guess, never a bare one erases it. + final earlier = bandSeen?[key]; + final best = band != null && (earlier == null || band.modelCertain) + ? band + : earlier ?? band; + if (best != null) bandSeen?[key] = best; + if (match == null && + (unfilteredScan || serviceUuids.any(hints.contains))) { + return null; + } + final entryId = match ?? entries.first.id; + return ( + device: device, + label: label, + rssi: rssi, + entryId: entryId, + // An entry that IS the band is not "also" a band, unless only the name + // matched: then the uncertain identity rides along, so nobody reads a + // WHOOP model off the name-matched entry. + alsoBand: best != null && (best.entry.id != entryId || !best.modelCertain) + ? best + : null, + ); } /// The OS-level service filter for a scan over [entries]: every entry's @@ -779,87 +811,42 @@ class HrsLink { return null; } - static Future pairNotifySensor( + /// Pairs [entry] on [device]; the decision itself is [pairNotifyDevice]. + /// [earlierBand] is what the scan already knew ([BandCandidate.alsoBand]) and + /// [confirmation] the user's explicit "pair it as a sensor anyway". + static Future pairNotifySensor( BandEntry entry, BluetoothDevice device, { String? label, String? tier, + BandIdentity? earlierBand, + PairConfirmation? confirmation, }) async { try { await device.connect(timeout: _connectTimeout); } catch (e) { debugPrint('[hrs] pair connect failed: $e'); - return 'That sensor did not answer. It may have gone back to sleep, or ' - 'it may already be connected to another phone or app.'; + return const PairOutcome.failed( + 'That sensor did not answer. It may have gone back to sleep, or it ' + 'may already be connected to another phone or app.'); } + final io = _DevicePairingIo(entry, device); try { - final services = await device.discoverServices(); - final link = GattBandLink( + return await pairNotifyDevice( entry: entry, - services: services, - onLog: (m) => debugPrint('[hrs] pair: $m'), - ); - final missing = - link.missingCharacteristics(entry.requiredCharacteristics); - if (missing.isNotEmpty) { - link.close(); - return 'That device answered, but it does not expose the ' - '${entry.label} data this needs ' - '(missing ${missing.map((u) => u.substring(0, 8)).join(", ")}). ' - 'Nothing was saved.'; - } - // A Garmin watch's data characteristic varies, so the generic list - // cannot name it; its own check stands in. - if (entry.id == kGarmin.id && - garminMlPair(garminMlCharsOf(services)) == null) { - link.close(); - return 'That device answered, but it does not expose the ' - '${entry.label} data this needs. Nothing was saved.'; - } - final refusal = identityRefusal( - entry, - services: [for (final s in services) s.uuid], - pmdFeatures: entry.id == kPolarPmd.id - ? await link.read(kPolarPmdControlChar) - : null, - ); - if (refusal != null) { - link.close(); - return refusal; - } - // Some notify-class bands (Pebble) gate everything past this point on - // OS-level bonding, triggered by a write here rather than by an - // app-layer key — see `BandEntry.bondTriggerCharacteristic`. - final bondChar = entry.bondTriggerCharacteristic; - if (bondChar != null && - !await link.write(bondChar, kPebblePairingTriggerValue)) { - link.close(); - return 'That device did not accept Bluetooth pairing. Nothing was ' - 'saved.'; - } - link.close(); - final deviceId = mintDeviceId(entry, device.remoteId.str); - await LocalDb.upsertDevice( - id: deviceId, - adapterId: entry.id, remoteId: device.remoteId.str, + io: io, + confirmation: confirmation, + earlierBand: earlierBand, label: label, - tier: deriveTier(tier, entry.id), + tier: tier, ); - // A replacement for the active wearable: syncs now land on this row, - // so derivation has to read it too. Best effort, the sensor is paired - // either way. - try { - await adoptReplacementWearable(deviceId); - } catch (e) { - debugPrint('[hrs] could not move the active wearable: $e'); - } - return null; } catch (e) { debugPrint('[hrs] pair setup failed: $e'); - return 'That sensor disconnected before it could be set up. Nothing was ' - 'saved.'; + return const PairOutcome.failed('That sensor disconnected before it ' + 'could be set up. Nothing was saved.'); } finally { + io.close(); // The pairing connection is not the session. `arm()` opens its own when // a workout starts, and holding this one would be a second GATT link // nobody asked for. @@ -1380,3 +1367,162 @@ class HrsLink { _host = null; } } + +/// [PairingIo] over a connected [BluetoothDevice] and [LocalDb]. Holds the GATT +/// link discovery builds so the PMD read and the bond write reuse it. +class _DevicePairingIo implements PairingIo { + _DevicePairingIo(this.entry, this.device); + final BandEntry entry; + final BluetoothDevice device; + GattBandLink? _link; + + @override + Future> discoverServices() async { + final services = await device.discoverServices(); + _link = GattBandLink( + entry: entry, + services: services, + onLog: (m) => debugPrint('[hrs] pair: $m'), + ); + return [ + for (final s in services) + DiscoveredService(s.uuid.str128, + [for (final c in s.characteristics) c.uuid.str128]), + ]; + } + + @override + Future?> readPmdFeatures() async => + _link?.read(kPolarPmdControlChar); + + // Some notify-class bands (Pebble) gate everything past this point on + // OS-level bonding, triggered by a write here rather than by an app-layer + // key — see `BandEntry.bondTriggerCharacteristic`. + @override + Future triggerBond() async => + await _link?.write(entry.bondTriggerCharacteristic!, + kPebblePairingTriggerValue) ?? + false; + + @override + Future persist(PairedDevice d) async { + await LocalDb.upsertDevice( + id: d.id, + adapterId: d.adapterId, + remoteId: d.remoteId, + label: d.label, + tier: d.tier, + ); + // A replacement for the active wearable: syncs now land on this row, so + // derivation has to read it too. Best effort, the sensor is paired either + // way. + try { + await adoptReplacementWearable(d.id); + } catch (e) { + debugPrint('[hrs] could not move the active wearable: $e'); + } + } + + void close() => _link?.close(); +} + +bool _hasCharacteristic(List services, String required) { + final want = required.substring(0, 8).toLowerCase(); + return services.any((s) => + s.characteristics.any((c) => normalizeGattUuid(c).startsWith(want))); +} + +/// The real pairing decision [HrsLink.pairNotifySensor] delegates to, after it +/// has connected: discovery, persistence and the two device writes/reads come +/// through [io] so no Bluetooth is needed. +/// +/// Order: discover (a throw gives a failed outcome), band cross-check +/// (it needs only the services, so a band missing a characteristic still +/// gets the band choice), required characteristics, Garmin multi-link check, +/// own-identity check (Coros, Polar PPI), bond trigger, THEN persist. Nothing is written or persisted on +/// any refusal. +/// +/// The band cross-check: the band is whatever GATT shows +/// ([bandIdentityFromGatt]), else [earlierBand] (advertisement, company id or +/// name evidence: standard-only discovery cannot erase it). It refuses with +/// [PairOutcome.band] unless it is [entry] itself or [confirmation] names this +/// [remoteId] AND that band entry. +Future pairNotifyDevice({ + required BandEntry entry, + required String remoteId, + required PairingIo io, + PairConfirmation? confirmation, + BandIdentity? earlierBand, + String? label, + String? tier, +}) async { + final List services; + try { + services = await io.discoverServices(); + } catch (_) { + return const PairOutcome.failed('That sensor disconnected before it ' + 'could be set up. Nothing was saved.'); + } + final gattBand = bandIdentityFromGatt([for (final s in services) s.uuid]); + final band = gattBand ?? earlierBand?.entry; + final confirmedBand = band != null && band.id != entry.id; + if (confirmedBand) { + final ok = confirmation != null && + confirmation.remoteId == remoteId && + confirmation.bandEntryId == band.id; + if (!ok) { + return PairOutcome.band(band, + modelCertain: gattBand != null || earlierBand?.modelCertain != false); + } + } + final missing = [ + for (final u in entry.requiredCharacteristics) + if (!_hasCharacteristic(services, u)) u, + ]; + if (missing.isNotEmpty) { + return PairOutcome.failed('That device answered, but it does not expose ' + 'the ${entry.label} data this needs ' + '(missing ${missing.map((u) => u.substring(0, 8)).join(", ")}). ' + 'Nothing was saved.'); + } + // A Garmin watch's data characteristic varies, so the generic list cannot + // name it; its own check stands in. + if (entry.id == kGarmin.id) { + final ml = [ + for (final s in services) + if (normalizeGattUuid(s.uuid) == normalizeGattUuid(kGarminService)) + for (final c in s.characteristics) normalizeGattUuid(c), + ]; + if (garminMlPair(ml) == null) { + return PairOutcome.failed('That device answered, but it does not expose ' + 'the ${entry.label} data this needs. Nothing was saved.'); + } + } + final refusal = HrsLink.identityRefusal( + entry, + services: [for (final s in services) Guid(s.uuid)], + pmdFeatures: entry.id == kPolarPmd.id ? await io.readPmdFeatures() : null, + ); + if (refusal != null) return PairOutcome.failed(refusal); + if (entry.bondTriggerCharacteristic != null && !await io.triggerBond()) { + return const PairOutcome.failed('That device did not accept Bluetooth ' + 'pairing. Nothing was saved.'); + } + try { + await io.persist(( + id: HrsLink.mintDeviceId(entry, remoteId), + adapterId: entry.id, + remoteId: remoteId, + label: label, + // A band the user kept as a plain sensor is still a wrist (or finger) + // optical source: deriveTier would rank it beat-to-beat off the + // heart-rate adapter's declared signals, above the band tier, on the + // R-peak rung and first for live heart rate. + tier: confirmedBand ? kBandSourceTier : HrsLink.deriveTier(tier, entry.id), + )); + } catch (_) { + return const PairOutcome.failed( + 'That sensor could not be saved. Nothing was saved.'); + } + return const PairOutcome.saved(); +} 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/ble/slot_comparison.dart b/lib/ble/slot_comparison.dart new file mode 100644 index 000000000..a5456be49 --- /dev/null +++ b/lib/ble/slot_comparison.dart @@ -0,0 +1,336 @@ +// What a device gives in each of the two columns the pairing screens draw: +// "Every day" (the main band, or the active wearable that fills the days the +// band left empty) and "Workouts only" (a workout-armed sensor). Capabilities +// come from the compute layer's eligibility rules (S6), never from raw-signal +// membership. +import '../compute/inputs/canonical.dart' + show Cell, MetricClass, capabilities, columnFor, kWorkoutArmedSensors; +import 'adapters/_registry.dart'; + +/// The two columns. A WHOOP fills [everyDay] as the main band; a ring or a +/// watch fills it as the active wearable (canonical.dart `activeWearable`, +/// `wearableSubstrate`); a workout-armed sensor fills [workoutsOnly]. +enum DeviceSlot { everyDay, workoutsOnly } + +enum DeviceCapability { + sleep, + recovery, + strain, + + /// Heart rate and resting heart rate (the column's `resting_hr` row). + heartRate, + hrv, + respiration, + skinTemperature, + steps, + bloodOxygen, + workoutHeartRate, + workoutBeatToBeat, +} + +/// How a capability is delivered. +/// native: this app computes it. conditional: only when [CapabilityStatus.reasonKey] +/// holds (an l10n-able key). vendorReported: the device's own number, shown as +/// the device's (never passed off as native, RULES B2). +enum CapabilityKind { native, conditional, vendorReported } + +class CapabilityStatus { + final CapabilityKind kind; + + /// Required (nonblank) when [kind] is conditional, else null. + final String? reasonKey; + const CapabilityStatus(this.kind, {this.reasonKey}); +} + +class SlotSide { + final Map sends; + + /// The table's rows ([kTableRows]) this side does not send. + final Set doesNotSend; + const SlotSide({required this.sends, required this.doesNotSend}); +} + +class SlotComparison { + /// What the entry gives every day: as the main band (a WHOOP) or as the + /// active wearable (a columned ring or watch). Empty for a chest strap. + final SlotSide everyDay; + + /// What the entry gives during workouts as a workout-armed sensor (a WHOOP + /// kept for workouts is read as one). Empty for a ring. + final SlotSide workoutsOnly; + + /// The column the device goes into, or null when it fits neither (a Coros + /// watch, a scale). + final DeviceSlot? slot; + const SlotComparison( + {required this.everyDay, required this.workoutsOnly, this.slot}); + + SlotSide side(DeviceSlot s) => switch (s) { + DeviceSlot.everyDay => everyDay, + DeviceSlot.workoutsOnly => workoutsOnly, + }; +} + +/// The whole-day rows of the table, in the order they are drawn. +const List kDayCapabilities = [ + DeviceCapability.sleep, + DeviceCapability.recovery, + DeviceCapability.strain, + DeviceCapability.heartRate, + DeviceCapability.hrv, + DeviceCapability.respiration, + DeviceCapability.skinTemperature, + DeviceCapability.steps, +]; + +/// Capabilities decided per workout session, not per day: the band records a +/// workout itself; a sensor only through the sensor path +/// (`kWorkoutArmedSensors`, canonical.dart; `HrsLink.deriveTier`). +const Set kSessionCapabilities = { + DeviceCapability.workoutHeartRate, + DeviceCapability.workoutBeatToBeat, +}; + +/// Every row the table draws: the whole-day rows, then the workout rows. +/// Blood oxygen is never one of them. +const List kTableRows = [ + ...kDayCapabilities, + DeviceCapability.workoutHeartRate, + DeviceCapability.workoutBeatToBeat, +]; + +/// THE mapping from a whole-day capability to its row in the compute layer's +/// metric x device columns (`columnFor`, compute/inputs/canonical.dart). A +/// capability's status for a wearable is read off `capabilities(column)[row]`. +const Map kCapabilityRow = { + DeviceCapability.sleep: 'sleep_window', + DeviceCapability.recovery: 'readiness', + DeviceCapability.strain: 'strain', + DeviceCapability.heartRate: 'resting_hr', + DeviceCapability.hrv: 'hrv', + DeviceCapability.respiration: 'respiratory_rate', + DeviceCapability.skinTemperature: 'skin_temp', + DeviceCapability.steps: 'steps', +}; + +// Blood oxygen is never claimed by a pairing comparison, for any device: the +// app refuses its own SpO2 (onehz_pipeline.dart kSpo2Refusal), and a vendor's +// reading is not a promise made at pairing. It is not in [kCapabilityRow]. +// (The enum value and its label stay so the absence can be asserted.) + +/// Where a claimed capability comes from: [rule] names the eligibility rule, +/// [source] is the repo file that holds it. +typedef CapabilityTrace = ({String rule, String source}); + +/// l10n keys (lib/l10n/app_*.arb) of the one-line captions a conditional +/// capability carries. +const String kReasonSkinTempNights = 'capReasonSkinTempNights'; +const String kReasonEstimated = 'capReasonEstimated'; +const String kReasonEarly = 'capReasonEarly'; + +/// A wearable's readiness is provisional until its inputs have 14 nights of +/// baseline (oura_inputs.dart `readiness`, onehz_pipeline.dart +/// kSkinTempDevMinNights). +const String kReasonFirstNights = 'capReasonFirstNights'; + +/// A wearable's sleep window is ours, its stages the device's own +/// (`sleep_stages` is a device-only row). +const String kReasonOwnStagesRing = 'capReasonOwnStagesRing'; +const String kReasonOwnStages = 'capReasonOwnStages'; + +/// WHOOP 4 has no step counter: its steps come from the strap's 100 Hz +/// pedometer over a live link only (live_step_runs.dart). +const String kReasonLiveSessions = 'capReasonLiveSessions'; + +/// A row that depends on whether the device sends it, where nobody has +/// confirmed it does: RR intervals are optional in the Heart Rate Service, so +/// a generic strap's (or a WHOOP read as one) beat timing is unconfirmed. A +/// device people use and send it (Polar's PPI stream) carries no caption. +const String kReasonUnconfirmed = 'capReasonUnconfirmed'; + +/// Pebble 2 SE has no HR sensor; only Pebble 2 can supply HR-based rows. +const String kReasonPebble2Only = 'capReasonPebble2Only'; + +/// [kReasonPebble2Only] on a row that is also an estimate on Pebble 2. +const String kReasonPebble2OnlyEstimated = 'capReasonPebble2OnlyEstimated'; + +/// l10n key of each capability's user-facing name. +const Map kCapabilityLabelKey = { + DeviceCapability.sleep: 'capLabelSleep', + DeviceCapability.recovery: 'capLabelRecovery', + DeviceCapability.strain: 'capLabelStrain', + DeviceCapability.heartRate: 'capLabelHeartRate', + DeviceCapability.hrv: 'capLabelHrv', + DeviceCapability.respiration: 'capLabelRespiration', + DeviceCapability.skinTemperature: 'capLabelSkinTemperature', + DeviceCapability.steps: 'capLabelSteps', + DeviceCapability.bloodOxygen: 'capLabelBloodOxygen', + DeviceCapability.workoutHeartRate: 'capLabelWorkoutHeartRate', + DeviceCapability.workoutBeatToBeat: 'capLabelWorkoutBeatToBeat', +}; + +typedef _Claim = ({CapabilityStatus status, CapabilityTrace trace}); + +const String _canonical = 'lib/compute/inputs/canonical.dart'; +const String _pipeline = 'lib/compute/onehz_pipeline.dart'; +const String _engine = 'lib/compute/derivation_engine.dart'; + +const CapabilityStatus _native = CapabilityStatus(CapabilityKind.native); + +CapabilityStatus _when(String reasonKey) => + CapabilityStatus(CapabilityKind.conditional, reasonKey: reasonKey); + +bool _nativeWhoop(BandEntry e) => kWhoopGen4.id == e.id || kWhoopGen5.id == e.id; + +/// The column [e] goes into, or null when it fits neither. +DeviceSlot? _slotOf(BandEntry e) { + if (_nativeWhoop(e)) return DeviceSlot.everyDay; + if (e.category == DeviceCategory.wearable && columnFor(e.id) != null) { + return DeviceSlot.everyDay; + } + if (kWorkoutArmedSensors.contains(e.id)) return DeviceSlot.workoutsOnly; + return null; +} + +/// Every capability [e] gives in [slot], each with the rule behind it. The one +/// place claims are made, so a claim cannot exist without its trace. +Map _claims(BandEntry e, DeviceSlot slot) => + switch (slot) { + DeviceSlot.everyDay => _everyDayClaims(e), + DeviceSlot.workoutsOnly => _workoutClaims(e), + }; + +Map _everyDayClaims(BandEntry e) { + if (!_nativeWhoop(e)) { + // The active wearable: its column's rows, behind its own switch. Coros has + // no column (and wearableEnabled() is false for it): nothing. + return e.category == DeviceCategory.wearable ? _columnClaims(e) : {}; + } + // The native pipeline (onehz_pipeline.dart): sleep, rmssd, resting HR, + // respiration, strain, readiness. Skin temperature needs a nightly mean, + // >= 3 prior nights and a baseline spread >= 1; WHOOP 5's deg C needs 14. + final out = { + for (final c in kDayCapabilities) + c: ( + status: c == DeviceCapability.skinTemperature + ? _when(kReasonSkinTempNights) + : _native, + trace: (rule: 'native WHOOP pipeline (${c.name})', source: _pipeline), + ), + }; + // Steps: WHOOP 5's cumulative on-chip counter rides every synced row; + // WHOOP 4 has none (derivation_engine.dart _stepCounterModulus), so only + // the live link's 100 Hz pedometer counts for it. + out[DeviceCapability.steps] = ( + status: e.id == kWhoopGen5.id ? _native : _when(kReasonLiveSessions), + trace: (rule: 'band steps (counter or live pedometer)', source: _engine), + ); + // The band records a workout itself. + for (final c in kSessionCapabilities) { + out[c] = (status: _native, trace: (rule: 'band workout session', source: _pipeline)); + } + return out; +} + +Map _workoutClaims(BandEntry e) { + // Only the sensors in kWorkoutArmedSensors (ble_hrs, polar_pmd) are session + // sources, and only with their switch on. A WHOOP kept for workouts is + // paired under ble_hrs, so it gets ble_hrs's claims. Anything else (a ring, + // Coros) sends nothing here. + final slotId = _nativeWhoop(e) ? kBleHrs.id : e.id; + if (!kWorkoutArmedSensors.contains(slotId)) return {}; + final out = {}; + final sig = declaredSignals(slotId).map((s) => s.name).toSet(); + const trace = ( + rule: 'workout session sensor (kWorkoutArmedSensors, flag on)', + source: _canonical, + ); + if (sig.contains('hr1Hz') || sig.contains('hrSparse')) { + out[DeviceCapability.workoutHeartRate] = (status: _native, trace: trace); + } + if (sig.contains('rrIntervals')) { + out[DeviceCapability.workoutBeatToBeat] = ( + status: slotId != kBleHrs.id ? _native : _when(kReasonUnconfirmed), + trace: trace, + ); + } + return out; +} + +/// [e]'s rows of the metric x device table, or none without a column. +Map _columnClaims(BandEntry e) { + final out = {}; + final col = columnFor(e.id); + if (col == null) return out; // Coros, sensors, scales: no column, no claim. + final cls = capabilities(col); + for (final m in kCapabilityRow.entries) { + var status = _statusOf(cls[m.value], col[m.value]); + if (status == null) continue; + if (status.kind == CapabilityKind.native) { + status = switch (m.key) { + // Ours is the window; the stages are the device's own. + DeviceCapability.sleep when cls['sleep_stages'] == MetricClass.device => + _when(e.kind == DeviceKind.ring + ? kReasonOwnStagesRing + : kReasonOwnStages), + _ => status, + }; + } + if (status.reasonKey == kReasonEarly) { + status = switch (m.key) { + DeviceCapability.recovery => _when(kReasonFirstNights), + DeviceCapability.skinTemperature => _when(kReasonSkinTempNights), + _ => status, + }; + } + // The shared Pebble entry covers the 2 SE too. It still serves the + // watch's reported sleep and steps without HR (dayCells), but cannot + // derive resting HR, strain or the resting-HR recovery estimate. + final pebbleHr = e.id == kPebble.id && + const {DeviceCapability.recovery, DeviceCapability.strain, + DeviceCapability.heartRate}.contains(m.key); + out[m.key] = ( + status: !pebbleHr + ? status + : _when(status.reasonKey == kReasonEstimated + ? kReasonPebble2OnlyEstimated + : kReasonPebble2Only), + trace: pebbleHr + ? (rule: 'Pebble 2 HR required for ${m.value}; SE has no HR sensor', + source: 'lib/compute/inputs/pebble_inputs.dart') + : (rule: 'metric x device column row ${m.value}', source: _canonical), + ); + } + return out; +} + +CapabilityStatus? _statusOf(MetricClass? cls, Cell? cell) => switch (cls) { + null || MetricClass.unavailable => null, + MetricClass.device => const CapabilityStatus(CapabilityKind.vendorReported), + MetricClass.estimated => _when(kReasonEstimated), + MetricClass.ours => cell?.provisionalAt != null ? _when(kReasonEarly) : _native, +}; + +/// The rule behind [c] for [e] in [slot], or null when no eligibility rule +/// supports the claim. +CapabilityTrace? capabilityTrace( + BandEntry e, + DeviceCapability c, { + required DeviceSlot slot, +}) => _claims(e, slot)[c]?.trace; + +SlotSide _side(BandEntry e, DeviceSlot slot) { + final sends = { + for (final m in _claims(e, slot).entries) m.key: m.value.status, + }; + return SlotSide( + sends: sends, + doesNotSend: kTableRows.toSet().difference(sends.keys.toSet()), + ); +} + +SlotComparison slotComparisonFor(BandEntry e) => SlotComparison( + everyDay: _side(e, DeviceSlot.everyDay), + workoutsOnly: _side(e, DeviceSlot.workoutsOnly), + slot: _slotOf(e), +); diff --git a/lib/ble/transport.dart b/lib/ble/transport.dart index 9f4432220..bf35138df 100644 --- a/lib/ble/transport.dart +++ b/lib/ble/transport.dart @@ -21,12 +21,19 @@ extension BleEngineTransport on BleEngine { /// this one radio scanner, and the `isScanning == false` await below is /// satisfied by ITS `stopScan` too — an unserialised scan silently ends /// having seen nothing and reports "No band found". + /// + /// [remoteId] and [generation] narrow it to the band the user chose + /// ([ScanAcceptPolicy.fitsChoice]): any other WHOOP in range is passed over, + /// never paired in its place. Future scan({ Duration timeout = const Duration(seconds: 12), + String? remoteId, + String? generation, }) => - withScanLock(() => _scanLocked(timeout)); + withScanLock(() => _scanLocked(timeout, remoteId, generation)); - Future _scanLocked(Duration timeout) async { + Future _scanLocked( + Duration timeout, String? remoteId, String? generation) async { // A phone-level blocker is NOT "nothing answered". Returning null for a // revoked Bluetooth permission classified it as `notFound` upstream, which // told the user to walk closer to a band that was never the problem — the @@ -77,6 +84,9 @@ extension BleEngineTransport on BleEngine { // not a matchable service UUID; WHOOP 5's `fd4b` member UUID needs no // such fallback, so it supplies none. if (found == null && + ScanAcceptPolicy.fitsChoice(r.device.remoteId.str, + r.advertisementData.serviceUuids.map((g) => g.str), + wantRemoteId: remoteId, wantGeneration: generation) && (kFramedBands.any((e) => e.nameMatcher?.call(name) ?? false) || advNames.any((s) => s == kWhoopMemberUuid16 || diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fe1fa7f57..ce06b46c8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -146,16 +146,11 @@ "dataBackedUpTo": "Gesichert nach {path}", "dataCouldNotOpenPicker": "Dateiauswahl konnte nicht geöffnet werden: {error}", "dataSummariesDidNotBodyShort": "Alle importierten Zeilen wurden gespeichert. Bei der Neuberechnung der tagesübergreifenden Auswertungen ist jedoch ein Fehler aufgetreten ({error}). Trends und Erkenntnisse beruhen daher noch auf den bisherigen Daten.", - "devicesAddASensor": "Sensor hinzufügen", - "devicesAddASensorSub": "Ein Brustgurt oder ein Ring, zusätzlich zum Band", "devicesWhichSourceWins": "Welche Quelle Vorrang hat", "devicesWhichSourceWinsSub": "Wenn zwei deiner Geräte dasselbe messen", "devicesResetToDefault": "Standardreihenfolge wiederherstellen", "devicesMySources": "Meine Quellen", "devicesNoBandPaired": "Kein Band gekoppelt", - "devicesNothingMeasuringYet": "Noch wird nichts gemessen", - "devicesPhoneCountingBody": "Dein Smartphone zählt nur Schritte. Herzfrequenz, Schlaf, Erholung und Temperatur bleiben leer, bis ein Band gekoppelt ist.", - "devicesNothingMeasuringBody": "Es ist kein Band gekoppelt, und dein Smartphone liefert keine Schrittzahlen. Deshalb zeigt die App keine geschätzten Werte an, sondern lässt die entsprechenden Felder leer.", "devicesPairABand": "Band koppeln", "devicesLive": "LIVE", "devicesQualityLadder": "QUALITÄTSSTUFEN", @@ -3472,5 +3467,126 @@ "type": "int" } } + }, + "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", + "capLabelSleep": "Schlaf", + "capLabelRecovery": "Erholung", + "capLabelStrain": "Belastung", + "capLabelHrv": "Herzfrequenzvariabilität", + "capLabelSkinTemperature": "Hauttemperatur", + "capLabelBloodOxygen": "Blutsauerstoff", + "capLabelWorkoutHeartRate": "Herzfrequenz beim Training", + "capLabelWorkoutBeatToBeat": "Abstand von Herzschlag zu Herzschlag beim Training", + "capLabelSteps": "Schritte", + "capReasonSkinTempNights": "Nach ein paar Nächten", + "capReasonEstimated": "Eine Schätzung, keine direkte Messung", + "capReasonEarly": "Erste Schätzung; wird in den ersten Wochen genauer", + "capVendorReported": "Eigener Messwert des Geräts", + "slotSendsHeading": "Liefert", + "slotDoesntSendHeading": "Liefert nicht", + "pairMainBandWhoopOnly": "Hauptband: nur WHOOP 4.0 oder WHOOP 5.0/MG. Andere Geräte lassen sich als zusätzliche Geräte hinzufügen.", + "pairAddDevice": "Gerät hinzufügen", + "pairBandNotSensor": "Dieses Gerät ist ein {label}, also ein Band und kein einfacher Sensor. Es wurde nichts gespeichert.", + "pairKindAll": "Alle", + "pairKindStrap": "Brustgurte & Armsensoren", + "pairKindRing": "Ringe", + "pairKindWatch": "Uhren", + "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", + "pairBandTitle": "Band koppeln", + "pairBandSubtitle": "Wähle das, das du trägst. Wir suchen es in der Nähe.", + "pairStrapOnlyLink": "Ich habe nur einen Herzfrequenzgurt", + "pairGoBack": "Zurück", + "pairSectionTitle": "Gerät koppeln", + "pairCardBandBody": "Nur WHOOP 4.0 oder WHOOP 5.0/MG. Schlaf, Erholung und Belastung rund um die Uhr.", + "pairUnpairBand": "Band trennen", + "pairAddDeviceSub": "Ringe, Uhren und Herzfrequenzgurte", + "pairAddDeviceSubtitle": "Bei jedem Gerät steht, wofür es passt: jeden Tag oder nur beim Training.", + "pairBandFoundBody": "Jeden Tag kann es viel mehr als nur beim Training. Was möchtest du?", + "pairBandFoundBodyHasBand": "Du hast schon ein Band. Es kann immer nur eines dein Band sein.", + "pairIdentifiedBody": "Es sah wie ein Herzfrequenzgurt aus, hat sich beim Verbinden aber als {label} gemeldet. Es wurde nichts gespeichert.", + "pairAsMyBand": "Als mein Band koppeln", + "pairSecondaryPaired": "Fürs Training gekoppelt.", + "capLabelHeartRate": "Herzfrequenz und Ruhepuls", + "capLabelRespiration": "Atmung", + "capReasonFirstNights": "In den ersten 14 Nächten geschätzt", + "capReasonOwnStagesRing": "Aus den eigenen Schlafphasen des Rings", + "capReasonOwnStages": "Aus den eigenen Schlafphasen des Geräts", + "capReasonLiveSessions": "Während Live-Sitzungen", + "slotEveryDay": "Jeden Tag", + "slotWorkoutsOnly": "Nur Training", + "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", + "pairBandNow": "Dein Band jetzt: {name}. Deine Daten bleiben beim Wechsel erhalten.", + "pairReplaceBand": "{name} trennen und dieses koppeln", + "pairReplaceConfirmTitle": "{name} trennen und stattdessen dieses Band koppeln?", + "pairReplaceConfirmBody": "Deine Daten bleiben erhalten.", + "pairReplaceConfirmYes": "Trennen und koppeln", + "pairCurrentBandFallback": "dein Band", + "pairWearableLead": "Es kann deine Tage mit Schlaf, Erholung und Herzfrequenz füllen.", + "pairWearableNote": "Füllt Tage ohne dein WHOOP. Experimentell, also schalte es unter Geräte ein.", + "pairWearableCardNote": "Füllt Tage ohne dein WHOOP. Experimentell, also schalte es unten ein.", + "pairStrapLead": "Es liefert Herzfrequenz beim Training. Schlaf, Erholung und tägliche Belastung zeichnet es nicht auf.", + "pairStrapNote": "Für beides muss „Beim Training nutzen“ an sein.", + "pairDeviceNamed": "{name} koppeln", + "pairUseForWorkouts": "Nur fürs Training nutzen", + "pairWhatItSends": "Was es sendet", + "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", + "pairWhoopRowLine": "Schlaf, Erholung, Belastung, Herzfrequenz und mehr", + "pairSetUpTitle": "Einrichten", + "pairReplaceUnpairFailed": "Dein Band ließ sich nicht trennen, daher wurde das neue nicht gekoppelt. Versuche es erneut.", + "capReasonUnconfirmed": "Falls das Gerät es sendet, nicht bestätigt", + "capReasonPebble2Only": "Nur Pebble 2. Die 2 SE hat keinen Herzfrequenzsensor.", + "capReasonPebble2OnlyEstimated": "Nur Pebble 2, und eine Schätzung, keine direkte Messung. Die 2 SE hat keinen Herzfrequenzsensor.", + "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.", + "pairWearableLeadPebble": "Pebble 2 kann deine Tage mit Schlaf, Erholung und Herzfrequenz füllen. Pebble 2 SE zeigt die eigenen Schlaf- und Schrittdaten der Uhr. Sie hat keinen Herzfrequenzsensor, daher kann diese App daraus weder die tägliche Belastung berechnen noch die Erholung schätzen.", + "slotColumnReason": "{column}: {reason}", + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ea800171..23769911a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -655,14 +655,6 @@ } } }, - "devicesAddASensor": "Add a sensor", - "@devicesAddASensor": { - "description": "Row/sheet title: add a sensor." - }, - "devicesAddASensorSub": "A heart-rate strap or a ring, alongside the band", - "@devicesAddASensorSub": { - "description": "Row sub." - }, "devicesWhichSourceWins": "Which source wins", "@devicesWhichSourceWins": { "description": "Row title: opens the per-signal priority editor." @@ -683,18 +675,6 @@ "@devicesNoBandPaired": { "description": "Status card title." }, - "devicesNothingMeasuringYet": "Nothing is measuring yet", - "@devicesNothingMeasuringYet": { - "description": "Status card title." - }, - "devicesPhoneCountingBody": "The phone counts steps and nothing else. Heart rate, sleep, recovery and temperature all abstain until a band is paired.", - "@devicesPhoneCountingBody": { - "description": "Status card body: phone only." - }, - "devicesNothingMeasuringBody": "No band is paired and no phone steps are arriving, so every metric in the app will abstain rather than estimate.", - "@devicesNothingMeasuringBody": { - "description": "Status card body: nothing measuring." - }, "devicesPairABand": "Pair a band", "@devicesPairABand": { "description": "Fix button." @@ -16331,5 +16311,466 @@ "type": "int" } } + }, + "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." + }, + "capLabelSleep": "Sleep", + "@capLabelSleep": { + "description": "Name of a thing a device sends: sleep." + }, + "capLabelRecovery": "Recovery", + "@capLabelRecovery": { + "description": "Name of a thing a device sends: the morning recovery score." + }, + "capLabelStrain": "Strain", + "@capLabelStrain": { + "description": "Name of a thing a device sends: strain." + }, + "capLabelHrv": "Heart-rate variability", + "@capLabelHrv": { + "description": "Name of a thing a device sends: heart-rate variability." + }, + "capLabelSkinTemperature": "Skin temperature", + "@capLabelSkinTemperature": { + "description": "Name of a thing a device sends: skin temperature." + }, + "capLabelBloodOxygen": "Blood oxygen", + "@capLabelBloodOxygen": { + "description": "Name of a thing a device sends: blood oxygen (only a device’s own reading)." + }, + "capLabelWorkoutHeartRate": "Heart rate during workouts", + "@capLabelWorkoutHeartRate": { + "description": "Table row: heart rate during a workout." + }, + "capLabelWorkoutBeatToBeat": "Beat-to-beat timing during workouts", + "@capLabelWorkoutBeatToBeat": { + "description": "Table row: beat-to-beat timing during a workout." + }, + "capLabelSteps": "Steps", + "@capLabelSteps": { + "description": "Name of a thing a device sends: steps." + }, + "capReasonSkinTempNights": "After a few nights", + "@capReasonSkinTempNights": { + "description": "Caption under Skin temperature: it needs a few nights first." + }, + "capReasonEstimated": "An estimate, not a direct reading", + "@capReasonEstimated": { + "description": "Why a value is conditional: it is an estimate, not a direct reading." + }, + "capReasonEarly": "Early estimate; it firms up over the first weeks", + "@capReasonEarly": { + "description": "Why a value is conditional: it is an early estimate that firms up over the first weeks." + }, + "capVendorReported": "The device’s own reading", + "@capVendorReported": { + "description": "Tag on a value that is the device’s own reading, not calculated by this app." + }, + "slotSendsHeading": "Sends", + "@slotSendsHeading": { + "description": "Heading above the list of what a device sends." + }, + "slotDoesntSendHeading": "Doesn’t send", + "@slotDoesntSendHeading": { + "description": "Heading above the list of what a secondary sensor does not send." + }, + "pairMainBandWhoopOnly": "Main band: WHOOP 4.0 or WHOOP 5.0/MG only. Other devices can be added as additional devices.", + "@pairMainBandWhoopOnly": { + "description": "Plain statement on the pairing screens: only WHOOP can be the main band; other devices are added as additional devices." + }, + "pairAddDevice": "Add a device", + "@pairAddDevice": { + "description": "Label of the button that starts adding a device (band or sensor is decided after the scan)." + }, + "pairBandNotSensor": "That device is a {label}, a band, not a plain sensor. Nothing was saved.", + "@pairBandNotSensor": { + "description": "Shown when a device picked as a plain sensor turns out to be a band. Nothing was saved. {label} is the band’s name.", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pairKindAll": "All", + "@pairKindAll": { + "description": "Filter pill that shows every device in the list." + }, + "pairKindStrap": "Chest straps & armbands", + "@pairKindStrap": { + "description": "Filter pill: heart-rate straps and armbands." + }, + "pairKindRing": "Rings", + "@pairKindRing": { + "description": "Filter pill for smart rings." + }, + "pairKindWatch": "Watches", + "@pairKindWatch": { + "description": "Filter pill for watches." + }, + "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." + }, + "pairBandTitle": "Pair your band", + "@pairBandTitle": { + "description": "Title of the first-run list of bands." + }, + "pairBandSubtitle": "Pick the one you wear. We will look for it nearby.", + "@pairBandSubtitle": { + "description": "Subtitle of the first-run list of bands." + }, + "pairStrapOnlyLink": "I only have a heart-rate strap", + "@pairStrapOnlyLink": { + "description": "Text link under the band list for someone with only a heart-rate strap." + }, + "pairGoBack": "Go back", + "@pairGoBack": { + "description": "Link that closes a confirm sheet without pairing." + }, + "pairSectionTitle": "Pair a device", + "@pairSectionTitle": { + "description": "Heading of the section in My sources that holds pairing." + }, + "pairCardBandBody": "WHOOP 4.0 or WHOOP 5.0/MG only. Sleep, recovery and strain, around the clock.", + "@pairCardBandBody": { + "description": "Body of the Your band card in Settings." + }, + "pairUnpairBand": "Unpair band", + "@pairUnpairBand": { + "description": "Red outline button that starts forgetting the paired band (a confirm dialog follows)." + }, + "pairAddDeviceSub": "Rings, watches and heart-rate straps", + "@pairAddDeviceSub": { + "description": "Line under the Add a device row in Settings." + }, + "pairAddDeviceSubtitle": "Each device shows where it fits: every day, or workouts only.", + "@pairAddDeviceSubtitle": { + "description": "Lead of the Add a device screen." + }, + "pairBandFoundBody": "It does much more every day than during workouts only. Which do you want?", + "@pairBandFoundBody": { + "description": "WHOOP sheet lead when no band is paired yet." + }, + "pairBandFoundBodyHasBand": "You already have a band. Only one can be your band at a time.", + "@pairBandFoundBodyHasBand": { + "description": "WHOOP sheet lead when a band is already paired." + }, + "pairIdentifiedBody": "It looked like a heart-rate strap, but when we connected it identified itself as {label}. Nothing was saved.", + "@pairIdentifiedBody": { + "description": "Body of the sheet shown when a device picked as a sensor identifies itself as a band after connecting. Nothing was saved.", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pairAsMyBand": "Pair it as my band", + "@pairAsMyBand": { + "description": "Button that pairs the device as the user’s main band." + }, + "pairSecondaryPaired": "Paired for workouts only.", + "@pairSecondaryPaired": { + "description": "Shown after a device is paired for workouts only." + }, + "capLabelHeartRate": "Heart rate and resting heart rate", + "@capLabelHeartRate": { + "description": "Table row: heart rate and resting heart rate, as one capability." + }, + "capLabelRespiration": "Respiration", + "@capLabelRespiration": { + "description": "Table row: breathing rate." + }, + "capReasonFirstNights": "Estimated for the first 14 nights", + "@capReasonFirstNights": { + "description": "Caption under Recovery for a ring or watch: its recovery is an estimate until 14 nights of baseline." + }, + "capReasonOwnStagesRing": "From the ring’s own sleep stages", + "@capReasonOwnStagesRing": { + "description": "Caption under Sleep for a ring: the sleep stages are the ring’s own." + }, + "capReasonOwnStages": "From the device’s own sleep stages", + "@capReasonOwnStages": { + "description": "Caption under Sleep for a watch or fitness band: the sleep stages are the device’s own." + }, + "capReasonLiveSessions": "During live sessions", + "@capReasonLiveSessions": { + "description": "Caption under Steps for WHOOP 4: it counts steps only while the app has a live connection." + }, + "slotEveryDay": "Every day", + "@slotEveryDay": { + "description": "Column and chip: the device is used every day (the main band, or the ring/watch that fills days)." + }, + "slotWorkoutsOnly": "Workouts only", + "@slotWorkoutsOnly": { + "description": "Column and chip: the device is used only during workouts." + }, + "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." + }, + "pairBandNow": "Your band now: {name}. Your data stays when you switch.", + "@pairBandNow": { + "description": "Note under the table when a band is already paired. {name} is that band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceBand": "Unpair {name} and pair this one", + "@pairReplaceBand": { + "description": "Main button when a band is already paired: unpair it and pair the tapped one. {name} is the paired band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmTitle": "Unpair {name} and pair this band instead?", + "@pairReplaceConfirmTitle": { + "description": "Confirm dialog before replacing the band. {name} is the paired band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmBody": "Your data stays.", + "@pairReplaceConfirmBody": { + "description": "Confirm dialog body: replacing the band keeps the data." + }, + "pairReplaceConfirmYes": "Unpair and pair", + "@pairReplaceConfirmYes": { + "description": "Confirm dialog button: unpair the old band and pair the new one." + }, + "pairCurrentBandFallback": "your band", + "@pairCurrentBandFallback": { + "description": "Stands in for the paired band’s name when it has none, inside a sentence." + }, + "pairWearableLead": "It can fill your days with sleep, recovery and heart rate.", + "@pairWearableLead": { + "description": "Sheet lead for a ring or watch." + }, + "pairWearableNote": "Fills days without your WHOOP. Experimental, so switch it on in Devices.", + "@pairWearableNote": { + "description": "Note under a ring’s or watch’s table on the pairing sheet. “Devices” is the device list in Settings." + }, + "pairWearableCardNote": "Fills days without your WHOOP. Experimental, so switch it on below.", + "@pairWearableCardNote": { + "description": "Note under a ring’s or watch’s table on its device page; the switch is below it." + }, + "pairStrapLead": "It adds heart rate while you work out. It does not record sleep, recovery or daily strain.", + "@pairStrapLead": { + "description": "Sheet lead for a heart-rate strap or sensor." + }, + "pairStrapNote": "Both need “Use during workouts” on.", + "@pairStrapNote": { + "description": "Note under a strap’s table: both workout rows need the switch. Quotes deviceUseWorkoutsTitle." + }, + "pairDeviceNamed": "Pair {name}", + "@pairDeviceNamed": { + "description": "Main button on a ring’s, watch’s or strap’s sheet. {name} is the device.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairUseForWorkouts": "Use it for workouts only", + "@pairUseForWorkouts": { + "description": "Text link on a WHOOP sheet: keep it for workouts only, not as the main band." + }, + "pairWhatItSends": "What it sends", + "@pairWhatItSends": { + "description": "Heading above the table on a device page." + }, + "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." + }, + "pairWhoopRowLine": "Sleep, recovery, strain, heart rate and more", + "@pairWhoopRowLine": { + "description": "Line under each WHOOP on the first-run list." + }, + "pairSetUpTitle": "Set up", + "@pairSetUpTitle": { + "description": "Top bar title of the first-run pairing screen." + }, + "pairReplaceUnpairFailed": "Could not unpair your band, so the new one was not paired. Try again.", + "@pairReplaceUnpairFailed": { + "description": "Shown when unpairing the old band failed during “Unpair (your band) and pair this one”; nothing else ran." + }, + "capReasonUnconfirmed": "If this device sends it, not confirmed", + "@capReasonUnconfirmed": { + "description": "Caption under a row the device may or may not send; nobody has confirmed it does." + }, + "capReasonPebble2Only": "Pebble 2 only. The 2 SE has no heart-rate sensor.", + "@capReasonPebble2Only": { + "description": "Caption under the heart rate, strain and recovery rows for Pebble: the shared Pebble entry covers the 2 SE, which has no heart-rate sensor." + }, + "capReasonPebble2OnlyEstimated": "Pebble 2 only, and an estimate, not a direct reading. The 2 SE has no heart-rate sensor.", + "@capReasonPebble2OnlyEstimated": { + "description": "Caption under Pebble's recovery row: Pebble 2 only (the 2 SE has no heart-rate sensor), and recovery is an estimate." + }, + "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." + }, + "pairWearableLeadPebble": "Pebble 2 can fill your days with sleep, recovery and heart rate. Pebble 2 SE shows the watch’s own sleep and steps. It has no heart-rate sensor, so this app cannot calculate daily strain or estimate recovery from it.", + "@pairWearableLeadPebble": { + "description": "Pebble-family pairing lead: SE shows the watch’s own sleep and steps; no HR sensor means no app-calculated daily strain or recovery estimate from the SE alone." + }, + "slotColumnReason": "{column}: {reason}", + "@slotColumnReason": { + "description": "Capability caption naming the column it applies to.", + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotCapabilityStatus": { + "description": "Accessible table result with capability, column and sends status.", + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 415c2de32..0d4930204 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -143,13 +143,8 @@ "dataBackedUpTo": "Copia de seguridad en {path}", "dataCouldNotOpenPicker": "No se pudo abrir el selector de archivos: {error}", "dataSummariesDidNotBodyShort": "Cada fila importada está en la base de datos, pero al reconstruir los resúmenes entre días se produjo un error ({error}), así que las tendencias y perspectivas siguen describiendo los datos que tenías antes.", - "devicesAddASensor": "Añadir un sensor", - "devicesAddASensorSub": "Una banda de frecuencia cardíaca o un anillo, junto a la banda", "devicesMySources": "Mis fuentes", "devicesNoBandPaired": "No hay banda emparejada", - "devicesNothingMeasuringYet": "Nada está midiendo todavía", - "devicesPhoneCountingBody": "El teléfono cuenta pasos y nada más. La frecuencia cardíaca, el sueño, la recuperación y la temperatura se abstienen hasta que se empareje una banda.", - "devicesNothingMeasuringBody": "No hay banda emparejada y no llegan pasos del teléfono, así que cada métrica de la app se abstendrá en lugar de estimar.", "devicesPairABand": "Emparejar una banda", "devicesLive": "EN VIVO", "devicesQualityLadder": "LA ESCALA DE CALIDAD", @@ -2890,5 +2885,126 @@ "type": "int" } } + }, + "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", + "capLabelSleep": "Sueño", + "capLabelRecovery": "Recuperación", + "capLabelStrain": "Esfuerzo", + "capLabelHrv": "Variabilidad de la frecuencia cardíaca", + "capLabelSkinTemperature": "Temperatura de la piel", + "capLabelBloodOxygen": "Oxígeno en sangre", + "capLabelWorkoutHeartRate": "Frecuencia cardíaca durante los entrenamientos", + "capLabelWorkoutBeatToBeat": "Tiempo entre latidos durante los entrenamientos", + "capLabelSteps": "Pasos", + "capReasonSkinTempNights": "Tras algunas noches", + "capReasonEstimated": "Una estimación, no una medición directa", + "capReasonEarly": "Estimación inicial; mejora durante las primeras semanas", + "capVendorReported": "La medición propia del dispositivo", + "slotSendsHeading": "Envía", + "slotDoesntSendHeading": "No envía", + "pairMainBandWhoopOnly": "Pulsera principal: solo WHOOP 4.0 o WHOOP 5.0/MG. Los demás dispositivos se pueden añadir como dispositivos adicionales.", + "pairAddDevice": "Añadir un dispositivo", + "pairBandNotSensor": "Ese dispositivo es una pulsera {label}, no un sensor sencillo. No se guardó nada.", + "pairKindAll": "Todos", + "pairKindStrap": "Bandas de pecho y brazo", + "pairKindRing": "Anillos", + "pairKindWatch": "Relojes", + "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", + "pairBandTitle": "Empareja tu pulsera", + "pairBandSubtitle": "Elige la que llevas puesta. La buscaremos cerca.", + "pairStrapOnlyLink": "Solo tengo una banda de frecuencia cardíaca", + "pairGoBack": "Volver", + "pairSectionTitle": "Emparejar un dispositivo", + "pairCardBandBody": "Solo WHOOP 4.0 o WHOOP 5.0/MG. Sueño, recuperación y esfuerzo, todo el día.", + "pairUnpairBand": "Desemparejar pulsera", + "pairAddDeviceSub": "Anillos, relojes y bandas de frecuencia cardiaca", + "pairAddDeviceSubtitle": "Cada dispositivo muestra dónde encaja: todos los días o solo en entrenamientos.", + "pairBandFoundBody": "Hace mucho más todos los días que solo en los entrenamientos. ¿Qué prefieres?", + "pairBandFoundBodyHasBand": "Ya tienes una pulsera. Solo una puede ser tu pulsera a la vez.", + "pairIdentifiedBody": "Parecía una banda de frecuencia cardíaca, pero al conectar se identificó como {label}. No se guardó nada.", + "pairAsMyBand": "Emparejar como mi pulsera", + "pairSecondaryPaired": "Emparejado solo para entrenamientos.", + "capLabelHeartRate": "Frecuencia cardiaca y en reposo", + "capLabelRespiration": "Respiración", + "capReasonFirstNights": "Estimada durante las primeras 14 noches", + "capReasonOwnStagesRing": "A partir de las fases de sueño del propio anillo", + "capReasonOwnStages": "A partir de las fases de sueño del propio dispositivo", + "capReasonLiveSessions": "Durante las sesiones en vivo", + "slotEveryDay": "Todos los días", + "slotWorkoutsOnly": "Solo entrenamientos", + "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", + "pairBandNow": "Tu pulsera ahora: {name}. Tus datos se conservan al cambiar.", + "pairReplaceBand": "Desemparejar {name} y emparejar esta", + "pairReplaceConfirmTitle": "¿Desemparejar {name} y emparejar esta pulsera en su lugar?", + "pairReplaceConfirmBody": "Tus datos se conservan.", + "pairReplaceConfirmYes": "Desemparejar y emparejar", + "pairCurrentBandFallback": "tu pulsera", + "pairWearableLead": "Puede completar tus días con sueño, recuperación y frecuencia cardiaca.", + "pairWearableNote": "Completa los días sin tu WHOOP. Es experimental, así que actívalo en Dispositivos.", + "pairWearableCardNote": "Completa los días sin tu WHOOP. Es experimental, así que actívalo abajo.", + "pairStrapLead": "Añade la frecuencia cardiaca mientras entrenas. No registra sueño, recuperación ni esfuerzo diario.", + "pairStrapNote": "Ambos necesitan «Usar durante los entrenamientos» activado.", + "pairDeviceNamed": "Emparejar {name}", + "pairUseForWorkouts": "Usarla solo para entrenamientos", + "pairWhatItSends": "Qué envía", + "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", + "pairWhoopRowLine": "Sueño, recuperación, esfuerzo, frecuencia cardiaca y más", + "pairSetUpTitle": "Configurar", + "pairReplaceUnpairFailed": "No se pudo desemparejar tu pulsera, así que no se emparejó la nueva. Inténtalo de nuevo.", + "capReasonUnconfirmed": "Si este dispositivo lo envía, sin confirmar", + "capReasonPebble2Only": "Solo Pebble 2. El 2 SE no tiene sensor de frecuencia cardiaca.", + "capReasonPebble2OnlyEstimated": "Solo Pebble 2, y es una estimación, no una medición directa. El 2 SE no tiene sensor de frecuencia cardiaca.", + "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.", + "pairWearableLeadPebble": "Pebble 2 puede completar tus días con sueño, recuperación y frecuencia cardiaca. Pebble 2 SE muestra el sueño y los pasos registrados por el propio reloj. No tiene sensor de frecuencia cardiaca, por lo que esta app no puede calcular la carga diaria ni estimar la recuperación a partir de sus datos.", + "slotColumnReason": "{column}: {reason}", + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7c26d7e89..a3227adf2 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -143,13 +143,8 @@ "dataBackedUpTo": "Sauvegardé dans {path}", "dataCouldNotOpenPicker": "Impossible d’ouvrir le sélecteur de fichiers : {error}", "dataSummariesDidNotBodyShort": "Chaque ligne importée est dans la base de données, mais la reconstruction des résumés inter-jours a échoué ({error}), donc les tendances et les analyses décrivent toujours les données que vous aviez avant.", - "devicesAddASensor": "Ajouter un capteur", - "devicesAddASensorSub": "Une ceinture cardio ou une bague, en plus du bracelet", "devicesMySources": "Mes sources", "devicesNoBandPaired": "Aucun bracelet jumelé", - "devicesNothingMeasuringYet": "Rien ne mesure encore", - "devicesPhoneCountingBody": "Le téléphone compte les pas et rien d’autre. Fréquence cardiaque, sommeil, récupération et température restent indisponibles tant qu’aucun bracelet n’est jumelé.", - "devicesNothingMeasuringBody": "Aucun bracelet n’est jumelé et aucun pas du téléphone n’arrive, donc chaque métrique de l’application restera indisponible plutôt que d’être estimée.", "devicesPairABand": "Jumeler un bracelet", "devicesLive": "EN DIRECT", "devicesQualityLadder": "L’ÉCHELLE DE QUALITÉ", @@ -2861,5 +2856,126 @@ "type": "int" } } + }, + "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", + "capLabelSleep": "Sommeil", + "capLabelRecovery": "Récupération", + "capLabelStrain": "Effort", + "capLabelHrv": "Variabilité de la fréquence cardiaque", + "capLabelSkinTemperature": "Température de la peau", + "capLabelBloodOxygen": "Oxygène dans le sang", + "capLabelWorkoutHeartRate": "Fréquence cardiaque pendant les séances", + "capLabelWorkoutBeatToBeat": "Intervalle entre les battements pendant les séances", + "capLabelSteps": "Pas", + "capReasonSkinTempNights": "Après quelques nuits", + "capReasonEstimated": "Une estimation, pas une mesure directe", + "capReasonEarly": "Estimation initiale ; elle s’affine au fil des premières semaines", + "capVendorReported": "La mesure propre à l’appareil", + "slotSendsHeading": "Envoie", + "slotDoesntSendHeading": "N’envoie pas", + "pairMainBandWhoopOnly": "Bracelet principal : uniquement WHOOP 4.0 ou WHOOP 5.0/MG. Les autres appareils peuvent être ajoutés comme appareils supplémentaires.", + "pairAddDevice": "Ajouter un appareil", + "pairBandNotSensor": "Cet appareil est un {label}, c’est-à-dire un bracelet, pas un simple capteur. Rien n’a été enregistré.", + "pairKindAll": "Tous", + "pairKindStrap": "Ceintures et brassards", + "pairKindRing": "Bagues", + "pairKindWatch": "Montres", + "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", + "pairBandTitle": "Associez votre bracelet", + "pairBandSubtitle": "Choisissez celui que vous portez. Nous le chercherons à proximité.", + "pairStrapOnlyLink": "J’ai seulement une ceinture cardiaque", + "pairGoBack": "Retour", + "pairSectionTitle": "Associer un appareil", + "pairCardBandBody": "WHOOP 4.0 ou WHOOP 5.0/MG uniquement. Sommeil, récupération et effort, 24 h sur 24.", + "pairUnpairBand": "Dissocier le bracelet", + "pairAddDeviceSub": "Bagues, montres et ceintures cardio", + "pairAddDeviceSubtitle": "Chaque appareil indique où il s’intègre : tous les jours, ou seulement pendant les entraînements.", + "pairBandFoundBody": "Il en fait bien plus tous les jours que pendant les entraînements seulement. Que préférez-vous ?", + "pairBandFoundBodyHasBand": "Vous avez déjà un bracelet. Un seul peut être votre bracelet à la fois.", + "pairIdentifiedBody": "Cela ressemblait à une ceinture cardiaque, mais à la connexion l’appareil s’est identifié comme {label}. Rien n’a été enregistré.", + "pairAsMyBand": "L’associer comme mon bracelet", + "pairSecondaryPaired": "Associé pour les entraînements uniquement.", + "capLabelHeartRate": "Fréquence cardiaque et au repos", + "capLabelRespiration": "Respiration", + "capReasonFirstNights": "Estimée pendant les 14 premières nuits", + "capReasonOwnStagesRing": "D’après les phases de sommeil de la bague", + "capReasonOwnStages": "D’après les phases de sommeil de l’appareil", + "capReasonLiveSessions": "Pendant les sessions en direct", + "slotEveryDay": "Tous les jours", + "slotWorkoutsOnly": "Entraînements seulement", + "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", + "pairBandNow": "Votre bracelet actuel : {name}. Vos données restent quand vous changez.", + "pairReplaceBand": "Dissocier {name} et associer celui-ci", + "pairReplaceConfirmTitle": "Dissocier {name} et associer ce bracelet à la place ?", + "pairReplaceConfirmBody": "Vos données restent.", + "pairReplaceConfirmYes": "Dissocier et associer", + "pairCurrentBandFallback": "votre bracelet", + "pairWearableLead": "Il peut compléter vos journées avec le sommeil, la récupération et la fréquence cardiaque.", + "pairWearableNote": "Complète les jours sans votre WHOOP. Expérimental : activez-le dans Appareils.", + "pairWearableCardNote": "Complète les jours sans votre WHOOP. Expérimental : activez-le ci-dessous.", + "pairStrapLead": "Il ajoute la fréquence cardiaque pendant l’entraînement. Il n’enregistre ni le sommeil, ni la récupération, ni l’effort quotidien.", + "pairStrapNote": "Les deux nécessitent « Utiliser pendant les entraînements » activé.", + "pairDeviceNamed": "Associer {name}", + "pairUseForWorkouts": "L’utiliser seulement pour les entraînements", + "pairWhatItSends": "Ce qu’il envoie", + "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", + "pairWhoopRowLine": "Sommeil, récupération, effort, fréquence cardiaque et plus", + "pairSetUpTitle": "Configuration", + "pairReplaceUnpairFailed": "Impossible de dissocier votre bracelet, le nouveau n’a donc pas été associé. Réessayez.", + "capReasonUnconfirmed": "Si cet appareil l’envoie, non confirmé", + "capReasonPebble2Only": "Pebble 2 uniquement. La 2 SE n’a pas de capteur de fréquence cardiaque.", + "capReasonPebble2OnlyEstimated": "Pebble 2 uniquement, et c’est une estimation, pas une mesure directe. La 2 SE n’a pas de capteur de fréquence cardiaque.", + "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é.", + "pairWearableLeadPebble": "Pebble 2 peut compléter vos journées avec le sommeil, la récupération et la fréquence cardiaque. Pebble 2 SE affiche le sommeil et les pas enregistrés par la montre. Elle n’a pas de capteur de fréquence cardiaque, donc cette app ne peut pas calculer la charge quotidienne ni estimer la récupération à partir de ses données.", + "slotColumnReason": "{column} : {reason}", + "slotCapabilityStatus": "{capability}, {column} : {status}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 990d67975..ab928b610 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -143,13 +143,8 @@ "dataBackedUpTo": "{path} पर बैकअप लिया गया", "dataCouldNotOpenPicker": "फ़ाइल पिकर नहीं खोला जा सका: {error}", "dataSummariesDidNotBodyShort": "हर आयातित पंक्ति डेटाबेस में है, लेकिन उन पर क्रॉस-डे सारांश फिर से बनाते समय त्रुटि आई ({error}), इसलिए रुझान और अंतर्दृष्टि अभी भी पहले वाला डेटा दिखा रहे हैं।", - "devicesAddASensor": "सेंसर जोड़ें", - "devicesAddASensorSub": "एक हृदय गति स्ट्रैप या एक रिंग, बैंड के साथ", "devicesMySources": "मेरे स्रोत", "devicesNoBandPaired": "कोई बैंड पेयर नहीं है", - "devicesNothingMeasuringYet": "अभी तक कुछ भी माप नहीं रहा", - "devicesPhoneCountingBody": "फ़ोन केवल कदम गिनता है और कुछ नहीं। जब तक बैंड पेयर नहीं होता, हृदय गति, नींद, रिकवरी और तापमान सभी अनुपस्थित रहते हैं।", - "devicesNothingMeasuringBody": "कोई बैंड पेयर नहीं है और फ़ोन के कदम नहीं आ रहे, इसलिए ऐप में हर मेट्रिक अनुमान लगाने के बजाय अनुपस्थित रहेगा।", "devicesPairABand": "एक बैंड पेयर करें", "devicesLive": "लाइव", "devicesQualityLadder": "गुणवत्ता सीढ़ी", @@ -2861,5 +2856,126 @@ "type": "int" } } + }, + "pairResetWhoop5Step1": "बैंड को कलाई से उतारें और हरी लाइटें बंद होने तक रुकें।", + "pairResetWhoop5Step2": "बैंड को किनारों से पकड़ें और ऊपर की सतह पर दो उंगलियों से ज़ोर से थपथपाएं, जब तक सिर्फ़ नीली लाइट न झपके। अगर नीली और सफ़ेद लाइट साथ दिखें, तो तब तक थपथपाते रहें जब तक सिर्फ़ नीली न बचे।", + "pairResetWhoop5Step3": "अगर WHOOP ऐप इस फ़ोन पर है, तो उसे ज़बरदस्ती बंद करें।", + "pairResetWhoop5Step4": "यहां वापस आएं और फिर से कोशिश करें।", + "pairResetGenericStep1": "निर्माता का ऐप खोलें और उसमें से इस डिवाइस को हटाएं।", + "pairResetGenericStep2": "फ़ोन की ब्लूटूथ सेटिंग में डिवाइस ढूंढें और “भूलें” चुनें।", + "pairResetGenericStep3": "डिवाइस को फिर से चालू करें, उसे इस फ़ोन के पास रखें और दोबारा कोशिश करें।", + "pairResetGenericNote": "ये सामान्य सुझाव हैं। हमने इस डिवाइस के सटीक चरणों की अभी जांच नहीं की है।", + "pairResetOfficialLink": "निर्माता का सहायता पृष्ठ खोलें", + "capLabelSleep": "नींद", + "capLabelRecovery": "रिकवरी", + "capLabelStrain": "शारीरिक परिश्रम", + "capLabelHrv": "हृदय गति में उतार-चढ़ाव (HRV)", + "capLabelSkinTemperature": "त्वचा का तापमान", + "capLabelBloodOxygen": "रक्त में ऑक्सीजन", + "capLabelWorkoutHeartRate": "वर्कआउट के दौरान हृदय गति", + "capLabelWorkoutBeatToBeat": "वर्कआउट के दौरान धड़कन-दर-धड़कन का समय", + "capLabelSteps": "कदम", + "capReasonSkinTempNights": "कुछ रातों के बाद", + "capReasonEstimated": "अनुमान है, सीधी माप नहीं", + "capReasonEarly": "शुरुआती अनुमान; पहले कुछ हफ़्तों में यह बेहतर होता जाता है", + "capVendorReported": "डिवाइस की अपनी माप", + "slotSendsHeading": "भेजता है", + "slotDoesntSendHeading": "नहीं भेजता", + "pairMainBandWhoopOnly": "मुख्य बैंड: केवल WHOOP 4.0 या WHOOP 5.0/MG। दूसरे डिवाइस अतिरिक्त डिवाइस के रूप में जोड़े जा सकते हैं।", + "pairAddDevice": "डिवाइस जोड़ें", + "pairBandNotSensor": "यह डिवाइस {label} है, यानी एक बैंड, साधारण सेंसर नहीं। कुछ भी सहेजा नहीं गया।", + "pairKindAll": "सभी", + "pairKindStrap": "चेस्ट स्ट्रैप और आर्मबैंड", + "pairKindRing": "रिंग", + "pairKindWatch": "घड़ियाँ", + "pairResetTitleBand": "आपका बैंड कहीं और पेयर है", + "pairResetTitleRing": "आपकी रिंग कहीं और पेयर है", + "pairResetBody": "हम {name} को पेयर करना पूरा नहीं कर सके। शायद वह अभी भी किसी दूसरे फ़ोन या ऐप से जुड़ा है।", + "pairResetHeading": "इसे रीसेट करने के लिए", + "pairResetWhoopTakeover": "यहाँ पेयर करने पर यह WHOOP ऐप से कट जाता है।", + "pairResetOfficialSub": "{host} · आपके ब्राउज़र में खुलता है", + "pairLinkOpenFailed": "लिंक नहीं खुल सका। पता है {url}", + "pairPickDifferentBand": "कोई दूसरा बैंड चुनें", + "pairBandTitle": "अपना बैंड पेयर करें", + "pairBandSubtitle": "वही चुनें जो आप पहनते हैं। हम उसे आस-पास खोजेंगे।", + "pairStrapOnlyLink": "मेरे पास सिर्फ़ हार्ट-रेट स्ट्रैप है", + "pairGoBack": "वापस जाएँ", + "pairSectionTitle": "डिवाइस पेयर करें", + "pairCardBandBody": "केवल WHOOP 4.0 या WHOOP 5.0/MG। नींद, रिकवरी और शारीरिक परिश्रम, चौबीसों घंटे।", + "pairUnpairBand": "बैंड अनपेयर करें", + "pairAddDeviceSub": "रिंग, घड़ियाँ और हार्ट-रेट स्ट्रैप", + "pairAddDeviceSubtitle": "हर डिवाइस दिखाता है कि वह कहाँ फ़िट होता है: हर दिन, या केवल वर्कआउट में।", + "pairBandFoundBody": "यह सिर्फ़ वर्कआउट की तुलना में हर दिन कहीं ज़्यादा करता है। आप क्या चाहते हैं?", + "pairBandFoundBodyHasBand": "आपके पास पहले से एक बैंड है। एक समय में केवल एक ही आपका बैंड हो सकता है।", + "pairIdentifiedBody": "यह हार्ट-रेट स्ट्रैप जैसा दिखा, लेकिन कनेक्ट होने पर इसने खुद को {label} बताया। कुछ भी सहेजा नहीं गया।", + "pairAsMyBand": "इसे मेरे बैंड के रूप में पेयर करें", + "pairSecondaryPaired": "केवल वर्कआउट के लिए पेयर किया गया।", + "capLabelHeartRate": "हृदय गति और आराम की हृदय गति", + "capLabelRespiration": "श्वसन", + "capReasonFirstNights": "पहली 14 रातों तक अनुमानित", + "capReasonOwnStagesRing": "रिंग के अपने नींद चरणों से", + "capReasonOwnStages": "डिवाइस के अपने नींद चरणों से", + "capReasonLiveSessions": "लाइव सत्रों के दौरान", + "slotEveryDay": "हर दिन", + "slotWorkoutsOnly": "केवल वर्कआउट", + "pairThisIsWhoop": "यह एक WHOOP बैंड है", + "pairThisIsRing": "यह एक रिंग है", + "pairThisIsWatch": "यह एक घड़ी है", + "pairThisIsFitnessBand": "यह एक फ़िटनेस बैंड है", + "pairThisIsStrap": "यह एक हार्ट-रेट स्ट्रैप है", + "pairThisIsSensor": "यह एक हार्ट-रेट सेंसर है", + "pairBandNow": "अभी आपका बैंड: {name}। बदलने पर आपका डेटा बना रहता है।", + "pairReplaceBand": "{name} को अनपेयर करें और इसे पेयर करें", + "pairReplaceConfirmTitle": "{name} को अनपेयर करके इसकी जगह यह बैंड पेयर करें?", + "pairReplaceConfirmBody": "आपका डेटा बना रहेगा।", + "pairReplaceConfirmYes": "अनपेयर करें और पेयर करें", + "pairCurrentBandFallback": "आपका बैंड", + "pairWearableLead": "यह आपके दिनों को नींद, रिकवरी और हृदय गति से भर सकता है।", + "pairWearableNote": "आपके WHOOP के बिना वाले दिनों को भरता है। प्रायोगिक है, इसलिए इसे डिवाइस में चालू करें।", + "pairWearableCardNote": "आपके WHOOP के बिना वाले दिनों को भरता है। प्रायोगिक है, इसलिए इसे नीचे चालू करें।", + "pairStrapLead": "यह वर्कआउट के दौरान हृदय गति जोड़ता है। यह नींद, रिकवरी या दैनिक शारीरिक परिश्रम रिकॉर्ड नहीं करता।", + "pairStrapNote": "दोनों के लिए “वर्कआउट के दौरान इस्तेमाल करें” चालू होना चाहिए।", + "pairDeviceNamed": "{name} पेयर करें", + "pairUseForWorkouts": "इसे केवल वर्कआउट के लिए इस्तेमाल करें", + "pairWhatItSends": "यह क्या भेजता है", + "pairTroubleTitle": "कनेक्ट करने में दिक्कत?", + "pairTroubleBody": "बैंड को पेयरिंग मोड में डालें, फिर दोबारा कोशिश करें। फिर भी नहीं हो रहा? हमसे पूछें।", + "pairTroubleModeLink": "{device} पेयरिंग मोड", + "pairTroubleDiscord": "Discord पर पूछें", + "pairTroubleDiscordSub": "OpenStrap समुदाय", + "pairWhoopRowLine": "नींद, रिकवरी, शारीरिक परिश्रम, हृदय गति और बहुत कुछ", + "pairSetUpTitle": "सेट अप", + "pairReplaceUnpairFailed": "आपका बैंड अनपेयर नहीं हो सका, इसलिए नया बैंड पेयर नहीं किया गया। फिर से कोशिश करें।", + "capReasonUnconfirmed": "अगर यह डिवाइस इसे भेजता है, पुष्टि नहीं हुई", + "capReasonPebble2Only": "केवल Pebble 2। 2 SE में हृदय गति सेंसर नहीं है।", + "capReasonPebble2OnlyEstimated": "केवल Pebble 2, और यह अनुमान है, सीधी माप नहीं। 2 SE में हृदय गति सेंसर नहीं है।", + "pairModeTitle": "इसे पेयरिंग मोड में डालें", + "pairModeNoSite": "यह किसी एक निर्माता का नहीं, एक सामान्य डिवाइस है, इसलिए निर्माता के कोई चरण नहीं हैं।", + "pairOuraExistingKeyGuidance": "क्या आप पहले से Oura ऐप इस्तेमाल करते हैं? रिंग को उस ऐप में पेयर रहने दें और उसकी मौजूदा कुंजी से पेयर करें। नीचे दिए रीसेट के चरण छोड़ दें। केवल नई कुंजी चाहिए तो ही रीसेट करें।", + "pairWearableLeadPebble": "Pebble 2 आपके दिनों को नींद, रिकवरी और हृदय गति से भर सकता है। Pebble 2 SE घड़ी की अपनी नींद और कदमों की रीडिंग दिखाता है। उसमें हृदय गति सेंसर नहीं है, इसलिए यह ऐप उसके डेटा से रोज़ का शारीरिक भार नहीं निकाल सकता और न ही रिकवरी का अनुमान लगा सकता है।", + "slotColumnReason": "{column}: {reason}", + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c86544a36..852484ab9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -241,16 +241,11 @@ } } }, - "devicesAddASensor": "Добавить датчик", - "devicesAddASensorSub": "Нагрудный датчик пульса или кольцо — вместе с браслетом", "devicesWhichSourceWins": "Приоритет источников", "devicesWhichSourceWinsSub": "Какой источник использовать, когда два устройства измеряют одно и то же", "devicesResetToDefault": "Вернуть порядок по умолчанию", "devicesMySources": "Мои источники", "devicesNoBandPaired": "Браслет не подключён", - "devicesNothingMeasuringYet": "Пока ничего не измеряется", - "devicesPhoneCountingBody": "Телефон считает только шаги. Пульс, сон, восстановление и температура недоступны, пока не подключён браслет.", - "devicesNothingMeasuringBody": "Браслет не подключён и шаги с телефона не поступают, поэтому приложение не будет оценивать показатели приблизительно.", "devicesPairABand": "Подключить браслет", "devicesLive": "СЕЙЧАС", "devicesQualityLadder": "КАЧЕСТВО ИСТОЧНИКОВ", @@ -7904,5 +7899,196 @@ "type": "int" } } + }, + "pairResetWhoop5Step1": "Снимите браслет с руки и подождите, пока погаснут зелёные огоньки.", + "pairResetWhoop5Step2": "Держите браслет за боковые стороны и сильно постукивайте двумя пальцами по верхней части, пока не будет мигать только синий. Если горят синий и белый вместе, продолжайте стучать, пока не останется только синий.", + "pairResetWhoop5Step3": "Если приложение WHOOP установлено на этом телефоне, принудительно закройте его.", + "pairResetWhoop5Step4": "Вернитесь сюда и попробуйте ещё раз.", + "pairResetGenericStep1": "Откройте приложение производителя и удалите из него это устройство.", + "pairResetGenericStep2": "В настройках Bluetooth телефона найдите устройство и выберите «Забыть».", + "pairResetGenericStep3": "Перезапустите устройство, поднесите его ближе к этому телефону и попробуйте ещё раз.", + "pairResetGenericNote": "Это общие советы. Точные шаги для этого устройства мы пока не проверяли.", + "pairResetOfficialLink": "Открыть страницу помощи производителя", + "capLabelSleep": "Сон", + "capLabelRecovery": "Восстановление", + "capLabelStrain": "Нагрузка", + "capLabelHrv": "Вариабельность пульса", + "capLabelSkinTemperature": "Температура кожи", + "capLabelBloodOxygen": "Кислород в крови", + "capLabelWorkoutHeartRate": "Пульс во время тренировок", + "capLabelWorkoutBeatToBeat": "Интервалы между ударами во время тренировок", + "capLabelSteps": "Шаги", + "capReasonSkinTempNights": "Через несколько ночей", + "capReasonEstimated": "Оценка, а не прямое измерение", + "capReasonEarly": "Предварительная оценка; за первые недели она становится точнее", + "capVendorReported": "Собственное измерение устройства", + "slotSendsHeading": "Передаёт", + "slotDoesntSendHeading": "Не передаёт", + "pairMainBandWhoopOnly": "Основной браслет: только WHOOP 4.0 или WHOOP 5.0/MG. Другие устройства можно добавить как дополнительные.", + "pairAddDevice": "Добавить устройство", + "pairBandNotSensor": "Это устройство — {label}, то есть браслет, а не простой датчик. Ничего не сохранено.", + "@pairBandNotSensor": { + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pairKindAll": "Все", + "pairKindStrap": "Нагрудные и плечевые датчики", + "pairKindRing": "Кольца", + "pairKindWatch": "Часы", + "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": "Выбрать другой браслет", + "pairBandTitle": "Подключите браслет", + "pairBandSubtitle": "Выберите то, что вы носите. Мы поищем его поблизости.", + "pairStrapOnlyLink": "У меня только пульсовой ремень", + "pairGoBack": "Назад", + "pairSectionTitle": "Подключить устройство", + "pairCardBandBody": "Только WHOOP 4.0 или WHOOP 5.0/MG. Сон, восстановление и нагрузка круглые сутки.", + "pairUnpairBand": "Удалить сопряжение с браслетом", + "pairAddDeviceSub": "Кольца, часы и нагрудные пульсометры", + "pairAddDeviceSubtitle": "У каждого устройства указано, для чего оно подходит: на каждый день или только для тренировок.", + "pairBandFoundBody": "Каждый день он даёт гораздо больше, чем только на тренировках. Что выберете?", + "pairBandFoundBodyHasBand": "У вас уже есть браслет. Основным может быть только один.", + "pairIdentifiedBody": "Выглядело как пульсовой ремень, но при подключении устройство назвалось {label}. Ничего не сохранено.", + "@pairIdentifiedBody": { + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pairAsMyBand": "Подключить как мой браслет", + "pairSecondaryPaired": "Подключено только для тренировок.", + "capLabelHeartRate": "Пульс и пульс в покое", + "capLabelRespiration": "Дыхание", + "capReasonFirstNights": "Первые 14 ночей — оценка", + "capReasonOwnStagesRing": "По фазам сна самого кольца", + "capReasonOwnStages": "По фазам сна самого устройства", + "capReasonLiveSessions": "Во время сеансов в реальном времени", + "slotEveryDay": "Каждый день", + "slotWorkoutsOnly": "Только тренировки", + "pairThisIsWhoop": "Это браслет WHOOP", + "pairThisIsRing": "Это кольцо", + "pairThisIsWatch": "Это часы", + "pairThisIsFitnessBand": "Это фитнес-браслет", + "pairThisIsStrap": "Это нагрудный пульсометр", + "pairThisIsSensor": "Это датчик пульса", + "pairBandNow": "Сейчас ваш браслет: {name}. При замене данные сохраняются.", + "@pairBandNow": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceBand": "Удалить сопряжение с {name} и сопрячь этот браслет", + "@pairReplaceBand": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmTitle": "Удалить сопряжение с {name} и выполнить сопряжение с этим браслетом?", + "@pairReplaceConfirmTitle": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmBody": "Ваши данные сохранятся.", + "pairReplaceConfirmYes": "Удалить сопряжение и сопрячь", + "pairCurrentBandFallback": "ваш браслет", + "pairWearableLead": "Он может заполнять ваши дни данными о сне, восстановлении и пульсе.", + "pairWearableNote": "Заполняет дни без вашего WHOOP. Экспериментально, поэтому включите это в разделе «Устройства».", + "pairWearableCardNote": "Заполняет дни без вашего WHOOP. Экспериментально, поэтому включите это ниже.", + "pairStrapLead": "Он добавляет пульс во время тренировки. Сон, восстановление и дневную нагрузку он не записывает.", + "pairStrapNote": "Для обоих нужно включить «Использовать во время тренировок».", + "pairDeviceNamed": "Подключить {name}", + "@pairDeviceNamed": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairUseForWorkouts": "Использовать только для тренировок", + "pairWhatItSends": "Что передаёт", + "pairTroubleTitle": "Не получается подключить?", + "pairTroubleBody": "Переведите браслет в режим сопряжения и попробуйте снова. Не помогло? Спросите нас.", + "pairTroubleModeLink": "Режим сопряжения {device}", + "@pairTroubleModeLink": { + "placeholders": { + "device": { + "type": "String" + } + } + }, + "pairTroubleDiscord": "Спросить в Discord", + "pairTroubleDiscordSub": "Сообщество OpenStrap", + "pairWhoopRowLine": "Сон, восстановление, нагрузка, пульс и многое другое", + "pairSetUpTitle": "Настройка", + "pairReplaceUnpairFailed": "Не удалось удалить сопряжение с вашим браслетом, поэтому новый не сопряжён. Попробуйте ещё раз.", + "capReasonUnconfirmed": "Если устройство это передаёт, не подтверждено", + "capReasonPebble2Only": "Только Pebble 2. У 2 SE нет датчика пульса.", + "capReasonPebble2OnlyEstimated": "Только Pebble 2, и это оценка, а не прямое измерение. У 2 SE нет датчика пульса.", + "pairModeTitle": "Переведите в режим сопряжения", + "pairModeNoSite": "Это универсальное устройство без единого производителя, поэтому инструкции производителя нет.", + "pairOuraExistingKeyGuidance": "Уже пользуетесь приложением Oura? Оставьте кольцо сопряжённым в этом приложении и выполните сопряжение с его текущим ключом. Пропустите шаги сброса ниже. Сбрасывайте кольцо только для получения нового ключа.", + "pairWearableLeadPebble": "Pebble 2 может заполнять ваши дни данными о сне, восстановлении и пульсе. Pebble 2 SE показывает сон и шаги по данным самих часов. У него нет датчика пульса, поэтому приложение не может рассчитать дневную нагрузку или оценить восстановление по его данным.", + "slotColumnReason": "{column}: {reason}", + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 924d3dbf9..09d29141d 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -655,14 +655,6 @@ } } }, - "devicesAddASensor": "เพิ่มเซ็นเซอร์", - "@devicesAddASensor": { - "description": "Row/sheet title: add a sensor." - }, - "devicesAddASensorSub": "สายคาดวัดหัวใจหรือแหวน ใช้ร่วมกับสายรัด", - "@devicesAddASensorSub": { - "description": "Row sub." - }, "devicesWhichSourceWins": "ลำดับความสำคัญของแหล่งข้อมูล", "@devicesWhichSourceWins": { "description": "Row title: opens the per-signal priority editor." @@ -683,18 +675,6 @@ "@devicesNoBandPaired": { "description": "Status card title." }, - "devicesNothingMeasuringYet": "ยังไม่มีอุปกรณ์กำลังวัด", - "@devicesNothingMeasuringYet": { - "description": "Status card title." - }, - "devicesPhoneCountingBody": "โทรศัพท์นับได้เฉพาะก้าว จะยังไม่แสดงอัตราการเต้นหัวใจ การนอน การฟื้นตัว และอุณหภูมิจนกว่าจะจับคู่สายรัด", - "@devicesPhoneCountingBody": { - "description": "Status card body: phone only." - }, - "devicesNothingMeasuringBody": "ยังไม่ได้จับคู่สายรัดและไม่มีข้อมูลก้าวจากโทรศัพท์ แอปจึงยังไม่แสดงค่าต่าง ๆ แทนการประมาณขึ้นมา", - "@devicesNothingMeasuringBody": { - "description": "Status card body: nothing measuring." - }, "devicesPairABand": "จับคู่สายรัด", "@devicesPairABand": { "description": "Fix button." @@ -16325,5 +16305,452 @@ "type": "int" } } + }, + "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." + }, + "capLabelSleep": "การนอน", + "@capLabelSleep": { + "description": "Name of a thing a device sends: sleep." + }, + "capLabelRecovery": "การฟื้นตัว", + "@capLabelRecovery": { + "description": "Name of a thing a device sends: the morning recovery score." + }, + "capLabelStrain": "ความหนัก", + "@capLabelStrain": { + "description": "Name of a thing a device sends: strain." + }, + "capLabelHrv": "ความแปรปรวนของอัตราการเต้นหัวใจ", + "@capLabelHrv": { + "description": "Name of a thing a device sends: heart-rate variability." + }, + "capLabelSkinTemperature": "อุณหภูมิผิว", + "@capLabelSkinTemperature": { + "description": "Name of a thing a device sends: skin temperature." + }, + "capLabelBloodOxygen": "ออกซิเจนในเลือด", + "@capLabelBloodOxygen": { + "description": "Name of a thing a device sends: blood oxygen (only a device’s own reading)." + }, + "capLabelWorkoutHeartRate": "อัตราการเต้นหัวใจขณะออกกำลังกาย", + "@capLabelWorkoutHeartRate": { + "description": "Table row: heart rate during a workout." + }, + "capLabelWorkoutBeatToBeat": "ช่วงเวลาระหว่างจังหวะหัวใจขณะออกกำลังกาย", + "@capLabelWorkoutBeatToBeat": { + "description": "Table row: beat-to-beat timing during a workout." + }, + "capLabelSteps": "ก้าว", + "@capLabelSteps": { + "description": "Name of a thing a device sends: steps." + }, + "capReasonSkinTempNights": "หลังจากผ่านไปสองสามคืน", + "@capReasonSkinTempNights": { + "description": "Caption under Skin temperature: it needs a few nights first." + }, + "capReasonEstimated": "เป็นค่าประมาณ ไม่ใช่ค่าที่วัดได้โดยตรง", + "@capReasonEstimated": { + "description": "Why a value is conditional: it is an estimate, not a direct reading." + }, + "capReasonEarly": "ค่าประมาณช่วงแรก จะแม่นยำขึ้นในช่วงสัปดาห์แรกๆ", + "@capReasonEarly": { + "description": "Why a value is conditional: it is an early estimate that firms up over the first weeks." + }, + "capVendorReported": "ค่าที่อุปกรณ์วัดเอง", + "@capVendorReported": { + "description": "Tag on a value that is the device’s own reading, not calculated by this app." + }, + "slotSendsHeading": "ส่ง", + "@slotSendsHeading": { + "description": "Heading above the list of what a device sends." + }, + "slotDoesntSendHeading": "ไม่ส่ง", + "@slotDoesntSendHeading": { + "description": "Heading above the list of what a secondary sensor does not send." + }, + "pairMainBandWhoopOnly": "สายรัดหลัก: WHOOP 4.0 หรือ WHOOP 5.0/MG เท่านั้น อุปกรณ์อื่นเพิ่มเป็นอุปกรณ์เสริมได้", + "@pairMainBandWhoopOnly": { + "description": "Plain statement on the pairing screens: only WHOOP can be the main band; other devices are added as additional devices." + }, + "pairAddDevice": "เพิ่มอุปกรณ์", + "@pairAddDevice": { + "description": "Label of the button that starts adding a device (band or sensor is decided after the scan)." + }, + "pairBandNotSensor": "อุปกรณ์นั้นคือ {label} ซึ่งเป็นสายรัด ไม่ใช่เซ็นเซอร์ธรรมดา ไม่ได้บันทึกอะไรไว้", + "@pairBandNotSensor": { + "description": "Shown when a device picked as a plain sensor turns out to be a band. Nothing was saved. {label} is the band’s name.", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pairKindAll": "ทั้งหมด", + "@pairKindAll": { + "description": "Filter pill that shows every device in the list." + }, + "pairKindStrap": "สายคาดอกและสายรัดต้นแขน", + "@pairKindStrap": { + "description": "Filter pill: heart-rate straps and armbands." + }, + "pairKindRing": "แหวน", + "@pairKindRing": { + "description": "Filter pill for smart rings." + }, + "pairKindWatch": "นาฬิกา", + "@pairKindWatch": { + "description": "Filter pill for watches." + }, + "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." + }, + "pairBandTitle": "จับคู่สายรัดของคุณ", + "@pairBandTitle": { + "description": "Title of the first-run list of bands." + }, + "pairBandSubtitle": "เลือกอันที่คุณสวมอยู่ เราจะค้นหาในบริเวณใกล้เคียง", + "@pairBandSubtitle": { + "description": "Subtitle of the first-run list of bands." + }, + "pairStrapOnlyLink": "ฉันมีแค่สายคาดวัดหัวใจ", + "@pairStrapOnlyLink": { + "description": "Text link under the band list for someone with only a heart-rate strap." + }, + "pairGoBack": "ย้อนกลับ", + "@pairGoBack": { + "description": "Link that closes a confirm sheet without pairing." + }, + "pairSectionTitle": "จับคู่อุปกรณ์", + "@pairSectionTitle": { + "description": "Heading of the section in My sources that holds pairing." + }, + "pairCardBandBody": "เฉพาะ WHOOP 4.0 หรือ WHOOP 5.0/MG การนอน การฟื้นตัว และความหนัก ตลอดวัน", + "@pairCardBandBody": { + "description": "Body of the Your band card in Settings." + }, + "pairUnpairBand": "ยกเลิกการจับคู่สายรัด", + "@pairUnpairBand": { + "description": "Red outline button that starts forgetting the paired band (a confirm dialog follows)." + }, + "pairAddDeviceSub": "แหวน นาฬิกา และสายวัดชีพจร", + "@pairAddDeviceSub": { + "description": "Line under the Add a device row in Settings." + }, + "pairAddDeviceSubtitle": "แต่ละอุปกรณ์จะบอกว่าเหมาะกับแบบไหน: ทุกวัน หรือเฉพาะตอนออกกำลังกาย", + "@pairAddDeviceSubtitle": { + "description": "Lead of the Add a device screen." + }, + "pairBandFoundBody": "ใช้ทุกวันได้ข้อมูลมากกว่าใช้เฉพาะตอนออกกำลังกายมาก คุณต้องการแบบไหน", + "@pairBandFoundBody": { + "description": "WHOOP sheet lead when no band is paired yet." + }, + "pairBandFoundBodyHasBand": "คุณมีสายรัดอยู่แล้ว ใช้เป็นสายรัดหลักได้ทีละหนึ่งเส้นเท่านั้น", + "@pairBandFoundBodyHasBand": { + "description": "WHOOP sheet lead when a band is already paired." + }, + "pairIdentifiedBody": "อุปกรณ์นี้ดูเหมือนสายคาดวัดหัวใจ แต่เมื่อเชื่อมต่อแล้วกลับระบุตัวเองว่าเป็น {label} ไม่ได้บันทึกอะไรไว้", + "@pairIdentifiedBody": { + "description": "Body of the sheet shown when a device picked as a sensor identifies itself as a band after connecting. Nothing was saved.", + "placeholders": { + "label": { + "type": "String" + } + } + }, + "pairAsMyBand": "จับคู่เป็นสายรัดของฉัน", + "@pairAsMyBand": { + "description": "Button that pairs the device as the user’s main band." + }, + "pairSecondaryPaired": "จับคู่แล้วสำหรับใช้เฉพาะตอนออกกำลังกาย", + "@pairSecondaryPaired": { + "description": "Shown after a device is paired for workouts only." + }, + "capLabelHeartRate": "อัตราการเต้นของหัวใจและขณะพัก", + "@capLabelHeartRate": { + "description": "Table row: heart rate and resting heart rate, as one capability." + }, + "capLabelRespiration": "การหายใจ", + "@capLabelRespiration": { + "description": "Table row: breathing rate." + }, + "capReasonFirstNights": "เป็นค่าประมาณใน 14 คืนแรก", + "@capReasonFirstNights": { + "description": "Caption under Recovery for a ring or watch: its recovery is an estimate until 14 nights of baseline." + }, + "capReasonOwnStagesRing": "จากระยะการนอนที่แหวนบันทึกเอง", + "@capReasonOwnStagesRing": { + "description": "Caption under Sleep for a ring: the sleep stages are the ring’s own." + }, + "capReasonOwnStages": "จากระยะการนอนที่อุปกรณ์บันทึกเอง", + "@capReasonOwnStages": { + "description": "Caption under Sleep for a watch or fitness band: the sleep stages are the device’s own." + }, + "capReasonLiveSessions": "ระหว่างเซสชันสด", + "@capReasonLiveSessions": { + "description": "Caption under Steps for WHOOP 4: it counts steps only while the app has a live connection." + }, + "slotEveryDay": "ทุกวัน", + "@slotEveryDay": { + "description": "Column and chip: the device is used every day (the main band, or the ring/watch that fills days)." + }, + "slotWorkoutsOnly": "เฉพาะตอนออกกำลังกาย", + "@slotWorkoutsOnly": { + "description": "Column and chip: the device is used only during workouts." + }, + "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." + }, + "pairBandNow": "สายรัดปัจจุบันของคุณ: {name} ข้อมูลของคุณยังอยู่เมื่อเปลี่ยน", + "@pairBandNow": { + "description": "Note under the table when a band is already paired. {name} is that band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceBand": "ยกเลิกการจับคู่ {name} แล้วจับคู่อันนี้", + "@pairReplaceBand": { + "description": "Main button when a band is already paired: unpair it and pair the tapped one. {name} is the paired band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmTitle": "ยกเลิกการจับคู่ {name} แล้วจับคู่สายรัดนี้แทนไหม", + "@pairReplaceConfirmTitle": { + "description": "Confirm dialog before replacing the band. {name} is the paired band’s name.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairReplaceConfirmBody": "ข้อมูลของคุณยังอยู่", + "@pairReplaceConfirmBody": { + "description": "Confirm dialog body: replacing the band keeps the data." + }, + "pairReplaceConfirmYes": "ยกเลิกและจับคู่", + "@pairReplaceConfirmYes": { + "description": "Confirm dialog button: unpair the old band and pair the new one." + }, + "pairCurrentBandFallback": "สายรัดของคุณ", + "@pairCurrentBandFallback": { + "description": "Stands in for the paired band’s name when it has none, inside a sentence." + }, + "pairWearableLead": "อุปกรณ์นี้เติมข้อมูลการนอน การฟื้นตัว และอัตราการเต้นของหัวใจให้แต่ละวันได้", + "@pairWearableLead": { + "description": "Sheet lead for a ring or watch." + }, + "pairWearableNote": "เติมวันที่ไม่มี WHOOP ของคุณ ยังเป็นฟีเจอร์ทดลอง จึงต้องเปิดเองในอุปกรณ์", + "@pairWearableNote": { + "description": "Note under a ring’s or watch’s table on the pairing sheet. “Devices” is the device list in Settings." + }, + "pairWearableCardNote": "เติมวันที่ไม่มี WHOOP ของคุณ ยังเป็นฟีเจอร์ทดลอง จึงต้องเปิดเองด้านล่าง", + "@pairWearableCardNote": { + "description": "Note under a ring’s or watch’s table on its device page; the switch is below it." + }, + "pairStrapLead": "เพิ่มอัตราการเต้นของหัวใจขณะออกกำลังกาย ไม่บันทึกการนอน การฟื้นตัว หรือความหนักรายวัน", + "@pairStrapLead": { + "description": "Sheet lead for a heart-rate strap or sensor." + }, + "pairStrapNote": "ทั้งสองอย่างต้องเปิด “ใช้ระหว่างออกกำลังกาย”", + "@pairStrapNote": { + "description": "Note under a strap’s table: both workout rows need the switch. Quotes deviceUseWorkoutsTitle." + }, + "pairDeviceNamed": "จับคู่ {name}", + "@pairDeviceNamed": { + "description": "Main button on a ring’s, watch’s or strap’s sheet. {name} is the device.", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "pairUseForWorkouts": "ใช้เฉพาะตอนออกกำลังกาย", + "@pairUseForWorkouts": { + "description": "Text link on a WHOOP sheet: keep it for workouts only, not as the main band." + }, + "pairWhatItSends": "สิ่งที่ส่งมา", + "@pairWhatItSends": { + "description": "Heading above the table on a device page." + }, + "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." + }, + "pairWhoopRowLine": "การนอน การฟื้นตัว ความหนัก อัตราการเต้นของหัวใจ และอื่นๆ", + "@pairWhoopRowLine": { + "description": "Line under each WHOOP on the first-run list." + }, + "pairSetUpTitle": "ตั้งค่า", + "@pairSetUpTitle": { + "description": "Top bar title of the first-run pairing screen." + }, + "pairReplaceUnpairFailed": "ยกเลิกการจับคู่สายรัดของคุณไม่สำเร็จ จึงยังไม่ได้จับคู่สายรัดใหม่ ลองอีกครั้ง", + "@pairReplaceUnpairFailed": { + "description": "Shown when unpairing the old band failed during “Unpair (your band) and pair this one”; nothing else ran." + }, + "capReasonUnconfirmed": "หากอุปกรณ์นี้ส่งข้อมูลนี้ ยังไม่ได้ยืนยัน", + "capReasonPebble2Only": "เฉพาะ Pebble 2 รุ่น 2 SE ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ", + "capReasonPebble2OnlyEstimated": "เฉพาะ Pebble 2 และเป็นค่าประมาณ ไม่ใช่ค่าที่วัดได้โดยตรง รุ่น 2 SE ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ", + "@capReasonUnconfirmed": { + "description": "Caption under a row the device may or may not send; nobody has confirmed it does." + }, + "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 อยู่แล้วใช่ไหม คงการจับคู่แหวนในแอปนั้นไว้ แล้วจับคู่ด้วยคีย์เดิมของแหวน ข้ามขั้นตอนรีเซ็ตด้านล่าง รีเซ็ตเฉพาะเมื่อต้องการคีย์ใหม่เท่านั้น", + "pairWearableLeadPebble": "Pebble 2 เติมข้อมูลการนอน การฟื้นตัว และอัตราการเต้นของหัวใจให้แต่ละวันได้ Pebble 2 SE แสดงข้อมูลการนอนและจำนวนก้าวที่นาฬิกาบันทึกเอง รุ่นนี้ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ แอปจึงคำนวณภาระร่างกายรายวันหรือประเมินการฟื้นตัวจากข้อมูลของรุ่นนี้ไม่ได้", + "slotColumnReason": "{column}: {reason}", + "slotCapabilityStatus": "{capability}, {column}: {status}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1cf2c9848..2473e6d88 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -143,13 +143,8 @@ "dataBackedUpTo": "已备份到 {path}", "dataCouldNotOpenPicker": "无法打开文件选择器:{error}", "dataSummariesDidNotBodyShort": "所有导入的行都已存入数据库,但重建跨日汇总时出错({error}),因此趋势和洞察仍在描述之前的数据。", - "devicesAddASensor": "添加传感器", - "devicesAddASensorSub": "心率带或戒指,与腕带一起使用", "devicesMySources": "我的数据源", "devicesNoBandPaired": "未配对腕带", - "devicesNothingMeasuringYet": "尚无任何测量", - "devicesPhoneCountingBody": "手机只统计步数,不统计其他任何数据。在配对腕带之前,心率、睡眠、恢复和体温均不提供数据。", - "devicesNothingMeasuringBody": "未配对腕带,也没有手机步数数据传入,因此应用中的每个指标都将不提供数据,而不是进行估算。", "devicesPairABand": "配对腕带", "devicesLive": "实时", "devicesQualityLadder": "质量阶梯", @@ -2861,5 +2856,126 @@ "type": "int" } } + }, + "pairResetWhoop5Step1": "把手环从手腕上取下,等绿灯熄灭。", + "pairResetWhoop5Step2": "握住手环两侧,用两根手指用力连续轻敲顶部,直到只有蓝灯闪烁。如果蓝灯和白灯同时出现,请继续敲,直到只剩蓝灯。", + "pairResetWhoop5Step3": "如果这部手机上装有 WHOOP 应用,请将其强制退出。", + "pairResetWhoop5Step4": "回到这里再试一次。", + "pairResetGenericStep1": "打开厂商的应用,把这台设备从里面移除。", + "pairResetGenericStep2": "在手机的蓝牙设置里找到这台设备,选择“忽略”或“取消配对”。", + "pairResetGenericStep3": "重启设备,把它靠近这部手机,再试一次。", + "pairResetGenericNote": "这些是通用建议。我们还没有核实这台设备的具体步骤。", + "pairResetOfficialLink": "打开厂商的帮助页面", + "capLabelSleep": "睡眠", + "capLabelRecovery": "恢复", + "capLabelStrain": "负荷", + "capLabelHrv": "心率变异性", + "capLabelSkinTemperature": "皮肤温度", + "capLabelBloodOxygen": "血氧", + "capLabelWorkoutHeartRate": "锻炼时的心率", + "capLabelWorkoutBeatToBeat": "锻炼时的逐次心跳间隔", + "capLabelSteps": "步数", + "capReasonSkinTempNights": "几晚之后", + "capReasonEstimated": "估算值,不是直接测量", + "capReasonEarly": "初步估算,前几周会越来越准", + "capVendorReported": "设备自己的读数", + "slotSendsHeading": "提供", + "slotDoesntSendHeading": "不提供", + "pairMainBandWhoopOnly": "主手环:仅支持 WHOOP 4.0 或 WHOOP 5.0/MG。其他设备可作为附加设备添加。", + "pairAddDevice": "添加设备", + "pairBandNotSensor": "这台设备是 {label},是手环,不是普通传感器。没有保存任何内容。", + "pairKindAll": "全部", + "pairKindStrap": "胸带和臂带", + "pairKindRing": "戒指", + "pairKindWatch": "手表", + "pairResetTitleBand": "你的手环已和别处配对", + "pairResetTitleRing": "你的戒指已和别处配对", + "pairResetBody": "我们没能完成 {name} 的配对。它很可能仍连着另一部手机或另一个应用。", + "pairResetHeading": "如何重置", + "pairResetWhoopTakeover": "在这里配对会使它与 WHOOP 应用断开。", + "pairResetOfficialSub": "{host} · 在浏览器中打开", + "pairLinkOpenFailed": "无法打开链接。地址是 {url}", + "pairPickDifferentBand": "换一个手环", + "pairBandTitle": "配对你的手环", + "pairBandSubtitle": "选择你佩戴的那一个,我们会在附近寻找它。", + "pairStrapOnlyLink": "我只有心率带", + "pairGoBack": "返回", + "pairSectionTitle": "配对设备", + "pairCardBandBody": "仅限 WHOOP 4.0 或 WHOOP 5.0/MG。全天候记录睡眠、恢复和负荷。", + "pairUnpairBand": "取消配对手环", + "pairAddDeviceSub": "戒指、手表和心率带", + "pairAddDeviceSubtitle": "每个设备都会标明适合的用法:每天,或仅运动时。", + "pairBandFoundBody": "每天佩戴比仅在运动时使用能提供多得多的数据。你想怎么用?", + "pairBandFoundBodyHasBand": "你已经有一个手环。同一时间只能有一个作为你的手环。", + "pairIdentifiedBody": "它看起来像心率带,但连接后它表明自己是 {label}。没有保存任何内容。", + "pairAsMyBand": "配对为我的手环", + "pairSecondaryPaired": "已配对,仅在运动时使用。", + "capLabelHeartRate": "心率和静息心率", + "capLabelRespiration": "呼吸", + "capReasonFirstNights": "前 14 晚为估算值", + "capReasonOwnStagesRing": "来自戒指自身的睡眠分期", + "capReasonOwnStages": "来自设备自身的睡眠分期", + "capReasonLiveSessions": "仅在实时连接期间", + "slotEveryDay": "每天", + "slotWorkoutsOnly": "仅运动时", + "pairThisIsWhoop": "这是 WHOOP 手环", + "pairThisIsRing": "这是一枚戒指", + "pairThisIsWatch": "这是一块手表", + "pairThisIsFitnessBand": "这是一款运动手环", + "pairThisIsStrap": "这是一条心率带", + "pairThisIsSensor": "这是一个心率传感器", + "pairBandNow": "当前手环:{name}。更换后你的数据仍会保留。", + "pairReplaceBand": "取消配对 {name} 并配对这个", + "pairReplaceConfirmTitle": "取消配对 {name},改为配对这个手环?", + "pairReplaceConfirmBody": "你的数据会保留。", + "pairReplaceConfirmYes": "取消配对并配对", + "pairCurrentBandFallback": "你的手环", + "pairWearableLead": "它可以为你的每一天补上睡眠、恢复和心率。", + "pairWearableNote": "填补没有 WHOOP 数据的日子。这是实验功能,请在“设备”中开启。", + "pairWearableCardNote": "填补没有 WHOOP 数据的日子。这是实验功能,请在下方开启。", + "pairStrapLead": "它在你运动时提供心率,不记录睡眠、恢复或每日负荷。", + "pairStrapNote": "两项都需要开启“在锻炼时使用”。", + "pairDeviceNamed": "配对 {name}", + "pairUseForWorkouts": "仅在运动时使用", + "pairWhatItSends": "它提供的数据", + "pairTroubleTitle": "连接遇到问题?", + "pairTroubleBody": "让手环进入配对模式,然后重试。还是不行?来问我们。", + "pairTroubleModeLink": "{device} 配对模式", + "pairTroubleDiscord": "在 Discord 提问", + "pairTroubleDiscordSub": "OpenStrap 社区", + "pairWhoopRowLine": "睡眠、恢复、负荷、心率等", + "pairSetUpTitle": "设置", + "pairReplaceUnpairFailed": "无法取消配对你的手环,因此没有配对新手环。请重试。", + "capReasonUnconfirmed": "如果此设备发送该数据,尚未确认", + "capReasonPebble2Only": "仅限 Pebble 2。2 SE 没有心率传感器。", + "capReasonPebble2OnlyEstimated": "仅限 Pebble 2,且是估算值,不是直接测量。2 SE 没有心率传感器。", + "pairModeTitle": "让它进入配对模式", + "pairModeNoSite": "这是没有单一厂商的通用设备,因此没有厂商步骤可循。", + "pairOuraExistingKeyGuidance": "已经在用 Oura 应用?请保留戒指在该应用中的配对,并使用戒指现有的密钥配对。跳过下面的重置步骤。只有想使用新密钥时才重置戒指。", + "pairWearableLeadPebble": "Pebble 2 可以为你的每一天补上睡眠、恢复和心率。Pebble 2 SE 显示手表自身记录的睡眠和步数。它没有心率传感器,因此本应用无法根据它的数据计算每日身体负荷或估算恢复。", + "slotColumnReason": "{column}:{reason}", + "slotCapabilityStatus": "{capability},{column}:{status}", + "@slotColumnReason": { + "placeholders": { + "column": { + "type": "String" + }, + "reason": { + "type": "String" + } + } + }, + "@slotCapabilityStatus": { + "placeholders": { + "capability": { + "type": "String" + }, + "column": { + "type": "String" + }, + "status": { + "type": "String" + } + } } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 0d6267716..6f2049fb7 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -4629,7 +4629,9 @@ class AppState extends ChangeNotifier { } // ── pairing (LOCAL only) ──────────────────────────────────────────────────── - Future scanForBand() => engine.scan(); + /// [remoteId] / [generation]: only the band the user chose ([BleEngine.scan]). + Future scanForBand({String? remoteId, String? generation}) => + engine.scan(remoteId: remoteId, generation: generation); /// True on iOS 18+, where pairing must go through the AccessorySetupKit picker so /// the band is provisioned for iOS-26 background relaunch (TN3115). False on Android diff --git a/lib/ui2/onboarding/pairing.dart b/lib/ui2/onboarding/pairing.dart index 36a9005ff..c8cbaed53 100644 --- a/lib/ui2/onboarding/pairing.dart +++ b/lib/ui2/onboarding/pairing.dart @@ -15,11 +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, 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'; /// Onboarding steps the user deliberately walked past. @@ -109,7 +115,17 @@ class PairingScreen extends StatefulWidget { /// Walk past pairing and open the app anyway. Supplied by the router. final VoidCallback? onSkip; - const PairingScreen({super.key, this.onSkip}); + /// The band the user chose on the list; its reset steps show when pairing + /// does not finish, and a WHOOP advertising the other generation is passed + /// over. Null (no model known) shows the plain failure cards. + 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}); @override State createState() => _PairingScreenState(); @@ -131,7 +147,8 @@ class _PairingScreenState extends State { if (await app.accessorySetupSupported()) { await app.pairViaAccessorySetup(); } else { - final found = await app.scanForBand(); + final found = await app.scanForBand( + remoteId: widget.remoteId, generation: widget.entry?.id); if (found == null) { if (mounted) setState(() => _phase = PairPhase.notFound); return; @@ -165,6 +182,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, ); } @@ -177,6 +198,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, @@ -184,10 +216,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 (G3). + 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 @@ -228,20 +350,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/add_device_flow.dart b/lib/ui2/pairing/add_device_flow.dart new file mode 100644 index 000000000..2e6d04eb5 --- /dev/null +++ b/lib/ui2/pairing/add_device_flow.dart @@ -0,0 +1,249 @@ +// What happens when the user taps a device found by the "Add a device" scan. +// One scan covers sensors and bands; which it is gets decided here, after the +// scan, and the user confirms before anything is saved. The pairing operation +// and the main-band flow are injected, so this runs without a radio. +import 'package:flutter/widgets.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../ble/band_identity.dart'; +import '../../ble/hrs_link.dart' show BandCandidate; +import 'pair_sheets.dart'; +import 'pair_text.dart' show pairL10n; + +enum AddDeviceOutcome { + /// The user backed out; nothing was saved. + cancelled, + + /// Saved as a secondary sensor ("You paired a secondary sensor."). + pairedSecondary, + + /// Saved, and not a secondary sensor (a scale, a thermometer). + pairedOther, + + /// Handed to the main-band flow; nothing was saved here. + openedBandFlow, + + /// Pairing failed; [AddDeviceResult.message] says why. + failed, + + /// The main-band flow cannot open right now (iOS, no provisioned band, so + /// the system pairing sheet would fail until a restart); + /// [AddDeviceResult.message] is the restart guidance. Nothing was saved. + bandFlowHeldBack, +} + +typedef AddDeviceResult = ({AddDeviceOutcome outcome, String? message}); + +AddDeviceResult _r(AddDeviceOutcome o, [String? message]) => + (outcome: o, message: message); + +/// Entries that cannot be paired from a list tap: their pairing installs a key +/// on their own screen (the Oura ring asks for it, a Mi Band gets one), so +/// every choice for them ends in the band flow. A plain save would leave a row +/// sync cannot use. ui2_pair_sheets_test checks every keyed sensor is here. +const Set _ownScreen = {'oura', 'miband234'}; + +/// What the "Add a device" scan listens for: every registry entry, the framed +/// WHOOP bands included, so a WHOOP that advertises only its own band service +/// still turns up and goes to the band-choice flow. +List addDeviceScanEntries() => List.unmodifiable(kBandRegistry); + +/// What the flow needs from the screen and the radio. +abstract interface class AddDeviceIo { + /// Whether a main band is paired right now (read at tap time). + bool get hasMainBand; + + /// The paired band's name, for "Unpair (your band) and pair this one"; null + /// when it has none (the sheet then says "your band"). + String? get mainBandName; + + /// Unpairs the main band through the app's own unpair (the one Settings' + /// "Unpair band" runs): the link and the pairing record go, every saved + /// reading stays. + Future unpairMainBand(); + + /// The pairing operation for [cand] under registry [entry]; the real one is + /// `HrsLink.pairNotifySensor`, tests pass a fake. + Future pair( + BandCandidate cand, BandEntry entry, PairConfirmation? confirmation); + + /// Ends this screen's own scan (awaited), so the next screen starts on a + /// radio that is not scanning. + Future stopScan(); + + /// Why the main-band flow cannot open right now, as restart guidance + /// (`HrsLink.scanHeldBackReason`); null when it can. + Future mainBandBlockedReason(); + + /// Opens the main-band pairing for [band], bound to the tapped device + /// [remoteId]. [modelKnown] false: only the name said WHOOP, so the + /// pairing must not assume [band]'s generation. + Future openBandFlow(BandEntry band, + {required String remoteId, bool modelKnown = true}); +} + +class AddDeviceFlow { + final AddDeviceIo io; + AddDeviceFlow(this.io); + + bool _running = false; + + /// A tap's flow is under way (a sheet, a confirm, an unpair, a pairing). + /// [onTap] refuses a second one until it ends. + bool get running => _running; + + static BandEntry? _entry(String id) => + kBandRegistry.where((e) => e.id == id).firstOrNull; + + /// Capture [c]'s navigator and localizations before the first await; the + /// sheets use it again after each one, so the caller must keep it mounted. + Future onTap(BuildContext c, BandCandidate cand) async { + if (_running) return _r(AddDeviceOutcome.cancelled); + _running = true; + try { + return await _onTap(c, cand); + } finally { + _running = false; + } + } + + Future _onTap(BuildContext c, BandCandidate cand) async { + final entry = _entry(cand.entryId); + if (entry == null) return _r(AddDeviceOutcome.cancelled); + // A band in its own right, or a sensor-looking device that is also a band. + final band = entry.role == DeviceRole.band ? entry : cand.alsoBand?.entry; + if (band != null) { + // A WHOOP heard by name only: the name cannot tell WHOOP 4 from 5. + final modelKnown = cand.alsoBand?.modelCertain != false; + final choice = await showBandFoundSheet(c, + band: band, + hasMainBand: io.hasMainBand, + mainBandName: io.mainBandName, + modelKnown: modelKnown, + deviceName: cand.label); + if (choice == null) return _r(AddDeviceOutcome.cancelled); + if (!c.mounted) return _r(AddDeviceOutcome.cancelled); + if (choice == BandSheetChoice.replaceBand) { + return _replace(c, band, cand, modelKnown: modelKnown); + } + // The band's own entry decides: a ring heard as a heart-rate strap + // still pairs only on its own (authenticated) screen. + if (choice == BandSheetChoice.asBand || _ownScreen.contains(band.id)) { + return _handoff(band, cand, modelKnown: modelKnown); + } + // A WHOOP kept as a secondary sensor is paired as a heart-rate sensor; + // a ring or a watch pairs under its own entry. + final pairAs = !band.canBeMainDevice + ? band + : entry.isFramed + ? kBleHrs + : entry; + return _pair(c, cand, pairAs, + confirmation: PairConfirmation( + remoteId: cand.device.remoteId.str, bandEntryId: band.id)); + } + if (!await confirmBrowse(c, entry, deviceName: cand.label) || + !c.mounted) { + return _r(AddDeviceOutcome.cancelled); + } + return _pair(c, cand, entry); + } + + /// The confirmation owed before pairing [entry]: a workout sensor and no + /// band shows the strap sheet (everything every day is a cross) before it + /// is saved as all there is. False when the user went back. A device paired + /// as its own entry (Colmi as Colmi) needs none. + Future confirmBrowse(BuildContext c, BandEntry entry, + {String? deviceName}) async { + if (!_isSecondarySensor(entry) || io.hasMainBand) return true; + return showStrapSheet(c, sensor: entry, deviceName: deviceName); + } + + /// "Unpair (your band) and pair this one": one confirm, then the app's own + /// unpair (every saved reading stays), then straight into the main-band + /// pairing for the tapped device. Cancel changes nothing. + Future _replace( + BuildContext c, BandEntry band, BandCandidate cand, + {bool modelKnown = true}) async { + final l = pairL10n(c); + if (!await showReplaceBandConfirm(c, mainBandName: io.mainBandName)) { + return _r(AddDeviceOutcome.cancelled); + } + try { + await io.unpairMainBand(); + } catch (e) { + // Nothing else runs: the new band is not paired over a half-done + // unpair, and the user is told so. + debugPrint('[pairing] replace: unpair failed: $e'); + return _r(AddDeviceOutcome.failed, l.pairReplaceUnpairFailed); + } + return _handoff(band, cand, modelKnown: modelKnown); + } + + /// Hands over to the main-band flow: the scan ends first, and where the + /// system pairing sheet cannot open the user gets restart guidance instead. + Future _handoff(BandEntry band, BandCandidate cand, + {bool modelKnown = true}) async { + await io.stopScan(); + if (band.isFramed) { + final held = await io.mainBandBlockedReason(); + if (held != null) return _r(AddDeviceOutcome.bandFlowHeldBack, held); + } + await io.openBandFlow(band, + remoteId: cand.device.remoteId.str, modelKnown: modelKnown); + return _r(AddDeviceOutcome.openedBandFlow); + } + + static bool _isSecondarySensor(BandEntry e) => + e.role == DeviceRole.sensor && e.category == DeviceCategory.workoutSensor; + + Future _pair(BuildContext c, BandCandidate cand, + BandEntry entry, + {PairConfirmation? confirmation}) async { + final outcome = await io.pair(cand, entry, confirmation); + // afterConnect checks `c.mounted` before it touches the context. + // ignore: use_build_context_synchronously + return afterConnect(c, cand, entry, outcome, confirmation: confirmation); + } + + /// What happens after a connect that returned [outcome] for [cand] under + /// [entry]: a band found asks again (nothing was saved) and re-runs only on + /// an explicit yes. Shared by the nearby list and the category screen. + Future afterConnect( + BuildContext c, BandCandidate cand, BandEntry entry, PairOutcome outcome, + {PairConfirmation? confirmation}) async { + final found = outcome.isBand; + if (found != null) { + // The connect showed it is a band. Nothing was saved; ask again with + // that in hand, and re-run only on an explicit yes. + // A WHOOP named only by its name keeps the name's uncertain model. + final modelKnown = outcome.modelCertain; + if (!c.mounted) return _r(AddDeviceOutcome.cancelled); + final choice = await showBandFoundSheet(c, + band: found, + hasMainBand: io.hasMainBand, + mainBandName: io.mainBandName, + afterConnect: true, + modelKnown: modelKnown, + deviceName: cand.label); + if (choice == null) return _r(AddDeviceOutcome.cancelled); + if (!c.mounted) return _r(AddDeviceOutcome.cancelled); + if (choice == BandSheetChoice.replaceBand) { + return _replace(c, found, cand, modelKnown: modelKnown); + } + if (choice == BandSheetChoice.asBand || _ownScreen.contains(found.id)) { + return _handoff(found, cand, modelKnown: modelKnown); + } + if (!c.mounted) return _r(AddDeviceOutcome.cancelled); + // A ring or a watch pairs under its own entry, never as a plain sensor. + return _pair(c, cand, found.canBeMainDevice ? entry : found, + confirmation: PairConfirmation( + remoteId: cand.device.remoteId.str, bandEntryId: found.id)); + } + final error = outcome.error; + if (error != null) return _r(AddDeviceOutcome.failed, error); + return _r(confirmation != null || _isSecondarySensor(entry) + ? AddDeviceOutcome.pairedSecondary + : AddDeviceOutcome.pairedOther); + } +} diff --git a/lib/ui2/pairing/category_filter_pills.dart b/lib/ui2/pairing/category_filter_pills.dart new file mode 100644 index 000000000..eaff7a790 --- /dev/null +++ b/lib/ui2/pairing/category_filter_pills.dart @@ -0,0 +1,86 @@ +// K3: filter pills over a device list ("All · WHOOP · Rings · Watches · +// Straps"). One widget, one file, so the list grouping can be swapped for +// another style (accordions, plain headers) without touching a screen. +import 'package:flutter/material.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../ui2.dart'; +import 'pair_text.dart'; + +/// [entries] narrowed to [group]; null keeps them all. +List filterByGroup(Iterable entries, PairFilter? group) => [ + for (final e in entries) + if (group == null || pairFilterOf(e.kind) == group) e, +]; + +class CategoryFilterPills extends StatelessWidget { + /// The kind of every entry currently in the list (repeats are fine). Only + /// groups that have one get a pill; they show in [PairFilter] order. + final Iterable kinds; + + /// The chosen group; null means All. Set by a tap, listened to by the screen. + final ValueNotifier selection; + + const CategoryFilterPills({ + super.key, + required this.kinds, + required this.selection, + }); + + @override + Widget build(BuildContext c) { + final present = {for (final k in kinds) ?pairFilterOf(k)}; + final shown = [ + for (final f in PairFilter.values) + if (present.contains(f)) f, + ]; + // One group (or none) has nothing to filter between. + if (shown.length < 2) return const SizedBox.shrink(); + final l = pairL10n(c); + return ValueListenableBuilder( + valueListenable: selection, + builder: (_, selected, _) => Wrap( + spacing: S.x2, + runSpacing: S.x2, + children: [ + _Pill(const ValueKey('pill-all'), l.pairKindAll, selected == null, + () => selection.value = null), + for (final f in shown) + _Pill(ValueKey('pill-${f.name}'), pairFilterLabel(l, f), + selected == f, () => selection.value = f), + ], + ), + ); + } +} + +class _Pill extends StatelessWidget { + final String text; + final bool selected; + final VoidCallback onTap; + const _Pill(Key key, this.text, this.selected, this.onTap) : super(key: key); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + return Semantics( + selected: selected, + child: Pressable( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: S.x3, vertical: 9), + decoration: BoxDecoration( + color: selected ? Color.alphaBlend(p.wash(C.blue), p.card) : null, + borderRadius: R.rPill, + border: Border.all( + color: selected ? p.on(C.blue).withValues(alpha: .7) : p.line), + ), + child: Text(text, + style: F.cap.copyWith( + color: selected ? p.ink : p.ink2, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500)), + ), + ), + ); + } +} diff --git a/lib/ui2/pairing/device_picker.dart b/lib/ui2/pairing/device_picker.dart index 8fd7d3166..b5cb0680d 100644 --- a/lib/ui2/pairing/device_picker.dart +++ b/lib/ui2/pairing/device_picker.dart @@ -38,17 +38,33 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../ble/adapters/_registry.dart'; +import '../../ble/band_identity.dart' show PairConfirmation, PairOutcome; import '../../ble/band_status_l10n.dart' show localizedBandStatus; import '../../ble/ble_state.dart' - show BleUnavailableException, bandStatusFor, classifyBleBlocker; + show + BleUnavailableException, + bandStatusFor, + classifyBleBlocker, + kMaxConcurrentSecondaryLinks; import '../../ble/hrs_link.dart'; import '../../ble/oura_link.dart' show pairOuraRingWithTypedKey; +import '../../data/db.dart' show LocalDb; import '../../l10n/app_localizations.dart'; import '../../state/app_state.dart'; import '../onboarding/pairing.dart' show PairingScreen; import '../ui2.dart'; +import 'add_device_flow.dart'; +import 'category_filter_pills.dart'; +import 'pair_band_view.dart'; +import 'pair_text.dart' + show PairFilter, pairFilterOf, pairL10n, slotPill; +import '../../ble/slot_comparison.dart' show slotComparisonFor; import '../profile/devices.dart' - show kPairableSensors, refuseIfSensorCapReached, sensorIcon; + show + kPairableSensors, + refuseIfSensorCapReached, + sensorCapReached, + sensorIcon; import '../profile/pair_sensor.dart' show PairSensorScreen; import '../profile/profile.dart' show SetRow; @@ -76,7 +92,8 @@ class DevicePickerScreen extends StatefulWidget { State createState() => _DevicePickerScreenState(); } -class _DevicePickerScreenState extends State { +class _DevicePickerScreenState extends State + implements AddDeviceIo, PairBandHost { final _query = TextEditingController(); String _q = ''; @@ -85,9 +102,21 @@ class _DevicePickerScreenState extends State { String? _heldBack; String? _problem; + /// The filter pill chosen over the results (null = All) and the good-news + /// line shown after a secondary sensor paired. + final _kind = ValueNotifier(null); + String? _notice; + /// Remote id of whichever candidate — nearby row or category tile — is /// mid-pair, so the rest of the screen can stop accepting taps rather /// than starting a second pairing attempt underneath the first. + /// The one flow every tap goes through, so a second tap mid-flow is + /// refused ([AddDeviceFlow.running]). + late final AddDeviceFlow _flow = AddDeviceFlow(this); + + /// True while [_flow] runs; disables the list (see [DevicePickerView.locked]). + bool _locked = false; + String? _busy; static List get _notifyEntries => @@ -97,12 +126,16 @@ class _DevicePickerScreenState extends State { void initState() { super.initState(); _query.addListener(() => setState(() => _q = _query.text)); - unawaited(_start()); + _kind.addListener(() => setState(() {})); + // The first-run band list does not scan: on iOS a scan creates the central + // that keeps the system pairing sheet from opening. + if (!widget.includeBand) unawaited(_start()); } @override void dispose() { _query.dispose(); + _kind.dispose(); // Ends THIS screen's scan early if it is the one running — see // `PairSensorScreen.dispose` for why this is never a bare // `FlutterBluePlus.stopScan()`, and `HrsLink._scanOwner` for why the @@ -132,7 +165,7 @@ class _DevicePickerScreenState extends State { }); try { await HrsLink.scanForAny( - _notifyEntries, + addDeviceScanEntries(), owner: this, onResults: (c) { if (mounted) setState(() => _found = c); @@ -153,64 +186,145 @@ class _DevicePickerScreenState extends State { } } - /// Pair a candidate found by the live "Nearby" scan directly — one tap, - /// no intermediate screen, because the scan already told us which - /// registry entry it is. + /// A device found by the live scan was tapped. [AddDeviceFlow] decides what + /// it is (a band or a sensor), asks the user where that matters, and calls + /// back here to do the pairing. Future _pickNearby(BandCandidate cand) async { + // One flow at a time: a second tap while a sheet, a confirm or the + // unpair of the old band is still running starts nothing. + if (_flow.running) return; + final l = AppLocalizations.of(context); + setState(() { + _problem = null; + _notice = null; + _locked = true; + }); + final AddDeviceResult result; + try { + result = await _flow.onTap(context, cand); + } finally { + if (mounted) setState(() => _locked = false); + } + if (!mounted) return; + switch (result.outcome) { + case AddDeviceOutcome.pairedSecondary: + case AddDeviceOutcome.pairedOther: + setState(() { + _found = const []; + if (result.outcome == AddDeviceOutcome.pairedSecondary) { + _notice = l?.pairSecondaryPaired ?? 'You paired a secondary sensor.'; + } + }); + await _afterPair(); + case AddDeviceOutcome.failed: + setState(() => _problem = result.message); + case AddDeviceOutcome.bandFlowHeldBack: + // The same restart guidance a held-back scan shows. + setState(() => _heldBack = result.message); + case AddDeviceOutcome.cancelled: + case AddDeviceOutcome.openedBandFlow: + break; + } + } + + @override + bool get hasMainBand => context.read().isPaired; + + @override + String? get mainBandName => context.read().strapName; + + /// The same unpair Settings' "Unpair band" runs (`AppState.unpair`): the + /// link and the pairing record go, every saved reading stays. + @override + Future unpairMainBand() => context.read().unpair(); + + @override + Future openBandFlow(BandEntry band, + {required String remoteId, bool modelKnown = true}) => + _openEntry(band, remoteId: remoteId, modelKnown: modelKnown); + + @override + Future stopScan() => HrsLink.stopScanIfRunning(this); + + @override + Future mainBandBlockedReason() => HrsLink.scanHeldBackReason(); + + /// A category tile, tapped deliberately: the same confirmation a nearby row + /// gets (a workout sensor and no band asks first), then the entry's own + /// screen, whose connect-time band refusals go through the same flow. + Future _browseEntry(BandEntry entry) async { + if (_flow.running) return; + if (!await _flow.confirmBrowse(context, entry)) return; + if (!mounted) return; + await _openEntry(entry); + } + + @override + void pickBand(BandEntry entry) => unawaited(_openEntry(entry)); + + @override + void strapOnlyConfirmed() => unawaited(_openEntry(kBleHrs)); + + /// The pairing operation itself, for [AddDeviceFlow]. + @override + Future pair( + BandCandidate cand, BandEntry entry, PairConfirmation? confirmation) async { final sensor = - kPairableSensors.where((s) => s.entry.id == cand.entryId).firstOrNull; - if (sensor == null) return; // Framed entries never reach this list. - // Only the ring's own screen asks for the key it may already hold; a - // direct pair here would install a new one and steer the user to a reset. - if (sensor.entry.id == kOura.id) return _openEntry(sensor.entry); + kPairableSensors.where((s) => s.entry.id == entry.id).firstOrNull; + final l = AppLocalizations.of(context); + if (sensor == null) { + return PairOutcome.failed(l?.devicePickerCannotPairFromHere ?? + 'That device cannot be paired from this screen.'); + } // Re-checked on every pair: this screen stays open after one for the // next, so `addSensor`'s check at the door does not bound a visit. // The minted id is the row only `pairNotifySensor` writes; a `pick` - // callback mints its own, so it is checked by adapter alone. - if (await refuseIfSensorCapReached(context, - repairingId: sensor.pick == null - ? HrsLink.mintDeviceId(sensor.entry, cand.device.remoteId.str) - : null, - adapterId: sensor.entry.id) || - !mounted) { - return; + // callback mints its own, so it is checked by adapter alone. The reason + // shows as this pair's failure, like any other. + if (sensorCapReached(await LocalDb.deviceRows(), + repairingId: sensor.pick == null + ? HrsLink.mintDeviceId(sensor.entry, cand.device.remoteId.str) + : null, + adapterId: sensor.entry.id)) { + return PairOutcome.failed( + l?.devicesSensorLimit(kMaxConcurrentSecondaryLinks) ?? + 'This phone will pair at most $kMaxConcurrentSecondaryLinks ' + 'sensors alongside your band. Remove one to add another.'); } - setState(() { - _busy = cand.device.remoteId.str; - _problem = null; - }); + if (!mounted) return const PairOutcome.failed(''); + setState(() => _busy = cand.device.remoteId.str); // OUR OWN SCAN, ENDED FIRST. It runs for a 15 s window and the user has // tapped a row several seconds into it, so without this the radio keeps // scanning through the connect — and every `onResults` batch reorders the - // list under the finger that is already committed to one row. `PairSensor` - // does not do this because it has one entry and a shorter list; here the - // reorder is visible. - // - // AWAITED, so the connect below starts on a radio that has really stopped - // scanning rather than one still tearing the scan down. Read before the - // await, because `context` after one is the lint's whole point. - final l = AppLocalizations.of(context); + // list under the finger that is already committed to one row. AWAITED, so + // the connect below starts on a radio that has really stopped scanning. await HrsLink.stopScanIfRunning(this); - String? failure; + PairOutcome outcome; try { - failure = sensor.pick != null - ? await sensor.pick!(cand.device) - : await HrsLink.pairNotifySensor(sensor.entry, cand.device, - label: cand.label); + if (sensor.pick != null) { + final failure = await sensor.pick!(cand.device); + outcome = failure == null + ? const PairOutcome.saved() + : PairOutcome.failed(failure); + } else { + outcome = await HrsLink.pairNotifySensor(sensor.entry, cand.device, + label: cand.label, + earlierBand: cand.alsoBand, + confirmation: confirmation); + } } catch (e) { - failure = l?.pairSensorCouldNotPair(e.toString()) ?? 'Could not pair that device: $e'; + outcome = PairOutcome.failed( + l?.pairSensorCouldNotPair(e.toString()) ?? 'Could not pair that device: $e'); } - if (!mounted) return; - setState(() { - _busy = null; - _problem = failure; - if (failure == null) _found = const []; - }); - if (failure == null) await _afterPair(); + if (mounted) setState(() => _busy = null); + return outcome; } - /// A category or brand row, tapped deliberately rather than found live. - Future _openEntry(BandEntry entry) async { + /// A category or brand row, tapped deliberately rather than found live; or + /// a scanned band handed over by [AddDeviceFlow] ([remoteId], [modelKnown] + /// as [AddDeviceIo.openBandFlow]). + Future _openEntry(BandEntry entry, + {String? remoteId, bool modelKnown = true}) async { if (entry.isFramed) { // The pushed screen covers this one without disposing it, so this // screen's 15 s scan would keep the scan lock — and "Find my band" @@ -219,7 +333,8 @@ class _DevicePickerScreenState extends State { await HrsLink.stopScanIfRunning(this); if (!mounted) return; await Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const PairingScreen(), + builder: (_) => PairingScreen( + entry: modelKnown ? entry : null, remoteId: remoteId), )); if (mounted) await _afterPair(); return; @@ -280,20 +395,28 @@ class _DevicePickerScreenState extends State { @override Widget build(BuildContext c) { final l = AppLocalizations.of(c); - final entries = [ - if (widget.includeBand) kWhoopGen4, - ..._notifyEntries, - ]; + if (widget.includeBand) { + // First run and re-pair: the main band first, nothing else. + return PairBandView( + entries: firstRunBandEntries(), + host: this, + onSkip: widget.onSkip, + onBack: widget.onBack, + problem: _problem, + ); + } + final entries = _notifyEntries; return DevicePickerView( query: _query, - title: l?.devicePickerTitle ?? 'Connect your devices', - subtitle: - l?.devicePickerSubtitle ?? 'Bring whatever you use. You can add more anytime.', + title: l?.pairAddDevice ?? 'Add a device', + subtitle: l?.pairAddDeviceSubtitle ?? + 'Scan once. After the scan we tell you whether a device is a band or a sensor.', found: _found, scanning: _scanning, heldBack: _heldBack, problem: _problem, busyRemoteId: _busy, + locked: _locked, categories: [ for (final e in entries) if ((_matches( @@ -314,7 +437,9 @@ class _DevicePickerScreenState extends State { ], onScan: _scan, onPickNearby: _pickNearby, - onOpenEntry: _openEntry, + onOpenEntry: _browseEntry, + kindSelection: _kind, + notice: _notice, onSkip: widget.onSkip, onBack: widget.onBack, ); @@ -344,6 +469,18 @@ class DevicePickerView extends StatelessWidget { final VoidCallback? onSkip; final VoidCallback? onBack; + /// The filter pills over the results (K3): the chosen kind (null = All) and + /// its setter. Null [kindSelection] draws no pills. The owner listens and + /// rebuilds this view when it changes. + final ValueNotifier? kindSelection; + + /// A good-news line shown above the results, e.g. after a sensor paired. + final String? notice; + + /// A pairing flow is running (a sheet, a confirm, the old band's unpair): + /// every row and the scan button wait for it. + final bool locked; + const DevicePickerView({ super.key, required this.query, @@ -360,13 +497,49 @@ class DevicePickerView extends StatelessWidget { this.onOpenEntry, this.onSkip, this.onBack, + this.kindSelection, + this.notice, + this.locked = false, }); + static DeviceKind? _kindOf(String entryId) => + kBandRegistry.where((e) => e.id == entryId).firstOrNull?.kind; + + /// What a found device physically is: the band it turned out to be when + /// it passed itself off as a heart-rate sensor, else its own entry's kind. + static DeviceKind? _foundKind(BandCandidate f) => + f.alsoBand?.entry.kind ?? _kindOf(f.entryId); + + static PairFilter? _groupOf(BandCandidate f) { + final k = _foundKind(f); + return k == null ? null : pairFilterOf(k); + } + @override Widget build(BuildContext c) { final p = P.of(c); final l = AppLocalizations.of(c); - final busy = busyRemoteId != null; + final busy = busyRemoteId != null || locked; + // The pills read the whole list; the filter narrows what is drawn. + final kinds = [ + for (final f in found) ?_foundKind(f), + for (final e in this.categories) e.entry.kind, + ]; + // A kind the search has emptied out is no filter: the pills hide when + // one kind is left, and a hidden selection would hide every row. + final groups = {for (final k in kinds) ?pairFilterOf(k)}; + final selected = kindSelection?.value; + final group = groups.contains(selected) ? selected : null; + final shownFound = group == null + ? found + : [for (final f in found) if (_groupOf(f) == group) f]; + // Shadows the field on purpose: the rows below keep their original text. + final categories = group == null + ? this.categories + : [ + for (final e in this.categories) + if (pairFilterOf(e.entry.kind) == group) e, + ]; return Scaffold( backgroundColor: p.bg, body: SafeArea( @@ -382,7 +555,17 @@ class DevicePickerView extends StatelessWidget { Text(subtitle, style: F.body.copyWith(color: p.ink3)), const SizedBox(height: S.x4), _SearchField(controller: query), + if (kindSelection != null && groups.length > 1) ...[ + const SizedBox(height: S.x3), + CategoryFilterPills( + kinds: kinds, selection: kindSelection!), + ], const SizedBox(height: S.x5), + if (notice != null) ...[ + StatusCard(notice!, '', + icon: LucideIcons.circleCheck), + const SizedBox(height: S.x4), + ], if (heldBack != null) StatusCard( l?.pairSensorSearchWouldHideSheet ?? @@ -394,10 +577,10 @@ class DevicePickerView extends StatelessWidget { ) else _NearbySection( - found: found, + found: shownFound, scanning: scanning, busyRemoteId: busyRemoteId, - onTap: onPickNearby, + onTap: locked ? null : onPickNearby, ), if (problem != null) ...[ const SizedBox(height: S.x4), @@ -424,6 +607,7 @@ class DevicePickerView extends StatelessWidget { ? null : () => onOpenEntry?.call(categories[i].entry), ), + ?_slotChip(c, categories[i].entry), if (i < categories.length - 1) Divider(color: p.line, height: 1), ], @@ -572,7 +756,11 @@ class _NearbySection extends StatelessWidget { ? (l?.devicesConnected ?? 'Connected') : '${cand.rssi} dBm'; final idle = cand.label == null ? '…$tail · $signal' : signal; - return SetRow( + // Where it fits, read off the band it turned out to be when it passed + // itself off as a heart-rate sensor. + final entry = cand.alsoBand?.entry ?? + kBandRegistry.where((e) => e.id == cand.entryId).firstOrNull; + final row = SetRow( sensorIcon(cand.entryId), C.green, cand.label ?? cand.entryId, @@ -591,9 +779,28 @@ class _NearbySection extends StatelessWidget { chevron: !busy, onTap: busy || onTap == null ? null : () => onTap!(cand), ); + final chip = entry == null ? null : _slotChip(c, entry); + if (chip == null) return row; + // Each device says where it fits before the user taps it. + return Column(children: [row, chip]); } } +/// The "Every day" / "Workouts only" chip under a device row, or null when +/// the device fits neither column. +Widget? _slotChip(BuildContext c, BandEntry e) { + final slot = slotComparisonFor(e).slot; + if (slot == null) return null; + return Align( + key: ValueKey('row-slot-${e.id}'), + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsets.only(bottom: S.x3), + child: slotPill(pairL10n(c), slot), + ), + ); +} + /// The one-line description under each device in the picker, localised. String deviceCategoryBlurb(AppLocalizations? l, BandEntry e) { return localizedText(l, switch (e.id) { diff --git a/lib/ui2/pairing/device_slot_card.dart b/lib/ui2/pairing/device_slot_card.dart new file mode 100644 index 000000000..bbaa223e8 --- /dev/null +++ b/lib/ui2/pairing/device_slot_card.dart @@ -0,0 +1,58 @@ +// S5: a paired device's own card, said plainly: where it fits ("Every day" or +// "Workouts only"), and THE table the pairing sheets show, with the column it +// is not in faded. The note says which switch it waits for; the switch itself +// sits right under the card (DeviceUseSwitch). +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../ble/slot_comparison.dart'; +import '../ui2.dart'; +import 'pair_text.dart'; +import 'slot_comparison_view.dart'; + +/// The column [e] goes into when it is paired beside (or instead of) the +/// band: a ring or a watch fills every day, a workout sensor workouts only. +/// Null for a WHOOP (its own page), a Coros watch or a scale. +DeviceSlot? pairedDeviceSlot(BandEntry e) => + e.canBeMainDevice ? null : slotComparisonFor(e).slot; + +class DeviceSlotCard extends StatelessWidget { + final BandEntry entry; + final DeviceSlot slot; + + const DeviceSlotCard({super.key, required this.entry, required this.slot}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final other = slot == DeviceSlot.everyDay + ? DeviceSlot.workoutsOnly + : DeviceSlot.everyDay; + return Column( + key: const ValueKey('device-slot-card'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // A Wrap, not a Row: at 2x text the chip drops under the heading + // instead of overflowing beside it. + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: S.x2, + runSpacing: S.x2, + children: [ + Text(l.pairWhatItSends, style: F.head.copyWith(color: p.ink)), + slotPill(l, slot, key: const ValueKey('device-slot-pill')), + ], + ), + const SizedBox(height: S.x3), + SlotTable(comparison: slotComparisonFor(entry), dim: other), + slot == DeviceSlot.everyDay + ? PairNote(LucideIcons.flaskConical, l.pairWearableCardNote, + color: C.orange) + : PairNote(LucideIcons.info, l.pairStrapNote), + ], + ); + } +} diff --git a/lib/ui2/pairing/pair_band_view.dart b/lib/ui2/pairing/pair_band_view.dart new file mode 100644 index 000000000..a7ca73b0c --- /dev/null +++ b/lib/ui2/pairing/pair_band_view.dart @@ -0,0 +1,187 @@ +// First-run "Pair your band": the devices that can be the user's main band, +// each marked "Every day", the always-open "Trouble connecting?" card, and +// the ways out (a heart-rate strap only, skip). +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../ble/slot_comparison.dart'; +import '../../l10n/display_text.dart'; +import '../ui2.dart'; +import 'category_filter_pills.dart'; +import 'pair_help.dart'; +import 'pair_sheets.dart'; +import 'pair_text.dart'; +import 'pair_widgets.dart'; + +/// The devices the first run offers as the main band, in registry order. +List firstRunBandEntries() => [ + for (final e in kBandRegistry) + if (e.canBeMainDevice) e, +]; + +/// What the first-run list asks of the screen around it. +abstract interface class PairBandHost { + /// A device row was tapped. + void pickBand(BandEntry entry); + + /// The user confirmed pairing a heart-rate strap as a sensor (no band). + void strapOnlyConfirmed(); +} + +class PairBandView extends StatefulWidget { + final List entries; + final PairBandHost host; + + /// Non-null only at true first run: walk past pairing. + final VoidCallback? onSkip; + + /// Where the back chevron goes when there is no route underneath to pop + /// (first-run onboarding); null keeps the default `Navigator.maybePop`. + final VoidCallback? onBack; + + /// The last failure, in a sentence. + final String? problem; + final PairLinkOpener openLink; + + const PairBandView({ + super.key, + required this.entries, + required this.host, + this.onSkip, + this.onBack, + this.problem, + this.openLink = const SystemLinkOpener(), + }); + + @override + State createState() => _PairBandViewState(); +} + +class _PairBandViewState extends State { + final _group = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _group.addListener(() => setState(() {})); + } + + @override + void dispose() { + _group.dispose(); + super.dispose(); + } + + Future _strapOnly() async { + if (await showStrapSheet(context, sensor: kBleHrs)) { + widget.host.strapOnlyConfirmed(); + } + } + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final shown = filterByGroup(widget.entries, _group.value); + return Scaffold( + backgroundColor: p.bg, + body: SafeArea( + child: Column(children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: S.x4), + child: NavBar(l.pairSetUpTitle, onBack: widget.onBack), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(S.x4, S.x2, S.x4, S.x10), + children: [ + Text(l.pairBandTitle, + key: const ValueKey('pair-band-title'), + style: F.t1.copyWith(color: p.ink)), + const SizedBox(height: S.x2), + Text(l.pairBandSubtitle, style: F.body.copyWith(color: p.ink2)), + const SizedBox(height: S.x2), + Text(l.pairMainBandWhoopOnly, + key: const ValueKey('main-band-whoop-only-note'), + style: F.cap.copyWith(color: p.ink3)), + const SizedBox(height: S.x4), + CategoryFilterPills( + kinds: [for (final e in widget.entries) e.kind], + selection: _group, + ), + if (shown.isNotEmpty) const SizedBox(height: S.x3), + for (var i = 0; i < shown.length; i++) ...[ + if (i > 0) const SizedBox(height: S.x3), + _BandRow( + entry: shown[i], + onTap: () => widget.host.pickBand(shown[i]), + ), + ], + if (widget.problem != null) ...[ + const SizedBox(height: S.x4), + StatusCard(l.pairSensorThatDidNotWork, widget.problem!, + icon: LucideIcons.circleAlert), + ], + const SizedBox(height: S.x4), + TroubleConnectingCard( + entries: widget.entries, openLink: widget.openLink), + TextLinkButton(l.pairStrapOnlyLink, onTap: _strapOnly), + if (widget.onSkip != null) ...[ + BigButton(l.pairingSkipForNow, + color: C.blue, soft: true, onTap: widget.onSkip), + const SizedBox(height: S.x2), + Text(l.pairingSkipNote, + style: F.cap.copyWith(color: p.ink3)), + ], + ], + ), + ), + ]), + ), + ); + } +} + +class _BandRow extends StatelessWidget { + final BandEntry entry; + final VoidCallback onTap; + const _BandRow({required this.entry, required this.onTap}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final slot = slotComparisonFor(entry).slot; + return Surface( + key: ValueKey('band-row-${entry.id}'), + onTap: onTap, + child: Row(children: [ + Container( + width: 48, + height: 48, + alignment: Alignment.center, + decoration: BoxDecoration(color: p.card2, borderRadius: R.rMd), + child: Icon(deviceKindIcon(entry.kind), size: 22, color: p.ink2), + ), + const SizedBox(width: S.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(uiText(c, entry.label), + style: F.body + .copyWith(color: p.ink, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + Text(l.pairWhoopRowLine, style: F.cap.copyWith(color: p.ink2)), + if (slot != null) ...[ + const SizedBox(height: S.x2), + slotPill(l, slot), + ], + ]), + ), + Icon(LucideIcons.chevronRight, size: 18, color: p.ink3), + ]), + ); + } +} diff --git a/lib/ui2/pairing/pair_choice_sheet.dart b/lib/ui2/pairing/pair_choice_sheet.dart new file mode 100644 index 000000000..03cb444ff --- /dev/null +++ b/lib/ui2/pairing/pair_choice_sheet.dart @@ -0,0 +1,123 @@ +// The confirm sheet the pairing screens share: the device, a title and a lead, +// THE table with the device's column washed, a note, one filled main button, +// an optional second choice as a text link, and "Go back". The callers differ +// only in words, so there is one sheet. +import 'package:flutter/material.dart'; + +import '../../ble/slot_comparison.dart'; +import '../ui2.dart'; +import 'pair_widgets.dart'; +import 'slot_comparison_view.dart'; + +/// What the user picked on a [PairChoiceSheet]. +enum PairChoice { primary, secondary, back } + +class PairChoiceSheet extends StatelessWidget { + /// The device's tile and its name ("WHOOP 5 · 7C21", "Oura Ring"). + final IconData deviceIcon; + final String deviceName; + + final String title; + final String? lead; + final SlotComparison comparison; + + /// The column the device goes into; washed on the table. + final DeviceSlot? focus; + + /// Icon-and-caption notes under the table. + final List notes; + + /// "Put it in pairing mode", above the main button; null shows none. + final Widget? pairingMode; + + final String primaryLabel; + final IconData? primaryIcon; + + /// The second choice, a text link under the button. Null hides it. + final String? secondaryLabel; + final String backLabel; + + const PairChoiceSheet({ + super.key, + required this.deviceIcon, + required this.deviceName, + required this.title, + this.lead, + required this.comparison, + this.focus, + this.notes = const [], + this.pairingMode, + required this.primaryLabel, + this.primaryIcon, + this.secondaryLabel, + required this.backLabel, + }); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + void pop(PairChoice v) => Navigator.of(c).pop(v); + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row(children: [ + Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: + BoxDecoration(color: p.card2, borderRadius: R.rSm), + child: Icon(deviceIcon, size: 16, color: p.ink2), + ), + const SizedBox(width: S.x2), + Expanded( + child: Text(deviceName, + key: const ValueKey('choice-device'), + style: F.cap.copyWith( + color: p.ink2, fontWeight: FontWeight.w600)), + ), + ]), + const SizedBox(height: S.x3), + Text(title, + key: const ValueKey('choice-title'), + style: F.t1.copyWith(color: p.ink)), + if (lead != null) ...[ + const SizedBox(height: S.x2), + Text(lead!, style: F.body.copyWith(color: p.ink2)), + ], + const SizedBox(height: S.x4), + SlotTable(comparison: comparison, focus: focus), + ...notes, + if (pairingMode != null) ...[ + const SizedBox(height: S.x5), + pairingMode!, + ], + const SizedBox(height: S.x5), + BigButton(primaryLabel, + key: const ValueKey('choice-primary'), + icon: primaryIcon, + onTap: () => pop(PairChoice.primary)), + if (secondaryLabel != null) + TextLinkButton(secondaryLabel!, + onTap: () => pop(PairChoice.secondary)), + TextLinkButton(backLabel, onTap: () => pop(PairChoice.back)), + ]), + ), + ); + } +} + +/// Shows [sheet] from the bottom; null when dismissed without a choice. +Future showPairChoiceSheet(BuildContext c, PairChoiceSheet sheet) { + final p = P.of(c); + return showModalBottomSheet( + context: c, + backgroundColor: p.card, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => sheet, + ); +} diff --git a/lib/ui2/pairing/pair_help.dart b/lib/ui2/pairing/pair_help.dart new file mode 100644 index 000000000..9a364ca19 --- /dev/null +++ b/lib/ui2/pairing/pair_help.dart @@ -0,0 +1,156 @@ +// H1: "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_outcome_text.dart b/lib/ui2/pairing/pair_outcome_text.dart new file mode 100644 index 000000000..655f5aff9 --- /dev/null +++ b/lib/ui2/pairing/pair_outcome_text.dart @@ -0,0 +1,16 @@ +import '../../ble/band_identity.dart'; +import '../../l10n/app_localizations.dart'; +import '../../l10n/display_text.dart' show localizedText; + +/// The sentence for a failed or refused pairing, in the user's language; null +/// when the device was saved. [l] is null only where no localizations are +/// mounted (a bare test widget), which falls back to English. +String? pairOutcomeText(AppLocalizations? l, PairOutcome o) { + final b = o.isBand; + if (b != null) { + return l?.pairBandNotSensor(localizedText(l, b.label)) ?? + 'That device is a ${b.label}, a band, not a plain sensor. ' + 'Nothing was saved.'; + } + return o.error; +} diff --git a/lib/ui2/pairing/pair_sheets.dart b/lib/ui2/pairing/pair_sheets.dart new file mode 100644 index 000000000..9d0f08645 --- /dev/null +++ b/lib/ui2/pairing/pair_sheets.dart @@ -0,0 +1,189 @@ +// The confirm sheets of the "Add a device" flow, all built on one +// PairChoiceSheet with THE table: +// - a WHOOP: "Pair it as my band" (or, with a band already paired, "Unpair +// and pair this one" and its one confirm), and "Use it for workouts +// only"; +// - a ring or a watch: "Every day" washed, the experimental note, "Pair (it)"; +// - a heart-rate strap with no band paired: "Workouts only" washed. +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/adapters/_registry.dart'; +import '../../ble/slot_comparison.dart'; +import '../../l10n/display_text.dart'; +import '../ui2.dart'; +import 'pair_choice_sheet.dart'; +import 'pair_text.dart'; +import 'pair_widgets.dart'; +import 'reset_steps_view.dart' show PairingModeSection; +import 'slot_comparison_view.dart'; + +/// What the user picked on the band-found sheet. Going back is null. +enum BandSheetChoice { + /// Pair it as the main band (no band paired yet). + asBand, + + /// Unpair the band paired now, then pair this one as the main band. Only + /// offered with a band paired; the caller confirms once before acting. + replaceBand, + + /// Pair it as itself, not as the main band: a WHOOP kept for workouts only + /// (read as a heart-rate sensor), or a ring or a watch. + asSensor, +} + +/// "This is a WHOOP band" (or a ring, or a watch). [afterConnect] is the same +/// sheet after the connect found the band (nothing was saved), with the +/// sentence that says so. [modelKnown] false: only the name matched, so the +/// sheet names no model. [deviceName] is the advertised name, when the scan +/// heard one. [mainBandName] names the band paired now ([hasMainBand]). +Future showBandFoundSheet( + BuildContext c, { + required BandEntry band, + required bool hasMainBand, + String? mainBandName, + bool afterConnect = false, + bool modelKnown = true, + String? deviceName, +}) async { + final l = pairL10n(c); + final label = modelKnown ? uiText(c, band.label) : 'WHOOP'; + final cmp = slotComparisonFor(band); + final icon = deviceKindIcon(band.kind); + final name = deviceName ?? label; + if (!band.canBeMainDevice) { + // A ring or a watch: it fills every day as the active wearable, behind + // its own switch; a Coros watch fits neither column. + final wearable = cmp.slot == DeviceSlot.everyDay; + final choice = await showPairChoiceSheet( + c, + PairChoiceSheet( + deviceIcon: icon, + deviceName: name, + title: deviceSheetTitle(l, band), + lead: afterConnect + ? l.pairIdentifiedBody(label) + : wearable + ? band.id == kPebble.id + ? l.pairWearableLeadPebble + : l.pairWearableLead + : null, + comparison: cmp, + focus: cmp.slot, + notes: [ + wearable + ? PairNote(LucideIcons.flaskConical, l.pairWearableNote, + color: C.orange) + : PairNote(LucideIcons.info, l.pairSensorExplainerCoros), + ], + pairingMode: PairingModeSection(entry: band), + primaryLabel: l.pairDeviceNamed(label), + primaryIcon: LucideIcons.bluetooth, + backLabel: l.pairGoBack, + ), + ); + return choice == PairChoice.primary ? BandSheetChoice.asSensor : null; + } + final current = mainBandName ?? l.pairCurrentBandFallback; + final choice = await showPairChoiceSheet( + c, + PairChoiceSheet( + deviceIcon: icon, + deviceName: name, + title: l.pairThisIsWhoop, + lead: afterConnect + ? l.pairIdentifiedBody(label) + : hasMainBand + ? l.pairBandFoundBodyHasBand + : l.pairBandFoundBody, + comparison: cmp, + focus: DeviceSlot.everyDay, + notes: [ + if (hasMainBand) PairNote(LucideIcons.watch, l.pairBandNow(current)), + ], + // A WHOOP heard by name only gets no model's steps. + pairingMode: modelKnown ? PairingModeSection(entry: band) : null, + primaryLabel: + hasMainBand ? l.pairReplaceBand(current) : l.pairAsMyBand, + primaryIcon: + hasMainBand ? LucideIcons.arrowLeftRight : LucideIcons.watch, + secondaryLabel: l.pairUseForWorkouts, + backLabel: l.pairGoBack, + ), + ); + return switch (choice) { + PairChoice.primary => + hasMainBand ? BandSheetChoice.replaceBand : BandSheetChoice.asBand, + PairChoice.secondary => BandSheetChoice.asSensor, + _ => null, + }; +} + +/// The one confirm before replacing the band: "Unpair (your band) and pair this +/// band instead? Your data stays." True only on the explicit yes. +Future showReplaceBandConfirm(BuildContext c, {String? mainBandName}) async { + final l = pairL10n(c); + final name = mainBandName ?? l.pairCurrentBandFallback; + final ok = await showDialog( + context: c, + builder: (d) { + final p = P.of(d); + return Dialog( + backgroundColor: p.card, + insetPadding: const EdgeInsets.symmetric(horizontal: S.x6), + shape: RoundedRectangleBorder( + borderRadius: R.rXl, side: BorderSide(color: p.line)), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(S.x5, S.x5, S.x5, S.x2), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(LucideIcons.arrowLeftRight, size: 24, color: p.ink2), + const SizedBox(height: S.x3), + Text(l.pairReplaceConfirmTitle(name), + key: const ValueKey('replace-confirm-title'), + style: F.t2.copyWith(color: p.ink)), + const SizedBox(height: S.x2), + Text(l.pairReplaceConfirmBody, + style: F.body.copyWith(color: p.ink2)), + const SizedBox(height: S.x5), + BigButton(l.pairReplaceConfirmYes, + key: const ValueKey('replace-confirm-yes'), + onTap: () => Navigator.of(d).pop(true)), + TextLinkButton(l.actionCancel, + onTap: () => Navigator.of(d).pop(false)), + ]), + ), + ); + }, + ); + return ok == true; +} + +/// "This is a heart-rate strap": "Workouts only" washed, the switch note and +/// "Pair (it)". True when the user chose to go ahead. [deviceName] is the +/// advertised name, when the scan heard one. +Future showStrapSheet(BuildContext c, + {required BandEntry sensor, String? deviceName}) async { + final l = pairL10n(c); + final label = uiText(c, sensor.label); + final cmp = slotComparisonFor(sensor); + final choice = await showPairChoiceSheet( + c, + PairChoiceSheet( + deviceIcon: deviceKindIcon(sensor.kind), + deviceName: deviceName ?? label, + title: deviceSheetTitle(l, sensor), + lead: l.pairStrapLead, + comparison: cmp, + focus: DeviceSlot.workoutsOnly, + notes: [PairNote(LucideIcons.info, l.pairStrapNote)], + pairingMode: PairingModeSection(entry: sensor), + primaryLabel: l.pairDeviceNamed(label), + primaryIcon: LucideIcons.bluetooth, + backLabel: l.pairGoBack, + ), + ); + return choice == PairChoice.primary; +} diff --git a/lib/ui2/pairing/pair_text.dart b/lib/ui2/pairing/pair_text.dart new file mode 100644 index 000000000..e9516cdcc --- /dev/null +++ b/lib/ui2/pairing/pair_text.dart @@ -0,0 +1,115 @@ +// 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 '../../ble/slot_comparison.dart'; +import '../../l10n/app_localizations.dart'; +import '../ui2.dart'; + +/// Localizations for [c]; English when none are mounted (a bare test widget), +/// the same fallback the rest of the pairing copy has. +AppLocalizations pairL10n(BuildContext c) => + AppLocalizations.of(c) ?? lookupAppLocalizations(const Locale('en')); + +String capabilityLabel(AppLocalizations l, DeviceCapability c) => switch (c) { + DeviceCapability.sleep => l.capLabelSleep, + DeviceCapability.recovery => l.capLabelRecovery, + DeviceCapability.strain => l.capLabelStrain, + DeviceCapability.heartRate => l.capLabelHeartRate, + DeviceCapability.hrv => l.capLabelHrv, + DeviceCapability.respiration => l.capLabelRespiration, + DeviceCapability.skinTemperature => l.capLabelSkinTemperature, + DeviceCapability.steps => l.capLabelSteps, + DeviceCapability.bloodOxygen => l.capLabelBloodOxygen, + DeviceCapability.workoutHeartRate => l.capLabelWorkoutHeartRate, + DeviceCapability.workoutBeatToBeat => l.capLabelWorkoutBeatToBeat, +}; + +/// The one-line caption under a capability, or null when it has none. +String? capabilityReason(AppLocalizations l, CapabilityStatus s) => + switch (s.kind) { + CapabilityKind.native => null, + CapabilityKind.vendorReported => l.capVendorReported, + CapabilityKind.conditional => switch (s.reasonKey) { + kReasonSkinTempNights => l.capReasonSkinTempNights, + kReasonEstimated => l.capReasonEstimated, + kReasonEarly => l.capReasonEarly, + kReasonFirstNights => l.capReasonFirstNights, + kReasonOwnStagesRing => l.capReasonOwnStagesRing, + kReasonOwnStages => l.capReasonOwnStages, + kReasonLiveSessions => l.capReasonLiveSessions, + kReasonUnconfirmed => l.capReasonUnconfirmed, + kReasonPebble2Only => l.capReasonPebble2Only, + kReasonPebble2OnlyEstimated => l.capReasonPebble2OnlyEstimated, + _ => null, + }, + }; + +/// The column's name: "Every day" or "Workouts only". +String slotLabel(AppLocalizations l, DeviceSlot s) => switch (s) { + DeviceSlot.everyDay => l.slotEveryDay, + DeviceSlot.workoutsOnly => l.slotWorkoutsOnly, +}; + +/// Each column's colour and icon, the same on the table, the chips and the +/// device card. +Color slotColor(DeviceSlot s) => switch (s) { + DeviceSlot.everyDay => C.teal, + DeviceSlot.workoutsOnly => C.purple, +}; + +IconData slotIcon(DeviceSlot s) => switch (s) { + DeviceSlot.everyDay => LucideIcons.sun, + DeviceSlot.workoutsOnly => LucideIcons.dumbbell, +}; + +/// The chip that says where a device fits. +Widget slotPill(AppLocalizations l, DeviceSlot s, {Key? key}) => + Pill(slotLabel(l, s), slotColor(s), icon: slotIcon(s), key: key); + +/// The filter pills' groups (K3): what the user would call the device. +/// A WHOOP is its own group; a Mi Band sits with the watches. Scales and +/// thermometers have no group and show under All only. +enum PairFilter { whoop, rings, watches, straps } + +PairFilter? pairFilterOf(DeviceKind k) => switch (k) { + DeviceKind.strap => PairFilter.whoop, + DeviceKind.ring => PairFilter.rings, + DeviceKind.watch || DeviceKind.band => PairFilter.watches, + DeviceKind.chestStrap || DeviceKind.armband => PairFilter.straps, + DeviceKind.scale || DeviceKind.thermometer || DeviceKind.other => null, +}; + +String pairFilterLabel(AppLocalizations l, PairFilter f) => switch (f) { + PairFilter.whoop => 'WHOOP', + PairFilter.rings => l.pairKindRing, + PairFilter.watches => l.pairKindWatch, + PairFilter.straps => l.pairKindStrap, +}; + +/// The sheet title for [e]: "This is a WHOOP band", "This is a ring", …. +String deviceSheetTitle(AppLocalizations l, BandEntry e) => switch (e.kind) { + _ when e.canBeMainDevice => l.pairThisIsWhoop, + 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..c2dbb779b --- /dev/null +++ b/lib/ui2/pairing/reset_steps_view.dart @@ -0,0 +1,216 @@ +// 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 when the device's own steps lack it. +List resetStepKeysFor(BandEntry e, {bool bondRefused = false}) { + final r = e.resetSteps; + final keys = r != null && r.kind == ResetKind.specific && r.keys!.isNotEmpty + ? r.keys! + : kGenericResetStepKeys; + const forget = 'pairResetGenericStep2'; + return bondRefused && !keys.contains(forget) ? [...keys, forget] : 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.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/lib/ui2/pairing/slot_comparison_view.dart b/lib/ui2/pairing/slot_comparison_view.dart new file mode 100644 index 000000000..2b85f5f23 --- /dev/null +++ b/lib/ui2/pairing/slot_comparison_view.dart @@ -0,0 +1,198 @@ +// C1: THE table of what a device gives "Every day" and "Workouts only": a row +// per capability, a check on a tint where it sends, a muted cross where it +// does not (never a dash). The pairing sheets wash the column the device goes +// into; the device page fades the column it is not in. One widget, one file, +// so the comparison style can be swapped without touching a screen. The facts +// come from `slotComparisonFor`; this only draws. +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../ble/slot_comparison.dart'; +import '../ui2.dart'; +import 'pair_text.dart'; + +/// One table row: null in a column means that column does not send it. +typedef SlotRow = ({ + DeviceCapability capability, + CapabilityStatus? everyDay, + CapabilityStatus? workouts, +}); + +/// The rows of [c]: every row of the table ([kTableRows]), in order. +List slotRowsFor(SlotComparison c) => [ + for (final k in kTableRows) + ( + capability: k, + everyDay: c.everyDay.sends[k], + workouts: c.workoutsOnly.sends[k], + ), +]; + +class SlotTable extends StatelessWidget { + final SlotComparison comparison; + + /// The column the device goes into, washed in its colour (the sheets). + final DeviceSlot? focus; + + /// The column the device is not in, faded (the device page). + final DeviceSlot? dim; + + const SlotTable({super.key, required this.comparison, this.focus, this.dim}); + + static const double colWidth = 104; + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + Widget cell(DeviceSlot s, Widget child) => Container( + key: ValueKey('slot-cell-${s.name}'), + width: colWidth, + alignment: Alignment.center, + color: focus == s ? p.wash(slotColor(s), strength: .55) : null, + child: Opacity(opacity: dim == s ? .28 : 1, child: child), + ); + Widget line(Widget label, Widget a, Widget b, + {Key? key, bool top = true, double v = S.x3}) => + Container( + key: key, + decoration: BoxDecoration( + border: top ? Border(top: BorderSide(color: p.line)) : null), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB(S.x4, v, S.x2, v), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: label), + ), + ), + cell(DeviceSlot.everyDay, a), + cell(DeviceSlot.workoutsOnly, b), + ]), + ), + ); + Widget head(DeviceSlot s) => Padding( + // Room above the icons, so they do not sit on the table's edge. + padding: const EdgeInsets.fromLTRB(S.x1, S.x3, S.x1, S.x2), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(slotIcon(s), size: 16, color: p.on(slotColor(s))), + const SizedBox(height: 3), + Text(slotLabel(l, s), + key: ValueKey('slot-head-${s.name}'), + textAlign: TextAlign.center, + style: F.cap.copyWith(color: p.ink, fontWeight: FontWeight.w700)), + ]), + ); + Widget row(SlotRow r) { + final k = r.capability.name; + final reasons = []; + for (final (slot, status) in [ + (DeviceSlot.everyDay, r.everyDay), + (DeviceSlot.workoutsOnly, r.workouts), + ]) { + final reason = status == null ? null : capabilityReason(l, status); + if (reason != null) { + reasons.add(l.slotColumnReason(slotLabel(l, slot), reason)); + } + } + return line( + key: ValueKey('slot-row-$k'), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(capabilityLabel(l, r.capability), + style: F.body.copyWith(color: p.ink)), + for (final reason in reasons) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(reason, style: F.cap.copyWith(color: p.ink3)), + ), + ]), + SlotMark(r.everyDay != null, capability: r.capability, + slot: DeviceSlot.everyDay, reason: r.everyDay?.kind == CapabilityKind.conditional + ? capabilityReason(l, r.everyDay!) : null, key: ValueKey('slot-everyDay-$k')), + SlotMark(r.workouts != null, capability: r.capability, + slot: DeviceSlot.workoutsOnly, reason: r.workouts?.kind == CapabilityKind.conditional + ? capabilityReason(l, r.workouts!) : null, key: ValueKey('slot-workoutsOnly-$k')), + ); + } + + final rows = slotRowsFor(comparison); + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: p.card2, borderRadius: R.rLg, border: Border.all(color: p.line)), + child: Column(children: [ + line(const SizedBox.shrink(), head(DeviceSlot.everyDay), + head(DeviceSlot.workoutsOnly), + top: false, v: S.x2), + // The workout rows name themselves ("… during workouts"); no group + // row. + for (final r in rows) row(r), + ]), + ); + } +} + +/// A check on a green tint where the column sends the row, a muted cross where +/// it does not. Announces the capability, column and status to a screen reader. +class SlotMark extends StatelessWidget { + final bool sends; + final DeviceCapability capability; + final DeviceSlot slot; + final String? reason; + const SlotMark(this.sends, + {super.key, required this.capability, required this.slot, this.reason}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final status = sends ? l.slotSendsHeading : l.slotDoesntSendHeading; + return Semantics( + label: l.slotCapabilityStatus(capabilityLabel(l, capability), + slotLabel(l, slot), reason == null ? status : '$status. $reason'), + excludeSemantics: true, + child: sends + ? Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: + BoxDecoration(color: p.wash(C.green), shape: BoxShape.circle), + child: Icon(LucideIcons.check, size: 15, color: p.on(C.green)), + ) + : SizedBox( + width: 24, + height: 24, + child: Icon(LucideIcons.x, size: 16, color: p.ink3)), + ); + } +} + +/// A caption with its icon, under a table: grey by default, or in [color]'s +/// ink (the orange "Experimental" note). +class PairNote extends StatelessWidget { + final IconData icon; + final String text; + final Color? color; + const PairNote(this.icon, this.text, {super.key, this.color}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final ink = color == null ? p.ink2 : p.on(color!); + return Padding( + padding: const EdgeInsets.only(top: S.x3), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(icon, size: 15, color: ink)), + const SizedBox(width: S.x2), + Expanded(child: Text(text, style: F.cap.copyWith(color: ink))), + ]), + ); + } +} diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 2bb2e1f75..3658ac7c7 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -85,6 +85,11 @@ import '../../sync/paired_device.dart' show cleanDeviceLabel; import '../../state/app_state.dart'; import '../../state/clock_format.dart' show formatClockOf; import '../pairing/device_picker.dart' show DevicePickerScreen; +import '../pairing/pair_text.dart' show pairL10n; +import '../../ble/slot_comparison.dart' show DeviceSlot; +import '../pairing/device_slot_card.dart' + show DeviceSlotCard, pairedDeviceSlot; +import '../pairing/pair_text.dart' show slotPill; import '../onboarding/profile_setup.dart' show formatDay; import '../ui2.dart'; import 'profile.dart'; @@ -1126,8 +1131,9 @@ class MyDevices extends StatelessWidget { } Widget _build(BuildContext c, AppState app, Set liveAdapterIds) { + final sources = rankSources(liveSources(app, liveAdapterIds: liveAdapterIds)); return MyDevicesView( - sources: rankSources(liveSources(app, liveAdapterIds: liveAdapterIds)), + sources: sources, // The band row's dot says connected or not. That covers six different // problems with six different fixes, and a user cannot fix a problem the // app will not name — so the engine's own verdict rides alongside it. @@ -1141,6 +1147,10 @@ class MyDevices extends StatelessWidget { // way back to pairing. So push it. onPair: () => goto(c, const RePair()), onAddSensor: () => addSensor(c), + onUnpair: () { + final band = sources.where((s) => s.isBand).firstOrNull; + _confirmForget(c, app, band?.displayName(c) ?? ''); + }, contendedSignals: contendedSignals(app), onSignalPriority: () => goto(c, const SignalPriorityScreen()), ); @@ -1464,6 +1474,9 @@ class MyDevicesView extends StatelessWidget { final List sources; final VoidCallback? onPair, onAddSensor; + /// "Unpair band" on the already-paired card; null hides the button. + final VoidCallback? onUnpair; + /// The band's own state, from `bandStatusFor`. Null when nothing is paired. final BandStatus? status; @@ -1478,6 +1491,7 @@ class MyDevicesView extends StatelessWidget { this.sources = const [], this.onPair, this.onAddSensor, + this.onUnpair, this.status, this.contendedSignals = const [], this.onSignalPriority, @@ -1500,7 +1514,6 @@ class MyDevicesView extends StatelessWidget { // the toggle sits on while a denied READ permission returns nothing. So // "a source exists" was the wrong test for this card: it told a user // whose phone was measuring nothing that "the phone counts steps". - final phoneCounting = sources.any((s) => !s.isBand && s.connected); final l = AppLocalizations.of(c); return Scaffold( backgroundColor: p.bg, @@ -1514,31 +1527,10 @@ class MyDevicesView extends StatelessWidget { child: ListView( padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10), children: [ - // FIRST, in the tier-2 slot the missing band would occupy — - // not appended under the phone. Someone who has just forgotten - // a band is here to add one, and the row they can see is not - // the one they came for. - if (!hasBand) ...[ - StatusCard( - phoneCounting - ? (l?.devicesNoBandPaired ?? 'No band is paired') - : (l?.devicesNothingMeasuringYet ?? - 'Nothing is measuring yet'), - phoneCounting - ? (l?.devicesPhoneCountingBody ?? - 'The phone counts steps and nothing else. Heart ' - 'rate, sleep, recovery and temperature all abstain ' - 'until a band is paired.') - : (l?.devicesNothingMeasuringBody ?? - 'No band is paired and no phone steps are arriving, ' - 'so every metric in the app will abstain rather ' - 'than estimate.'), - fix: l?.devicesPairABand ?? 'Pair a band', - icon: LucideIcons.watch, - onFix: onPair, - ), - if (sources.isNotEmpty) const SizedBox(height: S.x3), - ], + // "Pair a device": the band first (or who it is, when one is + // paired), then "Add a device" for anything else. + ..._pairSection(c, hasBand), + if (sources.isNotEmpty) const SizedBox(height: S.x3), // LIVE — the first of the two buckets. Only drawn when there is // something in it: a heading over nothing is the empty rung // problem again, one level up. @@ -1556,19 +1548,6 @@ class MyDevicesView extends StatelessWidget { ], const SizedBox(height: S.x3), ], - // At the foot of LIVE, because that is what it adds to. Not - // gated on having a band: a sensor is a second source, and - // someone with no band at all is exactly who benefits most - // from being able to pair one. - if (onAddSensor != null) - Surface( - pad: const EdgeInsets.symmetric(horizontal: S.x4), - child: SetRow(LucideIcons.plus, C.green, - l?.devicesAddASensor ?? 'Add a sensor', - sub: l?.devicesAddASensorSub ?? - 'A heart-rate strap or a ring, alongside the band', - onTap: onAddSensor), - ), // ONLY when something can actually contend. A reorder screen // over one device is a control with nothing to order, which is // the empty-rung problem this screen already refuses (§6.5). @@ -1611,6 +1590,123 @@ class MyDevicesView extends StatelessWidget { } } +extension _PairSection on MyDevicesView { + /// The "Pair a device" section. Empty when there is nothing to show (a + /// fixture with no callbacks and no band). + List _pairSection(BuildContext c, bool hasBand) { + final l = pairL10n(c); + final band = sources.where((s) => s.isBand).firstOrNull; + final children = [ + // ONE card for the band, paired or not, so the section keeps its + // height when the state flips: the status and the action in one box. + if ((!hasBand && onPair != null) || (hasBand && band != null)) + _YourBandCard( + bandName: hasBand ? band?.displayName(c) : null, + bandState: hasBand && band != null + ? _localizedSourceState(c, band) + : null, + onPair: onPair, + onUnpair: onUnpair, + ), + if (onAddSensor != null) + Surface( + pad: const EdgeInsets.symmetric(horizontal: S.x4), + child: SetRow(LucideIcons.plus, C.green, l.pairAddDevice, + sub: l.pairAddDeviceSub, onTap: onAddSensor), + ), + ]; + if (children.isEmpty) return const []; + return [ + Section(l.pairSectionTitle, Column(children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: S.x3), + children[i], + ], + ])), + const SizedBox(height: S.x3), + ]; + } +} + +/// A paired device that fits one of the two columns: a ring or a watch +/// (every day, as the active wearable) or a workout sensor such as a +/// heart-rate strap (workouts only). Null for the band, the phone, a scale, a +/// thermometer or a Coros watch. A WHOOP the user kept for workouts is stored +/// under the heart-rate-sensor adapter, so it is a workout sensor too. +({BandEntry entry, DeviceSlot slot})? slotDeviceOf(HealthSource s) { + if (s.isBand || s.deviceId == null) return null; + final e = kBandRegistry.where((e) => e.id == s.family).firstOrNull; + final slot = e == null ? null : pairedDeviceSlot(e); + return slot == null ? null : (entry: e!, slot: slot); +} + +/// The column chip a source row carries: the band is every day; a paired +/// device its own column. +DeviceSlot? sourceSlot(HealthSource s) => + s.isBand ? DeviceSlot.everyDay : slotDeviceOf(s)?.slot; + +/// "Your band": no band yet ("No band is paired", the WHOOP-only line and +/// "Pair your band"), or the paired band (its name, its state and "Unpair +/// band"). Both states keep the same shape: a heading row, two lines, one +/// button. +class _YourBandCard extends StatelessWidget { + /// The paired band's name; null when no band is paired. + final String? bandName; + final String? bandState; + final VoidCallback? onPair, onUnpair; + const _YourBandCard( + {this.bandName, this.bandState, this.onPair, this.onUnpair}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final l = pairL10n(c); + final paired = bandName != null; + return Surface( + key: ValueKey(paired ? 'band-paired-card' : 'pair-band-card'), + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: S.x2, + runSpacing: S.x2, + children: [ + Text(l.devicesYourBand, style: F.head.copyWith(color: p.ink)), + slotPill(l, DeviceSlot.everyDay), + ], + ), + const SizedBox(height: S.x2), + Text(paired ? bandName! : l.devicesNoBandPaired, + key: const ValueKey('your-band-status'), + style: F.body.copyWith(color: p.ink, fontWeight: FontWeight.w600)), + const SizedBox(height: 2), + paired + ? Text(bandState ?? '', style: F.cap.copyWith(color: p.ink2)) + : Text(l.pairCardBandBody, + key: const ValueKey('main-band-whoop-only-note'), + style: F.cap.copyWith(color: p.ink2)), + if (paired ? onUnpair != null : onPair != null) ...[ + const SizedBox(height: S.x4), + paired + ? BigButton(l.pairUnpairBand, + icon: LucideIcons.unlink, + color: C.red, + soft: true, + onTap: onUnpair) + : BigButton(l.pairBandTitle, + icon: LucideIcons.bluetooth, onTap: onPair), + ], + ]), + ); + } +} + +@visibleForTesting +Future debugConfirmForgetBand( + BuildContext c, AppState app, String name) => + _confirmForget(c, app, name); + + /// The one line under a source's name. /// /// "Not connected" is a statement about a radio link, and the phone has none: @@ -1625,7 +1721,10 @@ String sourceState(HealthSource s) { // resting state and not a fault — and a sensor whose tier could not be named // falls back to the phone's rung, where this function would otherwise have // told the user their chest strap was reporting steps. - if (s.deviceId != null) { + // A ring or watch fills whole days, so a workout never arms it: it reads + // as a band does, by sync and link, below. + final wearable = slotDeviceOf(s)?.slot == DeviceSlot.everyDay; + if (s.deviceId != null && !wearable) { if (s.connected) return 'Streaming beats'; // A source with no rung supplies no signal, so there is nothing for a // workout to arm and nothing being derived. Say that, rather than promise @@ -1641,7 +1740,7 @@ String sourceState(HealthSource s) { String _localizedSourceState(BuildContext c, HealthSource s) { final l = AppLocalizations.of(c); - if (s.deviceId != null) { + if (s.deviceId != null && slotDeviceOf(s)?.slot != DeviceSlot.everyDay) { if (s.connected) return l?.devicesStreamingBeats ?? 'Streaming beats'; return s.tier == null ? (l?.devicesStoringWhatItSends ?? 'Paired · storing what it sends') @@ -1669,7 +1768,11 @@ class SourceRow extends StatelessWidget { final p = P.of(c); final battery = s.batteryPct; final stacked = bigText(c); - final tierBadge = s.tier == null + // A secondary sensor is named by its role on its own page; a rung and an + // "Experimental" word on the row said nothing about what it sends. + final slot = sourceSlot(s); + final secondary = slotDeviceOf(s) != null; + final tierBadge = s.tier == null || secondary ? null : Pill( AppLocalizations.of(c)?.devicesTierRank(s.tier!.rank) ?? @@ -1740,11 +1843,16 @@ class SourceRow extends StatelessWidget { // IN THE WRAP, not a second trailing pill: this line already // wraps at accessibility sizes, and a pill column beside it does // not. The band's own page carries the explanation. - if (s.experimental) + if (s.experimental && !secondary) Text(AppLocalizations.of(c)?.devicesExperimental ?? 'Experimental', style: F.over.copyWith(color: p.on(C.orange))), ], ), + if (slot != null) ...[ + const SizedBox(height: S.x2), + slotPill(pairL10n(c), slot, + key: ValueKey('source-slot-${slot.name}')), + ], if (stacked && tierBadge != null) ...[ const SizedBox(height: S.x1), tierBadge, @@ -2416,7 +2524,12 @@ class DeviceDetailView extends StatelessWidget { fix: fault.fix ?? '', icon: LucideIcons.bluetoothOff), const SizedBox(height: S.x5), ], - if (s.tier case final t?) ...[ + if (slotDeviceOf(s) case final d?) ...[ + DeviceSlotCard(entry: d.entry, slot: d.slot), + const SizedBox(height: S.x4), + // The switch the card's note names, right under it. + DeviceUseSwitch(deviceId: s.deviceId!, adapterId: d.entry.id), + ] else if (s.tier case final t?) ...[ TierRow(t, filled: true), const SizedBox(height: S.x5), ], @@ -2432,23 +2545,28 @@ class DeviceDetailView extends StatelessWidget { Surface( pad: const EdgeInsets.symmetric(horizontal: S.x4), child: Column(children: [ - SetRow(LucideIcons.flaskConical, C.orange, - l?.devicesSupport ?? 'Support', - value: l?.devicesExperimental ?? 'Experimental', - sub: l?.devicesSensorExperimentalSub ?? - 'Decoded from the protocol, never checked ' - 'against the hardware — nobody here owns one.', - chevron: false), - Divider(color: p.line, height: 1), - SetRow(LucideIcons.database, C.teal, - l?.devicesWhatItDoes ?? 'What it does', - sub: s.tier == null - ? (l?.devicesWhatItDoesStored ?? - 'Everything it sends is saved under its name. Scores from it are experimental and off until it’s been checked against the hardware.') - : (l?.devicesWhatItDoesBeats ?? - 'Heartbeat timing is saved under its name, during workouts and a few minutes after. Scoring workouts from it is experimental and off for now.'), - chevron: false), - Divider(color: p.line, height: 1), + if (slotDeviceOf(s) == null) ...[ + SetRow(LucideIcons.flaskConical, C.orange, + l?.devicesSupport ?? 'Support', + value: l?.devicesExperimental ?? 'Experimental', + sub: l?.devicesSensorExperimentalSub ?? + 'Decoded from the protocol, never checked ' + 'against the hardware — nobody here owns one.', + chevron: false), + Divider(color: p.line, height: 1), + ], + // The every-day card above already describes wearables. + if (slotDeviceOf(s)?.slot != DeviceSlot.everyDay) ...[ + SetRow(LucideIcons.database, C.teal, + l?.devicesWhatItDoes ?? 'What it does', + sub: s.tier == null + ? (l?.devicesWhatItDoesStored ?? + 'Everything it sends is saved under its name. Scores from it are experimental and off until it’s been checked against the hardware.') + : (l?.devicesWhatItDoesBeats ?? + 'Heartbeat timing is saved under its name, during workouts and a few minutes after. Scoring workouts from it is experimental and off for now.'), + chevron: false), + Divider(color: p.line, height: 1), + ], SetRow(LucideIcons.refreshCw, C.purple, l?.devicesLastData ?? 'Last data', value: last == null ? '' : formatDayTime(last, l), @@ -2501,7 +2619,8 @@ class DeviceDetailView extends StatelessWidget { ), ), const SizedBox(height: S.x5), - if (s.family case final family?) + if (s.family case final family? + when slotDeviceOf(s) == null) DeviceUseSwitch( deviceId: s.deviceId!, adapterId: family), ], diff --git a/lib/ui2/profile/pair_sensor.dart b/lib/ui2/profile/pair_sensor.dart index cfecbc300..47eb2bc17 100644 --- a/lib/ui2/profile/pair_sensor.dart +++ b/lib/ui2/profile/pair_sensor.dart @@ -57,15 +57,21 @@ import 'package:provider/provider.dart'; import '../../ble/accessory_setup.dart'; import '../../ble/adapters/_registry.dart'; import '../../ble/band_status_l10n.dart' show localizedBandStatus; +import '../../ble/band_identity.dart' + show BandIdentity, PairConfirmation, PairOutcome; import '../../ble/ble_state.dart' show BleUnavailableException, bandStatusFor, classifyBleBlocker; import '../../ble/hrs_link.dart'; import '../../ble/ios_ble_restore.dart'; +import '../../ble/oura_link.dart' show pairOuraRingWithTypedKey; import '../../data/db.dart' show LocalDb; import '../../l10n/app_localizations.dart'; +import '../onboarding/pairing.dart' show PairingScreen; +import '../pairing/add_device_flow.dart'; +import '../pairing/pair_outcome_text.dart'; import '../../state/app_state.dart'; import '../ui2.dart'; -import 'devices.dart' show refuseIfSensorCapReached; +import 'devices.dart' show kPairableSensors, refuseIfSensorCapReached; import 'profile.dart'; import 'wearable_numbers.dart' show WearableUnlocks; @@ -110,7 +116,8 @@ class PairSensorScreen extends StatefulWidget { State createState() => _PairSensorScreenState(); } -class _PairSensorScreenState extends State { +class _PairSensorScreenState extends State + implements AddDeviceIo { List _found = const []; bool _scanning = false; @@ -279,9 +286,11 @@ class _PairSensorScreenState extends State { } } - Future _pick(BandCandidate c) => _pickDevice(c.device, label: c.label); + Future _pick(BandCandidate c) => + _pickDevice(c.device, label: c.label, earlierBand: c.alsoBand); - Future _pickDevice(BluetoothDevice device, {required String? label}) async { + Future _pickDevice(BluetoothDevice device, + {required String? label, BandIdentity? earlierBand}) async { // THE exact cap check. This screen stays open after a pair and offers // "Pair another", and every route into it (Add sensor, onboarding, // re-pair) only checked on the way in. The minted id is the row only @@ -320,11 +329,7 @@ class _PairSensorScreenState extends State { ? await widget.onPickedWithKey!(device, typedKey) : widget.onPicked != null ? await widget.onPicked!(device) - : await HrsLink.pairNotifySensor( - widget.entry, - device, - label: label, - ); + : await _pairPlain(device, label, earlierBand); } catch (e) { failure = l?.pairSensorCouldNotPair(e.toString()) ?? 'Could not pair that device: $e'; @@ -343,6 +348,85 @@ class _PairSensorScreenState extends State { if (failure == null) await _load(); } + /// The plain notify-class pairing. A connect that finds the device is a band + /// (nothing was saved) goes through the same confirmation a nearby row in + /// "Add a device" gets, so no path pairs a band by accident. + Future _pairPlain( + BluetoothDevice device, String? label, BandIdentity? earlierBand) async { + final l = AppLocalizations.of(context); + final outcome = await HrsLink.pairNotifySensor(widget.entry, device, + label: label, earlierBand: earlierBand); + if (outcome.isBand == null || !mounted) return pairOutcomeText(l, outcome); + final cand = ( + device: device, + label: label, + rssi: null, + entryId: widget.entry.id, + alsoBand: earlierBand, + ); + final r = await AddDeviceFlow(this) + .afterConnect(context, cand, widget.entry, outcome); + if (!mounted) return null; + return afterBandFoundText(l, outcome, r, + bandPairedNow: context.read().isPaired); + } + + @override + bool get hasMainBand => context.read().isPaired; + + @override + String? get mainBandName => context.read().strapName; + + /// The same unpair Settings' "Unpair band" runs (`AppState.unpair`). + @override + Future unpairMainBand() => context.read().unpair(); + + @override + Future pair(BandCandidate cand, BandEntry entry, + PairConfirmation? confirmation) async { + try { + return await HrsLink.pairNotifySensor(entry, cand.device, + label: cand.label, + earlierBand: cand.alsoBand, + confirmation: confirmation); + } catch (e) { + return PairOutcome.failed(AppLocalizations.of(context) + ?.pairSensorCouldNotPair(e.toString()) ?? + 'Could not pair that device: $e'); + } + } + + @override + Future stopScan() => HrsLink.stopScanIfRunning(this); + + @override + Future mainBandBlockedReason() => HrsLink.scanHeldBackReason(); + + @override + Future openBandFlow(BandEntry band, + {required String remoteId, bool modelKnown = true}) async { + if (!band.isFramed) { + // A ring pairs only on its own screen, with its key (the same screen + // "Add a device" opens for it). + final sensor = + kPairableSensors.where((s) => s.entry.id == band.id).firstOrNull; + if (sensor == null) return; + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PairSensorScreen( + entry: band, + onPicked: sensor.pick, + onPickedWithKey: + band.id == kOura.id ? pairOuraRingWithTypedKey : null, + ), + )); + return; + } + await Navigator.of(context).push(MaterialPageRoute( + builder: (_) => + PairingScreen(entry: modelKnown ? band : null, remoteId: remoteId), + )); + } + Future _forget(String id) async { await HrsLink.forgetDevice(id); if (!mounted) return; @@ -701,3 +785,28 @@ class PairSensorView extends StatelessWidget { ); } } + +/// What this screen says after a connect found a band and the user chose +/// what to do ([AddDeviceFlow.afterConnect]); null says nothing went wrong. +/// The main-band flow that opened may have paired the band: then +/// "Nothing was saved" would be false. A ring's own screen reported its own +/// outcome. [bandPairedNow] is whether a main band is paired once the flow +/// closed. +@visibleForTesting +String? afterBandFoundText( + AppLocalizations? l, + PairOutcome outcome, + AddDeviceResult r, { + required bool bandPairedNow, +}) => + switch (r.outcome) { + AddDeviceOutcome.pairedSecondary || AddDeviceOutcome.pairedOther => null, + AddDeviceOutcome.failed || AddDeviceOutcome.bandFlowHeldBack => r.message, + AddDeviceOutcome.openedBandFlow + when bandPairedNow || outcome.isBand?.isFramed == false => + null, + // Backed out, or the band flow did not pair it: nothing was saved. + AddDeviceOutcome.cancelled || + AddDeviceOutcome.openedBandFlow => + pairOutcomeText(l, outcome), + }; diff --git a/test/band_identity_test.dart b/test/band_identity_test.dart new file mode 100644 index 000000000..d5dfc3957 --- /dev/null +++ b/test/band_identity_test.dart @@ -0,0 +1,863 @@ +// Band cross-check: a sensor-scan hit that is really a band must be recognised. +import 'dart:io'; + +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/band_identity.dart'; +import 'package:openstrap_edge/ble/hrs_link.dart'; +import 'package:openstrap_edge/sync/paired_device.dart' show kBandSourceTier; +import 'package:openstrap_edge/ui2/pairing/add_device_flow.dart' show addDeviceScanEntries; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +BandEntry? _id(List svc, [String? name]) => + bandIdentityOf(advertisedServiceUuids: svc, name: name)?.entry; + +BandIdentity? _full(List svc, [String? name]) => + bandIdentityOf(advertisedServiceUuids: svc, name: name); + +void main() { + _gatt(); + _matching(); + _pairing(); + group('bandIdentityOf', () { + test('0x180D + WHOOP 4 service is gen4', () { + // WHY: the reported bug, a WHOOP 4 advertising 0x180D, filed as a strap. + expect(_id([kHeartRateServiceUuid, kWhoopGen4.service])?.id, 'gen4'); + }); + + test('0x180D + WHOOP 5 service is gen5', () { + // WHY: same bug on the WHOOP 5.0 / MG. + expect(_id([kHeartRateServiceUuid, kWhoopGen5.service])?.id, 'gen5'); + }); + + test('service match is case-insensitive and by 32-bit prefix', () { + // WHY: scan results carry upper/lower case and short forms; a framed band + // is identified by servicePrefix elsewhere in the registry. + expect(_id([kWhoopGen4.service.toUpperCase()])?.id, 'gen4'); + final sameFamily = '${kWhoopGen4.servicePrefix}-1111-2222-3333-444455556666'; + expect(_id([sameFamily])?.id, 'gen4'); + }); + + test('a service match settles the model', () { + // WHY: only a service tells gen4 from gen5; the UI may then name the model. + expect(_full([kHeartRateServiceUuid, kWhoopGen4.service])?.modelCertain, isTrue); + expect(_full([kHeartRateServiceUuid, kWhoopGen5.service])?.modelCertain, isTrue); + }); + + test("0x180D + \"Jordan's WHOOP\" is a WHOOP, model not certain", () { + // WHY: the owner names the band, so the name has no serial; it proves WHOOP, not which. + final r = _full([kHeartRateServiceUuid], "Jordan's WHOOP"); + expect(r, isNotNull); + expect(r!.modelCertain, isFalse); + expect({'gen4', 'gen5'}, contains(r.entry.id)); + }); + + test('name match is case-insensitive, anywhere in the name', () { + // WHY: users type any name; "whoop" can sit anywhere or in any case. + for (final n in ["jordan's whoop 4.0", 'WHOOP', 'My Whoop Band']) { + final r = _full([kHeartRateServiceUuid], n); + expect(r, isNotNull, reason: n); + expect(r!.modelCertain, isFalse, reason: n); + } + }); + + test('a serial-style name alone does not identify a band', () { + // WHY: "W 5AG0047521" (Discord) contains no "whoop"; do not guess from a serial shape. + expect(_id([kHeartRateServiceUuid], 'W 5AG0047521'), isNull); + }); + + test('only 0x180D and no name is not a band', () { + // WHY: a real chest strap must stay a sensor; never fabricate a band. + expect(_id([kHeartRateServiceUuid]), isNull); + expect(_id([kHeartRateServiceUuid], null), isNull); + expect(_id(const [], null), isNull); + }); + + test('only 0x180D with an ordinary strap name is not a band', () { + // WHY: names must be matched against band matchers only, not by guessing. + expect(_id([kHeartRateServiceUuid], 'Polar H10 12345678'), isNull); + expect(_id([kHeartRateServiceUuid], 'Polar H10 1234'), isNull); + }); + + test('Polar PMD service is a sensor, not a band', () { + // WHY: sensor-role entries never trigger the "this is a band" badge. + expect(_id([kPolarPmdService]), isNull); + expect(_id([kHeartRateServiceUuid, kPolarPmdService], 'Polar Sense AB12'), + isNull); + }); + + test('Coros service is the coros entry', () { + // WHY: Coros is a band; the check covers every band, not only WHOOP. + expect(_id([kCorosService])?.id, 'coros'); + }); + + test('a band service-data UUID alone identifies it (Garmin), name not needed', () { + // WHY: the name is user-set and may drop the brand; an entry's own + // service-data UUID is stronger. + final r = bandIdentityOf( + advertisedServiceUuids: [kHeartRateServiceUuid], + name: null, + serviceDataUuids: [kGarminServiceDataUuids.first]); + expect(r?.entry.id, 'garmin'); + expect(r?.modelCertain, isTrue); + }); + + test('service beats a misleading name: WHOOP 5 service, name "Polar H10"', () { + // WHY: the name is only a hint; a service match must win. + expect(_id([kWhoopGen5.service], 'Polar H10 1234')?.id, 'gen5'); + }); + + test('a band name alone is enough (Ultrahuman "UH_" name)', () { + // WHY: nameMatcher of ANY band entry counts, not just gen4's. + expect(_id(const [], 'UH_ABC123')?.id, 'ultrahuman'); + }); + }); + + group('HrsLink.candidateFromAd (sensor-scan per-advertisement seam)', () { + final sensors = [kBleHrs, kPolarPmd]; + BandCandidate? cand(List svc, String advName) => + HrsLink.candidateFromAd( + entries: sensors, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:01'), + serviceUuids: [for (final s in svc) Guid(s)], + advName: advName, + platformName: '', + rssi: -55, + ); + + test('(G3 r1 S4) the Add a device scan covers the framed WHOOP entries: a WHOOP-only advertisement is a candidate', () { + // WHY: a WHOOP that advertises only its own band service matched no + // sensor entry and was dropped, so the band could never be discovered + // from "Add a device". + final entries = addDeviceScanEntries(); + expect(entries.map((e) => e.id), containsAll(['gen4', 'gen5', 'ble_hrs', 'oura'])); + expect(entries.map((e) => e.id).toSet().length, kBandRegistry.length); + // (A by-name entry makes the OS filter empty: every advertisement is heard.) + final filter = HrsLink.scanServiceFilter(entries); + expect(filter.isEmpty || filter.contains(Guid(kWhoopGen5.service)), isTrue); + final c = HrsLink.candidateFromAd( + entries: entries, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:21'), + serviceUuids: [Guid(kWhoopGen5.service)], + advName: 'WHOOP 5A1B', + platformName: '', + rssi: -50, + ); + expect(c, isNotNull); + expect(c!.entryId, 'gen5'); + // Control: the same advertisement against the sensor list alone is dropped. + expect(cand([kWhoopGen5.service], 'WHOOP 5A1B'), isNull); + }); + + test('a WHOOP advertising 0x180D is a ble_hrs hit flagged alsoBand=gen4', () { + // WHY: the picker needs both facts: it matched the HR scan AND is a band. + final c = cand([kHeartRateServiceUuid, kWhoopGen4.service], 'WHOOP 4C01'); + expect(c, isNotNull); + expect(c!.entryId, 'ble_hrs'); + expect(c.alsoBand?.entry.id, 'gen4'); + expect(c.alsoBand?.modelCertain, isTrue); + expect(c.rssi, -55); + }); + + test('(CR r1) a Garmin company id on a 0x180D ad is a plain strap, not the watch', () async { + // WHY: 0x0087 names Garmin, not the watch. A Garmin heart-rate strap + // carrying it was flagged as the Garmin watch and refused at pairing. + final c = HrsLink.candidateFromAd( + entries: sensors, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:02'), + serviceUuids: [Guid(kHeartRateServiceUuid)], + advName: 'HRM-Pro:123456', + platformName: '', + rssi: -60, + manufacturerCompanyIds: [kGarminCompanyIds.first], + ); + expect(c?.entryId, 'ble_hrs'); + expect(c?.alsoBand, isNull); + final io = _FakeIo(() async => _strap()); + final out = await pairNotifyDevice( + entry: kBleHrs, remoteId: 'AA:BB:CC:DD:EE:02', io: io, + earlierBand: c?.alsoBand); + expect(out.saved, isTrue); + expect(io.writes, hasLength(1)); + // Control: the company id still finds a Garmin watch in a Garmin scan. + final watch = HrsLink.candidateFromAd( + entries: [kGarmin], + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:03'), + serviceUuids: const [], + advName: '', + platformName: '', + rssi: -60, + manufacturerCompanyIds: [kGarminCompanyIds.first], + ); + expect(watch?.entryId, kGarmin.id); + }); + + test('(CR r1) a framed band matched by its service prefix is a candidate', () { + // WHY: band identity matches a framed WHOOP by its 32-bit prefix; the + // entry match compared full UUIDs, so a WHOOP advertising a sibling + // UUID of its family was a band with no row in an unfiltered scan. + final sibling = '${kWhoopGen5.servicePrefix}-0000-1000-8000-00805f9b34fb'; + expect(sibling, isNot(kWhoopGen5.service)); + final c = HrsLink.candidateFromAd( + entries: kBandRegistry, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:04'), + serviceUuids: [Guid(sibling)], + advName: '', + platformName: '', + rssi: -60, + ); + expect(c?.entryId, 'gen5'); + }); + + test('(CR r1) a name-only WHOOP keeps its model uncertain', () { + // WHY: the name cannot tell WHOOP 4 from 5; the name-matched entry is + // gen4, and dropping the uncertain identity made it read as a WHOOP 4. + final c = HrsLink.candidateFromAd( + entries: kBandRegistry, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:05'), + serviceUuids: const [], + advName: 'WHOOP 4C0FFEE', + platformName: '', + rssi: -60, + ); + expect(c?.entryId, 'gen4'); + expect(c?.alsoBand?.entry.id, 'gen4'); + expect(c?.alsoBand?.modelCertain, isFalse); + // Control: a service settles the model, and the band is not "also". + final sure = HrsLink.candidateFromAd( + entries: kBandRegistry, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:06'), + serviceUuids: [Guid(kWhoopGen5.service)], + advName: 'WHOOP 4C0FFEE', + platformName: '', + rssi: -60, + ); + expect(sure?.entryId, 'gen5'); + expect(sure?.alsoBand, isNull); + }); + + test('(CR r1) a name that drops out keeps the row label over the OS cache', () { + // WHY: the OS-cached platform name can be stale; the row must not + // change under the user's finger. + final c = HrsLink.candidateFromAd( + entries: sensors, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:07'), + serviceUuids: [Guid(kHeartRateServiceUuid)], + advName: '', + platformName: 'Old Name', + rssi: -60, + previousLabel: 'Polar H10 1234', + ); + expect(c?.label, 'Polar H10 1234'); + // Control: with no earlier label the OS name is still used. + final first = HrsLink.candidateFromAd( + entries: sensors, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:08'), + serviceUuids: [Guid(kHeartRateServiceUuid)], + advName: '', + platformName: 'Old Name', + rssi: -60, + ); + expect(first?.label, 'Old Name'); + }); + + test('a chest strap has alsoBand null', () { + // WHY: no false "this is a band" badge on real sensors. + final c = cand([kHeartRateServiceUuid], 'Polar H10 12345678'); + expect(c, isNotNull); + expect(c!.entryId, 'ble_hrs'); + expect(c.alsoBand, isNull); + }); + + test('an advertisement no scanned entry claims yields no candidate', () { + // WHY: unrelated peripherals must not appear in the sensor list. + expect(cand(const [], 'Random Speaker'), isNull); + }); + + test('(S2) a service-data-only advertisement identifies a Mi scale and a Garmin', () { + // WHY: scanServiceData is registry Strings, the ad carries Guids; the + // two must be compared as Guids or service-data-only hits vanish. + final scale = HrsLink.candidateFromAd( + entries: [kMiScale2], + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:11'), + serviceUuids: const [], + advName: '', + platformName: '', + rssi: -70, + serviceDataUuids: [Guid(kMiScaleWeightService)], + ); + expect(scale?.entryId, kMiScale2.id); + final garmin = HrsLink.candidateFromAd( + entries: [kGarmin], + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:12'), + serviceUuids: const [], + advName: '', + platformName: '', + rssi: -70, + serviceDataUuids: [Guid(kGarminServiceDataUuids.first)], + ); + expect(garmin?.entryId, kGarmin.id); + }); + + test('(S1) band evidence from an earlier advertisement survives an HR-only one', () async { + // WHY: a WHOOP advertisement, then a bare 0x180D one for the same id. + // The candidate must still say "band", and pairing with standard-only + // discovery must refuse with zero writes (DT9). + final dev = BluetoothDevice.fromId('AA:BB:CC:DD:EE:21'); + final seen = {}; + BandCandidate? ad(List svc, String name) => + HrsLink.candidateFromAd( + entries: sensors, + device: dev, + serviceUuids: [for (final s in svc) Guid(s)], + advName: name, + platformName: '', + rssi: -50, + bandSeen: seen, + ); + final first = ad([kHeartRateServiceUuid, kWhoopGen4.service], 'WHOOP 4C01'); + expect(first?.alsoBand?.entry.id, 'gen4'); + final second = ad([kHeartRateServiceUuid], ''); + expect(second?.alsoBand?.entry.id, 'gen4'); + // A different id must not inherit it. + final other = HrsLink.candidateFromAd( + entries: sensors, + device: BluetoothDevice.fromId('AA:BB:CC:DD:EE:22'), + serviceUuids: [Guid(kHeartRateServiceUuid)], + advName: '', + platformName: '', + rssi: -50, + bandSeen: seen, + ); + expect(other?.alsoBand, isNull); + + final io = _FakeIo(() async => _strap()); + final out = await pairNotifyDevice( + entry: kBleHrs, + remoteId: dev.remoteId.str, + io: io, + earlierBand: second!.alsoBand, + ); + expect(out.isBand?.id, 'gen4'); + expect(io.writes, isEmpty); + }); + + test('(S1 r2) a band-only packet in an unfiltered scan still leaves evidence', () async { + // WHY: the unified picker scans unfiltered; a WHOOP-service-only packet + // matches no sensor entry and yields no row, but its evidence must stay + // for the later HR-only packet, or standard-only discovery saves it. + final dev = BluetoothDevice.fromId('AA:BB:CC:DD:EE:24'); + final seen = {}; + BandCandidate? ad(List svc) => HrsLink.candidateFromAd( + entries: sensors, + device: dev, + serviceUuids: [for (final s in svc) Guid(s)], + advName: '', + platformName: '', + rssi: -50, + bandSeen: seen, + unfilteredScan: true, + ); + expect(ad([kWhoopGen5.service]), isNull); + final hrOnly = ad([kHeartRateServiceUuid]); + expect(hrOnly?.entryId, 'ble_hrs'); + expect(hrOnly?.alsoBand?.entry.id, 'gen5'); + + final io = _FakeIo(() async => _strap()); + final out = await pairNotifyDevice( + entry: kBleHrs, + remoteId: dev.remoteId.str, + io: io, + earlierBand: hrOnly!.alsoBand, + ); + expect(out.isBand?.id, 'gen5'); + expect(io.writes, isEmpty); + }); + + test('(S1) a change in band evidence alone counts as a result change', () { + // WHY: the picker is only told about changed rows; a row that gains the + // "band" badge with the same signal and label must be re-sent. + final dev = BluetoothDevice.fromId('AA:BB:CC:DD:EE:23'); + final plain = (device: dev, label: 'X', rssi: -50, entryId: 'ble_hrs', alsoBand: null); + final banded = (device: dev, label: 'X', rssi: -50, entryId: 'ble_hrs', + alsoBand: (entry: kWhoopGen4, modelCertain: true)); + expect(HrsLink.candidateChanged(plain, banded), isTrue); + expect(HrsLink.candidateChanged(banded, banded), isFalse); + expect(HrsLink.candidateChanged(null, plain), isTrue); + }); + }); +} + +void _gatt() { + group('bandIdentityFromGatt (definitive, after discoverServices)', () { + test('WHOOP 4 / WHOOP 5 service in the full list gives that entry, no name needed', () { + // WHY: last line of defence before a band is saved as a sensor. + expect(bandIdentityFromGatt([kHeartRateServiceUuid, kWhoopGen4.service])?.id, 'gen4'); + expect(bandIdentityFromGatt(['0000180f-0000-1000-8000-00805f9b34fb', kWhoopGen5.service])?.id, 'gen5'); + }); + + test('only HR / battery / device-info services is null', () { + // WHY: a real strap must still pair as a sensor. + expect( + bandIdentityFromGatt([ + kHeartRateServiceUuid, + '0000180f-0000-1000-8000-00805f9b34fb', + '0000180a-0000-1000-8000-00805f9b34fb', + ]), + isNull); + }); + + test('a sensor-role service (Polar PMD) is null', () { + // WHY: only role==band entries count. + expect(bandIdentityFromGatt([kPolarPmdService]), isNull); + }); + }); + + group('PairOutcome (pairNotifySensor result)', () { + test('can carry "is a band" distinct from an error and from saved', () { + // WHY: the UI must show the slot comparison, not an error string. + final b = PairOutcome.band(kWhoopGen4); + expect(b.isBand?.id, 'gen4'); + expect(b.error, isNull); + expect(b.saved, isFalse); + final f = PairOutcome.failed('nope'); + expect(f.error, 'nope'); + expect(f.isBand, isNull); + expect(f.saved, isFalse); + expect(PairOutcome.saved().saved, isTrue); + }); + }); +} + +const _nus = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'; // Nordic UART, NOT Coros +const _hr = '0000180d-0000-1000-8000-00805f9b34fb'; +const _bat = '0000180f-0000-1000-8000-00805f9b34fb'; +const _dis = '0000180a-0000-1000-8000-00805f9b34fb'; + +void _matching() { + group('matching rules (S5)', () { + test('normalizeGattUuid: case and 16/32/128-bit forms are one UUID', () { + // WHY: scans report any form; comparing strings would miss "180D" vs the full form. + const full = '0000180d-0000-1000-8000-00805f9b34fb'; + expect(normalizeGattUuid('180D'), full); + expect(normalizeGattUuid('0000180D'), full); + expect(normalizeGattUuid('0000180D-0000-1000-8000-00805F9B34FB'), full); + expect(normalizeGattUuid(full), full); + expect(normalizeGattUuid('FEE1'), '0000fee1-0000-1000-8000-00805f9b34fb'); + }); + + test('Mi Band FEE1 matches in 16-bit, upper and 128-bit forms (advert and GATT)', () { + // WHY: a notify-class entry is matched by the full UUID, whatever its spelling. + for (final u in ['FEE1', 'fee1', '0000FEE1-0000-1000-8000-00805F9B34FB', kHuami234Service]) { + expect(_id([u])?.id, 'miband234', reason: 'advert $u'); + expect(bandIdentityFromGatt([u])?.id, 'miband234', reason: 'gatt $u'); + } + }); + + test('framed entries match by 32-bit prefix at GATT discovery too', () { + // WHY: a WHOOP family member differs after the prefix; the registry matches framed bands by servicePrefix. + final fam = '${kWhoopGen5.servicePrefix}-1111-2222-3333-444455556666'.toUpperCase(); + expect(bandIdentityFromGatt([fam])?.id, 'gen5'); + }); + + test('notify-class entries match the FULL UUID only: a near miss is no band', () { + // WHY: a shared prefix must not claim a stranger (prefix matching for notify entries is the bug). + final nearCoros = '${kCorosService.substring(0, kCorosService.length - 1)}1'; + final nearGarmin = '${kGarminService.substring(0, kGarminService.length - 1)}1'; + for (final u in [nearCoros, nearGarmin]) { + expect(_id([u]), isNull, reason: 'advert $u'); + expect(bandIdentityFromGatt([u]), isNull, reason: 'gatt $u'); + } + // positive controls: the exact UUIDs do match + expect(bandIdentityFromGatt([kCorosService])?.id, 'coros'); + expect(bandIdentityFromGatt([kGarminService.toUpperCase()])?.id, 'garmin'); + }); + + test('Nordic UART alone is not a Coros (shares the 6e400001 prefix)', () { + // WHY: kCorosService ends ...77656c6f6f70 ("weloop"); NUS ends ...e50e24dcca9e. + expect(kCorosService.substring(0, 8), _nus.substring(0, 8), + reason: 'control: the prefixes really collide'); + expect(_id([_nus]), isNull); + expect(_id([_hr, _nus]), isNull); + expect(bandIdentityFromGatt([_nus]), isNull); + expect(bandIdentityFromGatt([_hr, _bat, _nus]), isNull); + }); + + test('a sensor service never hides a band service in the same list', () { + // WHY: HR + PMD + band service together is a band; the sensor match must not win. + for (final order in [ + [_hr, kPolarPmdService, kWhoopGen4.service], + [kWhoopGen4.service, kPolarPmdService, _hr], + ]) { + expect(_id(order)?.id, 'gen4'); + expect(bandIdentityFromGatt(order)?.id, 'gen4'); + } + expect(_id([_hr, kCorosService])?.id, 'coros'); + }); + + test('service evidence beats a conflicting name or service-data UUID', () { + // WHY: names are user-set and company ids shared; a service is the strongest evidence. + expect(_full([kCorosService], 'My WHOOP')?.entry.id, 'coros'); + expect(_full([kCorosService], 'My WHOOP')?.modelCertain, isTrue); + expect(_id([kWhoopGen4.service], 'UH_ABC123'), isNotNull); + expect(_id([kWhoopGen4.service], 'UH_ABC123')?.id, 'gen4'); + final r = bandIdentityOf( + advertisedServiceUuids: [kWhoopGen5.service], + name: 'Garmin Forerunner', + serviceDataUuids: [kGarminServiceDataUuids.first]); + expect(r?.entry.id, 'gen5'); + }); + }); +} + +DiscoveredService _svc(String uuid, [List chars = const []]) => + DiscoveredService(uuid, chars); + +/// A genuine heart-rate strap's GATT: HRS with its measurement, battery, DIS. +List _strap() => [ + _svc(_hr, [kHeartRateMeasurementUuid]), + _svc(_bat), + _svc(_dis), + ]; + +/// The pairing seam's fake: scripted discovery, recorded writes. +class _FakeIo implements PairingIo { + _FakeIo(this._discover, + {this.bondOk = true, this.persistError}); + final Future> Function() _discover; + final bool bondOk; + final Object? persistError; + final writes = []; + var bondCalls = 0; + var pmdReads = 0; + + @override + Future> discoverServices() => _discover(); + @override + Future?> readPmdFeatures() async { + pmdReads++; + return null; + } + + @override + Future triggerBond() async { + bondCalls++; + return bondOk; + } + + @override + Future persist(PairedDevice d) async { + if (persistError != null) throw persistError!; + writes.add(d); + } +} + +class _Pairing { + _FakeIo? io; + List get writes => io?.writes ?? const []; + Future run( + BandEntry entry, + Future> Function() discover, { + String remoteId = 'AA:BB:CC:DD:EE:01', + PairConfirmation? confirmation, + BandIdentity? earlierBand, + }) => + pairNotifyDevice( + entry: entry, + remoteId: remoteId, + io: io = _FakeIo(discover), + confirmation: confirmation, + earlierBand: earlierBand, + ); +} + +void _pairing() { + group('pairNotifyDevice (the real pairing decision; injected discovery + persistence)', () { + test('(a) a WHOOP 5 behind a 0x180D advert is a band outcome, zero writes', () async { + // WHY (S3): the reported bug. Advertisement said only 0x180D under an + // ordinary name; only discovery reveals the band. Nothing may be saved. + final p = _Pairing(); + final out = await p.run(kBleHrs, + () async => [..._strap(), _svc(kWhoopGen5.service.toUpperCase())]); + expect(out.isBand?.id, 'gen5'); + expect(out.saved, isFalse); + expect(out.error, isNull); + expect(p.writes, isEmpty); + }); + + test('(a2) the same for a WHOOP 4', () async { + // WHY: both generations, not only the one the first test happened to use. + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => [..._strap(), _svc(kWhoopGen4.service)]); + expect(out.isBand?.id, 'gen4'); + expect(p.writes, isEmpty); + }); + + test('(b) discovery throwing is an error outcome, zero writes', () async { + // WHY (S3): a failed discovery must never fall through to "save anyway". + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => throw StateError('gatt 133')); + expect(out.error?.trim() ?? '', isNotEmpty); + expect(out.isBand, isNull); + expect(out.saved, isFalse); + expect(p.writes, isEmpty); + }); + + test('(c) a genuine strap (180D/180F/180A only) is saved exactly once', () async { + // WHY (S3): the safeguard must not block real sensors. Positive control for the zero-write tests. + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => _strap(), remoteId: 'AA:BB:CC:DD:EE:09'); + expect(out.saved, isTrue); + expect(p.writes, hasLength(1)); + final w = p.writes.single; + expect(w.adapterId, 'ble_hrs'); + expect(w.remoteId, 'AA:BB:CC:DD:EE:09'); + expect(w.id, HrsLink.mintDeviceId(kBleHrs, 'AA:BB:CC:DD:EE:09')); + expect(w.tier, 'beatToBeat', reason: 'HrsLink.deriveTier for a declared-rr entry'); + }); + + test('(S3) bond trigger and PMD read go through the interface only where they apply', () async { + // WHY: the interface methods are always present now; the decision to call + // them must stay with the entry (a plain strap writes nothing to the device). + final strap = _Pairing(); + await strap.run(kBleHrs, () async => _strap()); + expect(strap.io!.bondCalls, 0); + expect(strap.io!.pmdReads, 0); + List pebble() => [ + _svc(kPebbleServiceUuid, kPebble.requiredCharacteristics), + ]; + final bonded = _Pairing(); + final ok = await bonded.run(kPebble, () async => pebble()); + expect(ok.saved, isTrue); + expect(bonded.io!.bondCalls, 1); + final refused = _FakeIo(() async => pebble(), bondOk: false); + final out = await pairNotifyDevice( + entry: kPebble, remoteId: 'AA:BB:CC:DD:EE:31', io: refused); + expect(out.saved, isFalse); + expect(out.error?.trim() ?? '', isNotEmpty); + expect(refused.writes, isEmpty); + }); + + test('(CR 4500472602) a band missing the measurement characteristic is still a band outcome', () async { + // WHY: the band cross-check needs only the discovered services. Run + // after the characteristic check, a WHOOP without the measurement + // characteristic got "does not expose the data" instead of the band + // choice this flow exists to offer. + final p = _Pairing(); + final out = await p.run( + kBleHrs, () async => [_svc(_hr), _svc(kWhoopGen5.service)]); + expect(out.isBand?.id, 'gen5'); + expect(out.error, isNull); + expect(p.writes, isEmpty); + }); + + test('(CR 4500539072) a band outcome says whether its model is certain', () async { + // WHY: the flow must not guess certainty from whether the entry changed. + // GATT proves the model; a name match alone does not. + final byName = (entry: kWhoopGen4, modelCertain: false); + final proven = await _Pairing().run(kBleHrs, + () async => [..._strap(), _svc(kWhoopGen4.service)], + earlierBand: byName); + expect(proven.isBand?.id, 'gen4'); + expect(proven.modelCertain, isTrue, reason: 'GATT showed the gen4 service'); + final nameOnly = await _Pairing().run(kBleHrs, () async => _strap(), + earlierBand: byName); + expect(nameOnly.isBand?.id, 'gen4'); + expect(nameOnly.modelCertain, isFalse, reason: 'only the name said WHOOP'); + }); + + test('a strap missing its measurement characteristic fails, zero writes', () async { + // WHY: a row pointing at a device that cannot answer is a sensor that appears paired and never beats. + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => [_svc(_hr), _svc(_bat)]); + expect(out.error?.trim() ?? '', isNotEmpty); + expect(out.saved, isFalse); + expect(p.writes, isEmpty); + }); + + test('a persistence failure is an error outcome, not a saved one', () async { + // WHY: never report success for a row that was not written. + final out = await pairNotifyDevice( + entry: kBleHrs, + remoteId: 'AA:BB:CC:DD:EE:01', + io: _FakeIo(() async => _strap(), persistError: StateError('disk full')), + ); + expect(out.saved, isFalse); + expect(out.error?.trim() ?? '', isNotEmpty); + }); + + test('Coros paired under kCoros, its own service discovered, saves', () async { + // WHY (S4): the band refusal must not fire for the entry being paired. + final p = _Pairing(); + final out = await p.run(kCoros, + () async => [_svc(kCorosService), _svc(_bat, [kBatteryLevelUuid])]); + expect(out.saved, isTrue); + expect(p.writes.single.adapterId, 'coros'); + }); + + test('Coros without its own service (Nordic UART only) is refused, zero writes', () async { + // WHY (S5): a Nordic UART peripheral is not a Coros; identity is the full vendor UUID. + final p = _Pairing(); + final out = await p.run(kCoros, () async => [_svc(_nus), _svc(_bat, [kBatteryLevelUuid])]); + expect(out.saved, isFalse); + expect(out.error?.trim() ?? '', isNotEmpty); + expect(p.writes, isEmpty); + }); + + test('Garmin paired under kGarmin with its multi-link characteristics saves', () async { + // WHY (S4): garminMlPair needs a data characteristic 6A4E2810..2819; the + // Garmin service is a band service yet must not be refused as a band here. + final p = _Pairing(); + final out = await p.run(kGarmin, + () async => [_svc(kGarminService, [kGarminNotifyChar, kGarminWriteChar])]); + expect(out.saved, isTrue); + expect(p.writes.single.adapterId, 'garmin'); + }); + + test('Garmin service without a multi-link data characteristic fails, zero writes', () async { + // WHY: the Garmin shape check still stands behind the identity change. + final p = _Pairing(); + final out = await p.run(kGarmin, () async => [_svc(kGarminService, const [])]); + expect(out.saved, isFalse); + expect(out.error?.trim() ?? '', isNotEmpty); + expect(p.writes, isEmpty); + }); + + test('a WHOOP paired as a sensor WITH a matching confirmation saves', () async { + // WHY (S4): option C, the user deliberately uses the WHOOP as a plain HR sensor. + final p = _Pairing(); + final out = await p.run( + kBleHrs, + () async => [..._strap(), _svc(kWhoopGen5.service)], + remoteId: 'AA:BB:CC:DD:EE:05', + confirmation: const PairConfirmation( + remoteId: 'AA:BB:CC:DD:EE:05', bandEntryId: 'gen5'), + ); + expect(out.saved, isTrue); + expect(p.writes, hasLength(1)); + expect(p.writes.single.adapterId, 'ble_hrs'); + }); + + test('(CR r2) a WHOOP kept as a plain sensor is stored at the band tier, not beat-to-beat', () async { + // WHY: deriveTier off ble_hrs's declared signals says beatToBeat, which + // would rank a wrist optical WHOOP above the band, fill the R-peak + // rung and win live heart rate. + final p = _Pairing(); + await p.run( + kBleHrs, + () async => [..._strap(), _svc(kWhoopGen5.service)], + remoteId: 'AA:BB:CC:DD:EE:05', + confirmation: const PairConfirmation( + remoteId: 'AA:BB:CC:DD:EE:05', bandEntryId: 'gen5'), + ); + expect(p.writes.single.tier, kBandSourceTier); + expect(kBandSourceTier, 'wristOptical'); + // Positive control: a real chest strap keeps its beat-to-beat tier. + final strap = _Pairing(); + await strap.run(kBleHrs, () async => _strap()); + expect(strap.writes.single.tier, 'beatToBeat'); + }); + + test('a confirmation for another device is still refused', () async { + // WHY (S4): the confirmation is bound to the device id; a stale one must not unlock a different strap. + final p = _Pairing(); + final out = await p.run( + kBleHrs, + () async => [..._strap(), _svc(kWhoopGen5.service)], + remoteId: 'AA:BB:CC:DD:EE:05', + confirmation: const PairConfirmation( + remoteId: 'AA:BB:CC:DD:EE:99', bandEntryId: 'gen5'), + ); + expect(out.isBand?.id, 'gen5'); + expect(p.writes, isEmpty); + }); + + test('a confirmation for another matched entry is still refused', () async { + // WHY (S4): confirmed "WHOOP 4" does not cover a device that turns out to be a WHOOP 5 (or a Coros). + final p = _Pairing(); + final out = await p.run( + kBleHrs, + () async => [..._strap(), _svc(kWhoopGen5.service)], + remoteId: 'AA:BB:CC:DD:EE:05', + confirmation: const PairConfirmation( + remoteId: 'AA:BB:CC:DD:EE:05', bandEntryId: 'gen4'), + ); + expect(out.isBand?.id, 'gen5'); + expect(p.writes, isEmpty); + }); + + test('(N1) earlier band evidence survives a standard-only GATT: refused, zero writes', () async { + // WHY (DT9): advert/company/name said "band"; discovery shows only + // 180D/180F/180A. Absence at GATT must not erase that evidence. + final earlier = { + 'service': (entry: kWhoopGen4, modelCertain: true), + 'company id': (entry: kGarmin, modelCertain: true), + 'name hint': (entry: kWhoopGen4, modelCertain: false), + }; + for (final e in earlier.entries) { + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => _strap(), + remoteId: 'AA:BB:CC:DD:EE:07', earlierBand: e.value); + expect(out.isBand?.id, e.value.entry.id, reason: e.key); + expect(out.saved, isFalse, reason: e.key); + expect(p.writes, isEmpty, reason: e.key); + } + }); + + test('(N1) the same device with a matching confirmation is saved once', () async { + // WHY (DT9): positive control; the user's explicit yes unlocks exactly that evidence. + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => _strap(), + remoteId: 'AA:BB:CC:DD:EE:07', + earlierBand: (entry: kWhoopGen4, modelCertain: false), + confirmation: const PairConfirmation( + remoteId: 'AA:BB:CC:DD:EE:07', bandEntryId: 'gen4')); + expect(out.saved, isTrue); + expect(p.writes, hasLength(1)); + }); + + test('(N1) a confirmation for another device or entry does not unlock earlier evidence', () async { + // WHY (DT9): the confirmation stays bound to device and entry. + for (final c in const [ + PairConfirmation(remoteId: 'AA:BB:CC:DD:EE:99', bandEntryId: 'gen4'), + PairConfirmation(remoteId: 'AA:BB:CC:DD:EE:07', bandEntryId: 'gen5'), + ]) { + final p = _Pairing(); + final out = await p.run(kBleHrs, () async => _strap(), + remoteId: 'AA:BB:CC:DD:EE:07', + earlierBand: (entry: kWhoopGen4, modelCertain: false), + confirmation: c); + expect(out.isBand?.id, 'gen4'); + expect(p.writes, isEmpty); + } + }); + + test('(N1) a GATT-found band beats an uncertain earlier name guess', () async { + // WHY (DT9): GATT is definitive; a name-only gen4 guess must not mislabel a WHOOP 5. + final p = _Pairing(); + final out = await p.run( + kBleHrs, () async => [..._strap(), _svc(kWhoopGen5.service)], + remoteId: 'AA:BB:CC:DD:EE:07', + earlierBand: (entry: kWhoopGen4, modelCertain: false)); + expect(out.isBand?.id, 'gen5'); + expect(p.writes, isEmpty); + }); + + test('HrsLink.pairNotifySensor delegates to pairNotifyDevice (backstop)', () { + // WHY: the seam is worthless if production does not call it. A source + // check, kept only as a backstop; positive control proves the matcher. + final src = File('lib/ble/hrs_link.dart').readAsStringSync() + .split('\n').where((l) => !l.trimLeft().startsWith('//')).join('\n'); + final start = src.indexOf('static Future pairNotifySensor('); + final start2 = src.indexOf('static Future pairNotifySensor('); + final at = start >= 0 ? start : start2; + expect(at, greaterThanOrEqualTo(0), reason: 'pairNotifySensor not found'); + final end = src.indexOf('static ', at + 10); + final body = src.substring(at, end); + expect(RegExp(r'pairNotifyDevice\s*\(').hasMatch(body), isTrue); + expect(RegExp(r'pairNotifyDevice\s*\(').hasMatch('x = pairNotifyDevice(a);'), isTrue, + reason: 'matcher control'); + }); + }); +} diff --git a/test/device_picker_test.dart b/test/device_picker_test.dart index a3a79e5bf..809769882 100644 --- a/test/device_picker_test.dart +++ b/test/device_picker_test.dart @@ -15,7 +15,7 @@ import 'package:openstrap_edge/ui2/ui2.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; BandCandidate _cand(String id, String entryId, {String? label, int rssi = -60}) => - (device: BluetoothDevice.fromId(id), label: label, rssi: rssi, entryId: entryId); + (device: BluetoothDevice.fromId(id), label: label, rssi: rssi, entryId: entryId, alsoBand: null); Future _pump(WidgetTester t, DevicePickerView view, {bool settle = true}) async { diff --git a/test/device_sources_test.dart b/test/device_sources_test.dart index bf43a2aa4..980184bd2 100644 --- a/test/device_sources_test.dart +++ b/test/device_sources_test.dart @@ -79,7 +79,7 @@ void main() { home: MyDevicesView(sources: const [], onAddSensor: () {}), )); await t.pumpAndSettle(); - expect(find.text('Add a sensor'), findsOneWidget); + expect(find.text('Add a device'), findsOneWidget); const t1 = SourceTier.beatToBeat; final rung = find.text('Tier ${t1.rank} · ${t1.label}'); await t.scrollUntilVisible(rung, 300, scrollable: find.byType(Scrollable).first); @@ -95,7 +95,7 @@ void main() { )); await t.pumpAndSettle(); // The card that offers to pair a BAND is still there, because one is not. - expect(find.text('Pair a band'), findsOneWidget); + expect(find.byKey(const ValueKey('pair-band-card')), findsOneWidget); expect(find.text('Polar H10'), findsOneWidget); }); diff --git a/test/guards/pairing_reset_steps_guard_test.dart b/test/guards/pairing_reset_steps_guard_test.dart new file mode 100644 index 000000000..6cded2eea --- /dev/null +++ b/test/guards/pairing_reset_steps_guard_test.dart @@ -0,0 +1,244 @@ +// 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 (S7). +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 and the Hindi strain label are the fixed ones (G2 r1 S3-S5)', () { + // 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, and a + // Hindi "stress level" for physical strain (two different app metrics). + 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); + } + final hi = _arb(File('lib/l10n/app_hi.arb')); + expect(hi['capLabelStrain'], 'शारीरिक परिश्रम'); + }); + + test('every registry entry declares reset steps', () { + // WHY: an undeclared device would show a user stuck at pairing nothing. + final missing = [ + 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 (S9): 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 (S9): 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 (S9): 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 (S9): 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'], 'support.whoop.com'); + }); + }); + + group('noOfficialSite (S7)', () { + 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 (S9): only the reviewed official host may be linked. + expect(resetUrlProblem(bad.value, pinnedHost: host), isNotNull); + }); + } + }); + + group('follow-up list (S8)', () { + 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..0ad44e6db --- /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': 'support.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 (G2 r1 S3, S4). +/// 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/hrs_link_test.dart b/test/hrs_link_test.dart index 61a6bd8bf..58b8c84de 100644 --- a/test/hrs_link_test.dart +++ b/test/hrs_link_test.dart @@ -583,24 +583,24 @@ void main() { // A Polar advertises 0x180D and its name, never the PMD service. test('a Polar optical sensor on 0x180D is routed to PMD, not generic HR', () { - expect(HrsLink.hintedNameMatch(notify, [hr], 'polar sense 1a2b3c4d'), + expect(hintedNameMatch(notify, [hr.str128], 'polar sense 1a2b3c4d'), kPolarPmd.id); expect(HrsLink.scanServiceFilter([kPolarPmd]), contains(hr)); }); test('an H-series chest strap and an unnamed strap stay generic HR', () { - expect(HrsLink.hintedNameMatch(notify, [hr], 'polar h10 1a2b3c4d'), + expect(hintedNameMatch(notify, [hr.str128], 'polar h10 1a2b3c4d'), isNull); - expect(HrsLink.hintedNameMatch(notify, [hr], ''), isNull); + expect(hintedNameMatch(notify, [hr.str128], ''), isNull); // A Polar name without the hint is not enough on its own. - expect(HrsLink.hintedNameMatch(notify, const [], 'polar oh1 1a2b'), + expect(hintedNameMatch(notify, const [], 'polar oh1 1a2b'), isNull); }); test('a Coros is found by its name on the shared 0xFEE7 hint', () { expect( - HrsLink.hintedNameMatch( - notify, [Guid(kColmiAdvertisedHint)], 'coros pace 3 a1b2'), + hintedNameMatch( + notify, [Guid(kColmiAdvertisedHint).str128], 'coros pace 3 a1b2'), kCoros.id); }); }); diff --git a/test/pair_sensor_test.dart b/test/pair_sensor_test.dart index 0310dbc28..702144731 100644 --- a/test/pair_sensor_test.dart +++ b/test/pair_sensor_test.dart @@ -35,6 +35,7 @@ BandCandidate _cand(String id, {String? label, int? rssi = -60}) => ( label: label, rssi: rssi, entryId: 'ble_hrs', + alsoBand: null, ); Future _pump( diff --git a/test/pairing_device_role_test.dart b/test/pairing_device_role_test.dart new file mode 100644 index 000000000..a21e1bd3f --- /dev/null +++ b/test/pairing_device_role_test.dart @@ -0,0 +1,76 @@ +// Device role: which registry entries can be the user's main device. +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; + +void main() { + test('every registry entry declares a role', () { + // WHY: an undeclared role would let a sensor or a band slip into the wrong list. + final missing = [ + for (final e in kBandRegistry) + if (e.role == null) e.id, + ]; + expect(missing, isEmpty, reason: 'entries without role: $missing'); + }); + + test('the sensor set is exactly ble_hrs, polar_pmd, thermometer, scales', () { + // WHY: workout add-ons and scales must never be offered as the main device. + final sensors = { + for (final e in kBandRegistry) + if (e.role == DeviceRole.sensor) e.id, + }; + expect(sensors, + {'ble_hrs', 'polar_pmd', 'thermometer', 'miscale_bc', 'miscale2'}); + }); + + test('every other entry is a band, including both WHOOPs', () { + // WHY: the WHOOP bug was a band filed as a sensor; bands must stay bands. + expect(kWhoopGen4.role, DeviceRole.band); + expect(kWhoopGen5.role, DeviceRole.band); + final bands = { + for (final e in kBandRegistry) + if (e.role == DeviceRole.band) e.id, + }; + expect(bands, { + 'gen4', 'gen5', 'oura', 'coros', 'ultrahuman', 'miband234', 'pebble', + 'colmi', 'garmin', + }); + }); + + test('recognised as a band is not the same as can be the main device (DT10)', () { + // WHY: Coros and Garmin must trip the "this is a band" safeguard, but have + // no supported main-device path, so the first-run list must not offer them. + for (final e in [kWhoopGen4, kWhoopGen5]) { + expect(e.role, DeviceRole.band, reason: e.id); + expect(e.canBeMainDevice, isTrue, reason: e.id); + } + for (final e in [kCoros, kGarmin]) { + expect(e.role, DeviceRole.band, reason: '${e.id} is recognised'); + expect(e.canBeMainDevice, isFalse, reason: '${e.id} is not on W2'); + } + }); + + test('a sensor can never be the main device (DT10)', () { + // WHY: workout add-ons and scales stay off the first-run list. + for (final e in kBandRegistry) { + if (e.role == DeviceRole.sensor) { + expect(e.canBeMainDevice, isFalse, reason: e.id); + } + } + expect(kBandRegistry.where((e) => e.canBeMainDevice).map((e) => e.id), + containsAll(['gen4', 'gen5']), + reason: 'positive control: the W2 list is not empty'); + }); + + test('only the framed WHOOP bands can be the main device (DT12)', () { + // WHY: the main-band path (PairingScreen, AppState.isPaired) runs framed + // WHOOP only. Oura, Colmi, Mi Band and the rest are recognised as bands + // but pair as additional devices under their own adapter. + expect({for (final e in kBandRegistry) if (e.canBeMainDevice) e.id}, + {'gen4', 'gen5'}); + for (final e in kBandRegistry) { + if (e.role == DeviceRole.band && !e.isFramed) { + expect(e.canBeMainDevice, isFalse, reason: e.id); + } + } + }); +} diff --git a/test/russian_review_findings_test.dart b/test/russian_review_findings_test.dart index 59ac92395..cc9285c24 100644 --- a/test/russian_review_findings_test.dart +++ b/test/russian_review_findings_test.dart @@ -327,7 +327,7 @@ void main() { ); testWidgets('category search accepts original and Russian blurb', (t) async { - await t.pumpWidget(page(const DevicePickerScreen())); + await t.pumpWidget(page(const DevicePickerScreen(includeBand: false))); await t.pumpAndSettle(); final entry = kBleHrs; for (final query in [ diff --git a/test/sensor_cap_test.dart b/test/sensor_cap_test.dart index 7bb6af557..07a07e2b9 100644 --- a/test/sensor_cap_test.dart +++ b/test/sensor_cap_test.dart @@ -144,6 +144,7 @@ void main() { label: 'New sensor', rssi: -50, entryId: entry.id, + alsoBand: null, ), ], ), diff --git a/test/slot_comparison_test.dart b/test/slot_comparison_test.dart new file mode 100644 index 000000000..e7360f28f --- /dev/null +++ b/test/slot_comparison_test.dart @@ -0,0 +1,324 @@ +// Slot comparison: what a device gives "Every day" (the main band, or the +// active wearable) and "Workouts only" (a workout-armed sensor). Capabilities +// come from the compute layer's eligibility rules (S6): the metric x device +// columns (canonical.dart columnFor/capabilities), the native WHOOP pipeline, +// and the workout-armed sensor set. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/slot_comparison.dart'; +import 'package:openstrap_edge/compute/inputs/canonical.dart' + show MetricClass, capabilities, columnFor, kWorkoutArmedSensors; + +typedef C = DeviceCapability; +typedef K = CapabilityKind; + +// The table's rows, in the order the sheets draw them (owner-approved r4 +// mockup): the whole-day rows, then the workout rows. Never blood oxygen. +const _dayRows = [ + C.sleep, + C.recovery, + C.strain, + C.heartRate, + C.hrv, + C.respiration, + C.skinTemperature, + C.steps, +]; +const _workoutRows = [C.workoutHeartRate, C.workoutBeatToBeat]; + +Set _keys(SlotSide s) => s.sends.keys.toSet(); + +List _columnEntries() => + [for (final e in kBandRegistry) if (columnFor(e.id) != null) e]; + +void main() { + test('the table rows are the eight whole-day rows, then the two workout rows', () { + // WHY: every sheet and the device card draw the same rows in the same + // order; a drifting list changes every table at once. + expect(kDayCapabilities, _dayRows); + expect(kSessionCapabilities.toList(), _workoutRows); + expect(kTableRows, [..._dayRows, ..._workoutRows]); + expect(kTableRows, isNot(contains(C.bloodOxygen))); + }); + + group('WHOOP', () { + for (final e in [kWhoopGen4, kWhoopGen5]) { + test('${e.id}: every day, as the main band, sends every row', () { + // WHY: the native pipeline (onehz_pipeline.dart) computes sleep, + // recovery, strain, resting HR, HRV, respiration and skin temperature, + // and the band records workouts. + final cmp = slotComparisonFor(e); + expect(cmp.slot, DeviceSlot.everyDay); + final d = cmp.everyDay.sends; + expect(d.keys.toSet(), {..._dayRows, ..._workoutRows}); + for (final c in [C.sleep, C.recovery, C.strain, C.heartRate, C.hrv, + C.respiration, C.workoutHeartRate, C.workoutBeatToBeat]) { + expect(d[c]?.kind, K.native, reason: '${e.id} $c'); + } + // DT7: a nightly mean, >= 3 prior nights, baseline spread >= 1. + expect(d[C.skinTemperature]?.kind, K.conditional); + expect(d[C.skinTemperature]?.reasonKey, kReasonSkinTempNights); + }); + + test('${e.id}: workouts only, read as a heart-rate sensor', () { + // WHY: a WHOOP kept for workouts is paired under ble_hrs, so it sends + // heart rate, and beat timing only if the band sends RR intervals. + final w = slotComparisonFor(e).workoutsOnly.sends; + expect(w.keys.toSet(), _workoutRows.toSet()); + expect(w[C.workoutHeartRate]?.kind, K.native); + expect(w[C.workoutBeatToBeat]?.kind, K.conditional); + expect(w[C.workoutBeatToBeat]?.reasonKey, kReasonUnconfirmed); + }); + } + + test('steps: WHOOP 4 only during live sessions, WHOOP 5 from its own counter', () { + // WHY: gen4 has no on-chip counter (derivation_engine.dart + // _stepCounterModulus), so its steps come only from the strap's 100 Hz + // pedometer over a live link (live_step_runs.dart). gen5's cumulative + // counter rides every synced row (decoded_onehz.step_count). + final g4 = slotComparisonFor(kWhoopGen4).everyDay.sends[C.steps]; + expect(g4?.kind, K.conditional); + expect(g4?.reasonKey, kReasonLiveSessions); + expect(slotComparisonFor(kWhoopGen5).everyDay.sends[C.steps]?.kind, + K.native); + }); + }); + + group('wearables fill every day (the active wearable)', () { + test('the six columned entries are found (control for the loops below)', () { + expect({for (final e in _columnEntries()) e.id}, + {'garmin', 'pebble', 'miband234', 'ultrahuman', 'colmi', 'oura'}); + }); + + test('Oura: the rows read off kOuraColumn, with their captions', () { + // WHY: oura_inputs.dart: sleep off the ring's own stages, readiness + // provisional until 14 nights of baseline, skin temperature after a few + // nights, strain and steps not decoded; not workout-armed. + final cmp = slotComparisonFor(kOura); + expect(cmp.slot, DeviceSlot.everyDay); + final d = cmp.everyDay.sends; + expect(d.keys.toSet(), {C.sleep, C.recovery, C.heartRate, C.hrv, + C.respiration, C.skinTemperature}); + expect(d[C.sleep]?.reasonKey, kReasonOwnStagesRing); + expect(d[C.recovery]?.reasonKey, kReasonFirstNights); + expect(d[C.skinTemperature]?.reasonKey, kReasonSkinTempNights); + for (final c in [C.heartRate, C.hrv, C.respiration]) { + expect(d[c]?.kind, K.native, reason: '$c'); + } + expect(cmp.workoutsOnly.sends, isEmpty); + }); + + test('every columned wearable: every-day rows follow capabilities(column)', () { + // WHY: ours -> native (or a stated condition), device -> the device's own + // reading, estimated -> conditional, unavailable -> not sent. + for (final e in _columnEntries()) { + final col = columnFor(e.id)!; + final cls = capabilities(col); + final cmp = slotComparisonFor(e); + expect(cmp.slot, DeviceSlot.everyDay, reason: e.id); + expect(cmp.workoutsOnly.sends, isEmpty, reason: e.id); + final sends = cmp.everyDay.sends; + for (final m in kCapabilityRow.entries) { + final c = cls[m.value]; + final got = sends[m.key]; + final why = '${e.id} ${m.key} ($c)'; + switch (c) { + case null || MetricClass.unavailable: + expect(got, isNull, reason: why); + case MetricClass.device: + expect(got?.kind, K.vendorReported, reason: why); + case MetricClass.estimated: + expect(got?.kind, K.conditional, reason: why); + case MetricClass.ours: + expect(got, isNotNull, reason: why); + expect(got!.kind, isNot(K.vendorReported), reason: why); + } + if (got?.kind == K.conditional) { + expect(got!.reasonKey?.trim() ?? '', isNotEmpty, reason: why); + } + } + expect(sends.keys.toSet().intersection(_workoutRows.toSet()), isEmpty, + reason: '${e.id}: a wearable is no workout session source'); + } + }); + + test('Pebble HR rows are Pebble 2 only; SE keeps reported sleep and steps', () { + final cmp = slotComparisonFor(kPebble); + final d = cmp.everyDay.sends; + expect(d.keys.toSet(), {C.sleep, C.recovery, C.strain, C.heartRate, C.steps}); + for (final c in [C.recovery, C.strain, C.heartRate]) { + expect(d[c]?.kind, K.conditional, reason: 'SE has no HR sensor: $c'); + } + expect(d[C.strain]?.reasonKey, 'capReasonPebble2Only'); + expect(d[C.heartRate]?.reasonKey, 'capReasonPebble2Only'); + // WHY (CR 4500472602): Pebble 2's recovery is an estimate; the + // Pebble 2 only caption must not drop that. + expect(d[C.recovery]?.reasonKey, 'capReasonPebble2OnlyEstimated'); + expect(d[C.sleep]?.reasonKey, kReasonOwnStages); + expect(d[C.steps]?.kind, K.vendorReported); + expect(cmp.workoutsOnly.sends, isEmpty); + }); + + test('hand-read anchors: Garmin', () { + // garmin_inputs.dart: HRV the watch's own unless a strap gave beats, + // readiness an estimate without a strap, strain ours, no skin temp. + final g = slotComparisonFor(kGarmin).everyDay.sends; + expect(g.containsKey(C.skinTemperature), isFalse); + expect(g[C.hrv]?.kind, K.vendorReported); + expect(g[C.recovery]?.kind, K.conditional); + expect(g[C.strain]?.kind, K.native); + }); + }); + + group('workout sensors fill workouts only', () { + test('a chest strap: heart rate; beat timing if the strap sends it', () { + // WHY: kWorkoutArmedSensors arms ble_hrs; RR intervals are optional in + // the Heart Rate Service. + final cmp = slotComparisonFor(kBleHrs); + expect(cmp.slot, DeviceSlot.workoutsOnly); + expect(cmp.everyDay.sends, isEmpty); + final w = cmp.workoutsOnly.sends; + expect(w[C.workoutHeartRate]?.kind, K.native); + expect(w[C.workoutBeatToBeat]?.reasonKey, kReasonUnconfirmed); + expect(cmp.workoutsOnly.doesNotSend, _dayRows.toSet()); + }); + + test('Polar PMD: its PPI stream is beats, so no "not confirmed" caption', () { + final w = slotComparisonFor(kPolarPmd).workoutsOnly.sends; + expect(w[C.workoutBeatToBeat]?.kind, K.native); + expect(w[C.workoutHeartRate]?.reasonKey, isNull); + expect(slotComparisonFor(kPolarPmd).slot, DeviceSlot.workoutsOnly); + }); + + test('only the armed sensors and a WHOOP send anything for workouts', () { + expect(kWorkoutArmedSensors, {'ble_hrs', 'polar_pmd'}); + for (final e in kBandRegistry) { + final w = _keys(slotComparisonFor(e).workoutsOnly); + final armed = {'ble_hrs', 'polar_pmd', 'gen4', 'gen5'}.contains(e.id); + expect(w, armed ? _workoutRows.toSet() : isEmpty, reason: e.id); + } + }); + }); + + test('"not confirmed" only where the device may not send it: a generic strap, a WHOOP read as one', () { + // WHY (owner): devices people use (Oura, Polar) carry only factual + // captions; the unconfirmed one is for RR from the generic profile. + final unconfirmed = { + for (final e in kBandRegistry) + for (final s in DeviceSlot.values) + for (final m in slotComparisonFor(e).side(s).sends.entries) + if (m.value.reasonKey == kReasonUnconfirmed) '${e.id}:${m.key.name}', + }; + expect(unconfirmed, { + 'ble_hrs:workoutBeatToBeat', + 'gen4:workoutBeatToBeat', + 'gen5:workoutBeatToBeat', + }); + }); + + test('Coros, scales and thermometers fit neither column', () { + // WHY: canonical.dart wearableEnabled() is false for Coros and it has no + // column; nothing arms it for workouts. + expect(declaredSignals('coros').map((s) => s.name), contains('rrIntervals'), + reason: 'positive control: Coros does declare beats'); + for (final e in [kCoros, kThermometer, kMiScale2, kMiScaleComposition]) { + final cmp = slotComparisonFor(e); + expect(cmp.slot, isNull, reason: e.id); + expect(cmp.everyDay.sends, isEmpty, reason: e.id); + expect(cmp.workoutsOnly.sends, isEmpty, reason: e.id); + } + }); + + test('no registry entry claims blood oxygen, in either column (S1)', () { + expect(capabilities(columnFor('oura')!)['spo2'], MetricClass.device, + reason: 'positive control: a column does offer a vendor spo2'); + for (final e in kBandRegistry) { + final cmp = slotComparisonFor(e); + for (final s in DeviceSlot.values) { + expect(cmp.side(s).sends.containsKey(C.bloodOxygen), isFalse, + reason: '${e.id} $s'); + expect(cmp.side(s).doesNotSend.contains(C.bloodOxygen), isFalse, + reason: '${e.id} $s'); + expect(capabilityTrace(e, C.bloodOxygen, slot: s), isNull, + reason: '${e.id} $s'); + } + } + }); + + test('doesNotSend is the table rows minus sends, for every entry and column', () { + for (final e in kBandRegistry) { + final cmp = slotComparisonFor(e); + for (final s in DeviceSlot.values) { + final side = cmp.side(s); + expect(side.doesNotSend, kTableRows.toSet().difference(_keys(side)), + reason: '${e.id} $s'); + } + } + }); + + group('nothing fabricated (S6)', () { + test('every claim traces to an eligibility rule in an existing file', () { + var claims = 0; + for (final e in kBandRegistry) { + final cmp = slotComparisonFor(e); + for (final s in DeviceSlot.values) { + for (final c in cmp.side(s).sends.keys) { + claims++; + final t = capabilityTrace(e, c, slot: s); + final why = '${e.id} $s $c'; + expect(t, isNotNull, reason: why); + expect(t!.rule.trim(), isNotEmpty, reason: why); + expect(File(t.source).existsSync(), isTrue, reason: '$why: ${t.source}'); + } + } + } + expect(claims, greaterThan(20), reason: 'vacuity control'); + }); + + test('an every-day claim needs a column or the native WHOOP pipeline', () { + for (final e in kBandRegistry) { + final eligible = + columnFor(e.id) != null || e.id == 'gen4' || e.id == 'gen5'; + if (!eligible) { + expect(slotComparisonFor(e).everyDay.sends, isEmpty, reason: e.id); + } + } + }); + + test('every whole-day capability has a column row', () { + final rows = {for (final e in _columnEntries()) ...columnFor(e.id)!.keys}; + for (final c in _dayRows) { + expect(kCapabilityRow[c], isNotNull, reason: '$c'); + expect(rows, contains(kCapabilityRow[c]), reason: '$c'); + } + }); + }); + + test('every label and reason key the table can show has text in every locale', () { + final keys = {for (final c in kTableRows) kCapabilityLabelKey[c]!}; + for (final e in kBandRegistry) { + final cmp = slotComparisonFor(e); + for (final s in DeviceSlot.values) { + for (final st in cmp.side(s).sends.values) { + if (st.reasonKey != null) keys.add(st.reasonKey!); + } + } + } + expect(keys.length, greaterThan(kTableRows.length + 4), + reason: 'vacuity control: some reason keys were found'); + final arbs = Directory('lib/l10n').listSync().whereType().where( + (f) => RegExp(r'app_\w+\.arb$').hasMatch(f.path)); + expect(arbs.length, 8); + for (final f in arbs) { + final arb = jsonDecode(f.readAsStringSync()) as Map; + final bad = [ + for (final k in keys) + if (arb[k] is! String || (arb[k] as String).trim().isEmpty) k, + ]; + expect(bad, isEmpty, reason: '${f.path}: $bad'); + } + }); +} diff --git a/test/support/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_device_slot_card_test.dart b/test/ui2_device_slot_card_test.dart new file mode 100644 index 000000000..819d4bcf4 --- /dev/null +++ b/test/ui2_device_slot_card_test.dart @@ -0,0 +1,249 @@ +// The device slot card (S5) and how a paired device reads on the sources list +// and its detail page. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/slot_comparison.dart'; +import 'package:openstrap_edge/ui2/pairing/device_slot_card.dart'; +import 'package:openstrap_edge/ui2/profile/devices.dart'; +import 'package:openstrap_edge/ui2/profile/wearable_numbers.dart' show DeviceUseSwitch; + +import 'support/pair_ui_support.dart'; + +HealthSource _src(String family, {String name = 'Polar H10', bool isBand = false}) => + HealthSource( + name: name, + kind: 'Bluetooth heart rate sensor', + tier: SourceTier.beatToBeat, + icon: LucideIcons.heartPulse, + isBand: isBand, + deviceId: '$family-0a1b2c3d', + family: family, + ); + +Widget _card(BandEntry e, DeviceSlot slot) => Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: DeviceSlotCard(entry: e, slot: slot))); + +bool _icon(WidgetTester t, Finder within, IconData icon) => t + .widgetList(find.descendant(of: within, matching: find.byType(Icon))) + .any((i) => i.icon == icon); + +Finder _mark(String side, DeviceCapability c) => + find.byKey(ValueKey('slot-$side-${c.name}')); + +double _opacityOf(WidgetTester t, String side) => t + .widget(find.descendant( + of: find.byKey(ValueKey('slot-cell-$side')).first, + matching: find.byType(Opacity)) + .first) + .opacity; + +void main() { + test('a ring reads as a band does, not as a sensor waiting for a workout', () { + expect(sourceState(_src('oura', name: 'Oura Ring')), 'Not connected'); + expect(sourceState(_src('ble_hrs')), 'Waiting for a workout'); + }); + + group('which sources get the card', () { + test('a strap, a Polar sensor and a ring, not a band, a scale or a Coros watch', () { + expect(slotDeviceOf(_src('ble_hrs'))?.entry.id, 'ble_hrs'); + expect(slotDeviceOf(_src('ble_hrs'))?.slot, DeviceSlot.workoutsOnly); + expect(slotDeviceOf(_src('polar_pmd'))?.entry.id, 'polar_pmd'); + expect(slotDeviceOf(_src('polar_pmd'))?.slot, DeviceSlot.workoutsOnly); + // A columned wearable fills every day. + expect(slotDeviceOf(_src('oura'))?.slot, DeviceSlot.everyDay); + expect(slotDeviceOf(_src('colmi'))?.slot, DeviceSlot.everyDay); + expect(slotDeviceOf(_src('thermometer')), isNull); + expect(slotDeviceOf(_src('miscale2')), isNull); + expect(slotDeviceOf(_src('coros')), isNull); + // The primary band is never a slot device (it is every day by itself). + expect(slotDeviceOf(_src('gen5', isBand: true)), isNull); + expect(sourceSlot(_src('gen5', isBand: true)), DeviceSlot.everyDay); + // The phone has no device row. + const phone = HealthSource( + name: 'This phone', + kind: 'Motion coprocessor', + tier: SourceTier.phone, + icon: Icons.phone_android); + expect(slotDeviceOf(phone), isNull); + expect(sourceSlot(phone), isNull); + }); + }); + + group('DeviceSlotCard', () { + testWidgets('a strap: Workouts only chip, the Every day column faded, the other not', (t) async { + await pumpPair(t, _card(kBleHrs, DeviceSlot.workoutsOnly), size: const Size(412, 1100)); + expect(find.text('What it sends'), findsOneWidget); + final pill = find.byKey(const ValueKey('device-slot-pill')); + expect(find.descendant(of: pill, matching: find.text('Workouts only')), findsOneWidget); + expect(find.descendant(of: pill, matching: find.text('Every day')), findsNothing); + expect(_opacityOf(t, 'everyDay'), 0.28); + expect(_opacityOf(t, 'workoutsOnly'), 1); + }); + + testWidgets('a strap: heart rate and beat timing are ✓ under Workouts only, sleep ✗ in both', (t) async { + await pumpPair(t, _card(kBleHrs, DeviceSlot.workoutsOnly), size: const Size(412, 1100)); + final cmp = slotComparisonFor(kBleHrs); + expect(cmp.workoutsOnly.sends, isNotEmpty); + for (final k in cmp.workoutsOnly.sends.keys) { + expect(_icon(t, _mark('workoutsOnly', k), LucideIcons.check), isTrue, reason: k.name); + } + expect(_icon(t, _mark('workoutsOnly', DeviceCapability.workoutHeartRate), LucideIcons.check), isTrue); + expect(_icon(t, _mark('workoutsOnly', DeviceCapability.workoutBeatToBeat), LucideIcons.check), isTrue); + for (final side in ['everyDay', 'workoutsOnly']) { + expect(_icon(t, _mark(side, DeviceCapability.sleep), LucideIcons.x), isTrue, reason: side); + expect(_icon(t, _mark(side, DeviceCapability.sleep), LucideIcons.check), isFalse, reason: side); + } + expect(find.byIcon(LucideIcons.minus), findsNothing); + }); + + testWidgets('a strap’s note: both need “Use during workouts” on; no Experimental word', (t) async { + // WHY: a workout sensor records nothing until the switch is on (default off). + await pumpPair(t, _card(kBleHrs, DeviceSlot.workoutsOnly), size: const Size(412, 1100)); + expect(find.text('Both need “Use during workouts” on.'), findsOneWidget); + expect(find.textContaining('Experimental'), findsNothing); + expect(find.textContaining('Tier'), findsNothing); + }); + + testWidgets('a strap’s beat timing is conditional on the strap', (t) async { + await pumpPair(t, _card(kBleHrs, DeviceSlot.workoutsOnly), size: const Size(412, 1100)); + expect(find.text('Workouts only: If this device sends it, not confirmed'), findsOneWidget); + }); + + testWidgets('a ring: Every day chip, Workouts only faded, the orange experimental note', (t) async { + await pumpPair(t, _card(kOura, DeviceSlot.everyDay), size: const Size(412, 1400)); + final pill = find.byKey(const ValueKey('device-slot-pill')); + expect(find.descendant(of: pill, matching: find.text('Every day')), findsOneWidget); + expect(_opacityOf(t, 'workoutsOnly'), 0.28); + expect(_opacityOf(t, 'everyDay'), 1); + const note = 'Fills days without your WHOOP. Experimental, so switch it on below.'; + expect(find.text(note), findsOneWidget); + expect(find.text('Both need “Use during workouts” on.'), findsNothing); + // Orange, not the grey of the strap's note. + final grey = t.widget(find.text(note)).style!.color; + await pumpPair(t, _card(kBleHrs, DeviceSlot.workoutsOnly), size: const Size(412, 1100)); + final strapNote = t.widget(find.text('Both need “Use during workouts” on.')).style!.color; + expect(grey, isNot(strapNote)); + }); + + testWidgets('a ring sends in the Every day column, nothing in Workouts only', (t) async { + await pumpPair(t, _card(kOura, DeviceSlot.everyDay), size: const Size(412, 1400)); + expect(_icon(t, _mark('everyDay', DeviceCapability.sleep), LucideIcons.check), isTrue); + expect(_icon(t, _mark('workoutsOnly', DeviceCapability.sleep), LucideIcons.x), isTrue); + expect(_icon(t, _mark('workoutsOnly', DeviceCapability.workoutHeartRate), LucideIcons.x), isTrue); + }); + + for (final (e, s) in [ + (kOura, DeviceSlot.everyDay), + (kPolarPmd, DeviceSlot.workoutsOnly), + ]) { + testWidgets('German at 2x text does not overflow (${e.id})', (t) async { + await pumpPair(t, _card(e, s), + locale: const Locale('de'), textScale: 2, size: const Size(360, 2000)); + expect(t.takeException(), isNull); + expect(find.text('Was es sendet'), findsOneWidget); + }); + } + }); + + group('on the sources list', () { + testWidgets('a strap’s row shows its Workouts only chip, no Tier pill, no Experimental word', (t) async { + await pumpPair(t, Scaffold(body: SourceRow(_src('ble_hrs'))), size: const Size(412, 600)); + expect(find.byKey(const ValueKey('source-slot-workoutsOnly')), findsOneWidget); + expect(find.byKey(const ValueKey('source-slot-everyDay')), findsNothing); + expect(find.textContaining('Tier'), findsNothing); + expect(find.text('Experimental'), findsNothing); + expect(find.text('Waiting for a workout'), findsOneWidget); + }); + + testWidgets('a ring’s row shows an Every day chip and no Tier pill', (t) async { + await pumpPair( + t, + Scaffold( + body: SourceRow(HealthSource( + name: 'Oura Ring', + kind: 'Ring', + tier: SourceTier.wristOptical, + icon: Icons.circle, + deviceId: 'oura-1', + family: 'oura'))), + size: const Size(412, 600)); + expect(find.byKey(const ValueKey('source-slot-everyDay')), findsOneWidget); + expect(find.textContaining('Tier'), findsNothing); + expect(find.text('Experimental'), findsNothing); + }); + + testWidgets('the band’s row says Every day', (t) async { + await pumpPair( + t, + const Scaffold( + body: SourceRow(HealthSource( + name: 'WHOOP 5', + kind: 'WHOOP', + tier: SourceTier.wristOptical, + icon: Icons.watch, + isBand: true))), + size: const Size(412, 600)); + expect(find.byKey(const ValueKey('source-slot-everyDay')), findsOneWidget); + }); + + testWidgets('a device with no column (Coros) keeps its Tier pill and has no slot chip', (t) async { + await pumpPair( + t, + const Scaffold( + body: SourceRow(HealthSource( + name: 'Coros', + kind: 'Watch', + tier: SourceTier.wristOptical, + icon: Icons.watch, + deviceId: 'coros-1', + family: 'coros'))), + size: const Size(412, 600)); + expect(find.textContaining('Tier'), findsOneWidget); + expect(find.byKey(const ValueKey('source-slot-everyDay')), findsNothing); + expect(find.byKey(const ValueKey('source-slot-workoutsOnly')), findsNothing); + }); + }); + + group('on its detail page', () { + testWidgets('the card replaces the tier row and the Support row, and the use switch follows it', (t) async { + await pumpPair(t, DeviceDetailView(_src('ble_hrs')), size: const Size(412, 2000)); + expect(find.byKey(const ValueKey('device-slot-card')), findsOneWidget); + expect(find.textContaining('Tier'), findsNothing); + // No Support: Experimental row either. + expect(find.text('Support'), findsNothing); + expect(find.text('Experimental'), findsNothing); + // The switch widget sits right after the card in the page (it draws + // nothing here: no device is registered with WearableDisplay). + final items = t + .widgetList(find.descendant( + of: find.byType(DeviceDetailView), matching: find.byWidgetPredicate((w) => w is DeviceSlotCard || w is DeviceUseSwitch))) + .toList(); + expect(items.map((w) => w.runtimeType), [DeviceSlotCard, DeviceUseSwitch]); + }); + + testWidgets('a ring’s page gets the card and the switch too', (t) async { + await pumpPair(t, DeviceDetailView(_src('oura', name: 'Oura Ring')), size: const Size(412, 2400)); + expect(find.byKey(const ValueKey('device-slot-card')), findsOneWidget); + expect(find.byType(DeviceUseSwitch), findsOneWidget); + expect(find.textContaining('Tier'), findsNothing); + }); + + testWidgets('a band’s page does not get the card', (t) async { + await pumpPair( + t, + DeviceDetailView(const HealthSource( + name: 'WHOOP 5', + kind: 'WHOOP', + tier: SourceTier.wristOptical, + icon: Icons.watch, + isBand: true)), + size: const Size(412, 2000)); + expect(find.byKey(const ValueKey('device-slot-card')), findsNothing); + expect(find.byType(DeviceUseSwitch), findsNothing); + }); + }); +} diff --git a/test/ui2_pair_band_view_test.dart b/test/ui2_pair_band_view_test.dart new file mode 100644 index 000000000..c243a9a1a --- /dev/null +++ b/test/ui2_pair_band_view_test.dart @@ -0,0 +1,247 @@ +// First-run "Pair your band": the list, the filter pills, the strap-only +// sheet, the always-open Trouble connecting card and the way out. Rendered with fakes. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ui2/community_links.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_band_view.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_help.dart' show PairLinkOpener; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart' show PairFilter; + +import 'support/pair_ui_support.dart'; + +class _Links implements PairLinkOpener { + final opened = []; + @override + Future open(String url) async { + opened.add(url); + return true; + } +} + +class _Host implements PairBandHost { + final void Function(BandEntry) onPick; + final VoidCallback onStrap; + _Host(this.onPick, this.onStrap); + @override + void pickBand(BandEntry entry) => onPick(entry); + @override + void strapOnlyConfirmed() => onStrap(); +} + +Finder _row(BandEntry e) => find.byKey(ValueKey('band-row-${e.id}')); + +void main() { + group('which devices the first run offers', () { + test('only the WHOOP bands (DT12)', () { + final ids = [for (final e in firstRunBandEntries()) e.id]; + expect(ids, ['gen4', 'gen5']); + // Other bands pair as additional devices from "Add a device"; sensors, + // scales and the thermometer are never a main device. + for (final id in ['oura', 'colmi', 'miband234', 'ultrahuman', 'pebble', 'coros', 'garmin', + 'ble_hrs', 'polar_pmd', 'thermometer', 'miscale_bc', 'miscale2']) { + expect(ids, isNot(contains(id)), reason: id); + } + }); + }); + + group('PairBandView', () { + Widget view({ + List? entries, + ValueChanged? onPick, + VoidCallback? onStrap, + VoidCallback? onSkip, + VoidCallback? onBack, + String? problem, + _Links? links, + }) => + PairBandView( + entries: entries ?? firstRunBandEntries(), + host: _Host(onPick ?? (_) {}, onStrap ?? () {}), + onSkip: onSkip, + onBack: onBack, + problem: problem, + openLink: links ?? _Links(), + ); + + testWidgets('title, subtitle, and one row per device with what it sends', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + expect(find.text('Set up'), findsOneWidget); + expect(find.byKey(const ValueKey('pair-band-title')), findsOneWidget); + expect(find.text('Pair your band'), findsOneWidget); + expect(find.text('Pick the one you wear. We will look for it nearby.'), findsOneWidget); + for (final e in firstRunBandEntries()) { + expect(_row(e), findsOneWidget, reason: e.id); + expect( + find.descendant( + of: _row(e), + matching: find.text('Sleep, recovery, strain, heart rate and more')), + findsOneWidget, + reason: e.id); + } + }); + + testWidgets('each row carries the Every day chip', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + for (final e in firstRunBandEntries()) { + expect(find.descendant(of: _row(e), matching: find.text('Every day')), findsOneWidget, + reason: e.id); + expect(find.descendant(of: _row(e), matching: find.text('Workouts only')), findsNothing, + reason: e.id); + } + }); + + testWidgets('says plainly that only WHOOP is the main band', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + expect(find.byKey(const ValueKey('main-band-whoop-only-note')), findsOneWidget); + expect(find.text('Main band: WHOOP 4.0 or WHOOP 5.0/MG only. Other devices can be added as additional devices.'), findsOneWidget); + }); + + testWidgets('never says what a band would NOT send, nor primary/secondary/tier', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + for (final w in ['Doesn’t send', 'primary', 'Primary', 'secondary', 'Secondary', 'Tier', 'Experimental']) { + expect(find.textContaining(w), findsNothing, reason: w); + } + }); + + testWidgets('pills show only the groups in the list; Rings narrows it', (t) async { + // The view works for any list; the first run itself offers WHOOP only. + final all = [kWhoopGen5, kOura, kColmi, kUltrahuman, kMiBand234, kPebble]; + await pumpPair(t, view(entries: all), size: const Size(412, 2400)); + for (final k in ['All', 'WHOOP', 'Rings', 'Watches']) { + expect(find.text(k), findsOneWidget, reason: k); + } + expect(find.text('Chest straps & armbands'), findsNothing); + expect(find.text('Bands'), findsNothing); + expect(find.text('Scales'), findsNothing); + await t.tap(find.text('Rings')); + await t.pumpAndSettle(); + expect(_row(kOura), findsOneWidget); + expect(_row(kColmi), findsOneWidget); + expect(_row(kUltrahuman), findsOneWidget); + expect(_row(kWhoopGen5), findsNothing); + expect(_row(kMiBand234), findsNothing); + // The Mi Band is a watch, with the other watches. + await t.tap(find.text('Watches')); + await t.pumpAndSettle(); + expect(_row(kMiBand234), findsOneWidget); + expect(_row(kPebble), findsOneWidget); + expect(_row(kOura), findsNothing); + await t.tap(find.text('All')); + await t.pumpAndSettle(); + expect(_row(kWhoopGen5), findsOneWidget); + }); + + test('the pills’ groups are PairFilter values (WHOOP, rings, watches, straps)', () { + expect(PairFilter.values.map((f) => f.name), ['whoop', 'rings', 'watches', 'straps']); + }); + + testWidgets('tapping a row picks that device', (t) async { + final picked = []; + await pumpPair(t, view(onPick: (e) => picked.add(e.id)), size: const Size(412, 2400)); + await t.tap(_row(kWhoopGen5)); + expect(picked, ['gen5']); + }); + + testWidgets('the strap-only link opens the strap sheet', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + await t.tap(find.text('I only have a heart-rate strap')); + await t.pumpAndSettle(); + expect(find.text('This is a heart-rate strap'), findsOneWidget); + expect(find.text('Pair Bluetooth heart rate sensor'), findsOneWidget); + expect(find.text('Go back'), findsOneWidget); + expect(find.text('Sleep'), findsWidgets); + }); + + testWidgets('Go back closes the sheet and pairs nothing', (t) async { + var confirmed = 0; + await pumpPair(t, view(onStrap: () => confirmed++), size: const Size(412, 2400)); + await t.tap(find.text('I only have a heart-rate strap')); + await t.pumpAndSettle(); + await t.tap(find.text('Go back')); + await t.pumpAndSettle(); + expect(find.text('This is a heart-rate strap'), findsNothing); + expect(confirmed, 0); + }); + + testWidgets('Pair closes the sheet and carries on (strapOnlyConfirmed)', (t) async { + var confirmed = 0; + await pumpPair(t, view(onStrap: () => confirmed++), size: const Size(412, 2400)); + await t.tap(find.text('I only have a heart-rate strap')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair Bluetooth heart rate sensor')); + await t.pumpAndSettle(); + expect(find.text('This is a heart-rate strap'), findsNothing); + expect(confirmed, 1); + }); + + testWidgets('there is no “I can’t pair my band” button, and no dialog anywhere', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + expect(find.text('I can’t pair my band'), findsNothing); + await t.tap(_row(kWhoopGen5)); + await t.pumpAndSettle(); + expect(find.byType(Dialog), findsNothing); + expect(find.text('Need a hand pairing?'), findsNothing); + }); + + testWidgets('the Trouble connecting card is always there, with a link per WHOOP and Discord', (t) async { + final links = _Links(); + await pumpPair(t, view(links: links), size: const Size(412, 2400)); + expect(find.byKey(const ValueKey('trouble-connecting')), findsOneWidget); + expect(find.text('Trouble connecting?'), findsOneWidget); + for (final e in firstRunBandEntries()) { + expect(find.byKey(ValueKey('trouble-link-${e.id}')), findsOneWidget, reason: e.id); + } + await t.tap(find.byKey(const ValueKey('trouble-link-discord'))); + expect(links.opened, [kDiscordUrl]); + }); + + testWidgets('the card stays, unchanged, after trying a device; its links open each model’s page', (t) async { + final links = _Links(); + await pumpPair(t, view(links: links), size: const Size(412, 2400)); + await t.tap(_row(kWhoopGen5)); + await t.pumpAndSettle(); + expect(find.byKey(const ValueKey('trouble-connecting')), findsOneWidget); + await t.tap(find.byKey(ValueKey('trouble-link-${kWhoopGen5.id}'))); + expect(links.opened, [kWhoopGen5.resetSteps!.officialUrl]); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('Skip for now shows only when there is something to skip', (t) async { + await pumpPair(t, view(), size: const Size(412, 2400)); + expect(find.text('Skip for now'), findsNothing); + var skipped = 0; + await pumpPair(t, view(onSkip: () => skipped++), size: const Size(412, 2400)); + await t.tap(find.text('Skip for now')); + expect(skipped, 1); + }); + + testWidgets('the back chevron goes where the first run says', (t) async { + var back = 0; + await pumpPair(t, view(onBack: () => back++), size: const Size(412, 2400)); + await t.tap(find.byIcon(LucideIcons.chevronLeft)); + expect(back, 1); + }); + + testWidgets('a problem is said in a card', (t) async { + await pumpPair(t, view(problem: 'Bluetooth is off.'), size: const Size(412, 2400)); + expect(find.text('Bluetooth is off.'), findsOneWidget); + // The card is under it, not instead of it. + expect(find.byKey(const ValueKey('trouble-connecting')), findsOneWidget); + }); + + testWidgets('German at 2x text on a small phone does not overflow', (t) async { + await pumpPair(t, view(onSkip: () {}), + locale: const Locale('de'), textScale: 2, size: const Size(320, 700)); + expect(t.takeException(), isNull); + await t.scrollUntilVisible(find.text('Ich habe nur einen Herzfrequenzgurt'), 200); + await t.ensureVisible(find.text('Ich habe nur einen Herzfrequenzgurt')); + await t.pumpAndSettle(); + await t.tap(find.text('Ich habe nur einen Herzfrequenzgurt')); + await t.pumpAndSettle(); + expect(find.text('Das ist ein Herzfrequenzgurt'), findsOneWidget); + expect(t.takeException(), isNull); + }); + }); +} diff --git a/test/ui2_pair_device_settings_test.dart b/test/ui2_pair_device_settings_test.dart new file mode 100644 index 000000000..c4af7b38e --- /dev/null +++ b/test/ui2_pair_device_settings_test.dart @@ -0,0 +1,330 @@ +// Settings > My sources > "Pair a device": the one "Your band" card (no band, +// a band paired), and what the "Add a device" screen adds (pills, slot chips, +// the paired notice). +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' show BluetoothDevice; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/hrs_link.dart' show BandCandidate; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/pairing/device_picker.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart' show PairFilter; +import 'package:openstrap_edge/ui2/profile/devices.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +import 'support/pair_ui_support.dart'; + +HealthSource _band([String name = 'Tyler’s WHOOP 5.0 / MG']) => HealthSource( + name: name, + kind: 'WHOOP', + tier: SourceTier.wristOptical, + icon: Icons.watch, + connected: true, + isBand: true, + ); + +HealthSource _strap() => const HealthSource( + name: 'Polar H10', + kind: 'Bluetooth heart rate sensor', + tier: SourceTier.beatToBeat, + icon: Icons.favorite, + isBand: false, + deviceId: 'ble_hrs-0a1b2c3d', + family: 'ble_hrs', + ); + +BandCandidate _cand(String entryId, + {String id = 'AA:BB:CC:DD:EE:01', String? label, BandEntry? alsoBand}) => + ( + device: BluetoothDevice.fromId(id), + label: label, + rssi: -60, + entryId: entryId, + alsoBand: alsoBand == null ? null : (entry: alsoBand, modelCertain: true), + ); + +void main() { + group('My sources: Pair a device', () { + Widget view(List sources, + {VoidCallback? onPair, VoidCallback? onAdd, VoidCallback? onUnpair}) => + MyDevicesView( + sources: sources, + onPair: onPair, + onAddSensor: onAdd, + onUnpair: onUnpair); + + Finder inCard(String key, Finder f) => + find.descendant(of: find.byKey(ValueKey(key)), matching: f); + + testWidgets('no band: ONE Your band card says so, says WHOOP only, and Pair your band opens the band flow', (t) async { + var pair = 0; + await pumpPair(t, view(const [], onPair: () => pair++, onAdd: () {}), + size: const Size(412, 1400)); + expect(find.text('Pair a device'), findsOneWidget); + expect(find.byKey(const ValueKey('pair-band-card')), findsOneWidget); + expect(find.byKey(const ValueKey('band-paired-card')), findsNothing); + expect(inCard('pair-band-card', find.text('Your band')), findsOneWidget); + expect(inCard('pair-band-card', find.text('Every day')), findsOneWidget); + expect(inCard('pair-band-card', find.byKey(const ValueKey('your-band-status'))), + findsOneWidget); + expect(inCard('pair-band-card', find.text('No band is paired')), findsOneWidget); + expect( + inCard('pair-band-card', + find.text('WHOOP 4.0 or WHOOP 5.0/MG only. Sleep, recovery and strain, around the clock.')), + findsOneWidget); + expect(find.byKey(const ValueKey('main-band-whoop-only-note')), findsOneWidget); + final button = inCard('pair-band-card', find.byType(BigButton)); + expect(button, findsOneWidget); + expect(find.descendant(of: button, matching: find.text('Pair your band')), findsOneWidget); + await t.tap(button); + expect(pair, 1); + expect(find.text('Unpair band'), findsNothing); + }); + + testWidgets('no band: no separate “No band is paired” status card above the Your band card', (t) async { + // WHY: the owner's rule is ONE card; a second one said the same thing twice. + await pumpPair(t, view(const [], onPair: () {}, onAdd: () {}), size: const Size(412, 1400)); + expect(find.text('No band is paired'), findsOneWidget); + expect(find.text('Nothing is measuring yet'), findsNothing); + expect(find.byType(StatusCard), findsNothing); + expect(find.text('Pair a band'), findsNothing); + // One button for the band; the Add a device row is not a BigButton. + expect(find.byType(BigButton), findsOneWidget); + }); + + testWidgets('“Add a device” is its own row, below the band card', (t) async { + var add = 0; + await pumpPair(t, view(const [], onPair: () {}, onAdd: () => add++), size: const Size(412, 1400)); + expect(find.descendant(of: find.byKey(const ValueKey('pair-band-card')), matching: find.text('Add a device')), + findsNothing); + expect(find.text('Rings, watches and heart-rate straps'), findsOneWidget); + expect(t.getTopLeft(find.text('Add a device')).dy, + greaterThan(t.getBottomLeft(find.byKey(const ValueKey('pair-band-card'))).dy)); + await t.tap(find.text('Add a device')); + expect(add, 1); + }); + + testWidgets('the WHOOP-only line is in the card with no band; a paired band has no such line', (t) async { + await pumpPair(t, view(const [], onPair: () {}, onAdd: () {}), size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('main-band-whoop-only-note')), findsOneWidget); + await pumpPair(t, view([_band()], onUnpair: () {}, onAdd: () {}), size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('main-band-whoop-only-note')), findsNothing); + }); + + testWidgets('the old “Add a sensor” row is gone', (t) async { + await pumpPair(t, view(const [], onPair: () {}, onAdd: () {}), size: const Size(412, 1400)); + expect(find.text('Add a sensor'), findsNothing); + }); + + testWidgets('a paired strap is not a band: still the Your band card, no band', (t) async { + await pumpPair(t, view([_strap()], onPair: () {}, onAdd: () {}), size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('pair-band-card')), findsOneWidget); + expect(find.byKey(const ValueKey('band-paired-card')), findsNothing); + }); + + testWidgets('a band paired: the same card names it, shows its state, offers Unpair band; Add a device stays', (t) async { + var unpair = 0, add = 0; + await pumpPair(t, view([_band()], onPair: () {}, onAdd: () => add++, onUnpair: () => unpair++), + size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('pair-band-card')), findsNothing); + final card = find.byKey(const ValueKey('band-paired-card')); + expect(card, findsOneWidget); + expect(inCard('band-paired-card', find.text('Your band')), findsOneWidget); + expect(inCard('band-paired-card', find.text('Every day')), findsOneWidget); + expect( + t.widget(inCard('band-paired-card', find.byKey(const ValueKey('your-band-status')))).data, + 'Tyler’s WHOOP 5.0 / MG'); + expect(inCard('band-paired-card', find.text('Connected')), findsOneWidget); + // None of the old copy. + expect(find.text('Paired'), findsNothing); + expect(find.textContaining('You already have a band paired'), findsNothing); + expect(find.text('No band is paired'), findsNothing); + expect(find.byKey(const ValueKey('main-band-whoop-only-note')), findsNothing); + expect(find.text('Pair your band'), findsNothing); + await t.tap(inCard('band-paired-card', find.text('Unpair band'))); + expect(unpair, 1); + await t.tap(find.text('Add a device')); + expect(add, 1); + }); + + testWidgets('Unpair band is tinted red, soft, not a filled button', (t) async { + await pumpPair(t, view([_band()], onUnpair: () {}), size: const Size(412, 1400)); + final button = t.widget(find.byType(BigButton)); + expect(button.color, C.red); + expect(button.soft, isTrue); + expect(find.text('Unpair band'), findsOneWidget); + }); + + testWidgets('Pair your band is the filled one', (t) async { + await pumpPair(t, view(const [], onPair: () {}), size: const Size(412, 1400)); + expect(t.widget(find.byType(BigButton)).soft, isFalse); + }); + + testWidgets('no callbacks, no band: no Pair a device section at all', (t) async { + await pumpPair(t, const MyDevicesView(), size: const Size(412, 1400)); + expect(find.text('Pair a device'), findsNothing); + expect(find.byKey(const ValueKey('pair-band-card')), findsNothing); + }); + + testWidgets('the forget flow asks first, and Keep it paired changes nothing', (t) async { + final app = AppState(); + addTearDown(app.dispose); + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => debugConfirmForgetBand(c, app, 'Tyler’s WHOOP'), + child: const Text('go')))), + size: const Size(412, 900)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + expect(find.text('Forget Tyler’s WHOOP?'), findsOneWidget); + await t.tap(find.text('Keep it paired')); + await t.pumpAndSettle(); + expect(find.text('Forget Tyler’s WHOOP?'), findsNothing); + }); + + testWidgets('German at 2x text does not overflow, in either state', (t) async { + for (final (sources, card) in [ + (const [], 'pair-band-card'), + ([_band()], 'band-paired-card'), + ]) { + await pumpPair(t, view(sources, onPair: () {}, onAdd: () {}, onUnpair: () {}), + locale: const Locale('de'), textScale: 2, size: const Size(360, 1800)); + final ex = t.takeException(); + expect(ex, isNull, reason: ex is FlutterError ? ex.toStringDeep() : '$ex'); + expect(find.byKey(ValueKey(card)), findsOneWidget); + } + }); + }); + + group('Add a device (the unified scan screen)', () { + DevicePickerView view({ + List found = const [], + List<({BandEntry entry, String blurb, IconData icon})> categories = const [], + ValueNotifier? kindSelection, + String? notice, + ValueChanged? onPick, + }) => + DevicePickerView( + query: TextEditingController(), + title: 'Add a device', + subtitle: 'Scan once.', + found: found, + categories: categories, + kindSelection: kindSelection ?? ValueNotifier(null), + notice: notice, + onPickNearby: onPick, + ); + + testWidgets('a WHOOP heard as a strap gets the Every day chip, read off the band; a plain strap Workouts only', (t) async { + await pumpPair( + t, + view(found: [ + _cand('ble_hrs', label: 'Jordan’s WHOOP', alsoBand: kWhoopGen5, id: 'AA:01'), + _cand('ble_hrs', label: 'Polar H10', id: 'AA:02'), + ]), + size: const Size(412, 1400)); + expect(find.text('This is a band'), findsNothing); + // Both candidates are ble_hrs rows; the chip under each is its own. + final chips = find.byKey(const ValueKey('row-slot-ble_hrs')); + expect(find.byKey(ValueKey('row-slot-${kWhoopGen5.id}')), findsOneWidget); + expect(find.descendant(of: find.byKey(ValueKey('row-slot-${kWhoopGen5.id}')), matching: find.text('Every day')), + findsOneWidget); + expect(chips, findsOneWidget); + expect(find.descendant(of: chips, matching: find.text('Workouts only')), findsOneWidget); + }); + + testWidgets('a ring that is nearby gets the Every day chip', (t) async { + await pumpPair(t, view(found: [_cand('oura', label: 'Oura Ring')]), size: const Size(412, 1400)); + expect(find.descendant(of: find.byKey(const ValueKey('row-slot-oura')), matching: find.text('Every day')), + findsOneWidget); + expect(find.text('This is a band'), findsNothing); + }); + + testWidgets('a device that fits neither column (Coros) gets no chip', (t) async { + await pumpPair(t, view(found: [_cand('coros', label: 'Coros')]), size: const Size(412, 1400)); + expect(find.byKey(const ValueKey('row-slot-coros')), findsNothing); + }); + + testWidgets('Browse rows carry the chip too', (t) async { + await pumpPair( + t, + view(categories: [ + (entry: kOura, blurb: 'ring blurb', icon: Icons.circle), + (entry: kBleHrs, blurb: 'strap blurb', icon: Icons.favorite), + ]), + size: const Size(412, 1400)); + expect(find.descendant(of: find.byKey(const ValueKey('row-slot-oura')), matching: find.text('Every day')), + findsOneWidget); + expect(find.descendant(of: find.byKey(const ValueKey('row-slot-ble_hrs')), matching: find.text('Workouts only')), + findsOneWidget); + }); + + testWidgets('pills cover the found devices and the browse list; Rings narrows both', (t) async { + final kind = ValueNotifier(null); + Widget harness() => ValueListenableBuilder( + valueListenable: kind, + builder: (_, _, _) => view( + kindSelection: kind, + found: [ + _cand('oura', label: 'Oura Ring', id: 'AA:01'), + _cand('ble_hrs', label: 'Polar H10', id: 'AA:02'), + ], + categories: [ + (entry: kOura, blurb: 'ring blurb', icon: Icons.circle), + (entry: kBleHrs, blurb: 'strap blurb', icon: Icons.favorite), + ])); + await pumpPair(t, harness(), size: const Size(412, 1400)); + expect(find.text('Rings'), findsOneWidget); + expect(find.text('Chest straps & armbands'), findsOneWidget); + await t.tap(find.text('Rings')); + await t.pumpAndSettle(); + expect(kind.value, PairFilter.rings); + expect(find.text('Polar H10'), findsNothing); + expect(find.text('Oura Ring'), findsWidgets); + expect(find.text('strap blurb'), findsNothing); + expect(find.text('ring blurb'), findsOneWidget); + }); + + testWidgets('(CR r1) a kind the search emptied out filters nothing', (t) async { + // WHY: with one kind left the pills hide, "All" with them; a stale + // selection then hid every row with no control to clear it. + final kind = ValueNotifier(PairFilter.rings); + await pumpPair( + t, + view(kindSelection: kind, found: [ + _cand('ble_hrs', label: 'Polar H10', id: 'AA:02'), + ]), + size: const Size(412, 1400)); + expect(find.text('All'), findsNothing); + expect(find.text('Polar H10'), findsWidgets); + }); + + testWidgets('no pills when onKind is not supplied (the old screen is unchanged)', (t) async { + await pumpPair( + t, + DevicePickerView( + query: TextEditingController(), + title: 't', + subtitle: 's', + found: [_cand('oura', label: 'Oura Ring'), _cand('ble_hrs', label: 'Polar H10', id: 'AA:02')]), + size: const Size(412, 1400)); + expect(find.text('All'), findsNothing); + }); + + testWidgets('after a workouts-only device pairs: “Paired for workouts only.”', (t) async { + await pumpPair(t, view(notice: 'Paired for workouts only.'), size: const Size(412, 1400)); + expect(find.text('Paired for workouts only.'), findsOneWidget); + }); + + testWidgets('tapping a nearby row hands the candidate over', (t) async { + final got = []; + await pumpPair(t, view(found: [_cand('ble_hrs', label: 'Polar H10')], onPick: (c) => got.add(c.entryId)), + size: const Size(412, 1400)); + await t.tap(find.text('Polar H10')); + expect(got, ['ble_hrs']); + }); + }); +} diff --git a/test/ui2_pair_sheets_test.dart b/test/ui2_pair_sheets_test.dart new file mode 100644 index 000000000..c8f0c935a --- /dev/null +++ b/test/ui2_pair_sheets_test.dart @@ -0,0 +1,1003 @@ +// The "this is a band" / "sensor without a band" sheets, and the tap flow that +// decides which one a scanned device gets. Rendered, with a fake pairing +// operation: no BLE. +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' show BluetoothDevice; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/band_identity.dart'; +import 'package:openstrap_edge/ble/hrs_link.dart' show BandCandidate; +import 'package:openstrap_edge/ui2/pairing/add_device_flow.dart'; +import 'package:openstrap_edge/ui2/profile/devices.dart' show kPairableSensors; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/pairing/device_picker.dart' show DevicePickerView; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart' show PairFilter; +import 'package:openstrap_edge/ui2/profile/pair_sensor.dart' show afterBandFoundText; +import 'package:openstrap_edge/ui2/pairing/pair_sheets.dart'; +import 'package:openstrap_edge/ui2/pairing/slot_comparison_view.dart'; + +import 'support/pair_ui_support.dart'; + +BandCandidate _cand(String entryId, + {String id = 'AA:BB:CC:DD:EE:01', String? label, BandEntry? alsoBand}) => + ( + device: BluetoothDevice.fromId(id), + label: label, + rssi: -60, + entryId: entryId, + alsoBand: alsoBand == null ? null : (entry: alsoBand, modelCertain: true), + ); + +/// A screen with one button that runs [flow] on [cand] and prints the result. +class _Harness extends StatefulWidget { + final AddDeviceFlow flow; + final BandCandidate cand; + const _Harness(this.flow, this.cand); + @override + State<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends State<_Harness> { + String result = 'none'; + String second = 'none'; + @override + Widget build(BuildContext c) => Scaffold( + body: Column(children: [ + TextButton( + onPressed: () async { + final r = await widget.flow.onTap(c, widget.cand); + setState(() => result = '${r.outcome.name}|${r.message ?? ''}'); + }, + child: const Text('tap')), + // A competing tap on the same flow, as a second row would make. + TextButton( + onPressed: () async { + final r = await widget.flow.onTap(c, widget.cand); + setState(() => second = '${r.outcome.name}|${r.message ?? ''}'); + }, + child: const Text('tap again')), + Text(result, key: const ValueKey('result')), + Text(second, key: const ValueKey('second')), + ]), + ); +} + +class _Calls implements AddDeviceIo { + final pairs = <(String entryId, PairConfirmation? confirmation)>[]; + final opened = []; + final events = []; + String? heldBack; + final List outcomes; + _Calls([this.outcomes = const [PairOutcome.saved()]]); + int _i = 0; + bool _hasMain = false; + @override + bool get hasMainBand => _hasMain; + + String? bandName = 'Jordan’s WHOOP'; + @override + String? get mainBandName => _hasMain ? bandName : null; + + /// Holds the unpair open until completed (a slow unpair); null returns at + /// once. + Completer? unpairGate; + Object? unpairError; + @override + Future unpairMainBand() async { + events.add('unpair'); + await unpairGate?.future; + if (unpairError != null) throw unpairError!; + } + @override + Future pair(BandCandidate cand, BandEntry entry, + PairConfirmation? confirmation) async { + events.add('pair:${entry.id}'); + pairs.add((entry.id, confirmation)); + return outcomes[_i < outcomes.length ? _i++ : outcomes.length - 1]; + } + + final openedWith = <(String remoteId, bool modelKnown)>[]; + @override + Future openBandFlow(BandEntry band, + {required String remoteId, bool modelKnown = true}) async { + events.add('open:${band.id}'); + opened.add(band.id); + openedWith.add((remoteId, modelKnown)); + } + + @override + Future stopScan() async => events.add('stop'); + + @override + Future mainBandBlockedReason() async => heldBack; + + late AddDeviceFlow flowFor; + AddDeviceFlow flow({bool hasMain = false}) { + _hasMain = hasMain; + return flowFor = AddDeviceFlow(this); + } +} + +String _result(WidgetTester t) => + (t.widget(find.byKey(const ValueKey('result'))) as Text).data!; + +Future _tap(WidgetTester t, _Calls calls, BandCandidate cand, + {bool hasMain = false}) async { + await pumpPair(t, _Harness(calls.flow(hasMain: hasMain), cand), + size: const Size(412, 2000)); + await t.tap(find.text('tap')); + await t.pumpAndSettle(); +} + +void main() { + group('the band-found sheet', () { + Future open(WidgetTester t, BandEntry band, + {required bool hasMain, bool afterConnect = false}) async { + BandSheetChoice? got; + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => got = await showBandFoundSheet(c, + band: band, + hasMainBand: hasMain, + afterConnect: afterConnect), + child: const Text('open')))), + size: const Size(412, 2000)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + return got; + } + + testWidgets('no main band yet: pair it as my band, or use it for workouts only', (t) async { + await open(t, kWhoopGen5, hasMain: false); + expect(find.text('This is a WHOOP band'), findsOneWidget); + expect(find.text('WHOOP 5'), findsOneWidget, reason: 'the device line'); + expect(find.text('It does much more every day than during workouts only. Which do you want?'), findsOneWidget); + expect(find.text('Sleep'), findsOneWidget); + expect(find.text('Every day'), findsOneWidget); + expect(find.text('Workouts only'), findsOneWidget); + expect(find.text('Pair it as my band'), findsOneWidget); + expect(find.text('Use it for workouts only'), findsOneWidget); + expect(find.text('Go back'), findsOneWidget); + }); + + testWidgets('pairing mode is up front: WHOOP 5’s steps, above the main button', (t) async { + await open(t, kWhoopGen5, hasMain: false); + expect(find.text('Put it in pairing mode'), findsOneWidget); + final step = t.getTopLeft(find.byKey(const ValueKey('pairing-mode-step-gen5-1'))).dy; + expect(step, lessThan(t.getTopLeft(find.text('Pair it as my band')).dy)); + expect(step, greaterThan(t.getTopLeft(find.byType(SlotTable)).dy)); + }); + + testWidgets('a ring and a strap get their own pairing-mode section', (t) async { + await open(t, kOura, hasMain: false); + expect(find.byKey(const ValueKey('pairing-mode-oura')), findsOneWidget); + expect(find.byKey(const ValueKey('pairing-mode-step-oura-1')), findsOneWidget); + }); + + testWidgets('the table: every day sends sleep, workouts only does not; no dashes', (t) async { + await open(t, kWhoopGen5, hasMain: false); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-sleep'))).label, 'Sleep, Every day: Sends'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-workoutsOnly-sleep'))).label, 'Sleep, Workouts only: Doesn’t send'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-workoutsOnly-workoutHeartRate'))).label, 'Heart rate during workouts, Workouts only: Sends'); + expect(find.byIcon(LucideIcons.minus), findsNothing); + expect(find.text('Workouts only: If this device sends it, not confirmed'), findsOneWidget); + expect(find.text('Blood oxygen'), findsNothing); + }); + + testWidgets('the sheet washes the Every day column, not Workouts only', (t) async { + await open(t, kWhoopGen5, hasMain: false); + Color? colour(String slot) => (t + .widget(find + .descendant( + of: find.byKey(const ValueKey('slot-row-sleep')), + matching: find.byKey(ValueKey('slot-cell-$slot'))) + .first) + .color); + expect(colour('everyDay'), isNotNull); + expect(colour('workoutsOnly'), isNull); + }); + + testWidgets('the choices come back to the caller', (t) async { + BandSheetChoice? got; + Future run(String label) async { + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => got = await showBandFoundSheet(c, + band: kWhoopGen5, hasMainBand: false), + child: const Text('open')))), + size: const Size(412, 2000)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + await t.tap(find.text(label)); + await t.pumpAndSettle(); + } + + await run('Pair it as my band'); + expect(got, BandSheetChoice.asBand); + got = null; + await run('Use it for workouts only'); + expect(got, BandSheetChoice.asSensor); + got = BandSheetChoice.asBand; + await run('Go back'); + expect(got, isNull); + }); + + testWidgets('a main band already exists: unpair it and pair this one, or workouts only', (t) async { + BandSheetChoice? got; + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => got = await showBandFoundSheet(c, + band: kWhoopGen5, + hasMainBand: true, + mainBandName: 'Jordan’s WHOOP'), + child: const Text('open')))), + size: const Size(412, 2000)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(find.text('You already have a band. Only one can be your band at a time.'), findsOneWidget); + expect(find.text('Your band now: Jordan’s WHOOP. Your data stays when you switch.'), findsOneWidget); + expect(find.text('Pair it as my band'), findsNothing); + expect(find.text('Use it for workouts only'), findsOneWidget); + await t.tap(find.text('Unpair Jordan’s WHOOP and pair this one')); + await t.pumpAndSettle(); + expect(got, BandSheetChoice.replaceBand); + }); + + testWidgets('a paired band with no name is “your band”', (t) async { + await open(t, kWhoopGen5, hasMain: true); + expect(find.text('Unpair your band and pair this one'), findsOneWidget); + }); + + testWidgets('Coros fits neither column: a watch, all crosses, one Pair button', (t) async { + await open(t, kCoros, hasMain: false); + expect(find.text('This is a watch'), findsOneWidget); + expect(find.text('Pair Coros watch'), findsOneWidget); + expect(find.text('Pair it as my band'), findsNothing); + expect(find.byIcon(LucideIcons.check), findsNothing); + expect(find.textContaining('its readings never feed a score'), findsOneWidget); + }); + + testWidgets('a ring fills every day: the ring’s rows with their captions, and the experimental note', (t) async { + await open(t, kOura, hasMain: false); + expect(find.text('This is a ring'), findsOneWidget); + expect(find.text('It can fill your days with sleep, recovery and heart rate.'), findsOneWidget); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-sleep'))).label, 'Sleep, Every day: Sends. From the ring’s own sleep stages'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-workoutsOnly-sleep'))).label, 'Sleep, Workouts only: Doesn’t send'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-strain'))).label, 'Strain, Every day: Doesn’t send'); + expect(find.text('Every day: From the ring’s own sleep stages'), findsOneWidget); + expect(find.text('Every day: Estimated for the first 14 nights'), findsOneWidget); + expect(find.text('Every day: After a few nights'), findsOneWidget); + expect(find.text('Fills days without your WHOOP. Experimental, so switch it on in Devices.'), findsOneWidget); + expect(find.text('Pair Oura Ring'), findsOneWidget); + expect(find.text('Pair it as my band'), findsNothing); + }); + + testWidgets('after connecting: says it identified itself, and that nothing was saved', (t) async { + await open(t, kWhoopGen5, hasMain: false, afterConnect: true); + expect( + find.text('It looked like a heart-rate strap, but when we connected it identified itself as WHOOP 5. Nothing was saved.'), + findsOneWidget); + expect(find.text('Pair it as my band'), findsOneWidget); + }); + + testWidgets('German at 2x text does not overflow', (t) async { + BandSheetChoice? ignore; + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => ignore = await showBandFoundSheet(c, + band: kWhoopGen5, hasMainBand: false), + child: const Text('open')))), + locale: const Locale('de'), textScale: 2, size: const Size(360, 900)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(ignore, isNull); + }); + }); + + group('the strap sheet', () { + Future open(WidgetTester t, BandEntry sensor, void Function(bool?) got) async { + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => + got(await showStrapSheet(c, sensor: sensor)), + child: const Text('open')))), + size: const Size(412, 2000)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + } + + testWidgets('a heart-rate strap: workouts only, the switch note, and Pair', (t) async { + await open(t, kBleHrs, (_) {}); + expect(find.text('This is a heart-rate strap'), findsOneWidget); + expect(find.text('It adds heart rate while you work out. It does not record sleep, recovery or daily strain.'), findsOneWidget); + expect(find.text('Both need “Use during workouts” on.'), findsOneWidget); + expect(find.text('Workouts only: If this device sends it, not confirmed'), findsOneWidget); + expect(find.text('Pair Bluetooth heart rate sensor'), findsOneWidget); + expect(find.text('Go back'), findsOneWidget); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-sleep'))).label, 'Sleep, Every day: Doesn’t send'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-everyDay-workoutHeartRate'))).label, 'Heart rate during workouts, Every day: Doesn’t send'); + expect(t.getSemantics(find.byKey(const ValueKey('slot-workoutsOnly-workoutHeartRate'))).label, 'Heart rate during workouts, Workouts only: Sends'); + }); + + testWidgets('a generic strap says there are no maker’s steps', (t) async { + await open(t, kBleHrs, (_) {}); + expect(find.byKey(const ValueKey('pairing-mode-no-site-ble_hrs')), findsOneWidget); + }); + + testWidgets('another kind of sensor is a heart-rate sensor', (t) async { + await open(t, kPolarPmd, (_) {}); + expect(find.text('This is a heart-rate sensor'), findsOneWidget); + expect(find.text('This is a heart-rate strap'), findsNothing); + }); + + testWidgets('Pair returns true, Go back false', (t) async { + bool? got; + await open(t, kBleHrs, (v) => got = v); + await t.tap(find.text('Pair Bluetooth heart rate sensor')); + await t.pumpAndSettle(); + expect(got, isTrue); + await open(t, kBleHrs, (v) => got = v); + await t.tap(find.text('Go back')); + await t.pumpAndSettle(); + expect(got, isFalse); + }); + + testWidgets('German at 2x text does not overflow', (t) async { + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => showStrapSheet(c, sensor: kBleHrs), + child: const Text('open')))), + locale: const Locale('de'), textScale: 2, size: const Size(360, 900)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + }); + }); + + group('replacing the band', () { + final whoop = _cand('gen5', id: 'AA:00:00:00:00:77', label: 'WHOOP 5A1B'); + + testWidgets('confirm: the existing unpair runs, then the band flow opens for the tapped device', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoop, hasMain: true); + await t.tap(find.text('Unpair Jordan’s WHOOP and pair this one')); + await t.pumpAndSettle(); + expect(find.text('Unpair Jordan’s WHOOP and pair this band instead?'), findsOneWidget); + expect(find.text('Your data stays.'), findsOneWidget); + expect(calls.events, isEmpty, reason: 'nothing happens before the one confirm'); + await t.tap(find.text('Unpair and pair')); + await t.pumpAndSettle(); + expect(calls.events, ['unpair', 'stop', 'open:gen5']); + expect(calls.openedWith, [('AA:00:00:00:00:77', true)]); + expect(calls.pairs, isEmpty); + expect(_result(t), 'openedBandFlow|'); + }); + + testWidgets('cancel does nothing: no unpair, no pairing, no band flow', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoop, hasMain: true); + await t.tap(find.text('Unpair Jordan’s WHOOP and pair this one')); + await t.pumpAndSettle(); + await t.tap(find.text('Cancel')); + await t.pumpAndSettle(); + expect(calls.events, isEmpty); + expect(calls.pairs, isEmpty); + expect(calls.opened, isEmpty); + expect(_result(t), 'cancelled|'); + }); + + testWidgets('an unpair that throws: a failed result with the reason, and no pairing opens', (t) async { + final calls = _Calls()..unpairError = StateError('engine busy'); + await _tap(t, calls, whoop, hasMain: true); + await t.tap(find.text('Unpair Jordan’s WHOOP and pair this one')); + await t.pumpAndSettle(); + await t.tap(find.text('Unpair and pair')); + await t.pumpAndSettle(); + expect(calls.events, ['unpair']); + expect(calls.opened, isEmpty); + expect(calls.pairs, isEmpty); + expect(_result(t), + 'failed|Could not unpair your band, so the new one was not paired. Try again.'); + }); + + testWidgets('a second tap while the unpair is still running starts nothing', (t) async { + final calls = _Calls()..unpairGate = Completer(); + await _tap(t, calls, whoop, hasMain: true); + await t.tap(find.text('Unpair Jordan’s WHOOP and pair this one')); + await t.pumpAndSettle(); + await t.tap(find.text('Unpair and pair')); + await t.pump(); + expect(calls.events, ['unpair']); + // The unpair is still awaiting; a competing tap is refused at once. + await t.tap(find.text('tap again')); + await t.pumpAndSettle(); + expect(find.text('This is a WHOOP band'), findsNothing, + reason: 'no second sheet opened'); + expect((t.widget(find.byKey(const ValueKey('second'))) as Text).data, + 'cancelled|'); + calls.unpairGate!.complete(); + await t.pumpAndSettle(); + expect(calls.events, ['unpair', 'stop', 'open:gen5']); + expect(calls.opened, ['gen5']); + expect(_result(t), 'openedBandFlow|'); + // Once it ended, the flow takes a tap again. + expect(calls.flowFor.running, isFalse); + }); + + testWidgets('while a flow runs the Add a device list takes no taps', (t) async { + var taps = 0; + await pumpPair( + t, + DevicePickerView( + query: TextEditingController(), + title: 'Add a device', + subtitle: '', + found: [whoop], + locked: true, + onPickNearby: (_) => taps++, + ), + size: const Size(412, 2000)); + await t.tap(find.text('WHOOP 5A1B'), warnIfMissed: false); + await t.pumpAndSettle(); + expect(taps, 0); + await pumpPair( + t, + DevicePickerView( + query: TextEditingController(), + title: 'Add a device', + subtitle: '', + found: [whoop], + onPickNearby: (_) => taps++, + ), + size: const Size(412, 2000)); + await t.tap(find.text('WHOOP 5A1B')); + await t.pumpAndSettle(); + expect(taps, 1, reason: 'positive control: unlocked, the row takes a tap'); + }); + + testWidgets('a WHOOP heard as a strap is replaced the same way, bound to that device', (t) async { + final calls = _Calls(); + await _tap(t, calls, + _cand('ble_hrs', id: 'AA:00:00:00:00:78', label: 'Jordan’s other WHOOP', alsoBand: kWhoopGen4), + hasMain: true); + await t.tap(find.text('Unpair Jordan’s WHOOP and pair this one')); + await t.pumpAndSettle(); + await t.tap(find.text('Unpair and pair')); + await t.pumpAndSettle(); + expect(calls.events, ['unpair', 'stop', 'open:gen4']); + expect(calls.openedWith, [('AA:00:00:00:00:78', true)]); + }); + + testWidgets('after a connect found a band: the same replace, one confirm', (t) async { + final calls = _Calls([PairOutcome.band(kWhoopGen5)]); + await _tap(t, calls, _cand('ble_hrs', label: 'Polar H10'), hasMain: true); + await t.tap(find.text('Unpair Jordan’s WHOOP and pair this one')); + await t.pumpAndSettle(); + await t.tap(find.text('Unpair and pair')); + await t.pumpAndSettle(); + expect(calls.events, ['pair:ble_hrs', 'unpair', 'stop', 'open:gen5']); + }); + + testWidgets('the confirm fits in German at 2x text', (t) async { + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => showReplaceBandConfirm(c, + mainBandName: 'Jordans WHOOP'), + child: const Text('open')))), + locale: const Locale('de'), textScale: 2, size: const Size(360, 800)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(find.byType(SlotTable), findsNothing); + }); + }); + + group('tap flow', () { + final whoopAsStrap = _cand('ble_hrs', label: 'Jordan’s WHOOP', alsoBand: kWhoopGen5); + + testWidgets('a WHOOP heard as a strap, no band yet: sheet, then “secondary anyway” pairs with a confirmation bound to that device and entry', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoopAsStrap); + expect(find.text('This is a WHOOP band'), findsOneWidget); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.length, 1); + expect(calls.pairs.single.$1, 'ble_hrs'); + expect(calls.pairs.single.$2!.remoteId, 'AA:BB:CC:DD:EE:01'); + expect(calls.pairs.single.$2!.bandEntryId, 'gen5'); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('“Pair it as my band” goes to the main band flow and pairs nothing here', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoopAsStrap); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(calls.opened, ['gen5']); + expect(_result(t), 'openedBandFlow|'); + }); + + testWidgets('(CR r1) the band flow is bound to the tapped device, not the first WHOOP in range', (t) async { + // WHY: handing over only the entry let the main-band scan pair any + // nearby WHOOP of that kind. + final calls = _Calls(); + await _tap(t, calls, _cand('ble_hrs', id: 'AA:00:00:00:00:42', alsoBand: kWhoopGen5)); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.openedWith, [('AA:00:00:00:00:42', true)]); + }); + + testWidgets('(CR r1) a WHOOP known only by its name is not named a model', (t) async { + // WHY: the name cannot tell WHOOP 4 from 5; the name-matched entry is + // gen4, which must not become "This is WHOOP 4" or a WHOOP 4 pairing. + final calls = _Calls(); + final byName = ( + device: BluetoothDevice.fromId('AA:00:00:00:00:43'), + label: 'WHOOP 4C0FFEE', + rssi: -60, + entryId: 'gen4', + alsoBand: (entry: kWhoopGen4, modelCertain: false), + ); + await _tap(t, calls, byName); + expect(find.text('This is a WHOOP band'), findsOneWidget); + expect(find.text('WHOOP 4'), findsNothing, reason: 'no model named'); + expect(find.text('WHOOP 4C0FFEE'), findsOneWidget, reason: 'its own name'); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.openedWith, [('AA:00:00:00:00:43', false)]); + }); + + testWidgets('Go back pairs nothing and opens nothing', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoopAsStrap); + await t.tap(find.text('Go back')); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(calls.opened, isEmpty); + expect(_result(t), 'cancelled|'); + }); + + testWidgets('a band already paired: workouts only pairs with the confirmation', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoopAsStrap, hasMain: true); + expect(find.text('Pair it as my band'), findsNothing); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.single.$2!.bandEntryId, 'gen5'); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('a plain strap and no band: the strap sheet, then pairs without a confirmation', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('ble_hrs', label: 'Polar H10')); + expect(find.text('This is a heart-rate strap'), findsOneWidget); + expect(find.text('Polar H10'), findsOneWidget, reason: 'its own name'); + await t.tap(find.text('Pair Bluetooth heart rate sensor')); + await t.pumpAndSettle(); + expect(calls.pairs.single.$1, 'ble_hrs'); + expect(calls.pairs.single.$2, isNull); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('a plain strap, declined: nothing pairs', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('ble_hrs')); + await t.tap(find.text('Go back')); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(_result(t), 'cancelled|'); + }); + + testWidgets('a plain strap with a band already paired pairs at once, no sheet', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('ble_hrs'), hasMain: true); + expect(find.text('This is a heart-rate strap'), findsNothing); + expect(calls.pairs.single.$1, 'ble_hrs'); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('a thermometer is not a secondary sensor: no sheet, and no secondary message', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('thermometer')); + expect(find.text('This is a heart-rate strap'), findsNothing); + expect(calls.pairs.single.$1, 'thermometer'); + expect(_result(t), 'pairedOther|'); + }); + + testWidgets('a connect that finds a band: the identified-itself sheet; confirming re-runs pairing with the confirmation', (t) async { + final calls = _Calls([PairOutcome.band(kWhoopGen5), const PairOutcome.saved()]); + await _tap(t, calls, _cand('ble_hrs', label: 'Polar H10'), hasMain: true); + expect(find.textContaining('identified itself as WHOOP 5. Nothing was saved.'), findsOneWidget); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.length, 2); + expect(calls.pairs[0].$2, isNull); + expect(calls.pairs[1].$2!.remoteId, 'AA:BB:CC:DD:EE:01'); + expect(calls.pairs[1].$2!.bandEntryId, 'gen5'); + expect(_result(t), 'pairedSecondary|'); + }); + + testWidgets('a connect that finds a band, no main band: “Pair it as my band” opens that flow', (t) async { + final calls = _Calls([PairOutcome.band(kWhoopGen5)]); + await _tap(t, calls, _cand('ble_hrs'), hasMain: false); + // The strap sheet first (nothing known yet), then the band sheet after connect. + await t.tap(find.text('Pair Bluetooth heart rate sensor')); + await t.pumpAndSettle(); + expect(find.textContaining('identified itself as WHOOP 5'), findsOneWidget); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.pairs.length, 1); + expect(calls.opened, ['gen5']); + expect(_result(t), 'openedBandFlow|'); + }); + + testWidgets('a connect that finds a band, then Go back: pairing ran once and nothing was saved', (t) async { + final calls = _Calls([PairOutcome.band(kWhoopGen5)]); + await _tap(t, calls, _cand('ble_hrs'), hasMain: true); + await t.ensureVisible(find.text('Go back')); + await t.pumpAndSettle(); + await t.tap(find.text('Go back')); + await t.pumpAndSettle(); + expect(calls.pairs.length, 1); + expect(_result(t), 'cancelled|'); + }); + + testWidgets('a failure comes back as its sentence', (t) async { + final calls = _Calls([const PairOutcome.failed('The sensor did not answer.')]); + await _tap(t, calls, _cand('ble_hrs'), hasMain: true); + expect(_result(t), 'failed|The sensor did not answer.'); + }); + + testWidgets('a ring (a band in its own right) gets the band sheet, and its own screen does the pairing', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('oura', label: 'Oura Ring')); + expect(find.text('This is a ring'), findsOneWidget); + // Not a WHOOP, so there is no main-band choice (DT12): one button. + expect(find.text('Pair it as my band'), findsNothing); + await t.tap(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + // The ring needs its own key screen, so it never pairs from here. + expect(calls.pairs, isEmpty); + expect(calls.opened, ['oura']); + }); + }); + + group('(CR r2) a ring heard as a heart-rate strap', () { + final ouraAsStrap = + _cand('ble_hrs', id: 'AA:00:00:00:00:91', label: 'Oura', alsoBand: kOura); + + testWidgets('the tap opens the ring’s own screen, never a plain strap pairing', (t) async { + final calls = _Calls(); + await _tap(t, calls, ouraAsStrap); + expect(find.text('This is a ring'), findsOneWidget); + await t.tap(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(calls.opened, ['oura']); + expect(_result(t), 'openedBandFlow|'); + }); + + testWidgets('a connect that finds the ring does the same', (t) async { + final calls = _Calls([PairOutcome.band(kOura)]); + await _tap(t, calls, _cand('ble_hrs', label: 'HRM'), hasMain: true); + await t.ensureVisible(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair Oura Ring')); + await t.pumpAndSettle(); + expect(calls.pairs.length, 1, reason: 'only the first connect'); + expect(calls.opened, ['oura']); + expect(_result(t), 'openedBandFlow|'); + }); + }); + + group('(CR r2) a WHOOP heard as a strap is filtered as a WHOOP', () { + testWidgets('the WHOOP pill shows it, the Straps pill does not', (t) async { + final group = ValueNotifier(null); + await pumpPair( + t, + // The screen that owns the selection rebuilds on it, as the picker + // does. + ValueListenableBuilder( + valueListenable: group, + builder: (_, _, _) => DevicePickerView( + query: TextEditingController(), + title: 'Add a device', + subtitle: '', + found: [ + _cand('ble_hrs', id: 'AA:00:00:00:00:A1', label: 'Jordan’s WHOOP', alsoBand: kWhoopGen5), + _cand('ble_hrs', id: 'AA:00:00:00:00:A2', label: 'Polar H10'), + ], + kindSelection: group, + ), + ), + size: const Size(412, 2000)); + expect(find.byKey(const ValueKey('pill-whoop')), findsOneWidget); + group.value = PairFilter.whoop; + await t.pumpAndSettle(); + expect(find.text('Jordan’s WHOOP'), findsOneWidget); + expect(find.text('Polar H10'), findsNothing); + group.value = PairFilter.straps; + await t.pumpAndSettle(); + expect(find.text('Jordan’s WHOOP'), findsNothing); + expect(find.text('Polar H10'), findsOneWidget); + }); + }); + + group('(CR r2) after the band flow, the sensor screen tells the truth', () { + final l = lookupAppLocalizations(const Locale('en')); + final found = PairOutcome.band(kWhoopGen5); + const opened = (outcome: AddDeviceOutcome.openedBandFlow, message: null); + test('the band got paired: no "Nothing was saved"', () { + expect(afterBandFoundText(l, found, opened, bandPairedNow: true), isNull); + }); + test('the band flow closed without pairing: nothing was saved, said so', () { + expect(afterBandFoundText(l, found, opened, bandPairedNow: false), + contains('Nothing was saved')); + }); + test('a ring’s own screen reported its own outcome', () { + expect( + afterBandFoundText(l, PairOutcome.band(kOura), opened, + bandPairedNow: false), + isNull); + }); + test('backed out, or failed, as before', () { + expect( + afterBandFoundText( + l, found, (outcome: AddDeviceOutcome.cancelled, message: null), + bandPairedNow: false), + contains('Nothing was saved')); + expect( + afterBandFoundText( + l, found, (outcome: AddDeviceOutcome.failed, message: 'x'), + bandPairedNow: true), + 'x'); + }); + }); + + group('G3 r1 S2: browsing goes through the same confirmations', () { + // A button that runs confirmBrowse and prints the answer. + Future browse(WidgetTester t, _Calls calls, BandEntry e, + {required bool hasMain, String? tapLabel}) async { + bool? got; + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async => + got = await calls.flow(hasMain: hasMain).confirmBrowse(c, e), + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + if (tapLabel != null) { + await t.tap(find.text(tapLabel)); + await t.pumpAndSettle(); + } + return got; + } + + testWidgets('the generic heart-rate sensor with no main band asks first; Go back says no', (t) async { + final calls = _Calls(); + final got = await browse(t, calls, kBleHrs, hasMain: false, tapLabel: 'Go back'); + expect(got, isFalse); + }); + + testWidgets('... and “Pair” says yes', (t) async { + final calls = _Calls(); + expect(await browse(t, calls, kBleHrs, hasMain: false, tapLabel: 'Pair Bluetooth heart rate sensor'), isTrue); + }); + + testWidgets('Polar PMD is a workout sensor too: same sheet', (t) async { + final calls = _Calls(); + await browse(t, calls, kPolarPmd, hasMain: false); + expect(find.text('This is a heart-rate sensor'), findsOneWidget); + }); + + testWidgets('with a main band, or for a device that is its own entry, no sheet', (t) async { + final calls = _Calls(); + expect(await browse(t, calls, kBleHrs, hasMain: true), isTrue); + expect(find.text('This is a heart-rate strap'), findsNothing); + expect(await browse(t, calls, kColmi, hasMain: false), isTrue); + expect(find.text('This is a heart-rate strap'), findsNothing); + }); + + testWidgets('a connect that finds a band (category path): band-found sheet, then re-pair with the confirmation', (t) async { + final calls = _Calls([const PairOutcome.saved()]); + bool? ok; + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () async { + final r = await calls.flow(hasMain: true).afterConnect( + c, _cand('ble_hrs'), kBleHrs, PairOutcome.band(kWhoopGen5)); + ok = r.outcome == AddDeviceOutcome.pairedSecondary; + }, + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + expect(find.textContaining('identified itself as WHOOP 5. Nothing was saved.'), findsOneWidget); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.single.$2!.bandEntryId, 'gen5'); + expect(ok, isTrue); + }); + }); + + testWidgets('(CR 4500501772) a name-only WHOOP found on connect keeps its unknown model', (t) async { + // WHY: the connect can only confirm what the name said (gen4 is the + // name-matched entry); a WHOOP 5 heard by name must not enter the band + // flow as a WHOOP 4. + final calls = _Calls(); + final byName = ( + device: BluetoothDevice.fromId('AA:00:00:00:00:44'), + label: 'WHOOP 4C0FFEE', + rssi: -60, + entryId: 'ble_hrs', + alsoBand: (entry: kWhoopGen4, modelCertain: false), + ); + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => calls.flow(hasMain: false).afterConnect( + c, byName, kBleHrs, + const PairOutcome.band(kWhoopGen4, modelCertain: false)), + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + expect(find.textContaining('WHOOP 4.'), findsNothing, reason: 'no model named'); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.openedWith, [('AA:00:00:00:00:44', false)]); + }); + + testWidgets('(CR 4500539072) a model GATT confirmed is named, even when the name guessed it', (t) async { + final calls = _Calls(); + final byName = ( + device: BluetoothDevice.fromId('AA:00:00:00:00:45'), + label: 'WHOOP 4C0FFEE', + rssi: -60, + entryId: 'ble_hrs', + alsoBand: (entry: kWhoopGen4, modelCertain: false), + ); + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => calls.flow(hasMain: false).afterConnect( + c, byName, kBleHrs, const PairOutcome.band(kWhoopGen4)), + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.openedWith, [('AA:00:00:00:00:45', true)]); + }); + + testWidgets('(CR 4500539075) a ring found on connect pairs under its own entry', (t) async { + // WHY: kept under ble_hrs, a Colmi would be a workout-only strap with no + // ring history sync (and fail outright without 0x2A37). + final calls = _Calls([const PairOutcome.saved()]); + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => calls.flow(hasMain: true).afterConnect( + c, _cand('ble_hrs', label: 'R02_1A2B'), kBleHrs, + const PairOutcome.band(kColmi)), + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + await t.ensureVisible(find.text('Pair Colmi ring')); + await t.tap(find.text('Pair Colmi ring')); + await t.pumpAndSettle(); + expect(calls.pairs.map((p) => p.$1), ['colmi']); + }); + + testWidgets('(CR 4500539075) a ring heard as a heart-rate sensor pairs under its own entry from the list', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('ble_hrs', label: 'R02_1A2B', alsoBand: kColmi)); + await t.ensureVisible(find.text('Pair Colmi ring')); + await t.tap(find.text('Pair Colmi ring')); + await t.pumpAndSettle(); + expect(calls.pairs.map((p) => p.$1), ['colmi']); + }); + + for (final sensor in kPairableSensors.where((s) => s.pick != null)) { + testWidgets('(CR 4500569334) ${sensor.entry.id} found on connect goes to its own screen, never a plain save', (t) async { + // WHY: its pairing installs a key (its `pick`). A plain notify pair + // would save a row sync cannot use. + final calls = _Calls([const PairOutcome.saved()]); + await pumpPair( + t, + Builder( + builder: (c) => Scaffold( + body: TextButton( + onPressed: () => calls.flow(hasMain: true).afterConnect( + c, _cand('ble_hrs'), kBleHrs, + PairOutcome.band(sensor.entry)), + child: const Text('go')))), + size: const Size(412, 2000)); + await t.tap(find.text('go')); + await t.pumpAndSettle(); + final pair = find.textContaining('Pair ').last; + await t.ensureVisible(pair); + await t.tap(pair); + await t.pumpAndSettle(); + expect(calls.pairs, isEmpty); + expect(calls.opened, [sensor.entry.id]); + }); + } + + group('G3 r1 S3: the handoff to the main-band flow', () { + final whoopOnly = _cand('gen5', label: 'WHOOP 5A1B'); + + testWidgets('the scan is stopped BEFORE the main-band flow opens', (t) async { + final calls = _Calls(); + await _tap(t, calls, whoopOnly); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.events, ['stop', 'open:gen5']); + expect(_result(t), 'openedBandFlow|'); + }); + + testWidgets('where the system pairing sheet cannot open (iOS, no provisioned band) restart guidance replaces it', (t) async { + final calls = _Calls()..heldBack = 'Restart the app before you pair it.'; + await _tap(t, calls, whoopOnly); + await t.tap(find.text('Pair it as my band')); + await t.pumpAndSettle(); + expect(calls.opened, isEmpty); + expect(calls.events, ['stop']); + expect(_result(t), 'bandFlowHeldBack|Restart the app before you pair it.'); + }); + }); + + group('G3 r1 S4: a WHOOP-only advertisement', () { + testWidgets('the band-choice flow runs for a scanned framed entry; a secondary choice pairs under ble_hrs with the confirmation', (t) async { + final calls = _Calls(); + await _tap(t, calls, _cand('gen5', label: 'WHOOP 5A1B'), hasMain: true); + expect(find.text('This is a WHOOP band'), findsOneWidget); + await t.tap(find.text('Use it for workouts only')); + await t.pumpAndSettle(); + expect(calls.pairs.single.$1, 'ble_hrs'); + expect(calls.pairs.single.$2!.bandEntryId, 'gen5'); + expect(_result(t), 'pairedSecondary|'); + }); + }); +} diff --git a/test/ui2_pairing_blocks_test.dart b/test/ui2_pairing_blocks_test.dart new file mode 100644 index 000000000..ff7195523 --- /dev/null +++ b/test/ui2_pairing_blocks_test.dart @@ -0,0 +1,422 @@ +// The two swappable pairing building blocks: the capability matrix (C1) and +// the category filter pills (K3), plus the registry field the pills read. +// Rendered, not read: both are layout. +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' show Tristate; + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart' show SemanticsNode; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/slot_comparison.dart'; +import 'package:openstrap_edge/ui2/pairing/category_filter_pills.dart'; +import 'package:openstrap_edge/ui2/ui2.dart' show S; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart'; +import 'package:openstrap_edge/ui2/pairing/slot_comparison_view.dart'; + +import 'support/pair_ui_support.dart'; + +Finder _mark(String side, DeviceCapability c) => + find.byKey(ValueKey('slot-$side-${c.name}')); + +bool _hasIcon(WidgetTester t, Finder mark, IconData icon) => t + .widgetList(find.descendant(of: mark, matching: find.byType(Icon))) + .any((i) => i.icon == icon); + +bool _sel(SemanticsNode n) => n.getSemanticsData().flagsCollection.isSelected == Tristate.isTrue; + +void main() { + group('BandEntry.kind', () { + test('every registry entry says what it physically is', () { + // WHY: an `other` default would put a new device under the catch-all pill. + final other = [ + for (final e in kBandRegistry) + if (e.kind == DeviceKind.other) e.id + ]; + expect(other, isEmpty); + }); + + test('the kinds the owner named', () { + expect(kWhoopGen4.kind, DeviceKind.strap); + expect(kWhoopGen5.kind, DeviceKind.strap); + expect(kOura.kind, DeviceKind.ring); + expect(kColmi.kind, DeviceKind.ring); + expect(kUltrahuman.kind, DeviceKind.ring); + expect(kGarmin.kind, DeviceKind.watch); + expect(kMiBand234.kind, DeviceKind.band); + expect(kBleHrs.kind, DeviceKind.chestStrap); + expect(kThermometer.kind, DeviceKind.thermometer); + expect(kMiScale2.kind, DeviceKind.scale); + }); + }); + + group('CategoryFilterPills (K3)', () { + Widget pills(List kinds, + {PairFilter? selected, ValueNotifier? selection}) => + Scaffold( + body: CategoryFilterPills( + kinds: kinds, + selection: selection ?? ValueNotifier(selected)), + ); + + testWidgets('all five filters keep reading order when the strap label wraps', + (t) async { + await pumpPair( + t, + pills([ + DeviceKind.chestStrap, + DeviceKind.watch, + DeviceKind.ring, + DeviceKind.strap, + ]), + size: const Size(600, 800)); + // The fuller strap label can wrap to the next line. Pin reading order + // in both dimensions, so wrapping does not masquerade as reordering. + final positions = [ + for (final k in ['All', 'WHOOP', 'Rings', 'Watches', 'Chest straps & armbands']) + t.getTopLeft(find.text(k)), + ]; + final readingOrder = [...positions]..sort((a, b) { + final row = a.dy.compareTo(b.dy); + return row == 0 ? a.dx.compareTo(b.dx) : row; + }); + expect(positions, readingOrder); + expect(positions.toSet().length, 5); + }); + + testWidgets('only the groups that have entries get a pill', (t) async { + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.strap, DeviceKind.ring])); + expect(find.text('All'), findsOneWidget); + expect(find.text('WHOOP'), findsOneWidget); + expect(find.text('Rings'), findsOneWidget); + expect(find.text('Watches'), findsNothing); + expect(find.text('Chest straps & armbands'), findsNothing); + }); + + testWidgets('a Mi Band (DeviceKind.band) is under Watches, not a pill of its own', + (t) async { + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.band])); + expect(find.text('Watches'), findsOneWidget); + expect(find.text('Bands'), findsNothing); + expect(pairFilterOf(DeviceKind.band), PairFilter.watches); + expect(pairFilterOf(DeviceKind.watch), PairFilter.watches); + }); + + testWidgets('a scale shows under All only: it earns no pill', (t) async { + await pumpPair(t, pills([DeviceKind.scale, DeviceKind.thermometer])); + expect(find.text('All'), findsNothing); + expect(pairFilterOf(DeviceKind.scale), isNull); + expect(pairFilterOf(DeviceKind.thermometer), isNull); + // With a ring beside it the scale still adds no pill. + await pumpPair(t, pills([DeviceKind.scale, DeviceKind.ring, DeviceKind.watch])); + expect(find.text('All'), findsOneWidget); + expect(find.text('Rings'), findsOneWidget); + expect(find.text('Watches'), findsOneWidget); + expect(find.byType(CategoryFilterPills), findsOneWidget); + }); + + test('every kind maps to its group', () { + expect(pairFilterOf(DeviceKind.strap), PairFilter.whoop); + expect(pairFilterOf(DeviceKind.ring), PairFilter.rings); + expect(pairFilterOf(DeviceKind.chestStrap), PairFilter.straps); + expect(pairFilterOf(DeviceKind.armband), PairFilter.straps); + expect(pairFilterOf(DeviceKind.other), isNull); + }); + + testWidgets('a list with a single group shows no pills at all', (t) async { + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.ring])); + expect(find.text('All'), findsNothing); + expect(find.text('Rings'), findsNothing); + }); + + testWidgets('tapping a pill reports its group; All reports null', (t) async { + final got = []; + final sel = ValueNotifier(PairFilter.rings); + sel.addListener(() => got.add(sel.value)); + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.watch], selection: sel)); + await t.tap(find.text('Watches')); + await t.tap(find.text('All')); + expect(got, [PairFilter.watches, null]); + }); + + testWidgets('the selected pill is marked selected for screen readers, the rest are not', + (t) async { + await pumpPair( + t, pills([DeviceKind.ring, DeviceKind.watch], selected: PairFilter.rings)); + final s = t.getSemantics(find.byKey(const ValueKey('pill-rings'))); + expect(_sel(s), isTrue); + final o = t.getSemantics(find.byKey(const ValueKey('pill-watches'))); + expect(_sel(o), isFalse); + }); + + testWidgets('nothing selected means All is the selected pill', (t) async { + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.watch])); + expect(_sel(t.getSemantics(find.byKey(const ValueKey('pill-all')))), isTrue); + }); + + testWidgets('labels follow the locale', (t) async { + await pumpPair(t, pills([DeviceKind.ring, DeviceKind.watch]), + locale: const Locale('de')); + expect(find.text('Alle'), findsOneWidget); + expect(find.text('Ringe'), findsOneWidget); + expect(find.text('Uhren'), findsOneWidget); + }); + + testWidgets('five groups wrap instead of overflowing at 2x text on a narrow phone', + (t) async { + await pumpPair( + t, + pills([ + DeviceKind.strap, + DeviceKind.ring, + DeviceKind.watch, + DeviceKind.chestStrap + ]), + textScale: 2, + size: const Size(320, 700)); + expect(t.takeException(), isNull); + }); + + test('filterByGroup keeps only that group, null keeps all', () { + final all = [kWhoopGen5, kOura, kColmi, kMiBand234, kBleHrs, kMiScale2]; + expect(filterByGroup(all, null), all); + expect(filterByGroup(all, PairFilter.rings), [kOura, kColmi]); + expect(filterByGroup(all, PairFilter.whoop), [kWhoopGen5]); + expect(filterByGroup(all, PairFilter.watches), [kMiBand234]); + expect(filterByGroup(all, PairFilter.straps), [kBleHrs]); + }); + }); + + group('slot rows (pure)', () { + final whoop = slotComparisonFor(kWhoopGen5); + final strap = slotComparisonFor(kBleHrs); + + test('every comparison lists the whole table, in order, and never blood oxygen', () { + for (final c in [whoop, strap, slotComparisonFor(kOura), slotComparisonFor(kCoros)]) { + expect([for (final r in slotRowsFor(c)) r.capability], [ + DeviceCapability.sleep, + DeviceCapability.recovery, + DeviceCapability.strain, + DeviceCapability.heartRate, + DeviceCapability.hrv, + DeviceCapability.respiration, + DeviceCapability.skinTemperature, + DeviceCapability.steps, + DeviceCapability.workoutHeartRate, + DeviceCapability.workoutBeatToBeat, + ]); + } + }); + + test('a WHOOP sends every day and, read as a strap, during workouts', () { + final rows = {for (final r in slotRowsFor(whoop)) r.capability: r}; + expect(rows[DeviceCapability.sleep]!.everyDay, isNotNull); + expect(rows[DeviceCapability.sleep]!.workouts, isNull); + expect(rows[DeviceCapability.workoutHeartRate]!.everyDay, isNotNull); + expect(rows[DeviceCapability.workoutHeartRate]!.workouts, isNotNull); + }); + + test('skin temperature keeps its condition', () { + final r = slotRowsFor(whoop) + .firstWhere((r) => r.capability == DeviceCapability.skinTemperature); + expect(r.everyDay!.kind, CapabilityKind.conditional); + expect(r.everyDay!.reasonKey, kReasonSkinTempNights); + }); + + test('a strap alone sends nothing every day: its rows are its workout lines plus the gaps', () { + final rows = {for (final r in slotRowsFor(strap)) r.capability: r}; + // WHOOP: "Doesn't send" must stay visible for what a workout sensor lacks. + expect(rows[DeviceCapability.sleep]!.workouts, isNull); + expect(rows[DeviceCapability.sleep]!.everyDay, isNull); + expect(rows[DeviceCapability.workoutHeartRate]!.workouts, isNotNull); + expect(rows[DeviceCapability.workoutHeartRate]!.everyDay, isNull); + }); + }); + + group('SlotTable (C1)', () { + Widget table(SlotComparison c, {DeviceSlot? focus, DeviceSlot? dim}) => + Scaffold( + body: SingleChildScrollView( + child: SlotTable(comparison: c, focus: focus, dim: dim))); + + const rowNames = [ + 'Sleep', + 'Recovery', + 'Strain', + 'Heart rate and resting heart rate', + 'Heart-rate variability', + 'Respiration', + 'Skin temperature', + 'Steps', + 'Heart rate during workouts', + 'Beat-to-beat timing during workouts', + ]; + + testWidgets('every row is drawn, under the two column heads', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + expect(find.text('Every day'), findsOneWidget); + expect(find.text('Workouts only'), findsOneWidget); + expect(find.byKey(const ValueKey('slot-head-everyDay')), findsOneWidget); + expect(find.byKey(const ValueKey('slot-head-workoutsOnly')), findsOneWidget); + for (final c in kTableRows) { + expect(find.byKey(ValueKey('slot-row-${c.name}')), findsOneWidget, reason: c.name); + } + for (final n in rowNames) { + expect(find.text(n), findsOneWidget, reason: n); + } + // The workout rows name themselves; there is no group row. + expect(find.text('DURING WORKOUTS'), findsNothing); + expect(find.text('During workouts'), findsNothing); + }); + + testWidgets('the header leaves room above the column icons', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + final top = t.getTopLeft(find.byType(SlotTable)).dy; + final icon = t.getTopLeft(find.descendant( + of: find.byKey(const ValueKey('slot-cell-everyDay')).first, + matching: find.byType(Icon))) + .dy; + // Was 1 px (the table's border) before the owner asked for room. + expect(icon - top, greaterThanOrEqualTo(S.x3)); + }); + + testWidgets('blood oxygen is never a row', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + expect(find.byKey(const ValueKey('slot-row-bloodOxygen')), findsNothing); + expect(find.text('Blood oxygen'), findsNothing); + }); + + testWidgets('the workout rows come last, after Steps', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + final steps = t.getTopLeft(find.text('Steps')).dy; + expect(t.getTopLeft(find.text('Heart rate during workouts')).dy, greaterThan(steps)); + expect(t.getTopLeft(find.text('Beat-to-beat timing during workouts')).dy, + greaterThan(t.getTopLeft(find.text('Heart rate during workouts')).dy)); + }); + + testWidgets('a check in the column that sends, a cross in the one that does not', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + expect(_hasIcon(t, _mark('everyDay', DeviceCapability.sleep), LucideIcons.check), isTrue); + expect(_hasIcon(t, _mark('workoutsOnly', DeviceCapability.sleep), LucideIcons.x), isTrue); + expect(_hasIcon(t, _mark('workoutsOnly', DeviceCapability.sleep), LucideIcons.check), isFalse); + expect(_hasIcon(t, _mark('everyDay', DeviceCapability.sleep), LucideIcons.x), isFalse); + // A WHOOP kept for workouts sends heart rate in both columns. + expect(_hasIcon(t, _mark('workoutsOnly', DeviceCapability.workoutHeartRate), LucideIcons.check), isTrue); + }); + + testWidgets('a heart-rate strap crosses the whole-day column out', (t) async { + await pumpPair(t, table(slotComparisonFor(kBleHrs))); + for (final c in kDayCapabilities) { + expect(_hasIcon(t, _mark('everyDay', c), LucideIcons.x), isTrue, reason: c.name); + expect(_hasIcon(t, _mark('workoutsOnly', c), LucideIcons.x), isTrue, reason: c.name); + } + expect(_hasIcon(t, _mark('workoutsOnly', DeviceCapability.workoutHeartRate), LucideIcons.check), isTrue); + expect(_hasIcon(t, _mark('everyDay', DeviceCapability.workoutHeartRate), LucideIcons.x), isTrue); + }); + + testWidgets('no dash anywhere: a "no" is a cross', (t) async { + for (final e in [kWhoopGen5, kBleHrs, kOura, kCoros]) { + await pumpPair(t, table(slotComparisonFor(e))); + expect(find.byIcon(LucideIcons.minus), findsNothing, reason: e.id); + } + }); + + testWidgets('marks name the capability, column and sends status to a screen reader', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + expect(t.getSemantics(_mark('everyDay', DeviceCapability.sleep)).label, 'Sleep, Every day: Sends'); + expect(t.getSemantics(_mark('workoutsOnly', DeviceCapability.sleep)).label, 'Sleep, Workouts only: Doesn’t send'); + }); + + testWidgets('a conditional capability shows its caption under the label', (t) async { + await pumpPair(t, table(slotComparisonFor(kWhoopGen5))); + expect(find.text('Every day: After a few nights'), findsOneWidget); + // Read as a workout sensor, a WHOOP's beat timing is conditional too. + expect(find.text('Workouts only: If this device sends it, not confirmed'), findsOneWidget); + // WHOOP 5 counts steps natively: no live-session caption. + expect(find.text('Every day: During live sessions'), findsNothing); + // The caption sits under its label, in the same row. + final row = find.byKey(const ValueKey('slot-row-skinTemperature')); + expect(find.descendant(of: row, matching: find.text('Skin temperature')), findsOneWidget); + expect(find.descendant(of: row, matching: find.text('Every day: After a few nights')), findsOneWidget); + expect(t.getTopLeft(find.text('Every day: After a few nights')).dy, + greaterThan(t.getTopLeft(find.text('Skin temperature')).dy)); + }); + + testWidgets('a strap says its beat timing is only if it sends it', (t) async { + await pumpPair(t, table(slotComparisonFor(kBleHrs))); + expect(find.text('Workouts only: If this device sends it, not confirmed'), findsOneWidget); + }); + + testWidgets('a ring’s captions: its own sleep stages and the first 14 nights', (t) async { + await pumpPair(t, table(slotComparisonFor(kOura))); + expect(find.text('Every day: From the ring’s own sleep stages'), findsOneWidget); + expect(find.text('Every day: Estimated for the first 14 nights'), findsOneWidget); + }); + + testWidgets('a vendor-reported value says so instead of looking native', (t) async { + await pumpPair( + t, + const Scaffold( + body: SingleChildScrollView( + child: SlotTable( + comparison: SlotComparison( + everyDay: SlotSide(sends: { + DeviceCapability.sleep: + CapabilityStatus(CapabilityKind.vendorReported) + }, doesNotSend: {}), + workoutsOnly: SlotSide(sends: {}, doesNotSend: {DeviceCapability.sleep})))))); + expect(find.text('Every day: The device’s own reading'), findsOneWidget); + }); + + testWidgets('focus washes only that column', (t) async { + Color? colorOf(String s) => t + .widget(find.byKey(ValueKey('slot-cell-$s')).first) + .color; + await pumpPair(t, table(slotComparisonFor(kBleHrs), focus: DeviceSlot.workoutsOnly)); + expect(colorOf('workoutsOnly'), isNotNull); + expect(colorOf('everyDay'), isNull); + await pumpPair(t, table(slotComparisonFor(kOura), focus: DeviceSlot.everyDay)); + expect(colorOf('everyDay'), isNotNull); + expect(colorOf('workoutsOnly'), isNull); + await pumpPair(t, table(slotComparisonFor(kOura))); + expect(colorOf('everyDay'), isNull); + expect(colorOf('workoutsOnly'), isNull); + }); + + testWidgets('dim fades only the other column (Opacity 0.28)', (t) async { + double opacityIn(String s) => t + .widget(find.descendant( + of: find.byKey(ValueKey('slot-cell-$s')).first, + matching: find.byType(Opacity)) + .first) + .opacity; + await pumpPair(t, table(slotComparisonFor(kBleHrs), dim: DeviceSlot.everyDay)); + expect(opacityIn('everyDay'), 0.28); + expect(opacityIn('workoutsOnly'), 1); + await pumpPair(t, table(slotComparisonFor(kOura), dim: DeviceSlot.workoutsOnly)); + expect(opacityIn('workoutsOnly'), 0.28); + expect(opacityIn('everyDay'), 1); + }); + + test('the WHOOP-only strings exist, non-blank, in every locale', () { + for (final f in Directory('lib/l10n').listSync().whereType().where((f) => RegExp(r'app_\w+\.arb$').hasMatch(f.path))) { + final arb = jsonDecode(f.readAsStringSync()) as Map; + for (final k in ['pairMainBandWhoopOnly']) { + expect(arb[k], isA(), reason: '${f.path} $k'); + expect((arb[k] as String).trim(), isNotEmpty, reason: '${f.path} $k'); + } + } + }); + + testWidgets('German heads and rows render without overflow at 2x text', (t) async { + for (final e in [kWhoopGen5, kBleHrs, kOura]) { + await pumpPair(t, table(slotComparisonFor(e), focus: DeviceSlot.everyDay, dim: DeviceSlot.workoutsOnly), + locale: const Locale('de'), textScale: 2, size: const Size(360, 900)); + expect(t.takeException(), isNull, reason: e.id); + expect(find.text('Schlaf'), findsOneWidget); + } + }); + }); +} diff --git a/test/ui2_pairing_help_reset_test.dart b/test/ui2_pairing_help_reset_test.dart new file mode 100644 index 000000000..9cd4f4cfe --- /dev/null +++ b/test/ui2_pairing_help_reset_test.dart @@ -0,0 +1,449 @@ +// 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'); + }); + + group('Trouble connecting card (H1)', () { + 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('(CR r2) a refused bond lists the Bluetooth-settings Forget step, 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)); + expect(resetStepKeysFor(kWhoopGen5, bondRefused: true).last, + '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_pairing_review_test.dart b/test/ui2_pairing_review_test.dart new file mode 100644 index 000000000..ecfabec7a --- /dev/null +++ b/test/ui2_pairing_review_test.dart @@ -0,0 +1,256 @@ +// PR #669 review regressions: exercise the rendered sheets, recovery screens, +// table and device details, rather than just their copy helpers. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/adapters/_registry.dart'; +import 'package:openstrap_edge/ble/slot_comparison.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/onboarding/pairing.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_help.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_sheets.dart'; +import 'package:openstrap_edge/ui2/pairing/pair_text.dart'; +import 'package:openstrap_edge/ui2/pairing/slot_comparison_view.dart'; +import 'package:openstrap_edge/ui2/profile/devices.dart'; + +import 'support/pair_ui_support.dart'; + +const _ouraKey = 'Already use the Oura app? Keep the ring in that app and pair with its existing key. Skip the reset steps below. Only reset it if you want a new key.'; +const _pebbleLead = 'Pebble 2 can fill your days with sleep, recovery and heart rate. Pebble 2 SE shows the watch’s own sleep and steps. It has no heart-rate sensor, so this app cannot calculate daily strain or estimate recovery from it.'; +const _strapLead = 'It adds heart rate while you work out. It does not record sleep, recovery or daily strain.'; + +Future _openBand(WidgetTester t, BandEntry entry) async { + await pumpPair(t, Builder(builder: (c) => Scaffold(body: TextButton( + onPressed: () => showBandFoundSheet(c, band: entry, hasMainBand: false), + child: const Text('open')))), size: const Size(412, 2400)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); +} + +void _expectKeyBeforeReset(WidgetTester t) { + final guidance = find.text(_ouraKey); + expect(guidance, findsOneWidget); + final reset = find.text('Open the maker’s app and remove this device from it.'); + expect(reset, findsOneWidget, reason: 'new-key reset path stays available'); + expect(t.getTopLeft(guidance).dy, lessThan(t.getTopLeft(reset).dy)); +} + +void main() { + testWidgets('Oura sheet preserves the existing key before reset steps', (t) async { + await _openBand(t, kOura); + _expectKeyBeforeReset(t); + }); + + testWidgets('Oura trouble card preserves the existing key before reset steps', (t) async { + await pumpPair(t, Scaffold(body: SingleChildScrollView( + child: TroubleConnectingCard(entries: [kOura]))), + size: const Size(412, 2400)); + _expectKeyBeforeReset(t); + }); + + testWidgets('Oura bond-refused screen preserves the existing key before reset steps', (t) async { + await pumpPair(t, PairingView(phase: PairPhase.bondRefused, + onPair: () {}, resetEntry: kOura), size: const Size(412, 2400)); + _expectKeyBeforeReset(t); + expect(find.text('In your phone’s Bluetooth settings, find the device and choose Forget.'), + findsOneWidget); + }); + + testWidgets('Pebble lead keeps HR for Pebble 2 and excludes it for SE', (t) async { + await _openBand(t, kPebble); + expect(find.text(_pebbleLead), findsOneWidget); + expect(find.text('It can fill your days with sleep, recovery and heart rate.'), findsNothing); + }); + + testWidgets('Pebble HR captions qualify every affected row and its accessible mark', (t) async { + await _openBand(t, kPebble); + const only = 'Pebble 2 only. The 2 SE has no heart-rate sensor.'; + for (final (cap, reason) in [ + (DeviceCapability.recovery, 'Pebble 2 only, and an estimate, not a ' + 'direct reading. The 2 SE has no heart-rate sensor.'), + (DeviceCapability.strain, only), + (DeviceCapability.heartRate, only), + ]) { + final row = find.byKey(ValueKey('slot-row-${cap.name}')); + expect(find.descendant(of: row, matching: find.text('Every day: $reason')), + findsOneWidget, reason: '${cap.name} must exclude SE'); + final l = lookupAppLocalizations(const Locale('en')); + expect(t.getSemantics(find.byKey(ValueKey('slot-everyDay-${cap.name}'))).label, + '${capabilityLabel(l, cap)}, Every day: Sends. $reason'); + } + final sleep = find.byKey(const ValueKey('slot-row-sleep')); + expect(find.descendant(of: sleep, matching: find.text( + 'Every day: From the device’s own sleep stages')), findsOneWidget); + final steps = find.byKey(const ValueKey('slot-row-steps')); + expect(find.descendant(of: steps, matching: find.text( + 'Every day: The device’s own reading')), findsOneWidget); + }); + + testWidgets('strap lead excludes daily strain precisely', (t) async { + await pumpPair(t, Builder(builder: (c) => Scaffold(body: TextButton( + onPressed: () => showStrapSheet(c, sensor: kBleHrs), + child: const Text('open')))), size: const Size(412, 2400)); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + expect(find.text(_strapLead), findsOneWidget); + }); + + test('Russian replacement and unpair actions remove the pairing', () { + final l = lookupAppLocalizations(const Locale('ru')); + final actions = [l.pairReplaceConfirmTitle('WHOOP'), l.pairReplaceBand('WHOOP'), + l.pairReplaceConfirmYes, l.pairReplaceUnpairFailed, l.pairUnpairBand]; + for (final text in actions) { + expect(text.toLowerCase(), contains('сопряжени'), reason: text); + expect(text.toLowerCase(), isNot(contains('отключ')), reason: text); + } + }); + + test('Thai strap filter names chest straps and armbands', () { + final l = lookupAppLocalizations(const Locale('th')); + expect(l.pairKindStrap, contains('คาดอก')); + expect(l.pairKindStrap, contains('ต้นแขน')); + }); + + testWidgets('WHOOP unconfirmed beat timing caption names only Workouts only', (t) async { + await pumpPair(t, Scaffold(body: SingleChildScrollView( + child: SlotTable(comparison: slotComparisonFor(kWhoopGen5)))), + size: const Size(412, 2000)); + final row = find.byKey(const ValueKey('slot-row-workoutBeatToBeat')); + expect(find.descendant(of: row, matching: find.text( + 'Workouts only: If this device sends it, not confirmed')), findsOneWidget); + expect(find.descendant(of: row, matching: find.text( + 'Every day: If this device sends it, not confirmed')), findsNothing); + }); + + testWidgets('different reasons in both columns remain associated with each column', (t) async { + await pumpPair(t, const Scaffold(body: SingleChildScrollView( + child: SlotTable(comparison: SlotComparison( + everyDay: SlotSide(sends: {DeviceCapability.sleep: + CapabilityStatus(CapabilityKind.vendorReported)}, doesNotSend: {}), + workoutsOnly: SlotSide(sends: {DeviceCapability.sleep: + CapabilityStatus(CapabilityKind.conditional, reasonKey: kReasonUnconfirmed)}, + doesNotSend: {}), + )))), size: const Size(412, 2000)); + final row = find.byKey(const ValueKey('slot-row-sleep')); + expect(find.descendant(of: row, matching: find.text( + 'Every day: The device’s own reading')), findsOneWidget); + expect(find.descendant(of: row, matching: find.text( + 'Workouts only: If this device sends it, not confirmed')), findsOneWidget); + }); + + testWidgets('each table mark announces its capability, column and status', (t) async { + await pumpPair(t, Scaffold(body: SingleChildScrollView( + child: SlotTable(comparison: slotComparisonFor(kWhoopGen5)))), + size: const Size(412, 2000)); + for (final (key, label) in [ + ('slot-everyDay-sleep', 'Sleep, Every day: Sends'), + ('slot-workoutsOnly-sleep', 'Sleep, Workouts only: Doesn’t send'), + ('slot-everyDay-workoutBeatToBeat', 'Beat-to-beat timing during workouts, Every day: Sends'), + ('slot-workoutsOnly-workoutBeatToBeat', 'Beat-to-beat timing during workouts, Workouts only: Sends. If this device sends it, not confirmed'), + ]) { + expect(t.getSemantics(find.byKey(ValueKey(key))).label, label, reason: key); + } + }); + + for (final family in ['colmi', 'oura']) { + testWidgets('$family detail uses its every-day description without workout-only copy', (t) async { + final l = lookupAppLocalizations(const Locale('en')); + await pumpPair(t, DeviceDetailView(HealthSource(name: family, kind: 'Ring', + tier: SourceTier.beatToBeat, icon: Icons.circle, isBand: false, + deviceId: '$family-test', family: family)), size: const Size(412, 2400)); + expect(find.text(l.pairWearableCardNote), findsOneWidget, + reason: 'the every-day card explains its role'); + expect(find.text(l.devicesWhatItDoesBeats), findsNothing); + }); + } + + testWidgets('workout strap detail retains the workout description', (t) async { + final l = lookupAppLocalizations(const Locale('en')); + await pumpPair(t, const DeviceDetailView(HealthSource(name: 'Strap', kind: 'Strap', + tier: SourceTier.beatToBeat, icon: Icons.favorite, isBand: false, + deviceId: 'ble_hrs-test', family: 'ble_hrs')), size: const Size(412, 2400)); + expect(find.text(l.devicesWhatItDoesBeats), findsOneWidget); + }); + + test('every Pebble locale names the SE limits and watch-reported readings', () { + const copy = { + 'en': ('Pebble 2 only. The 2 SE has no heart-rate sensor.', + 'Pebble 2 SE shows the watch’s own sleep and steps. It has no heart-rate sensor, so this app cannot calculate daily strain or estimate recovery from it.'), + 'de': ('Nur Pebble 2. Die 2 SE hat keinen Herzfrequenzsensor.', + 'Pebble 2 SE zeigt die eigenen Schlaf- und Schrittdaten der Uhr. Sie hat keinen Herzfrequenzsensor, daher kann diese App daraus weder die tägliche Belastung berechnen noch die Erholung schätzen.'), + 'es': ('Solo Pebble 2. El 2 SE no tiene sensor de frecuencia cardiaca.', + 'Pebble 2 SE muestra el sueño y los pasos registrados por el propio reloj. No tiene sensor de frecuencia cardiaca, por lo que esta app no puede calcular la carga diaria ni estimar la recuperación a partir de sus datos.'), + 'fr': ('Pebble 2 uniquement. La 2 SE n’a pas de capteur de fréquence cardiaque.', + 'Pebble 2 SE affiche le sommeil et les pas enregistrés par la montre. Elle n’a pas de capteur de fréquence cardiaque, donc cette app ne peut pas calculer la charge quotidienne ni estimer la récupération à partir de ses données.'), + 'hi': ('केवल Pebble 2। 2 SE में हृदय गति सेंसर नहीं है।', + 'Pebble 2 SE घड़ी की अपनी नींद और कदमों की रीडिंग दिखाता है। उसमें हृदय गति सेंसर नहीं है, इसलिए यह ऐप उसके डेटा से रोज़ का शारीरिक भार नहीं निकाल सकता और न ही रिकवरी का अनुमान लगा सकता है।'), + 'ru': ('Только Pebble 2. У 2 SE нет датчика пульса.', + 'Pebble 2 SE показывает сон и шаги по данным самих часов. У него нет датчика пульса, поэтому приложение не может рассчитать дневную нагрузку или оценить восстановление по его данным.'), + 'th': ('เฉพาะ Pebble 2 รุ่น 2 SE ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ', + 'Pebble 2 SE แสดงข้อมูลการนอนและจำนวนก้าวที่นาฬิกาบันทึกเอง รุ่นนี้ไม่มีเซ็นเซอร์วัดอัตราการเต้นของหัวใจ แอปจึงคำนวณภาระร่างกายรายวันหรือประเมินการฟื้นตัวจากข้อมูลของรุ่นนี้ไม่ได้'), + 'zh': ('仅限 Pebble 2。2 SE 没有心率传感器。', + 'Pebble 2 SE 显示手表自身记录的睡眠和步数。它没有心率传感器,因此本应用无法根据它的数据计算每日身体负荷或估算恢复。'), + }; + expect(AppLocalizations.supportedLocales.map((l) => l.languageCode).toSet(), + copy.keys.toSet()); + for (final entry in copy.entries) { + final l = lookupAppLocalizations(Locale(entry.key)); + expect(capabilityReason(l, const CapabilityStatus(CapabilityKind.conditional, + reasonKey: 'capReasonPebble2Only')), entry.value.$1, reason: entry.key); + expect(l.pairWearableLeadPebble, endsWith(entry.value.$2), reason: entry.key); + // Recovery is also an estimate on Pebble 2: its caption keeps both. + final both = capabilityReason(l, const CapabilityStatus( + CapabilityKind.conditional, reasonKey: kReasonPebble2OnlyEstimated))!; + expect(both.toLowerCase(), contains(l.capReasonEstimated.toLowerCase()), + reason: entry.key); + expect(both, contains('2 SE'), reason: entry.key); + } + }); + + for (final locale in AppLocalizations.supportedLocales) { + testWidgets('Pebble conditions render and are announced in $locale at 2x text', (t) async { + final l = lookupAppLocalizations(locale); + String reason(String key) => capabilityReason(l, + CapabilityStatus(CapabilityKind.conditional, reasonKey: key))!; + await pumpPair(t, Scaffold(body: SingleChildScrollView( + child: SlotTable(comparison: slotComparisonFor(kPebble)))), + locale: locale, textScale: 2, size: const Size(360, 900)); + for (final (cap, key) in [ + (DeviceCapability.recovery, kReasonPebble2OnlyEstimated), + (DeviceCapability.strain, kReasonPebble2Only), + (DeviceCapability.heartRate, kReasonPebble2Only), + ]) { + final row = find.byKey(ValueKey('slot-row-${cap.name}')); + expect(find.descendant(of: row, matching: find.text( + l.slotColumnReason(l.slotEveryDay, reason(key)))), findsOneWidget); + final mark = find.byKey(ValueKey('slot-everyDay-${cap.name}')); + await t.ensureVisible(mark); + await t.pumpAndSettle(); + expect(t.getSemantics(mark).label, contains(reason(key))); + } + expect(t.takeException(), isNull); + }); + } + + test('every conditional mark in every locale has its caption', () { + // A check whose condition has no caption would claim more than the device + // gives. Every registry entry, both columns, every shipped locale. + var seen = 0; + for (final locale in AppLocalizations.supportedLocales) { + final l = lookupAppLocalizations(locale); + for (final e in kBandRegistry) { + final cmp = slotComparisonFor(e); + for (final side in [cmp.everyDay, cmp.workoutsOnly]) { + for (final MapEntry(key: cap, value: st) in side.sends.entries) { + if (st.kind != CapabilityKind.conditional) continue; + seen++; + final caption = capabilityReason(l, st); + expect(caption, isNotNull, + reason: '${e.id} $cap ${st.reasonKey} in $locale has no caption'); + expect(caption!.trim(), isNotEmpty); + } + } + } + } + expect(seen, greaterThan(0), reason: 'the registry has conditional marks'); + }); +} diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart index 07b5db88c..a2830c267 100644 --- a/test/ui2_router_test.dart +++ b/test/ui2_router_test.dart @@ -502,8 +502,15 @@ void main() { sources: [band(connected: false)], status: bandStatusFor(connection: 'disconnected')), )); - // Exactly one "Not connected": the row. No card repeating it. - expect(find.text('Not connected'), findsOneWidget); + // No failure card. "Not connected" is said by the row and by the + // "Your band" card's state line, never by a StatusCard. + expect(find.byType(StatusCard), findsNothing); + expect(find.text('Not connected'), findsNWidgets(2)); + expect( + find.descendant( + of: find.byKey(const ValueKey('band-paired-card')), + matching: find.text('Not connected')), + findsOneWidget); }); }); @@ -532,8 +539,8 @@ void main() { child: MaterialApp( theme: buildTheme(Brightness.light), home: const MyDevices()), )); - expect(find.text('Your band'), findsOneWidget); - expect(find.text('Pair a band'), findsNothing); + expect(find.byKey(const ValueKey('band-paired-card')), findsOneWidget); + expect(find.text('Pair your band'), findsNothing); // Forget. `unpair()` itself is platform-bound (ASK, the engine, the // foreground service); what it leaves behind for this screen is this. @@ -541,10 +548,15 @@ void main() { app.notifyListeners(); await tester.pump(); - expect(find.text('Your band'), findsNothing); + // The band's row is gone; the one "Your band" left is the card's + // heading, now saying so and offering the way back. + expect(find.byKey(const ValueKey('band-paired-card')), findsNothing); + expect(find.text('Your band'), findsOneWidget); + expect(find.text('No band is paired'), findsOneWidget); + expect(find.widgetWithText(BigButton, 'Pair your band'), findsOneWidget); expect(find.text('This phone'), findsOneWidget, reason: 'the phone row is what used to swallow the empty state'); - expect(find.text('Pair a band'), findsOneWidget); + expect(find.byKey(const ValueKey('pair-band-card')), findsOneWidget); }); testWidgets('and for someone who only ever had the phone', (tester) async { @@ -564,7 +576,7 @@ void main() { onPair: () => pairs++, ), )); - await tester.tap(find.text('Pair a band')); + await tester.tap(find.widgetWithText(BigButton, 'Pair your band')); expect(pairs, 1); }); @@ -605,7 +617,7 @@ void main() { isBand: true), ], onPair: () {}), )); - expect(find.text('Pair a band'), findsNothing); + expect(find.text('Pair your band'), findsNothing); }); }); diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index db67c887f..2d3a4210d 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -272,6 +272,18 @@ const _notComponents = { // `start_session_card_test.dart`, which renders it at a real phone width and // asserts the copy is not truncated and nothing overflows. 'StartCard', + // The pairing screens' building blocks (lib/ui2/pairing): each is one + // swappable piece of a pairing screen, driven by registry data and pumped, + // state by state, by ui2_pairing_blocks_test.dart, + // ui2_pairing_help_reset_test.dart, ui2_pair_band_view_test.dart and + // ui2_device_slot_card_test.dart. A + // gallery cell is too narrow for the slot table and the sheets, and the + // sheets open over a route. + 'SlotTable', 'SlotMark', 'PairNote', 'CategoryFilterPills', + 'TroubleConnectingCard', 'ResetStepsView', 'PairingModeSection', + 'PairBandView', + 'PairChoiceSheet', 'PairStepRow', 'TextLinkButton', 'ChoiceRow', + 'DeviceSlotCard', // tabs and drill-downs 'HomeScreen', 'HealthScreen', 'WorkoutScreen', 'NutritionScreen', 'WellnessScreen', 'CycleTab', 'MetricDetail', 'ReadinessDetail',