diff --git a/PulseLoop/RingProtocol/RingBLEClient.swift b/PulseLoop/RingProtocol/RingBLEClient.swift index e84fc10..6aa68c0 100644 --- a/PulseLoop/RingProtocol/RingBLEClient.swift +++ b/PulseLoop/RingProtocol/RingBLEClient.swift @@ -153,6 +153,11 @@ final class RingBLEClient: NSObject { lastError = "Bluetooth is not powered on." return } + // An explicit scan takes over from any ambient (Quick Connect) scan cleanly: clear the flag so + // `stopAmbientScan()` later won't kill this explicit scan's radio, and stop the current scan + // before restarting fresh with a cleared discovered list. + isAmbientScanning = false + central.stopScan() discovered = [] discoveredPeripherals = [:] lastError = nil @@ -168,6 +173,31 @@ final class RingBLEClient: NSObject { if state == .scanning { state = .idle } } + /// True while an ambient (Quick Connect) background scan is running. Kept separate from `state` so + /// the passive scan never flips the connection-state machine / header pill. + private(set) var isAmbientScanning = false + + /// Ambient scan for the Quick Connect popup: populate `discovered` WITHOUT touching `state`. Unlike + /// `startScanning()`, this never sets `state = .scanning`, so the header pill stays "Disconnected" + /// (there's no ring paired) rather than reading as an in-progress action. No-op if an explicit + /// scan/connect/connection is already in flight — those own the radio and set `state` themselves. + func startAmbientScan() { + guard isBluetoothReady, !isAmbientScanning else { return } + guard state != .scanning, state != .connecting, state != .connected else { return } + isAmbientScanning = true + // Same wide, no-filter scan as `startScanning()` (matching happens in didDiscover), but we + // don't clear `discovered`/`discoveredPeripherals` or touch `state`. + central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + } + + /// Stop the ambient scan. Only calls `central.stopScan()` if no explicit scan is active — an + /// explicit `startScanning()`/`connect` that took over must keep the radio running. + func stopAmbientScan() { + guard isAmbientScanning else { return } + isAmbientScanning = false + if state != .scanning { central.stopScan() } + } + func connect(to id: UUID, selectedModelID: String? = nil) { // Prefer the freshly-scanned object; fall back to the system cache (paired/known). guard let target = discoveredPeripherals[id] ?? central.retrievePeripherals(withIdentifiers: [id]).first else { @@ -279,6 +309,7 @@ final class RingBLEClient: NSObject { advertisedName: String? ) { central.stopScan() + isAmbientScanning = false // an explicit connect owns the radio; drop the ambient (Quick Connect) scan autoReconnect = true // Force-close any stale connection (incl. a different peripheral) before opening a new one, so // a reconnect after an idle drop can't collide with an orphaned handle. iOS analogue of diff --git a/PulseLoop/Views/QuickConnectNavigation.swift b/PulseLoop/Views/QuickConnectNavigation.swift new file mode 100644 index 0000000..fae17eb --- /dev/null +++ b/PulseLoop/Views/QuickConnectNavigation.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Shared state for the Quick Connect auto-pairing popup. Mirrors `CoachNavigation`: a single +/// `@Observable` singleton that `MainTabView` observes to drive a bottom sheet. +/// +/// While the app is foregrounded and NO ring is paired, `MainTabView` ambiently scans for rings +/// (see `RingBLEClient.startAmbientScan()`). When a close, known ring shows up, it sets `candidate` +/// + `isPresented` to slide up the "Is this your ring?" sheet. `dismissed` remembers the peripheral +/// UUIDs the user closed this session so we don't re-nag them for the same ring. +@MainActor +@Observable +final class QuickConnectNavigation { + static let shared = QuickConnectNavigation() + + /// The ring currently offered in the popup. + var candidate: RingBLEClient.DiscoveredRing? + /// Drives the Quick Connect sheet. + var isPresented = false + /// Peripheral UUIDs the user dismissed this session — never re-offered until relaunch. + var dismissed: Set = [] + + /// Offer a ring: stash it and present the sheet. + func present(_ ring: RingBLEClient.DiscoveredRing) { + candidate = ring + isPresented = true + } + + /// User closed the popup on this ring — remember it so we don't re-offer it, and dismiss. + func dismiss(_ id: UUID) { + dismissed.insert(id) + isPresented = false + candidate = nil + } + + /// Just close the sheet (e.g. after a successful connect) without blocklisting the ring. + func close() { + isPresented = false + candidate = nil + } +} diff --git a/PulseLoop/Views/QuickConnectSheet.swift b/PulseLoop/Views/QuickConnectSheet.swift new file mode 100644 index 0000000..698f163 --- /dev/null +++ b/PulseLoop/Views/QuickConnectSheet.swift @@ -0,0 +1,175 @@ +import SwiftUI +import UIKit + +/// The Quick Connect bottom sheet: "Ring found nearby — is this your ring?" It shows the detected +/// ring's product art, resolved model name, its advertised Bluetooth name (e.g. "R09_AC03"), and a +/// signal-strength readout, then offers Connect / scan-for-others / Close. +/// +/// iOS exposes NO MAC address, so identity is the advertised NAME + product image only — there is +/// deliberately no address shown. Connect calls the real `RingBLEClient.connect(to:selectedModelID:)`; +/// the sheet auto-dismisses once `ble.state == .connected`. +struct QuickConnectSheet: View { + @Environment(RingBLEClient.self) private var ble + @State private var quickConnect = QuickConnectNavigation.shared + @State private var isConnecting = false + @State private var showOthers = false + @State private var successHaptic = UINotificationFeedbackGenerator() + + /// The ring being offered. Snapshotted from the nav state so the layout is stable even if + /// `discovered` reshuffles underneath us. + let candidate: RingBLEClient.DiscoveredRing + + /// Resolve the exact catalog model for the candidate (drives the image + display name). Falls back + /// to the Colmi family when the advertisement didn't tag a device type. + private var model: WearableModel? { + WearableModel.resolve( + advertisedName: candidate.name, + selectedModelID: candidate.wearableModelID, + family: candidate.deviceType ?? .colmiR02 + ) + } + + /// Other likely rings nearby (excluding the current candidate) for the "scan for others" list. + private var otherRings: [RingBLEClient.DiscoveredRing] { + ble.discovered.filter { $0.isLikelyRing && $0.id != candidate.id } + } + + var body: some View { + ScrollView { + VStack(spacing: 20) { + header + + RingArtView(tint: model?.tint ?? PulseColors.accent, size: 150, imageName: model?.imageName) + + VStack(spacing: 6) { + Text(model?.displayName ?? candidate.deviceType?.displayName ?? "Smart ring") + .font(PulseFont.numberL) + .foregroundStyle(PulseColors.textPrimary) + // The raw advertised Bluetooth name — the only stable, iOS-exposed identity. + Text(candidate.name) + .font(PulseFont.footnote.monospaced()) + .foregroundStyle(PulseColors.textSecondary) + SignalStrengthDots(rssi: candidate.rssi) + .padding(.top, 2) + } + + actionButtons + + if showOthers { othersList } + } + .padding(24) + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + .background(PulseColors.background.ignoresSafeArea()) + .onChange(of: ble.state) { _, state in + // Connected — celebrate and dismiss (persisting the ring stops the ambient scan upstream). + if state == .connected { + successHaptic.notificationOccurred(.success) + quickConnect.close() + } + } + .onAppear { successHaptic.prepare() } + } + + private var header: some View { + VStack(spacing: 4) { + Text("Ring found nearby") + .font(PulseFont.footnote.weight(.semibold)) + .foregroundStyle(PulseColors.accent) + .textCase(.uppercase) + Text("Is this your ring?") + .font(PulseFont.title3.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var actionButtons: some View { + VStack(spacing: 10) { + if isConnecting || ble.state == .connecting || ble.state == .reconnecting { + HStack(spacing: 8) { + ProgressView() + Text("Connecting…") + .font(PulseFont.callout.weight(.semibold)) + .foregroundStyle(PulseColors.textSecondary) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + } else { + PrimaryButton(title: "Connect", systemImage: "dot.radiowaves.left.and.right") { + connect(candidate) + } + + SecondaryButton(title: "Not this one — scan for others", systemImage: "magnifyingglass") { + withAnimation(.easeInOut(duration: 0.2)) { showOthers.toggle() } + } + + SecondaryButton(title: "Close", systemImage: "xmark") { + quickConnect.dismiss(candidate.id) + } + } + } + } + + private var othersList: some View { + VStack(spacing: 8) { + if otherRings.isEmpty { + InlineEmptyState( + title: "No other rings nearby", + message: "Wake another ring by tapping or moving it, and keep it close." + ) + } else { + ForEach(otherRings) { ring in + Button { connect(ring) } label: { otherRow(ring) } + .buttonStyle(.plain) + } + } + } + .pulseGlassContainer(spacing: 8) // discovered glass rows blend/morph together + } + + private func otherRow(_ ring: RingBLEClient.DiscoveredRing) -> some View { + let resolved = WearableModel.resolve( + advertisedName: ring.name, + selectedModelID: ring.wearableModelID, + family: ring.deviceType ?? .colmiR02 + ) + return HStack { + VStack(alignment: .leading, spacing: 2) { + Text(resolved?.displayName ?? ring.deviceType?.displayName ?? ring.name) + .font(PulseFont.subheadline.weight(.medium)) + .foregroundStyle(PulseColors.textPrimary) + Text(ring.name) + .font(PulseFont.caption2.monospaced()) + .foregroundStyle(PulseColors.textMuted) + } + Spacer() + SignalStrengthDots(rssi: ring.rssi) + .accessibilityHidden(true) + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity) + .pulseGlass(RoundedRectangle(cornerRadius: 14, style: .continuous), interactive: true) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(resolved?.displayName ?? ring.name), \(signalLabel(ring.rssi))")) + } + + private func signalLabel(_ rssi: Int) -> String { + rssi >= -65 ? "Strong signal" : rssi >= -80 ? "Medium signal" : "Weak signal" + } + + /// Kick off a real connection to `ring` through the shared client. + private func connect(_ ring: RingBLEClient.DiscoveredRing) { + isConnecting = true + let resolved = WearableModel.resolve( + advertisedName: ring.name, + selectedModelID: ring.wearableModelID, + family: ring.deviceType ?? .colmiR02 + ) + ble.connect(to: ring.id, selectedModelID: resolved?.id) + } +} diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 06e0ec3..286474b 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -136,13 +136,63 @@ struct RootAppView: View { struct MainTabView: View { @Binding var path: NavigationPath @Environment(RingSyncCoordinator.self) private var coordinator + @Environment(RingBLEClient.self) private var ble @Environment(\.modelContext) private var modelContext + @Environment(\.scenePhase) private var scenePhase @State private var selected: MainTab @State private var nav = CoachNavigation.shared + @State private var quickConnect = QuickConnectNavigation.shared @State private var coachStore = CoachSettingsStore.shared /// Route requested from inside the Coach sheet, pushed once the sheet dismisses. @State private var pendingCoachRoute: AppRoute? + /// RSSI floor for a Quick Connect offer — only rings physically close (in the user's hand / + /// pairing mode) clear this, so we don't nag about a ring across the room or a neighbor's. + private let quickConnectRSSIThreshold = -65 + + /// Quick Connect ambiently scans whenever we're foreground and NOT connected to (or actively + /// connecting to) a ring. We deliberately do NOT gate on `hasLastKnownRing`: a healthy paired ring + /// auto-reconnects (→ `.connecting`/`.connected`), which turns the scan off, so it only runs when + /// the app is genuinely idle-disconnected — i.e. the ring is gone/forgotten/unreachable and the + /// user likely wants to (re)pair. `.scanning` is excluded so we never fight the explicit pairing + /// screen. (MainTabView is post-onboarding, so onboarding is never a case.) + private var shouldAmbientScan: Bool { + scenePhase == .active + && ble.state != .connected + && ble.state != .connecting + && ble.state != .reconnecting + && ble.state != .scanning + } + + /// Refresh the ambient scan to match `shouldAmbientScan`; also close a stale popup once a ring is + /// paired/connecting so it can't linger after the user pairs elsewhere. + private func syncAmbientScan() { + if shouldAmbientScan { + ble.startAmbientScan() + } else { + ble.stopAmbientScan() + if ble.state == .connected || ble.state == .connecting { quickConnect.close() } + } + } + + /// Pick the best Quick Connect candidate from a fresh `discovered` list and, if eligible, present + /// the popup. Best = a close, known ring the user hasn't already dismissed and isn't the paired one. + private func evaluateQuickConnectCandidate() { + guard shouldAmbientScan, !quickConnect.isPresented, ble.state != .connected else { return } + // Note: we do NOT exclude `lastKnownIdentifier` — a stored ring that isn't currently + // connected/connecting (forgotten at the OS level, replaced, or out of range long enough that + // silent reconnect gave up) is exactly what the user wants offered. A healthy paired ring is + // already `.connected`/`.connecting`, so `shouldAmbientScan` gates this off for it anyway. + let best = ble.discovered + .filter { + $0.isLikelyRing + && $0.rssi >= quickConnectRSSIThreshold + && !quickConnect.dismissed.contains($0.id) + } + .max { $0.rssi < $1.rssi } + if let best { quickConnect.present(best) } + } + init(path: Binding) { self._path = path // Optional `-startTab vitals` launch arg (parsed into UserDefaults) for screenshot tooling. @@ -251,6 +301,21 @@ struct MainTabView: View { CoachView(onOpenWorkout: requestCoachRoute) .presentationDragIndicator(.visible) // grabber ("pull tab") at the top of the sheet } + // Quick Connect: while no ring is paired and the app is foregrounded, an ambient BLE scan + // (below) surfaces a close, known ring in this bottom sheet — "Is this your ring?". + .sheet(isPresented: $quickConnect.isPresented) { + if let candidate = quickConnect.candidate { + QuickConnectSheet(candidate: candidate) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + } + // Drive the ambient scan off foreground + pairing state, and offer a candidate as rings appear. + .onAppear(perform: syncAmbientScan) + .onChange(of: scenePhase) { _, _ in syncAmbientScan() } + .onChange(of: ble.hasLastKnownRing) { _, _ in syncAmbientScan() } + .onChange(of: ble.state) { _, _ in syncAmbientScan() } + .onChange(of: ble.discovered) { _, _ in evaluateQuickConnectCandidate() } .background(PulseColors.background.ignoresSafeArea()) .toolbar(.hidden, for: .navigationBar) }