Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions PulseLoop/RingProtocol/RingBLEClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions PulseLoop/Views/QuickConnectNavigation.swift
Original file line number Diff line number Diff line change
@@ -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<UUID> = []

/// 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
}
}
175 changes: 175 additions & 0 deletions PulseLoop/Views/QuickConnectSheet.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
65 changes: 65 additions & 0 deletions PulseLoop/Views/RootViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavigationPath>) {
self._path = path
// Optional `-startTab vitals` launch arg (parsed into UserDefaults) for screenshot tooling.
Expand Down Expand Up @@ -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)
}
Expand Down
Loading