diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index f9228cde1..f429aa04a 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -318,8 +318,8 @@ struct Alarm: Identifiable, Codable, Equatable { persistentMinutes = 0 case .fastDrop: soundFile = .bigClockTicking - delta = 18 - monitoringWindow = 2 + delta = 15 + monitoringWindow = 3 case .fastRise: soundFile = .cartoonFailStringsTrumpet delta = 10 @@ -329,7 +329,7 @@ struct Alarm: Identifiable, Codable, Equatable { threshold = 16 case .notLooping: soundFile = .sciFiEngineShutDown - threshold = 31 + threshold = 16 case .missedBolus: soundFile = .dholShuffleloop monitoringWindow = 15 diff --git a/LoopFollow/Alarm/AlarmListView.swift b/LoopFollow/Alarm/AlarmListView.swift index de6e8552e..9748f99c3 100644 --- a/LoopFollow/Alarm/AlarmListView.swift +++ b/LoopFollow/Alarm/AlarmListView.swift @@ -195,6 +195,9 @@ struct AlarmListView: View { AddAlarmSheet { type in let new = Alarm(type: type) store.value.append(new) + // First alarm the user adds is the moment notifications become + // useful — request authorization here rather than at app launch. + NotificationAuthorization.requestIfNeeded() sheetInfo = .editor(id: new.id, isNew: true) } diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 30f14c9ba..29c6128d9 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -46,6 +46,15 @@ class AlarmManager { let now = Date() var alarmTriggered = false + // No alarm activity while onboarding is on screen — existing or + // QR-imported alarms shouldn't sound mid-setup. + if Observable.shared.isOnboardingActive.value { + if Observable.shared.currentAlarm.value != nil { + stopAlarm() + } + return + } + let config = Storage.shared.alarmConfiguration.value // Honor the "Snooze All" setting. If active, stop any current alarm and exit. diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index bfacf9a04..3c364fc2c 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -2,7 +2,6 @@ // AppDelegate.swift import AVFoundation -import EventKit import UIKit import UserNotifications @@ -13,6 +12,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() + // Notification and calendar permissions are no longer requested here. + // They're deferred to the moment the user opts into the feature that + // needs them (alarms request notifications via NotificationAuthorization; + // the Calendar settings screen requests calendar access), so a fresh + // install isn't fronted with permission prompts before onboarding. + // Before-First-Unlock detection. isProtectedDataAvailable is false on ANY // locked launch, so it alone isn't a BFU signal — post-first-unlock // UserDefaults (class C) reads fine while locked. Only true BFU makes a key @@ -44,29 +49,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - let options: UNAuthorizationOptions = [.alert, .sound, .badge] - notificationCenter.requestAuthorization(options: options) { - didAllow, _ in - if !didAllow { - LogManager.shared.log(category: .general, message: "User has declined notifications") - } - } - - let store = EKEventStore() - store.requestCalendarAccess { granted, error in - if !granted { - LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))") - return - } - } - let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: []) UNUserNotificationCenter.current().setNotificationCategories([category]) UNUserNotificationCenter.current().delegate = self - _ = BLEManager.shared + // Only spin up Bluetooth if the user has chosen a BLE-based background + // refresh. Initializing BLEManager creates a CBCentralManager, which + // triggers the Bluetooth permission prompt — deferring it keeps that + // prompt off fresh installs until the feature is actually enabled. + if Storage.shared.backgroundRefreshType.value.isBluetooth { + _ = BLEManager.shared + } // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications _ = VolumeButtonHandler.shared diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift index 1755f2fe8..cd7bb7a40 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -16,6 +16,7 @@ struct MainTabView: View { @ObservedObject private var activeBanner = Observable.shared.activeBanner @State private var showTelemetryConsent = false + @State private var showOnboarding = false private var orderedItems: [TabItem] { Storage.shared.orderedTabBarItems() @@ -63,21 +64,61 @@ struct MainTabView: View { // onAppear (not app launch) keeps it off the BG-only refresh path. MainViewController.bootstrap() - // One-time consent prompt. Previously presented by SceneDelegate, - // which was removed in the storyboard→SwiftUI migration; without - // this, fresh installs stay permanently undecided and telemetry - // never sends. The storage flag keeps it to a single appearance. - if !Storage.shared.telemetryConsentDecisionMade.value { - showTelemetryConsent = true + // Show the first-run onboarding once for everyone. Returning users + // get a prominent Skip on the welcome screen. The telemetry consent + // prompt is deferred until onboarding is dismissed so the two never + // appear on top of one another. + if !Storage.shared.hasCompletedOnboarding.value { + Observable.shared.isOnboardingActive.value = true + showOnboarding = true + } else { + runPostOnboardingPrompts() } } - .sheet(isPresented: $showTelemetryConsent) { + .fullScreenCover(isPresented: $showOnboarding, onDismiss: { + Observable.shared.isOnboardingActive.value = false + + // Covers both finishing and skipping onboarding — the telemetry and + // notification steps live inside the flow, so anyone who skips still + // needs these handled here. + runPostOnboardingPrompts() + }) { + OnboardingContainerView(onClose: { showOnboarding = false }) + } + .sheet(isPresented: $showTelemetryConsent, onDismiss: { + // Ask for notifications only once telemetry is resolved, so the system + // prompt never stacks on top of the consent sheet. + requestNotificationsIfAlarmsEnabled() + }) { // User must explicitly choose — no swipe-to-dismiss. TelemetryConsentView() .interactiveDismissDisabled(true) } } + /// Runs after onboarding closes, whether it was completed or skipped. Telemetry + /// consent and notification permission both live inside the onboarding flow, so + /// a skip would otherwise bypass them. Telemetry consent goes first (as a + /// sheet); the notification request follows on its dismissal so the two never + /// appear at once. When the user completed the flow these are already decided, + /// so both calls are no-ops. + private func runPostOnboardingPrompts() { + if !Storage.shared.telemetryConsentDecisionMade.value { + showTelemetryConsent = true // notifications requested on its dismiss + } else { + requestNotificationsIfAlarmsEnabled() + } + } + + /// Deferred-permission policy: only ask for notifications once there's an + /// enabled alarm that needs them. Safe to call repeatedly — it's a no-op once + /// the status is determined. + private func requestNotificationsIfAlarmsEnabled() { + if Storage.shared.alarms.value.contains(where: { $0.isEnabled }) { + NotificationAuthorization.requestIfNeeded() + } + } + @ViewBuilder private func tabContent(for item: TabItem) -> some View { switch item { diff --git a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift index dab55f242..e16b344de 100644 --- a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift +++ b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift @@ -8,6 +8,12 @@ import Foundation class BLEManager: NSObject, ObservableObject { static let shared = BLEManager() + /// Whether the shared instance has been created (and therefore a + /// CBCentralManager exists / the Bluetooth prompt has been triggered). + /// Reading this does not instantiate `shared`, so callers can avoid forcing + /// Bluetooth initialization — and its permission prompt — when not needed. + private(set) static var isInitialized = false + @Published private(set) var devices: [BLEDevice] = [] private var centralManager: CBCentralManager! @@ -31,6 +37,7 @@ class BLEManager: NSObject, ObservableObject { override private init() { super.init() + BLEManager.isInitialized = true centralManager = CBCentralManager( delegate: self, diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift index 21cbdf43f..1bf23fe0c 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift @@ -33,6 +33,12 @@ class BackgroundRefreshSettingsViewModel: ObservableObject { private func handleBackgroundRefreshTypeChange(_ newValue: BackgroundRefreshType) { LogManager.shared.log(category: .general, message: "Background refresh type changed to: \(newValue.rawValue)") - BLEManager.shared.disconnect() + // Touch BLEManager only when switching to a Bluetooth mode (the user is + // opting in, so the permission prompt belongs here) or when it's already + // running and needs to be torn down. Switching between non-BLE modes must + // not initialize Bluetooth — that would prompt without cause. + if newValue.isBluetooth || BLEManager.isInitialized { + BLEManager.shared.disconnect() + } } } diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index 0ba3664b1..8b844c983 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -76,7 +76,10 @@ class BackgroundAlertManager { removeDeliveredNotifications() let isBluetoothActive = Storage.shared.backgroundRefreshType.value.isBluetooth - let expectedHeartbeat = BLEManager.shared.expectedHeartbeatInterval() + // Only query BLEManager for a Bluetooth mode — touching it otherwise would + // initialize CoreBluetooth and trigger the permission prompt for users + // (e.g. Silent Tune) who never opted into Bluetooth. + let expectedHeartbeat = isBluetoothActive ? BLEManager.shared.expectedHeartbeatInterval() : nil // Define alerts let alerts: [BackgroundAlert] = [ diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 911a77818..07a6246bc 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -1,6 +1,7 @@ // LoopFollow // NightscoutUtils.swift +import CryptoKit import Foundation class NightscoutUtils { @@ -408,6 +409,163 @@ class NightscoutUtils { return responseString } + // MARK: - Token Provisioning + + /// Name of the Nightscout authorization subject LoopFollow creates when a + /// user provisions a token from their API secret. + static let provisionedSubjectName = "LoopFollow" + + private struct AuthSubject: Decodable { + let id: String? + let name: String? + let accessToken: String? + let roles: [String]? + + enum CodingKeys: String, CodingKey { + case id = "_id" + case name, accessToken, roles + } + } + + /// Creates (or reuses) a read-only Nightscout access token using the site's + /// API secret. The secret only authorizes these requests and is never + /// persisted. Returns the access token for a `readable` subject named + /// `provisionedSubjectName`. + /// + /// The full API secret authenticates as Nightscout's `admin` role (the `*` + /// permission), which includes `admin:api:subjects:create`. + /// + /// Nightscout serves the subjects list from an in-memory cache that doesn't + /// refresh promptly after a write, so a freshly-created subject (and its + /// token) can't be read back reliably right after creating it. Instead we + /// derive the token locally: it's a pure function of the subject's `_id` + /// (returned by the create call) and the API secret. See `accessToken(for:)`. + static func provisionReadOnlyToken(url: String, secret: String) async throws -> String { + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { throw NightscoutError.emptyAddress } + guard let baseURL = URL(string: trimmedURL), + trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://") + else { throw NightscoutError.invalidURL } + + let secretHash = sha1Hex(secret) + + // Reuse an existing subject if one is already visible (idempotent re-runs + // once the site's cache has caught up). + if let existing = try await fetchProvisionedToken(baseURL: baseURL, secretHash: secretHash) { + return existing + } + + let id = try await createReadOnlySubject(baseURL: baseURL, secretHash: secretHash) + return accessToken(forName: provisionedSubjectName, id: id, secretHash: secretHash) + } + + /// Returns `true` when `secret` authenticates as the site's API secret. + /// Read-only: it probes an admin-gated endpoint with the hashed secret and + /// creates nothing, so it's safe to call just to find out what the user pasted. + static func verifyAPISecret(url: String, secret: String) async -> Bool { + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedSecret = secret.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSecret.isEmpty, + let baseURL = URL(string: trimmedURL), + trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://") + else { return false } + + let endpoint = baseURL.appendingPathComponent("api/v2/authorization/subjects") + var request = URLRequest(url: endpoint) + request.httpMethod = "GET" + request.setValue(sha1Hex(trimmedSecret), forHTTPHeaderField: "api-secret") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.cachePolicy = .reloadIgnoringLocalCacheData + + do { + let (_, response) = try await URLSession.shared.data(for: request) + return (response as? HTTPURLResponse)?.statusCode == 200 + } catch { + return false + } + } + + private static func fetchProvisionedToken(baseURL: URL, secretHash: String) async throws -> String? { + let url = baseURL.appendingPathComponent("api/v2/authorization/subjects") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(secretHash, forHTTPHeaderField: "api-secret") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.cachePolicy = .reloadIgnoringLocalCacheData + + let (data, response) = try await URLSession.shared.data(for: request) + try validateProvisioningResponse(response) + + let subjects = try JSONDecoder().decode([AuthSubject].self, from: data) + return subjects.first(where: { $0.name == provisionedSubjectName })?.accessToken + } + + /// Creates the subject and returns its `_id`. + private static func createReadOnlySubject(baseURL: URL, secretHash: String) async throws -> String { + let url = baseURL.appendingPathComponent("api/v2/authorization/subjects") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(secretHash, forHTTPHeaderField: "api-secret") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "name": provisionedSubjectName, + "roles": ["readable"], + ]) + + let (data, response) = try await URLSession.shared.data(for: request) + try validateProvisioningResponse(response) + + // Nightscout returns the created subject wrapped in an array + // (`[{…}]`) on current versions, but a bare object on some older ones, + // so accept either shape. + let subject = try decodeCreatedSubject(from: data) + guard let id = subject.id, !id.isEmpty else { throw NightscoutError.unknown } + return id + } + + private static func decodeCreatedSubject(from data: Data) throws -> AuthSubject { + let decoder = JSONDecoder() + if let array = try? decoder.decode([AuthSubject].self, from: data) { + guard let first = array.first else { throw NightscoutError.unknown } + return first + } + return try decoder.decode(AuthSubject.self, from: data) + } + + /// Reproduces Nightscout's subject-token derivation (`lib/authorization`): + /// abbrev = name lowercased, non-`\w` characters removed, first 10 chars + /// digest = sha1( sha1Hex(apiSecret) + subjectId ) + /// token = "\(abbrev)-\(digest[0..<16])" + private static func accessToken(forName name: String, id: String, secretHash: String) -> String { + let allowed = Set("abcdefghijklmnopqrstuvwxyz0123456789_") + let abbrev = String(name.lowercased().filter { allowed.contains($0) }.prefix(10)) + let digest = sha1Hex(secretHash + id) + return abbrev + "-" + String(digest.prefix(16)) + } + + private static func validateProvisioningResponse(_ response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { + throw NightscoutError.networkError + } + switch http.statusCode { + case 200 ..< 300: + return + case 401, 403: + // The API secret was missing or wrong. + throw NightscoutError.invalidToken + case 404: + throw NightscoutError.siteNotFound + default: + throw NightscoutError.unknown + } + } + + private static func sha1Hex(_ string: String) -> String { + Insecure.SHA1.hash(data: Data(string.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } + static func extractErrorReason(from responseString: String) -> String { // 1) Try to parse the entire string as JSON and return the "message" if let data = responseString.data(using: .utf8) { diff --git a/LoopFollow/Helpers/NotificationAuthorization.swift b/LoopFollow/Helpers/NotificationAuthorization.swift new file mode 100644 index 000000000..e8ed69afd --- /dev/null +++ b/LoopFollow/Helpers/NotificationAuthorization.swift @@ -0,0 +1,30 @@ +// LoopFollow +// NotificationAuthorization.swift + +import UserNotifications + +/// Requests notification authorization lazily, the first time the user opts into +/// a feature that needs it (alarms). This keeps the system prompt off the very +/// first launch so it doesn't front the onboarding flow. +enum NotificationAuthorization { + /// Asks for authorization only when the user hasn't decided yet. Safe to call + /// repeatedly — it's a no-op once the status is determined. `completion` runs + /// on the main queue after the prompt is dismissed (or immediately when the + /// status was already determined), so a caller can wait for the system prompt + /// before moving on. + static func requestIfNeeded(completion: @escaping () -> Void = {}) { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + guard settings.authorizationStatus == .notDetermined else { + DispatchQueue.main.async { completion() } + return + } + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + if !granted { + LogManager.shared.log(category: .general, message: "User has declined notifications") + } + DispatchQueue.main.async { completion() } + } + } + } +} diff --git a/LoopFollow/Helpers/Telemetry.swift b/LoopFollow/Helpers/Telemetry.swift index 26246a728..afd90117f 100644 --- a/LoopFollow/Helpers/Telemetry.swift +++ b/LoopFollow/Helpers/Telemetry.swift @@ -348,7 +348,7 @@ struct TelemetryConsentView: View { VStack(alignment: .leading, spacing: 16) { Text("You can choose to share anonymous information with the developers to help improve LoopFollow—such as app and iOS version, device type, which app you're following, and a few settings. Your health data, credentials, time zone, and logs remain on your device.") - Text("You can change this any time in Settings → Diagnostics.") + Text("You can change this any time in Settings → General → Diagnostics.") .font(.subheadline) .foregroundColor(.secondary) diff --git a/LoopFollow/Helpers/Views/TogglableSecureInput.swift b/LoopFollow/Helpers/Views/TogglableSecureInput.swift index f6ed70343..fc88a40e9 100644 --- a/LoopFollow/Helpers/Views/TogglableSecureInput.swift +++ b/LoopFollow/Helpers/Views/TogglableSecureInput.swift @@ -28,13 +28,14 @@ struct TogglableSecureInput: View { .submitLabel(.done) .focused($isFocused) } else { - HStack { - Spacer() - Text(maskString) - .font(.body.monospaced()) - .foregroundColor(.primary) - .allowsHitTesting(false) - } + // A real (masked) SecureField, not static text, so the + // value stays editable while hidden and — crucially — + // iOS AutoFill/keychain has a secure field to fill. + SecureField(placeholder, text: $text) + .multilineTextAlignment(.trailing) + .textContentType(textContentType) + .submitLabel(.done) + .focused($isFocused) } } @@ -82,19 +83,15 @@ struct TogglableSecureInput: View { } .contentShape(Rectangle()) .onTapGesture { - if !isVisible { - isVisible = true - if style == .singleLine { - isFocused = true - } else if style == .multiLine { - isMultilineFocused = true - } - } else { - if style == .singleLine { - isFocused = true - } else if style == .multiLine { - isMultilineFocused = true - } + switch style { + case .singleLine: + // The hidden state is already an editable SecureField, so tapping + // just focuses it — no need to reveal the plaintext to type. + isFocused = true + case .multiLine: + // The multi-line editor is only editable once revealed. + if !isVisible { isVisible = true } + isMultilineFocused = true } } } diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index a5245e23c..8d8573abf 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -49,16 +49,43 @@ struct NightscoutSettingsView: View { } private var tokenSection: some View { - Section(header: Text("Token")) { + Section { HStack { Text("Access Token") TogglableSecureInput( - placeholder: "Enter Token", + placeholder: "Token or API secret", text: $viewModel.nightscoutToken, style: .singleLine, textContentType: .password ) } + + if viewModel.tokenIsVerifiedSecret || viewModel.isProvisioningToken { + Button { + viewModel.createReadOnlyToken(fromSecret: viewModel.nightscoutToken) + } label: { + HStack { + if viewModel.isProvisioningToken { + ProgressView() + Text("Creating read-only token…") + } else { + Image(systemName: "wand.and.stars") + Text("That's your API secret — create a read-only token") + } + } + } + .disabled(viewModel.isProvisioningToken) + } + + if let error = viewModel.tokenProvisionError { + Text(error) + .font(.footnote) + .foregroundColor(.red) + } + } header: { + Text("Token") + } footer: { + Text("Paste a Nightscout token. If your site needs one and you only have the API secret, paste that instead — LoopFollow can create a read-only token for you.") } } diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index f15a5d309..1065cc892 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -35,6 +35,29 @@ class NightscoutSettingsViewModel: ObservableObject { @Published var nightscoutStatus: String = "Checking..." + /// The most recent verification error, kept so the onboarding address page can + /// tell "reachable Nightscout that needs a token" apart from "can't reach it". + @Published var lastError: NightscoutUtils.NightscoutError? + + /// True when the most recent error means the site is a reachable Nightscout + /// that simply needs (a different) token. + private var errorIsTokenRelated: Bool { + switch lastError { + case .tokenRequired, .invalidToken: return true + default: return false + } + } + + /// The site responded as a Nightscout instance, even if it needs a token. + var addressReachable: Bool { + isConnected || errorIsTokenRelated + } + + /// The site is reachable but requires a token we don't have yet. + var addressNeedsToken: Bool { + !isConnected && errorIsTokenRelated + } + @Published var webSocketEnabled: Bool = Storage.shared.webSocketEnabled.value { didSet { Storage.shared.webSocketEnabled.value = webSocketEnabled @@ -62,6 +85,25 @@ class NightscoutSettingsViewModel: ObservableObject { private var checkStatusSubject = PassthroughSubject() private var checkStatusWorkItem: DispatchWorkItem? + /// While confirming a freshly provisioned token, the retry loop owns the + /// status label, so the ordinary debounced check is suppressed to avoid + /// flickering "Invalid Token" before the server has caught up. + private var isConfirmingProvisionedToken = false + + /// Set when a token we just created is correct (the create call returned its + /// id, so the secret was valid and the token is a deterministic function of + /// it) but the site hasn't started accepting it yet. Some hosts only reload + /// their auth cache on a restart, which can take minutes — far longer than we + /// can spin during onboarding — so this is treated as a success-pending state + /// the user can proceed from, not an error. + @Published private(set) var provisionedTokenPending = false + + /// True while a read-only token is being created from the API secret. + @Published var isProvisioningToken = false + + /// The most recent token-provisioning failure, for inline display. + @Published var tokenProvisionError: String? + init() { initialURL = Storage.shared.url.value initialToken = Storage.shared.token.value @@ -84,6 +126,10 @@ class NightscoutSettingsViewModel: ObservableObject { private func triggerCheckStatus() { checkStatusWorkItem?.cancel() + // Any manual edit invalidates a pending-provisioned state and any earlier + // "this is your API secret" verdict. + provisionedTokenPending = false + tokenIsVerifiedSecret = false nightscoutStatus = "Checking..." checkStatusWorkItem = DispatchWorkItem { @@ -127,6 +173,7 @@ class NightscoutSettingsViewModel: ObservableObject { } func checkNightscoutStatus() { + if isConfirmingProvisionedToken { return } NightscoutUtils.verifyURLAndToken { error, _, nsWriteAuth, nsAdminAuth in DispatchQueue.main.async { Storage.shared.nsWriteAuth.value = nsWriteAuth @@ -137,7 +184,139 @@ class NightscoutSettingsViewModel: ObservableObject { } } + /// Applies a token that LoopFollow just created and confirms it works. + /// + /// A freshly created subject isn't always recognized immediately: each + /// Nightscout server instance only reloads its in-memory subject cache on a + /// write, and multi-instance deployments don't share that cache — so the + /// first validation can be routed to an instance that hasn't caught up yet. + /// Rather than fail (and make the user tap again), we poll for a few seconds + /// with a reassuring status before surfacing any error. + func confirmProvisionedToken(_ token: String) { + isConfirmingProvisionedToken = true + provisionedTokenPending = false + isConnected = false + nightscoutStatus = "Finishing connection…" + nightscoutToken = token + verifyProvisionedTokenLoop(attempt: 0) + } + + private func verifyProvisionedTokenLoop(attempt: Int) { + let maxAttempts = 8 + NightscoutUtils.verifyURLAndToken { [weak self] error, _, nsWriteAuth, nsAdminAuth in + DispatchQueue.main.async { + guard let self else { return } + if error == nil { + self.isConfirmingProvisionedToken = false + self.provisionedTokenPending = false + Storage.shared.nsWriteAuth.value = nsWriteAuth + Storage.shared.nsAdminAuth.value = nsAdminAuth + self.updateStatusLabel(error: nil) + } else if attempt + 1 < maxAttempts { + self.nightscoutStatus = "Finishing connection…" + let delay = min(0.5 + Double(attempt) * 0.25, 2.0) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.verifyProvisionedTokenLoop(attempt: attempt + 1) + } + } else { + // The token is correct but the site hasn't started accepting + // it yet. Surface a calm "pending" state the user can proceed + // from rather than a red error. + self.isConfirmingProvisionedToken = false + self.provisionedTokenPending = true + self.lastError = nil + } + } + } + } + + // MARK: - Token provisioning from API secret + + /// Nightscout access tokens look like `name-<16 hex>`; anything else the user + /// puts in the token field — most often their API secret — won't match. + private static let tokenFormat = "^[^-\\s]+-[0-9a-fA-F]{16}$" + + private func looksLikeToken(_ value: String) -> Bool { + value.range(of: Self.tokenFormat, options: .regularExpression) != nil + } + + /// Set once the value in the token field has been confirmed to authenticate as + /// the site's API secret. Drives the "create a token from this" suggestion. + /// This is a verified result (an actual auth probe), not a format guess, so it + /// only appears when creating a token will genuinely work. + @Published private(set) var tokenIsVerifiedSecret = false + + /// Bumped on every edit so a slow verification for an old value can't land on a + /// newer one. + private var secretCheckGeneration = 0 + + /// After a failed token check, find out whether what the user pasted is in fact + /// the API secret — verified against the server, not guessed from its shape. + private func verifyTokenIsSecret() { + let candidate = nightscoutToken + guard !candidate.isEmpty, !looksLikeToken(candidate) else { + tokenIsVerifiedSecret = false + return + } + secretCheckGeneration += 1 + let generation = secretCheckGeneration + let url = nightscoutURL + Task { + let isSecret = await NightscoutUtils.verifyAPISecret(url: url, secret: candidate) + await MainActor.run { + guard generation == self.secretCheckGeneration, + self.nightscoutToken == candidate else { return } + self.tokenIsVerifiedSecret = isSecret + } + } + } + + /// Creates (or reuses) a read-only token from the given API secret and applies + /// it. The secret authorizes the create call only and is never stored. + func createReadOnlyToken(fromSecret secret: String) { + let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + tokenProvisionError = nil + isProvisioningToken = true + let url = nightscoutURL + + Task { + do { + let token = try await NightscoutUtils.provisionReadOnlyToken(url: url, secret: trimmed) + await MainActor.run { + self.isProvisioningToken = false + self.confirmProvisionedToken(token) + } + } catch { + await MainActor.run { + self.isProvisioningToken = false + self.tokenProvisionError = NightscoutSettingsViewModel.provisioningMessage(for: error) + } + } + } + } + + static func provisioningMessage(for error: Error) -> String { + guard let nsError = error as? NightscoutUtils.NightscoutError else { + return "Could not create a token. Please try again." + } + switch nsError { + case .invalidToken: + return "That API secret was rejected. Check it and try again." + case .invalidURL, .emptyAddress: + return "Please enter a valid site URL first." + case .siteNotFound: + return "Couldn't reach that site. Check the URL." + case .networkError: + return "Network error. Check your connection and try again." + case .tokenRequired, .unknown: + return "Could not create a token. Please try again." + } + } + func updateStatusLabel(error: NightscoutUtils.NightscoutError?) { + lastError = error if let error = error { isConnected = false switch error { @@ -157,8 +336,19 @@ class NightscoutSettingsViewModel: ObservableObject { nightscoutStatus = "Address Empty" } NightscoutSocketManager.shared.disconnect() + + // A site that's reachable but rejects the value as a token is the one + // case where the user may have pasted their API secret — verify it so + // we can offer to turn it into a token. + switch error { + case .invalidToken, .tokenRequired: + verifyTokenIsSecret() + default: + tokenIsVerifiedSecret = false + } } else { isConnected = true + tokenIsVerifiedSecret = false let authStatus: String if Storage.shared.nsAdminAuth.value { authStatus = "Admin" @@ -178,6 +368,52 @@ class NightscoutSettingsViewModel: ObservableObject { NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) } + // MARK: - Adaptive status (onboarding) + + enum ConnectionStatusKind { + case idle + case checking + case needsToken + case pending + case connected + case error + } + + /// A coarse status used to drive the onboarding status pill's color and icon. + var statusKind: ConnectionStatusKind { + if isConfirmingProvisionedToken { return .checking } + if nightscoutURL.isEmpty { return .idle } + if isConnected { return .connected } + // Token created and correct, just not accepted by the site yet. + if provisionedTokenPending { return .pending } + if nightscoutStatus == "Checking..." { return .checking } + // The site is reachable and simply needs a token — that's an expected + // step, not an error, so it's shown positively rather than red. + if addressNeedsToken { return .needsToken } + return .error + } + + /// A friendly, contextual status line that updates as the user fills fields, + /// rather than a fixed "Status" label that can read as stale. + var friendlyStatus: String { + switch statusKind { + case .idle: + return "Enter your site address to connect." + case .checking: + return isConfirmingProvisionedToken ? "Finishing connection…" : "Checking your connection…" + case .needsToken: + return "Site found — it needs a token." + case .pending: + return "Token created. Your site can take a few minutes to start accepting it — you can continue." + case .connected: + if Storage.shared.nsAdminAuth.value { return "Connected — admin access" } + if Storage.shared.nsWriteAuth.value { return "Connected — read & write" } + return "Connected — read-only" + case .error: + return nightscoutStatus + } + } + private func observeWebSocketState() { updateWebSocketStatus() NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged) diff --git a/LoopFollow/Onboarding/ConnectionStatusPill.swift b/LoopFollow/Onboarding/ConnectionStatusPill.swift new file mode 100644 index 000000000..48e5df230 --- /dev/null +++ b/LoopFollow/Onboarding/ConnectionStatusPill.swift @@ -0,0 +1,49 @@ +// LoopFollow +// ConnectionStatusPill.swift + +import SwiftUI + +/// A pinned, color-coded status banner shown at the top of the onboarding +/// connect screens (Nightscout and Dexcom). It morphs as the connection state +/// changes, giving both screens the same live, above-the-fold status treatment. +/// +/// The pill itself is state-agnostic: each connect screen maps its own view +/// model's status into a `color`, an optional `systemImage`, and whether to show +/// a spinner, so the two different status enums can share one look. +struct ConnectionStatusPill: View { + let color: Color + let message: String + var isLoading: Bool = false + var systemImage: String? + + var body: some View { + HStack(spacing: 10) { + Group { + if isLoading { + ProgressView().scaleEffect(0.85) + } else if let systemImage { + Image(systemName: systemImage).foregroundColor(color) + } + } + .frame(width: 20) + + Text(message) + .font(.subheadline.weight(.medium)) + .foregroundColor(color) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(color.opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(color.opacity(0.3), lineWidth: 1) + ) + } +} diff --git a/LoopFollow/Onboarding/LoopFollowLogo.swift b/LoopFollow/Onboarding/LoopFollowLogo.swift new file mode 100644 index 000000000..2288ff91f --- /dev/null +++ b/LoopFollow/Onboarding/LoopFollowLogo.swift @@ -0,0 +1,176 @@ +// LoopFollow +// LoopFollowLogo.swift + +import SwiftUI + +/// The LoopFollow mark, rebuilt in SwiftUI as the full app-icon face — a glassy +/// rounded square with the blue "loop" ring — so it has real visual mass when +/// tilted in 3D (a bare ring collapses to a line edge-on). +/// +/// Geometry and layers mirror the icon source artwork (loopfollow-icon.svg, +/// 1024×1024): all fractions below are the SVG coordinates divided by 1024. +struct LoopFollowLogo: View { + var size: CGFloat = 120 + + // Colors sampled from the app icon (loopfollow-icon.svg). + private let lightBlue = Color(red: 0.357, green: 0.639, blue: 0.961) // #5BA3F5 + private let midBlue = Color(red: 0.290, green: 0.565, blue: 0.886) // #4A90E2 + private let darkBlue = Color(red: 0.227, green: 0.482, blue: 0.784) // #3A7BC8 + + var body: some View { + let corner = size * 0.225 + let ringDiameter = size * 0.879 // outer r = 450 + let holeDiameter = size * 0.615 // inner r = 315 + + ZStack { + // Glassy near-white card (the icon face). + RoundedRectangle(cornerRadius: corner, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color(red: 0.973, green: 0.976, blue: 0.980), // #F8F9FA + .white, + Color(red: 0.941, green: 0.949, blue: 0.961), // #F0F2F5 + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + // Soft highlight fading down the top half of the card. + LinearGradient( + stops: [ + .init(color: .white.opacity(0.6), location: 0), + .init(color: .white.opacity(0.3), location: 0.2), + .init(color: .clear, location: 0.5), + ], + startPoint: .top, + endPoint: .bottom + ) + + // Curved glass reflection over the upper half. + radialGlow( + width: size * 1.172, height: size * 0.781, + stops: [ + .init(color: .white.opacity(0.5), location: 0), + .init(color: .white.opacity(0.2), location: 0.5), + .init(color: .clear, location: 1), + ] + ) + .offset(y: -size * 0.25) + .opacity(0.8) + + // Blue glass disc. + Circle() + .fill( + LinearGradient( + stops: [ + .init(color: lightBlue, location: 0), + .init(color: midBlue, location: 0.3), + .init(color: midBlue, location: 0.7), + .init(color: darkBlue, location: 1), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: ringDiameter, height: ringDiameter) + + // Darkening toward the disc's outer edge for depth. + Circle() + .fill( + RadialGradient( + stops: [ + .init(color: .clear, location: 0.7), + .init(color: .black.opacity(0.15), location: 0.85), + .init(color: .black.opacity(0.25), location: 1), + ], + center: .center, + startRadius: 0, + endRadius: ringDiameter / 2 + ) + ) + .frame(width: ringDiameter, height: ringDiameter) + + // White hole that turns the disc into the loop ring. + Circle() + .fill(Color.white.opacity(0.98)) + .frame(width: holeDiameter, height: holeDiameter) + + // Glass highlight on the white hole. + radialGlow( + width: size * 0.742, height: size * 0.391, + stops: [ + .init(color: .white.opacity(0.4), location: 0), + .init(color: .white.opacity(0.1), location: 0.6), + .init(color: .clear, location: 1), + ] + ) + .offset(y: -size * 0.129) + .opacity(0.7) + + // Broad sheen across the top of the ring; its lower edge draws the + // glass "cut line" just below the middle of the icon. + Ellipse() + .fill(Color.white.opacity(0.25)) + .frame(width: size * 0.82, height: size * 0.488) + .offset(y: -size * 0.188) + .blur(radius: size * 0.003) + + // Subtle shadow pooling under the ring. + Ellipse() + .fill(Color.black.opacity(0.05)) + .frame(width: size * 0.879, height: size * 0.195) + .offset(y: size * 0.184) + .blur(radius: size * 0.002) + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous)) + } + + /// Elliptical radial glow: SwiftUI's RadialGradient is circular, so draw it + /// in a circle and squash vertically to match the SVG's elliptical gradients. + private func radialGlow(width: CGFloat, height: CGFloat, stops: [Gradient.Stop]) -> some View { + Circle() + .fill( + RadialGradient( + gradient: Gradient(stops: stops), + center: .center, + startRadius: 0, + endRadius: width / 2 + ) + ) + .frame(width: width, height: width) + .scaleEffect(x: 1, y: height / width) + } +} + +/// LoopFollow logo that lands like a coin: it starts edge-on (rotated 90° about +/// the vertical axis) and springs open to face the viewer, overshooting a little +/// past flat and rocking back to rest. Respects Reduce Motion by rendering flat. +struct AnimatedLoopFollowLogo: View { + var size: CGFloat = 140 + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var angle: Double = 90 + + var body: some View { + LoopFollowLogo(size: size) + .rotation3DEffect( + .degrees(angle), + axis: (x: 0, y: 1, z: 0), + perspective: 0.7 + ) + // Grounding shadow so the landing reads as dimensional. + .shadow(color: .black.opacity(0.28), radius: size * 0.08, x: 0, y: size * 0.06) + .onAppear { + if reduceMotion { + angle = 0 + } else { + withAnimation(.spring(response: 0.85, dampingFraction: 0.5).delay(0.15)) { + angle = 0 + } + } + } + } +} diff --git a/LoopFollow/Onboarding/OnboardingContainerView.swift b/LoopFollow/Onboarding/OnboardingContainerView.swift new file mode 100644 index 000000000..35ca8dc73 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingContainerView.swift @@ -0,0 +1,138 @@ +// LoopFollow +// OnboardingContainerView.swift + +import SwiftUI + +/// Root of the first-run onboarding wizard. Owns the shared chrome — progress +/// bar and Back/Next footer — and swaps in the view for the current step. +struct OnboardingContainerView: View { + @StateObject private var viewModel: OnboardingViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + init(onClose: @escaping () -> Void) { + _viewModel = StateObject(wrappedValue: OnboardingViewModel(onClose: onClose)) + } + + var body: some View { + VStack(spacing: 0) { + if viewModel.step.showsProgressHeader { + header + } + + stepContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if viewModel.step.usesSharedFooter { + footer + } + } + .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } + + // MARK: - Chrome + + private var header: some View { + VStack(spacing: 6) { + HStack(spacing: 12) { + OnboardingProgressBar(progress: viewModel.progress) + Button("Skip") { viewModel.skip() } + .font(.subheadline) + .foregroundColor(.secondary) + } + + if !viewModel.progressLabel.isEmpty { + Text(viewModel.progressLabel) + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 4) + } + + @ViewBuilder + private var stepContent: some View { + let content = Group { + switch viewModel.step { + case .welcome: + WelcomeStepView(viewModel: viewModel) + case .overview: + OverviewStepView(onboarding: viewModel) + case .dataSource: + DataSourceChoiceStepView(viewModel: viewModel) + case .connect: + switch viewModel.dataSource { + case .dexcom: + DexcomConnectStepView(viewModel: viewModel.dexcomViewModel, onboarding: viewModel) + case .copyFromPhone: + ConnectImportStepView(viewModel: viewModel) + default: + NightscoutConnectStepView(viewModel: viewModel.nightscoutViewModel, onboarding: viewModel) + } + case .units: + UnitsStepView(onboarding: viewModel) + case .generalAlarms: + GeneralAlarmsStepView() + case .alarms: + AlarmsStepView(viewModel: viewModel) + case .tabOrder: + TabOrderStepView() + case .notifications: + NotificationsStepView(viewModel: viewModel) + case .telemetry: + TelemetryStepView(viewModel: viewModel) + case .completion: + CompletionStepView(viewModel: viewModel) + } + } + + if reduceMotion { + content.id(viewModel.step) + } else { + content + .id(viewModel.step) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } + } + + private var footer: some View { + OnboardingNavFooter( + continueEnabled: viewModel.canProceed, + showBack: viewModel.canGoBack, + onBack: { withStepAnimation { viewModel.goBack() } }, + onContinue: { withStepAnimation { viewModel.advance() } } + ) + } + + private func withStepAnimation(_ change: () -> Void) { + if reduceMotion { + change() + } else { + withAnimation(.easeInOut(duration: 0.3)) { change() } + } + } +} + +/// Thin segmented progress indicator shown at the top of each chrome'd step. +private struct OnboardingProgressBar: View { + let progress: Double + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color(.systemGray5)) + Capsule() + .fill(Color.accentColor) + .frame(width: max(0, min(1, progress)) * geo.size.width) + } + } + .frame(height: 6) + } +} diff --git a/LoopFollow/Onboarding/OnboardingNavFooter.swift b/LoopFollow/Onboarding/OnboardingNavFooter.swift new file mode 100644 index 000000000..1b0f3e2d4 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingNavFooter.swift @@ -0,0 +1,39 @@ +// LoopFollow +// OnboardingNavFooter.swift + +import SwiftUI + +/// The shared Back / Continue footer used both by the container's chrome and by +/// phases that manage their own internal pages (connect, alarms), so the controls +/// look and behave identically everywhere. +struct OnboardingNavFooter: View { + var continueTitle: String = "Continue" + var continueEnabled: Bool = true + var showBack: Bool = true + var onBack: () -> Void + var onContinue: () -> Void + + var body: some View { + HStack { + if showBack { + Button(action: onBack) { + Label("Back", systemImage: "chevron.left") + .font(.body.weight(.medium)) + } + .buttonStyle(.bordered) + } + + Spacer() + + Button(action: onContinue) { + Text(continueTitle) + .font(.body.weight(.semibold)) + .frame(minWidth: 120) + } + .buttonStyle(.borderedProminent) + .disabled(!continueEnabled) + } + .padding() + .background(.bar) + } +} diff --git a/LoopFollow/Onboarding/OnboardingStep.swift b/LoopFollow/Onboarding/OnboardingStep.swift new file mode 100644 index 000000000..0ae033ef4 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingStep.swift @@ -0,0 +1,69 @@ +// LoopFollow +// OnboardingStep.swift + +import Foundation + +/// The phases of the first-run onboarding wizard. +/// +/// Progress is tracked by phase, not by page: the set of phases is stable, while +/// some phases contain a variable number of internal pages (the Nightscout connect +/// phase can be one or two pages; the alarms phase is one page per offered alarm, +/// which differs between Nightscout and Dexcom). Those phases report their +/// within-phase position through `OnboardingViewModel.phaseProgress`, so the +/// overall progress bar stays smooth no matter how many pages a phase has. +/// +/// `connect` renders the Nightscout, Dexcom, or import view depending on the data +/// source the user picks in `dataSource`. +enum OnboardingStep: CaseIterable, Hashable { + case welcome + case overview + case dataSource + case connect + case units + case generalAlarms + case alarms + case tabOrder + case notifications + case telemetry + case completion + + /// Steps that show the progress bar + phase label + Skip at the top. The + /// welcome and completion screens are full-bleed and provide their own CTA. + var showsProgressHeader: Bool { + switch self { + case .welcome, .completion: + return false + default: + return true + } + } + + /// Steps that use the shared Back / Continue footer. Phases that contain their + /// own internal pages or custom primary buttons (overview, connect, units, + /// alarms, notifications, telemetry) supply their own footer instead, as do the + /// full-bleed welcome and completion screens. + var usesSharedFooter: Bool { + switch self { + case .dataSource, .generalAlarms, .tabOrder: + return true + default: + return false + } + } + + /// Short name shown in the progress header for this phase. + var phaseTitle: String { + switch self { + case .welcome, .completion: return "" + case .overview: return "Overview" + case .dataSource: return "Data source" + case .connect: return "Connect" + case .units: return "Units & metrics" + case .generalAlarms: return "Alarm basics" + case .alarms: return "Alarms" + case .tabOrder: return "Tabs" + case .notifications: return "Notifications" + case .telemetry: return "Privacy" + } + } +} diff --git a/LoopFollow/Onboarding/OnboardingStepHeader.swift b/LoopFollow/Onboarding/OnboardingStepHeader.swift new file mode 100644 index 000000000..1b6395ef8 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingStepHeader.swift @@ -0,0 +1,38 @@ +// LoopFollow +// OnboardingStepHeader.swift + +import SwiftUI + +/// Consistent icon + title + subtitle header used at the top of each step body. +/// +/// The header deliberately carries no horizontal margin: it shares whatever +/// leading edge its container uses, so it lines up exactly with the content +/// below it — the section cards in a `Form`, or the `.padding(.horizontal)` +/// cards in a plain `VStack`. Callers add their own horizontal padding to match. +struct OnboardingStepHeader: View { + let systemImage: String + let title: String + let subtitle: String + + var body: some View { + // Left-aligned: justified/centered body copy is harder to read, so the + // header reads as a natural top-down intro. + VStack(alignment: .leading, spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .padding(.bottom, 2) + + Text(title) + .font(.title2.weight(.bold)) + .multilineTextAlignment(.leading) + + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + } +} diff --git a/LoopFollow/Onboarding/OnboardingViewModel.swift b/LoopFollow/Onboarding/OnboardingViewModel.swift new file mode 100644 index 000000000..5cd894857 --- /dev/null +++ b/LoopFollow/Onboarding/OnboardingViewModel.swift @@ -0,0 +1,387 @@ +// LoopFollow +// OnboardingViewModel.swift + +import Combine +import SwiftUI +import UserNotifications + +/// Drives the onboarding wizard: tracks the current phase, the chosen data +/// source, the seeded alarms, and persists everything when the user finishes. +/// +/// The child connection view models are the same ones used by the regular +/// settings screens, so URL/token/credential entry and validation behave +/// identically here and there. +@MainActor +final class OnboardingViewModel: ObservableObject { + enum DataSource: Hashable { + case nightscout + case dexcom + case copyFromPhone + } + + /// A single recommended alarm offered on the alarms phase. Display fields are + /// carried explicitly (rather than derived from `AlarmType`) so two alarms of + /// the same type — a warning Low and an Urgent Low — can read differently. + struct SeedAlarm: Identifiable { + let id = UUID() + var alarm: Alarm + var isEnabled: Bool + var title: String + var detail: String + /// Needs Nightscout loop/uploader data, so it's hidden for Dexcom-only. + var requiresNightscout: Bool + + var type: AlarmType { alarm.type } + + /// Use each alarm type's own icon rather than a bespoke one. + var icon: String { alarm.type.icon } + } + + /// Position within a multi-page phase, reported by that phase's view so the + /// overall progress bar and label can reflect it. + struct PhaseProgress: Equatable { + var page: Int + var count: Int + } + + @Published var step: OnboardingStep = .welcome + @Published var dataSource: DataSource? + @Published var seedAlarms: [SeedAlarm] + + /// Within-phase position for phases that own internal pages (connect, alarms). + /// Reset to `nil` whenever the phase changes. + @Published var phaseProgress: PhaseProgress? + + /// Set once a QR settings import on the "copy from another phone" path + /// succeeds, so the connect phase can be considered complete. + @Published var didImportSettings = false + + /// Whether the notification permission is still undecided. The notifications + /// phase is only shown when it is — there's no point prompting someone who has + /// already granted or denied it (iOS won't show the prompt again anyway). + /// Defaults to `true`; resolved asynchronously at launch, well before the user + /// reaches that phase. + @Published private var notificationsUndecided = true + + let nightscoutViewModel = NightscoutSettingsViewModel() + let dexcomViewModel = DexcomSettingsViewModel() + + /// Called to dismiss the onboarding cover. + private let onClose: () -> Void + private var cancellables = Set() + + /// Whether to show the in-flow telemetry consent phase. Captured once at + /// launch so the decision the phase records doesn't remove it from under the + /// navigation while the user is still on it. + private let includeTelemetryStep: Bool + + /// Alarm types the user already has, captured at launch. Onboarding only + /// offers recommended alarms whose type the user doesn't already own, so a + /// returning user is helped to add new ones without touching their existing + /// (possibly custom-named) alarms. + private let existingAlarmTypes: Set + + init(onClose: @escaping () -> Void) { + self.onClose = onClose + includeTelemetryStep = !Storage.shared.telemetryConsentDecisionMade.value + existingAlarmTypes = Set(Storage.shared.alarms.value.map(\.type)) + let hasNightscout = !Storage.shared.url.value.isEmpty + let hasDexcom = !Storage.shared.shareUserName.value.isEmpty + && !Storage.shared.sharePassword.value.isEmpty + isAlreadyConfigured = hasNightscout || hasDexcom + seedAlarms = OnboardingViewModel.defaultSeedAlarms() + + // Re-publish child changes so the footer's `canProceed` stays in sync + // with live connection validation. + nightscoutViewModel.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + dexcomViewModel.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + + UNUserNotificationCenter.current().getNotificationSettings { settings in + let undecided = settings.authorizationStatus == .notDetermined + Task { @MainActor [weak self] in + self?.notificationsUndecided = undecided + } + } + } + + // MARK: - Derived state + + /// True when the user already had a working data source at launch — used to + /// make skipping prominent for returning users and to decide whether the + /// data-source/connect phases are shown. Captured once in `init`, not read + /// live: the connect step persists the URL/credentials as the user types, so + /// a live read would flip this to `true` mid-`.connect`, drop those phases + /// from `activeSteps`, and make `advance()` fall through to `finish()` — + /// silently skipping units, alarms, and the rest of setup. + let isAlreadyConfigured: Bool + + var canProceed: Bool { + switch step { + case .welcome, .overview, .units, .generalAlarms, .alarms, + .tabOrder, .notifications, .telemetry, .completion: + return true + case .dataSource: + return dataSource != nil + case .connect: + switch dataSource { + case .nightscout: return nightscoutViewModel.isConnected || nightscoutViewModel.provisionedTokenPending + case .dexcom: return dexcomViewModel.canVerifyProceed + case .copyFromPhone: return didImportSettings + case .none: return false + } + } + } + + /// Whether Nightscout loop/uploader data is (or will be) available — used to + /// gate alarms that depend on it. True for the Nightscout source, or any path + /// that ends with a Nightscout URL configured (including a QR import). + private var hasNightscoutData: Bool { + dataSource == .nightscout || !Storage.shared.url.value.isEmpty + } + + /// Whether a seeded alarm should be offered: its data source must be available + /// and the user must not already have an alarm of that type. + func isOffered(_ seed: SeedAlarm) -> Bool { + guard !seed.requiresNightscout || hasNightscoutData else { return false } + return !existingAlarmTypes.contains(seed.type) + } + + var offeredSeedAlarms: [SeedAlarm] { + seedAlarms.filter { isOffered($0) } + } + + private var alarmsOffered: Bool { !offeredSeedAlarms.isEmpty } + + /// True when a returning user already has a working data source and there are + /// no recommended alarms left to add — nothing essential to configure, so the + /// overview becomes a short "you're all set" confirmation instead of a plan. + var hasNothingToSetUp: Bool { + isAlreadyConfigured && !alarmsOffered + } + + /// The phases actually shown for the current configuration, in order. Optional + /// phases are included only when relevant; a returning user skips the phases + /// they've already completed. + var activeSteps: [OnboardingStep] { + var steps: [OnboardingStep] = [.welcome, .overview] + + // Already set up and nothing to add — the overview is the last stop, with + // a "Done" that finishes. Notification/telemetry consent is still handled + // after dismissal, the same as when skipping. + if hasNothingToSetUp { + return steps + } + + // A returning user with a working data source skips source selection and + // the connection screen; everyone else sets them up. + if !isAlreadyConfigured { + steps.append(contentsOf: [.dataSource, .connect]) + } + + steps.append(.units) + if alarmsOffered { + steps.append(contentsOf: [.generalAlarms, .alarms]) + } + steps.append(.tabOrder) + if notificationsUndecided { + steps.append(.notifications) + } + if includeTelemetryStep { + steps.append(.telemetry) + } + steps.append(.completion) + return steps + } + + /// Phases that show the progress header — the unit over which the bar fills. + private var chromePhases: [OnboardingStep] { + activeSteps.filter { $0.showsProgressHeader } + } + + /// Progress fraction (0...1). Each phase is one slot; a multi-page phase fills + /// its slot proportionally via `phaseProgress`, so the bar stays smooth no + /// matter how many pages a phase turns out to have. + var progress: Double { + guard !chromePhases.isEmpty else { return 0 } + guard let index = chromePhases.firstIndex(of: step) else { + return step == .completion ? 1 : 0 + } + let within: Double + if let progress = phaseProgress, progress.count > 1 { + within = Double(progress.page) / Double(progress.count) + } else { + within = 0 + } + return (Double(index) + within) / Double(chromePhases.count) + } + + /// The phase name shown in the header, with a local page count for multi-page + /// phases (e.g. "Alarms · 3 of 13"). + var progressLabel: String { + let title = step.phaseTitle + if let progress = phaseProgress, progress.count > 1 { + return "\(title) · \(min(progress.page + 1, progress.count)) of \(progress.count)" + } + return title + } + + // MARK: - Navigation + + var canGoBack: Bool { + guard let index = activeSteps.firstIndex(of: step) else { return false } + return index > 0 + } + + func advance() { + phaseProgress = nil + let steps = activeSteps + guard let index = steps.firstIndex(of: step) else { + // Defensive: the current step should always be in `activeSteps`. + // If it ever isn't (a state change removed it while we were on it), + // continue to the next still-active step in canonical order rather + // than silently ending setup via `finish()`. + let canonical = OnboardingStep.allCases + let currentRank = canonical.firstIndex(of: step) ?? canonical.count + if let next = steps.first(where: { (canonical.firstIndex(of: $0) ?? 0) > currentRank }) { + step = next + } else { + finish() + } + return + } + guard index + 1 < steps.count else { + finish() + return + } + step = steps[index + 1] + } + + func goBack() { + phaseProgress = nil + guard let index = activeSteps.firstIndex(of: step), index > 0 else { return } + step = activeSteps[index - 1] + } + + /// Skip the rest of setup. Marks onboarding complete without seeding alarms + /// or touching units, leaving any existing configuration untouched. + func skip() { + Storage.shared.hasCompletedOnboarding.value = true + onClose() + } + + /// Finish setup: seed selected alarms, mark units/onboarding complete, and + /// kick a refresh so the home screen loads data immediately. + func finish() { + persistSeededAlarms() + Storage.shared.hasConfiguredUnits.value = true + Storage.shared.hasCompletedOnboarding.value = true + onClose() + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } + + // MARK: - Alarm seeding + + private func persistSeededAlarms() { + var alarms = Storage.shared.alarms.value + let existingTypes = Set(alarms.map(\.type)) + + for seed in offeredSeedAlarms where seed.isEnabled { + // Don't re-add a type the user already configured. The two seeded Low + // alarms (warning + urgent) are both added on a fresh install because + // `existingTypes` is sampled once, before any are appended. + guard !existingTypes.contains(seed.type) else { continue } + alarms.append(seed.alarm) + } + + Storage.shared.alarms.value = alarms + } + + // MARK: - Default seed set + + /// The recommended alarms, in page order. Glucose and phone alarms are offered + /// to everyone; loop/pump/insulin alarms require Nightscout data. + private static func defaultSeedAlarms() -> [SeedAlarm] { + func seed( + _ type: AlarmType, + title: String, + detail: String, + requiresNightscout: Bool, + configure: (inout Alarm) -> Void = { _ in } + ) -> SeedAlarm { + var alarm = Alarm(type: type) + configure(&alarm) + return SeedAlarm( + alarm: alarm, + isEnabled: true, + title: title, + detail: detail, + requiresNightscout: requiresNightscout + ) + } + + return [ + seed(.low, title: "Low glucose", + detail: "Warns when glucose is low, now or soon.", + requiresNightscout: false) + { + $0.name = "Low glucose" + $0.belowBG = 70 + $0.predictiveMinutes = 20 + }, + seed(.low, title: "Urgent low", + detail: "A separate warning for when glucose is very low.", + requiresNightscout: false) + { + $0.name = "Urgent low" + $0.belowBG = 54 + $0.predictiveMinutes = 0 + $0.persistentMinutes = 0 + }, + seed(.high, title: "High glucose", + detail: "Warns when glucose is high.", + requiresNightscout: false) + { + $0.aboveBG = 180 + }, + seed(.fastDrop, title: "Fast drop", + detail: "Warns when glucose is falling quickly.", + requiresNightscout: false), + seed(.missedReading, title: "Missed readings", + detail: "Warns when glucose stops updating.", + requiresNightscout: false), + seed(.notLooping, title: "Not looping", + detail: "Warns when the loop stops running.", + requiresNightscout: true), + seed(.battery, title: "Looping phone battery", + detail: "Warns when the battery of the phone running Loop or Trio is low.", + requiresNightscout: true), + seed(.iob, title: "IOB", + detail: "Warns when insulin on board is high.", + requiresNightscout: true), + seed(.cob, title: "COB", + detail: "Warns when carbs on board are high.", + requiresNightscout: true), + seed(.sensorChange, title: "Sensor change", + detail: "Reminds you when the CGM sensor is due.", + requiresNightscout: true) + { + $0.threshold = 10 + $0.activeOption = .day + }, + seed(.pumpChange, title: "Pump change", + detail: "Reminds you when the pump site is due.", + requiresNightscout: true) + { + $0.threshold = 3 + $0.activeOption = .day + }, + seed(.pump, title: "Pump insulin", + detail: "Warns when pump reservoir insulin is low.", + requiresNightscout: true), + ] + } +} diff --git a/LoopFollow/Onboarding/Steps/AlarmsStepView.swift b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift new file mode 100644 index 000000000..5efe46da7 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/AlarmsStepView.swift @@ -0,0 +1,260 @@ +// LoopFollow +// AlarmsStepView.swift + +import HealthKit +import SwiftUI + +/// The alarms phase: one recommended alarm per page, each with a toggle and a few +/// of its most useful settings. The number of pages depends on the data source +/// (Dexcom-only followers don't see loop/pump alarms), which the phase reports via +/// `phaseProgress` so the overall progress bar stays accurate. +struct AlarmsStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + + /// Page index into `offeredIndices`. + @State private var index = 0 + + /// Indices into `viewModel.seedAlarms` that are offered for this data source. + private var offeredIndices: [Int] { + viewModel.seedAlarms.indices.filter { viewModel.isOffered(viewModel.seedAlarms[$0]) } + } + + var body: some View { + VStack(spacing: 0) { + if let seedIndex = offeredIndices[safe: index] { + alarmPage(seedIndex: seedIndex) + } + + OnboardingNavFooter( + continueEnabled: true, + showBack: true, + onBack: goBack, + onContinue: goForward + ) + } + .onAppear(perform: reportProgress) + .onChange(of: index) { _ in reportProgress() } + } + + private func reportProgress() { + viewModel.phaseProgress = .init(page: index, count: offeredIndices.count) + } + + private func goForward() { + if index < offeredIndices.count - 1 { + withAnimation(.easeInOut(duration: 0.25)) { index += 1 } + } else { + viewModel.advance() + } + } + + private func goBack() { + if index > 0 { + withAnimation(.easeInOut(duration: 0.25)) { index -= 1 } + } else { + viewModel.goBack() + } + } + + // MARK: - Page + + private func alarmPage(seedIndex: Int) -> some View { + let seed = $viewModel.seedAlarms[seedIndex] + let display = viewModel.seedAlarms[seedIndex] + return Form { + Section { + EmptyView() + } header: { + VStack(spacing: 8) { + Image(systemName: display.icon) + .font(.system(size: 40, weight: .semibold)) + .foregroundStyle(Color.accentColor) + Text(display.title) + .font(.title2.weight(.bold)) + Text(display.detail) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section { + Toggle("Enable this alarm", isOn: seed.isEnabled) + + if seed.wrappedValue.isEnabled { + controls(for: seed) + } + } footer: { + if seed.wrappedValue.isEnabled, let text = explanation(for: seed.wrappedValue) { + Text(text) + } + } + } + } + + // MARK: - Per-alarm controls + + @ViewBuilder + private func controls(for seed: Binding) -> some View { + switch seed.wrappedValue.type { + case .low: + bgPicker(seed, title: "Alert below", range: 40 ... 150, keyPath: \.belowBG) + intStepper(seed, label: "Warn early by", range: 0 ... 30, step: 5, unit: "min", keyPath: \.predictiveMinutes) + case .high: + bgPicker(seed, title: "Alert above", range: 120 ... 350, keyPath: \.aboveBG) + intStepper(seed, label: "Only after high for", range: 0 ... 60, step: 5, unit: "min", keyPath: \.persistentMinutes) + case .fastDrop: + bgPicker(seed, title: "Drop per reading", range: 3 ... 54, keyPath: \.delta) + intStepper(seed, label: "Readings in a row", range: 1 ... 3, step: 1, unit: "", keyPath: \.monitoringWindow) + case .missedReading: + doubleStepper(seed, label: "No reading for", range: 11 ... 121, step: 5, unit: "min", keyPath: \.threshold) + case .notLooping: + doubleStepper(seed, label: "No loop for", range: 16 ... 61, step: 5, unit: "min", keyPath: \.threshold) + case .battery: + doubleStepper(seed, label: "At or below", range: 0 ... 100, step: 5, unit: "%", keyPath: \.threshold) + case .iob: + doubleStepper(seed, label: "Alert above", range: 0 ... 30, step: 1, unit: "U", keyPath: \.threshold) + case .cob: + doubleStepper(seed, label: "Alert above", range: 0 ... 200, step: 5, unit: "g", keyPath: \.threshold) + case .sensorChange: + doubleStepper(seed, label: "Remind after", range: 1 ... 15, step: 1, unit: "days", keyPath: \.threshold) + activePicker(seed) + case .pumpChange: + doubleStepper(seed, label: "Remind after", range: 1 ... 7, step: 1, unit: "days", keyPath: \.threshold) + activePicker(seed) + case .pump: + doubleStepper(seed, label: "Alert below", range: 0 ... 100, step: 5, unit: "U", keyPath: \.threshold) + default: + EmptyView() + } + } + + private func bgPicker( + _ seed: Binding, + title: String, + range: ClosedRange, + keyPath: WritableKeyPath + ) -> some View { + BGPicker(title: title, range: range, value: doubleBinding(seed, keyPath: keyPath)) + } + + private func doubleStepper( + _ seed: Binding, + label: String, + range: ClosedRange, + step: Double, + unit: String, + keyPath: WritableKeyPath + ) -> some View { + let value = doubleBinding(seed, keyPath: keyPath) + return Stepper(value: value, in: range, step: step) { + labelRow(label, value: "\(formatted(value.wrappedValue)) \(unit)") + } + } + + private func intStepper( + _ seed: Binding, + label: String, + range: ClosedRange, + step: Int, + unit: String, + keyPath: WritableKeyPath + ) -> some View { + let value = intBinding(seed, keyPath: keyPath) + let text = unit.isEmpty ? "\(value.wrappedValue)" : "\(value.wrappedValue) \(unit)" + return Stepper(value: value, in: range, step: step) { + labelRow(label, value: text) + } + } + + /// Plain-language summary of what a recommended alarm will do, shown as the + /// section footer. Fast drop earns one because its per-reading-times-window + /// behaviour isn't obvious from the two controls alone. + private func explanation(for seed: OnboardingViewModel.SeedAlarm) -> String? { + switch seed.type { + case .fastDrop: + let alarm = seed.alarm + let fallback = Alarm(type: .fastDrop) + let delta = alarm.delta ?? fallback.delta ?? 0 + let window = alarm.monitoringWindow ?? fallback.monitoringWindow ?? 0 + guard delta > 0, window > 0 else { return nil } + + let unit = Localizer.getPreferredUnit().localizedShortUnitString + let perReading = Localizer.formatQuantity(delta) + + if window == 1 { + return "Warns when glucose falls by at least \(perReading) \(unit) between two readings." + } + let total = Localizer.formatQuantity(delta * Double(window)) + let minutes = window * 5 + return "Warns when glucose falls by at least \(perReading) \(unit) on each of \(window) readings in a row — about \(total) \(unit) over roughly \(minutes) minutes." + default: + return nil + } + } + + /// Day/night picker for reminder alarms (sensor/pump change), so they don't + /// fire overnight by default. Reuses the same menu picker as the full editor. + private func activePicker(_ seed: Binding) -> some View { + let binding = Binding( + get: { seed.wrappedValue.alarm.activeOption }, + set: { seed.wrappedValue.alarm.activeOption = $0 } + ) + return AlarmEnumMenuPicker(title: "Active during", selection: binding) + } + + private func labelRow(_ label: String, value: String) -> some View { + HStack { + Text(label) + Spacer() + Text(value).foregroundColor(.secondary) + } + } + + private func formatted(_ value: Double) -> String { + value == value.rounded() ? String(Int(value)) : String(format: "%.1f", value) + } + + private func doubleBinding( + _ seed: Binding, + keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { seed.wrappedValue.alarm[keyPath: keyPath] ?? typeDefault(seed, keyPath) }, + set: { seed.wrappedValue.alarm[keyPath: keyPath] = $0 } + ) + } + + private func intBinding( + _ seed: Binding, + keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { seed.wrappedValue.alarm[keyPath: keyPath] ?? typeDefault(seed, keyPath) }, + set: { seed.wrappedValue.alarm[keyPath: keyPath] = $0 } + ) + } + + /// Fallback value for a control, read from a fresh `Alarm(type:)` so the + /// onboarding controls share the one source of truth for per-type defaults + /// and can't drift from them. Only a safety net — seeded alarms already carry + /// these values, so the fallback is rarely exercised. + private func typeDefault(_ seed: Binding, _ keyPath: KeyPath) -> Double { + Alarm(type: seed.wrappedValue.type)[keyPath: keyPath] ?? 0 + } + + private func typeDefault(_ seed: Binding, _ keyPath: KeyPath) -> Int { + Alarm(type: seed.wrappedValue.type)[keyPath: keyPath] ?? 0 + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/LoopFollow/Onboarding/Steps/CompletionStepView.swift b/LoopFollow/Onboarding/Steps/CompletionStepView.swift new file mode 100644 index 000000000..b0e9cb261 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/CompletionStepView.swift @@ -0,0 +1,108 @@ +// LoopFollow +// CompletionStepView.swift + +import SwiftUI + +struct CompletionStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var animate = false + + /// A short tease of optional features worth discovering later — not part of + /// setup, just a nudge toward a few things that are easy to miss. + private struct Gem: Identifiable { + let id = UUID() + let icon: String + let title: String + let detail: String + } + + private let gems: [Gem] = [ + Gem(icon: "person.crop.square", + title: "Contact image", + detail: "Show your glucose on your Apple Watch face through a contact."), + Gem(icon: "bell.badge", + title: "Custom alarms", + detail: "Build your own alarms beyond the recommended set."), + Gem(icon: "dot.radiowaves.left.and.right", + title: "Remote commands", + detail: "Send carbs, boluses, and temp targets to Trio or Loop."), + ] + + var body: some View { + VStack(spacing: 0) { + Spacer(minLength: 24) + + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 84, weight: .semibold)) + .foregroundStyle(.green.gradient) + .scaleEffect(animate || reduceMotion ? 1 : 0.6) + .opacity(animate || reduceMotion ? 1 : 0) + + Text("You're all set") + .font(.largeTitle.weight(.bold)) + .multilineTextAlignment(.center) + + Text("You're ready to go. Adjust everything later from the Menu — and when you have a moment, these are worth a look:") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + gemsCard + .padding(.top, 28) + .padding(.horizontal, 24) + + Spacer(minLength: 24) + + Button { viewModel.finish() } label: { + Text("Finish") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.spring(response: 0.5, dampingFraction: 0.6).delay(0.1)) { + animate = true + } + } + } + + private var gemsCard: some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(gems) { gem in + HStack(alignment: .top, spacing: 12) { + Image(systemName: gem.icon) + .font(.body) + .foregroundStyle(Color.accentColor) + .frame(width: 26) + + VStack(alignment: .leading, spacing: 2) { + Text(gem.title) + .font(.subheadline.weight(.semibold)) + Text(gem.detail) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer(minLength: 0) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + } +} diff --git a/LoopFollow/Onboarding/Steps/ConnectImportStepView.swift b/LoopFollow/Onboarding/Steps/ConnectImportStepView.swift new file mode 100644 index 000000000..ef25b59ae --- /dev/null +++ b/LoopFollow/Onboarding/Steps/ConnectImportStepView.swift @@ -0,0 +1,84 @@ +// LoopFollow +// ConnectImportStepView.swift + +import SwiftUI + +/// The "copy from another phone" connect path: scan a QR code exported from an +/// already-configured LoopFollow and apply those settings. Reuses the same +/// scanner, preview, and import logic as Settings → Import/Export. +struct ConnectImportStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @StateObject private var importVM = ImportExportSettingsViewModel() + + private var importSucceeded: Bool { + importVM.qrCodeErrorMessage.contains("Successfully imported") + } + + var body: some View { + VStack(spacing: 0) { + form + OnboardingNavFooter( + continueEnabled: viewModel.didImportSettings, + showBack: viewModel.canGoBack, + onBack: { viewModel.goBack() }, + onContinue: { viewModel.advance() } + ) + } + } + + private var form: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "qrcode", + title: "Copy from another phone", + subtitle: "On your other phone, open Settings → Nightscout (or Dexcom Share) → Import/Export and export to a QR code. Then scan it here." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section { + Button { + importVM.isShowingQRCodeScanner = true + } label: { + HStack { + Image(systemName: "qrcode.viewfinder") + .foregroundColor(.accentColor) + Text(viewModel.didImportSettings ? "Scan a different code" : "Scan QR Code") + } + } + } + + if viewModel.didImportSettings { + Section { + Label("Settings imported — you're connected.", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + } + } else if !importVM.qrCodeErrorMessage.isEmpty { + Section { + Text(importVM.qrCodeErrorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + } + .sheet(isPresented: $importVM.isShowingQRCodeScanner) { + SimpleQRCodeScannerView { result in + importVM.handleQRCodeScanResult(result) + } + } + .sheet(isPresented: $importVM.showImportConfirmation) { + ImportConfirmationView(viewModel: importVM) + } + .onChange(of: importVM.qrCodeErrorMessage) { message in + if message.contains("Successfully imported") { + viewModel.didImportSettings = true + } + } + } +} diff --git a/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift new file mode 100644 index 000000000..7021ae46e --- /dev/null +++ b/LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift @@ -0,0 +1,84 @@ +// LoopFollow +// DataSourceChoiceStepView.swift + +import SwiftUI + +struct DataSourceChoiceStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + + var body: some View { + ScrollView { + VStack(spacing: 24) { + OnboardingStepHeader( + systemImage: "antenna.radiowaves.left.and.right", + title: "Choose a data source", + subtitle: "LoopFollow needs a glucose data source. Pick one now — you can change or add more later in Settings." + ) + .padding(.horizontal) + + VStack(spacing: 14) { + choiceCard( + source: .nightscout, + icon: "globe", + title: "Nightscout", + detail: "Follow a Nightscout site. Works with Loop, Trio, and most uploaders. Enables the full set of LoopFollow features." + ) + choiceCard( + source: .dexcom, + icon: "sensor.tag.radiowaves.forward.fill", + title: "Dexcom Share", + detail: "Follow glucose directly from a Dexcom Share account, without a Nightscout site." + ) + choiceCard( + source: .copyFromPhone, + icon: "qrcode", + title: "Copy from another phone", + detail: "Already using LoopFollow on another phone? Scan its QR code to copy the connection here." + ) + } + .padding(.horizontal) + } + .padding(.bottom, 24) + } + } + + private func choiceCard(source: OnboardingViewModel.DataSource, icon: String, title: String, detail: String) -> some View { + let isSelected = viewModel.dataSource == source + return Button { + viewModel.dataSource = source + } label: { + HStack(alignment: .top, spacing: 14) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + .foregroundColor(.primary) + Text(detail) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer(minLength: 0) + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(isSelected ? Color.accentColor : Color(.systemGray3)) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } +} diff --git a/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift new file mode 100644 index 000000000..a932d9f68 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift @@ -0,0 +1,97 @@ +// LoopFollow +// DexcomConnectStepView.swift + +import SwiftUI + +struct DexcomConnectStepView: View { + @ObservedObject var viewModel: DexcomSettingsViewModel + @ObservedObject var onboarding: OnboardingViewModel + + var body: some View { + VStack(spacing: 0) { + ConnectionStatusPill( + color: statusColor, + message: viewModel.statusMessage, + isLoading: viewModel.statusKind == .checking, + systemImage: statusIcon + ) + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 8) + .animation(.easeInOut(duration: 0.25), value: viewModel.statusKind) + + form + OnboardingNavFooter( + continueEnabled: viewModel.canVerifyProceed, + showBack: onboarding.canGoBack, + onBack: { onboarding.goBack() }, + onContinue: { onboarding.advance() } + ) + } + } + + private var form: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "sensor.tag.radiowaves.forward.fill", + title: "Connect Dexcom Share", + subtitle: "Sign in with the Dexcom Share account that shares glucose data." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section(header: Text("Dexcom Share")) { + HStack { + Text("Username") + TextField("Enter Username", text: $viewModel.userName) + .textContentType(.username) + .autocapitalization(.none) + .disableAutocorrection(true) + .multilineTextAlignment(.trailing) + } + + HStack { + Text("Password") + TogglableSecureInput( + placeholder: "Enter Password", + text: $viewModel.password, + style: .singleLine, + textContentType: .password + ) + } + + Picker("Server", selection: $viewModel.server) { + Text("US").tag("US") + Text("Outside US").tag("NON-US") + } + .pickerStyle(.segmented) + } + } + } + + // MARK: - Status pill mapping + + private var statusColor: Color { + switch viewModel.statusKind { + case .idle: return .secondary + case .checking: return .orange + case .connected: return .green + case .error: return .red + } + } + + private var statusIcon: String? { + switch viewModel.statusKind { + case .idle: return "circle" + case .checking: return nil + case .connected: return "checkmark.circle.fill" + case .error: return "exclamationmark.triangle.fill" + } + } +} diff --git a/LoopFollow/Onboarding/Steps/GeneralAlarmsStepView.swift b/LoopFollow/Onboarding/Steps/GeneralAlarmsStepView.swift new file mode 100644 index 000000000..e7d4added --- /dev/null +++ b/LoopFollow/Onboarding/Steps/GeneralAlarmsStepView.swift @@ -0,0 +1,83 @@ +// LoopFollow +// GeneralAlarmsStepView.swift + +import SwiftUI + +/// A short set of the alarm-wide settings that matter most up front: when the +/// day and night periods begin (used by day/night alarm options) and how alarm +/// sound is handled. The full set lives in the Menu. +struct GeneralAlarmsStepView: View { + @ObservedObject private var cfgStore = Storage.shared.alarmConfiguration + + private var dayBinding: Binding { + timeBinding(\.dayStart) + } + + private var nightBinding: Binding { + timeBinding(\.nightStart) + } + + var body: some View { + Form { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: "slider.horizontal.3", + title: "Alarm basics", + subtitle: "Set when your day and night begin, and how alarm sound behaves. You can fine-tune everything later in the Menu." + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + + Section { + DatePicker("Day starts", selection: dayBinding, displayedComponents: [.hourAndMinute]) + .datePickerStyle(.compact) + DatePicker("Night starts", selection: nightBinding, displayedComponents: [.hourAndMinute]) + .datePickerStyle(.compact) + } header: { + Text("Day / Night") + } footer: { + Text("Alarms can behave differently during the day and at night.") + } + + Section { + Toggle("Override system volume", isOn: $cfgStore.value.overrideSystemOutputVolume) + + if cfgStore.value.overrideSystemOutputVolume { + HStack { + Image(systemName: "speaker.fill") + .foregroundColor(.secondary) + Slider(value: $cfgStore.value.forcedOutputVolume, in: 0 ... 1) + Image(systemName: "speaker.wave.3.fill") + .foregroundColor(.secondary) + } + } + + Toggle("Play sound during calls", isOn: $cfgStore.value.audioDuringCalls) + } header: { + Text("Sound") + } footer: { + Text("Overriding the system volume lets alarms be heard even when your phone is silenced or in a Focus mode.") + } + } + } + + private func timeBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { + var components = Calendar.current.dateComponents([.year, .month, .day], from: Date()) + components.hour = cfgStore.value[keyPath: keyPath].hour + components.minute = cfgStore.value[keyPath: keyPath].minute + return Calendar.current.date(from: components) ?? Date() + }, + set: { newDate in + let hm = Calendar.current.dateComponents([.hour, .minute], from: newDate) + cfgStore.value[keyPath: keyPath] = TimeOfDay(hour: hm.hour ?? 0, minute: hm.minute ?? 0) + } + ) + } +} diff --git a/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift b/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift new file mode 100644 index 000000000..10dee8c01 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift @@ -0,0 +1,293 @@ +// LoopFollow +// NightscoutConnectStepView.swift + +import SwiftUI + +/// The Nightscout connect phase, split into two internal pages: +/// 1. **Address** — enter the site URL; we validate it. A public site (or a URL +/// that already carries a token) is done here. A reachable site that needs a +/// token advances to page 2. An unreachable/invalid address stays put with the +/// error shown in the status pill so the user can correct it. +/// 2. **Token** — paste a token or create a read-only one from the API secret. +struct NightscoutConnectStepView: View { + @ObservedObject var viewModel: NightscoutSettingsViewModel + @ObservedObject var onboarding: OnboardingViewModel + + private enum Page { case address, token } + private enum TokenMode: Hashable { case haveToken, createFromSecret } + + @State private var page: Page = .address + @State private var mode: TokenMode = .haveToken + @State private var apiSecret: String = "" + + var body: some View { + VStack(spacing: 0) { + ConnectionStatusPill( + color: statusColor, + message: viewModel.friendlyStatus, + isLoading: viewModel.statusKind == .checking, + systemImage: statusIcon + ) + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 8) + .animation(.easeInOut(duration: 0.25), value: viewModel.statusKind) + + switch page { + case .address: + addressForm + case .token: + tokenForm + } + + footer + } + } + + // MARK: - Pages + + private var addressForm: some View { + Form { + titleSection( + "Connect to Nightscout", + "Enter your site address. We'll check it, and only ask for a token if your site needs one." + ) + urlSection + } + } + + private var tokenForm: some View { + Form { + if viewModel.isConnected || viewModel.provisionedTokenPending { + tokenDoneSection + } else { + titleSection( + "Add a token", + "This site needs a token. Paste one, or have LoopFollow create a read-only token from your API secret." + ) + tokenModeSection + + switch mode { + case .haveToken: + tokenSection + case .createFromSecret: + secretSection + } + } + } + } + + /// Shown once a token is in place, so the page reads as "done — continue" + /// rather than still asking the user to do something. + private var tokenDoneSection: some View { + Section { + EmptyView() + } header: { + VStack(alignment: .leading, spacing: 10) { + Text(viewModel.provisionedTokenPending ? "Token created" : "You're connected") + .font(.title2.weight(.bold)) + .foregroundColor(.primary) + Text(viewModel.provisionedTokenPending + ? "Your read-only token is ready. Tap Continue to keep going — your site may take a few minutes to start accepting it." + : "Your read-only token is set up. Tap Continue to keep going.") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + + @ViewBuilder + private var footer: some View { + switch page { + case .address: + OnboardingNavFooter( + continueEnabled: viewModel.isConnected || viewModel.addressNeedsToken, + showBack: onboarding.canGoBack, + onBack: { onboarding.goBack() }, + onContinue: addressContinue + ) + case .token: + OnboardingNavFooter( + continueEnabled: viewModel.isConnected || viewModel.provisionedTokenPending, + showBack: true, + onBack: { withAnimation(.easeInOut(duration: 0.25)) { page = .address } }, + onContinue: { onboarding.advance() } + ) + } + } + + private func addressContinue() { + if viewModel.isConnected { + onboarding.advance() + } else if viewModel.addressNeedsToken { + withAnimation(.easeInOut(duration: 0.25)) { page = .token } + } + } + + // MARK: - Sections + + private func titleSection(_ title: String, _ subtitle: String) -> some View { + Section { + EmptyView() + } header: { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.title2.weight(.bold)) + .foregroundColor(.primary) + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + + private var urlSection: some View { + Section { + // `verbatim:` keeps SwiftUI from auto-linking the example URL in accent + // blue (which reads as an "active" field). + ZStack(alignment: .leading) { + if viewModel.nightscoutURL.isEmpty { + Text(verbatim: "https://your-site.example.com") + .foregroundColor(.secondary) + } + TextField("", text: $viewModel.nightscoutURL) + .textContentType(.URL) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .foregroundColor(.primary) + .onChange(of: viewModel.nightscoutURL) { newValue in + viewModel.processURL(newValue) + } + } + } header: { + Text("Site URL") + } footer: { + Text("Enter or paste your full Nightscout address. If the link already includes a token, we'll fill both in for you.") + } + } + + private var tokenModeSection: some View { + Section { + Picker("Token", selection: $mode) { + Text("I have a token").tag(TokenMode.haveToken) + Text("Create one for me").tag(TokenMode.createFromSecret) + } + .pickerStyle(.segmented) + } footer: { + if mode == .createFromSecret { + Text("Your API secret is used once to create a read-only access token and is never stored.") + } else { + Text("Type or paste a token, or a full Nightscout URL that includes a token.") + } + } + } + + private var tokenSection: some View { + Section(header: Text("Access Token")) { + HStack { + Text("Token") + TogglableSecureInput( + placeholder: "Enter Token", + text: $viewModel.nightscoutToken, + style: .singleLine, + textContentType: .password + ) + } + + if viewModel.tokenIsVerifiedSecret || viewModel.isProvisioningToken { + Button { + viewModel.createReadOnlyToken(fromSecret: viewModel.nightscoutToken) + } label: { + HStack { + if viewModel.isProvisioningToken { + ProgressView() + Text("Creating read-only token…") + } else { + Image(systemName: "wand.and.stars") + Text("That's your API secret — create a read-only token") + } + } + } + .disabled(viewModel.isProvisioningToken) + } + + if let error = viewModel.tokenProvisionError { + Text(error) + .font(.footnote) + .foregroundColor(.red) + } + } + } + + private var secretSection: some View { + Section(header: Text("API Secret")) { + HStack { + Text("Secret") + TogglableSecureInput( + placeholder: "Enter API Secret", + text: $apiSecret, + style: .singleLine, + textContentType: .password + ) + } + + Button { + viewModel.createReadOnlyToken(fromSecret: apiSecret) + } label: { + HStack { + Spacer() + if viewModel.isProvisioningToken { + ProgressView() + } else { + Text("Create Read-Only Token") + .fontWeight(.semibold) + } + Spacer() + } + } + .disabled(viewModel.isProvisioningToken + || apiSecret.isEmpty + || viewModel.nightscoutURL.isEmpty) + + if let error = viewModel.tokenProvisionError { + Text(error) + .font(.footnote) + .foregroundColor(.red) + } + } + } + + // MARK: - Status pill mapping + + private var statusColor: Color { + switch viewModel.statusKind { + case .idle: return .secondary + case .checking: return .orange + case .pending: return .blue + case .needsToken, .connected: return .green + case .error: return .red + } + } + + private var statusIcon: String? { + switch viewModel.statusKind { + case .idle: return "globe" + case .checking: return nil + case .pending: return "clock.badge.checkmark" + case .needsToken: return "checkmark.circle" + case .connected: return "checkmark.circle.fill" + case .error: return "exclamationmark.triangle.fill" + } + } +} diff --git a/LoopFollow/Onboarding/Steps/NotificationsStepView.swift b/LoopFollow/Onboarding/Steps/NotificationsStepView.swift new file mode 100644 index 000000000..cdae54c31 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/NotificationsStepView.swift @@ -0,0 +1,66 @@ +// LoopFollow +// NotificationsStepView.swift + +import SwiftUI + +/// A short context screen ahead of the system notification prompt, following +/// Apple's pre-alert guidance. The system prompt is triggered from here — before +/// the final screen — so it never appears on top of "You're all set". +struct NotificationsStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @State private var requesting = false + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 20) { + Image(systemName: "bell.badge.fill") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(Color.accentColor) + + Text("Stay informed") + .font(.title2.weight(.bold)) + + Text("LoopFollow uses notifications to deliver your alarms — like low or high glucose, or a missed reading. Without them, alarms can't reach you when the app is in the background.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + + Text("We'll ask iOS for permission next. You can change this any time in the Settings app.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.top, 8) + + Spacer() + + VStack(spacing: 12) { + Button { + guard !requesting else { return } + requesting = true + NotificationAuthorization.requestIfNeeded { + viewModel.advance() + } + } label: { + Text("Enable Notifications") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .disabled(requesting) + + Button { viewModel.advance() } label: { + Text("Not now") + .font(.body.weight(.medium)) + } + .disabled(requesting) + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/LoopFollow/Onboarding/Steps/OverviewStepView.swift b/LoopFollow/Onboarding/Steps/OverviewStepView.swift new file mode 100644 index 000000000..9854ecdce --- /dev/null +++ b/LoopFollow/Onboarding/Steps/OverviewStepView.swift @@ -0,0 +1,132 @@ +// LoopFollow +// OverviewStepView.swift + +import SwiftUI + +/// A quick map of what the rest of setup covers, so the user knows what to +/// expect. The list reflects the phases that will actually run — a returning +/// user who's already connected won't see "Connect your data" — and collapses to +/// a short "you're all set" when there's nothing left to configure. +struct OverviewStepView: View { + @ObservedObject var onboarding: OnboardingViewModel + + private struct Item: Identifiable { + let id = UUID() + let icon: String + let title: String + let detail: String + } + + /// The overview rows, derived from the phases that will actually run. + private var items: [Item] { + let steps = onboarding.activeSteps + var result: [Item] = [] + if steps.contains(.connect) { + result.append(Item(icon: "antenna.radiowaves.left.and.right", + title: "Connect your data", + detail: "Link a Nightscout site or a Dexcom Share account.")) + } + if steps.contains(.units) { + result.append(Item(icon: "ruler", + title: "Units & metrics", + detail: "Choose how glucose and statistics are shown.")) + } + if steps.contains(.alarms) { + result.append(Item(icon: "bell.badge.fill", + title: "Recommended alarms", + detail: "Turn on a few useful alarms with sensible defaults.")) + } + if steps.contains(.tabOrder) { + result.append(Item(icon: "square.grid.2x2", + title: "Finish up", + detail: "Arrange your tabs and set notification preferences.")) + } + return result + } + + var body: some View { + VStack(spacing: 0) { + if onboarding.hasNothingToSetUp { + allSet + } else { + checklist + } + footer + } + } + + // MARK: - Content + + private var checklist: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + OnboardingStepHeader( + systemImage: "list.bullet.rectangle", + title: "Here's what we'll do", + subtitle: "A quick, straightforward setup — about 5 minutes. You can change anything later in Settings." + ) + .padding(.horizontal) + + VStack(spacing: 14) { + ForEach(items) { item in + HStack(alignment: .top, spacing: 14) { + Image(systemName: item.icon) + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.headline) + Text(item.detail) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + } + } + .padding(.horizontal) + } + .padding(.bottom, 24) + } + } + + private var allSet: some View { + ScrollView { + OnboardingStepHeader( + systemImage: "checkmark.circle", + title: "You're all set", + subtitle: "Your data source is connected and your alarms are in place — there's nothing to set up here. You can fine-tune anything later in Settings." + ) + .padding(.horizontal) + .padding(.top, 24) + } + } + + // MARK: - Footer + + private var footer: some View { + OnboardingNavFooter( + continueTitle: onboarding.hasNothingToSetUp ? "Done" : "Continue", + continueEnabled: true, + showBack: onboarding.canGoBack, + onBack: { onboarding.goBack() }, + onContinue: { + if onboarding.hasNothingToSetUp { + onboarding.finish() + } else { + onboarding.advance() + } + } + ) + } +} diff --git a/LoopFollow/Onboarding/Steps/TabOrderStepView.swift b/LoopFollow/Onboarding/Steps/TabOrderStepView.swift new file mode 100644 index 000000000..268857898 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/TabOrderStepView.swift @@ -0,0 +1,23 @@ +// LoopFollow +// TabOrderStepView.swift + +import SwiftUI + +/// Lets the user arrange which features live in the tab bar versus the Menu, +/// during onboarding. Reuses the same drag-to-reorder list as the Settings +/// "Tabs" screen so behavior stays identical. +struct TabOrderStepView: View { + var body: some View { + VStack(spacing: 0) { + OnboardingStepHeader( + systemImage: "square.grid.2x2", + title: "Arrange your tabs", + subtitle: "Pick which features sit in the tab bar. You can always reach everything through the Menu." + ) + .padding(.horizontal) + .padding(.top, 8) + + TabCustomizationModal() + } + } +} diff --git a/LoopFollow/Onboarding/Steps/TelemetryStepView.swift b/LoopFollow/Onboarding/Steps/TelemetryStepView.swift new file mode 100644 index 000000000..02bf5d70c --- /dev/null +++ b/LoopFollow/Onboarding/Steps/TelemetryStepView.swift @@ -0,0 +1,79 @@ +// LoopFollow +// TelemetryStepView.swift + +import SwiftUI + +/// In-flow telemetry consent. Mirrors `TelemetryConsentView` but records the +/// decision and continues the wizard instead of dismissing a sheet. +struct TelemetryStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @State private var showPreview = false + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 16) { + Image(systemName: "chart.bar.doc.horizontal") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(Color.accentColor) + + Text("Help us help you") + .font(.title2.weight(.bold)) + + Text("You can choose to share anonymous information with the developers to help improve LoopFollow — such as app and iOS version, device type, which app you're following, and a few settings. Your health data, credentials, time zone, and logs remain on your device.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + + Button { showPreview = true } label: { + Label("See exactly what's sent", systemImage: "doc.text.magnifyingglass") + .font(.subheadline) + } + + Text("You can change this any time in Settings → General → Diagnostics.") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.top, 8) + + Spacer() + + VStack(spacing: 12) { + Button { decide(true) } label: { + Text("Yes, send anonymous stats") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + + Button { decide(false) } label: { + Text("No thanks") + .font(.body.weight(.medium)) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .sheet(isPresented: $showPreview) { + NavigationStack { + TelemetryPreviewView() + } + } + } + + private func decide(_ enabled: Bool) { + Storage.shared.telemetryEnabled.value = enabled + Storage.shared.telemetryConsentDecisionMade.value = true + if enabled { + // Fire the inaugural ping immediately, then start the 24h cadence. + Task.detached { + await TelemetryClient.shared.maybeSend() + TelemetryClient.shared.scheduleRecurring() + } + } + viewModel.advance() + } +} diff --git a/LoopFollow/Onboarding/Steps/UnitsStepView.swift b/LoopFollow/Onboarding/Steps/UnitsStepView.swift new file mode 100644 index 000000000..25138e611 --- /dev/null +++ b/LoopFollow/Onboarding/Steps/UnitsStepView.swift @@ -0,0 +1,86 @@ +// LoopFollow +// UnitsStepView.swift + +import SwiftUI + +/// The units phase, split into two lighter pages so it doesn't feel heavy: +/// 1. **Units** — the glucose display unit on its own. +/// 2. **Statistics** — range mode and the glycemic/variability metrics LoopFollow +/// shows. +/// +/// Both pages reuse `UnitsConfigurationView`, each rendering only its subset of +/// sections. The phase reports its within-phase position through +/// `phaseProgress` so the progress bar fills smoothly across the two pages. +struct UnitsStepView: View { + @ObservedObject var onboarding: OnboardingViewModel + + private enum Page: Int, CaseIterable { case units, statistics } + @State private var page: Page = .units + + var body: some View { + VStack(spacing: 0) { + Form { + switch page { + case .units: + header( + systemImage: "ruler", + title: "Units", + subtitle: "Choose how glucose values are displayed. You can change this later in Settings." + ) + UnitsConfigurationView(sections: .units) + case .statistics: + header( + systemImage: "chart.bar.xaxis", + title: "Statistics", + subtitle: "Choose how LoopFollow's statistics are measured and displayed." + ) + UnitsConfigurationView(sections: .statistics) + } + } + + footer + } + .onAppear { reportProgress() } + .onChange(of: page) { _ in reportProgress() } + } + + private func reportProgress() { + onboarding.phaseProgress = .init(page: page.rawValue, count: Page.allCases.count) + } + + private func header(systemImage: String, title: String, subtitle: String) -> some View { + Section { + EmptyView() + } header: { + OnboardingStepHeader( + systemImage: systemImage, + title: title, + subtitle: subtitle + ) + .textCase(nil) + .padding(.bottom, 8) + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + + @ViewBuilder + private var footer: some View { + switch page { + case .units: + OnboardingNavFooter( + continueEnabled: true, + showBack: onboarding.canGoBack, + onBack: { onboarding.goBack() }, + onContinue: { withAnimation(.easeInOut(duration: 0.25)) { page = .statistics } } + ) + case .statistics: + OnboardingNavFooter( + continueEnabled: true, + showBack: true, + onBack: { withAnimation(.easeInOut(duration: 0.25)) { page = .units } }, + onContinue: { onboarding.advance() } + ) + } + } +} diff --git a/LoopFollow/Onboarding/Steps/WelcomeStepView.swift b/LoopFollow/Onboarding/Steps/WelcomeStepView.swift new file mode 100644 index 000000000..d619d722d --- /dev/null +++ b/LoopFollow/Onboarding/Steps/WelcomeStepView.swift @@ -0,0 +1,84 @@ +// LoopFollow +// WelcomeStepView.swift + +import SwiftUI + +struct WelcomeStepView: View { + @ObservedObject var viewModel: OnboardingViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var animate = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 20) { + AnimatedLoopFollowLogo(size: 140) + .frame(height: 160) + .opacity(animate || reduceMotion ? 1 : 0) + + Text("Welcome to LoopFollow") + .font(.largeTitle.weight(.bold)) + .multilineTextAlignment(.center) + + Text(viewModel.isAlreadyConfigured + ? "You're already set up. You can skip this guide, or walk through it to review your settings." + : "Let's get you connected to your data and set up a few recommended alarms. It only takes a few minutes.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Spacer() + + VStack(spacing: 12) { + if viewModel.isAlreadyConfigured { + Button { viewModel.skip() } label: { + primaryLabel("Skip") + } + .buttonStyle(.borderedProminent) + + Button { advance() } label: { + Text("Review setup anyway") + .font(.body.weight(.medium)) + } + } else { + Button { advance() } label: { + primaryLabel("Get Started") + } + .buttonStyle(.borderedProminent) + + Button { viewModel.skip() } label: { + Text("Skip") + .font(.body.weight(.medium)) + } + } + } + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.spring(response: 0.6, dampingFraction: 0.7).delay(0.1)) { + animate = true + } + } + } + + private func primaryLabel(_ text: String) -> some View { + Text(text) + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + + private func advance() { + if reduceMotion { + viewModel.advance() + } else { + withAnimation(.easeInOut(duration: 0.3)) { viewModel.advance() } + } + } +} diff --git a/LoopFollow/Settings/DexcomSettingsView.swift b/LoopFollow/Settings/DexcomSettingsView.swift index 99a4dc32b..2f43360fd 100644 --- a/LoopFollow/Settings/DexcomSettingsView.swift +++ b/LoopFollow/Settings/DexcomSettingsView.swift @@ -14,8 +14,9 @@ struct DexcomSettingsView: View { Form { Section(header: Text("Dexcom Settings")) { HStack { - Text("User Name") - TextField("Enter User Name", text: $viewModel.userName) + Text("Username") + TextField("Enter Username", text: $viewModel.userName) + .textContentType(.username) .autocapitalization(.none) .disableAutocorrection(true) .multilineTextAlignment(.trailing) @@ -26,13 +27,14 @@ struct DexcomSettingsView: View { TogglableSecureInput( placeholder: "Enter Password", text: $viewModel.password, - style: .singleLine + style: .singleLine, + textContentType: .password ) } Picker("Server", selection: $viewModel.server) { Text("US").tag("US") - Text("NON-US").tag("NON-US") + Text("Outside US").tag("NON-US") } .pickerStyle(SegmentedPickerStyle()) } diff --git a/LoopFollow/Settings/DexcomSettingsViewModel.swift b/LoopFollow/Settings/DexcomSettingsViewModel.swift index 8c7fd5324..5a929cbe6 100644 --- a/LoopFollow/Settings/DexcomSettingsViewModel.swift +++ b/LoopFollow/Settings/DexcomSettingsViewModel.swift @@ -3,8 +3,16 @@ import Combine import Foundation +import ShareClient class DexcomSettingsViewModel: ObservableObject { + enum ConnectionStatusKind { + case idle + case checking + case connected + case error + } + /// Whether this is a fresh setup (credentials were empty when view appeared) private(set) var isFreshSetup: Bool = false @@ -12,6 +20,7 @@ class DexcomSettingsViewModel: ObservableObject { willSet { if newValue != userName { Storage.shared.shareUserName.value = newValue + scheduleVerification() } } } @@ -20,6 +29,7 @@ class DexcomSettingsViewModel: ObservableObject { willSet { if newValue != password { Storage.shared.sharePassword.value = newValue + scheduleVerification() } } } @@ -28,6 +38,7 @@ class DexcomSettingsViewModel: ObservableObject { willSet { if newValue != server { Storage.shared.shareServer.value = newValue + scheduleVerification() } } } @@ -37,7 +48,94 @@ class DexcomSettingsViewModel: ObservableObject { !userName.isEmpty && !password.isEmpty } + // MARK: - Verification + + @Published var statusKind: ConnectionStatusKind = .idle + @Published var statusMessage: String = "Enter your username and password" + + /// True when a real Dexcom Share login succeeded. + @Published private(set) var isVerified: Bool = false + + /// The credentials were explicitly rejected by Dexcom (as opposed to a network + /// failure we can't draw a conclusion from). + private(set) var loginRejected: Bool = false + + /// Can move on: verified, or the only problem is that we couldn't reach Dexcom + /// (so we don't trap a user on a flaky network). A rejected login always blocks. + var canVerifyProceed: Bool { + hasCredentials && statusKind != .checking && !loginRejected + } + + private var verifyGeneration = 0 + private var cancellables = Set() + private let verifySubject = PassthroughSubject() + init() { isFreshSetup = Storage.shared.shareUserName.value.isEmpty + + verifySubject + .debounce(for: .seconds(1.5), scheduler: DispatchQueue.main) + .sink { [weak self] in self?.verify() } + .store(in: &cancellables) + + scheduleVerification() + } + + /// Resets status to "checking" and queues a debounced verification. + private func scheduleVerification() { + verifyGeneration += 1 + loginRejected = false + isVerified = false + if hasCredentials { + statusKind = .checking + statusMessage = "Checking your account…" + verifySubject.send() + } else { + statusKind = .idle + statusMessage = "Enter your username and password" + } + } + + private func verify() { + guard hasCredentials else { return } + + let generation = verifyGeneration + let serverURL = server == "US" + ? KnownShareServers.US.rawValue + : KnownShareServers.NON_US.rawValue + let client = ShareClient(username: userName, password: password, shareServer: serverURL) + + client.fetchData(1) { [weak self] error, _ in + DispatchQueue.main.async { + guard let self, generation == self.verifyGeneration else { return } + + if let error = error { + switch error { + case .loginError: + self.statusKind = .error + self.statusMessage = "Username or password not accepted" + self.isVerified = false + self.loginRejected = true + case .httpError: + self.statusKind = .error + self.statusMessage = "Network error — check your connection" + self.isVerified = false + self.loginRejected = false + default: + // Login succeeded but there's no recent reading yet; the + // credentials are valid, which is all we're confirming. + self.statusKind = .connected + self.statusMessage = "Connected" + self.isVerified = true + self.loginRejected = false + } + } else { + self.statusKind = .connected + self.statusMessage = "Connected" + self.isVerified = true + self.loginRejected = false + } + } + } } } diff --git a/LoopFollow/Settings/UnitsConfigurationView.swift b/LoopFollow/Settings/UnitsConfigurationView.swift index 427c4e5bd..2124077c7 100644 --- a/LoopFollow/Settings/UnitsConfigurationView.swift +++ b/LoopFollow/Settings/UnitsConfigurationView.swift @@ -5,26 +5,69 @@ import SwiftUI /// Reusable view for configuring units and metrics. /// Can be embedded in Forms or used standalone during onboarding. +/// +/// Onboarding splits this into two lighter pages — the glucose unit on its own, +/// and the LoopFollow statistics (range, glycemic, variability) on a second page +/// — by rendering a subset of the sections via `sections`. Settings shows them +/// all together (the default). struct UnitsConfigurationView: View { + /// Which groups of sections to render. Onboarding uses `.units` and + /// `.statistics` on separate pages; everywhere else uses `.all`. + struct Sections: OptionSet { + let rawValue: Int + /// Glucose display unit (mg/dL vs mmol/L). + static let units = Sections(rawValue: 1 << 0) + /// Range mode plus the glycemic and variability metrics. + static let statistics = Sections(rawValue: 1 << 1) + static let all: Sections = [.units, .statistics] + } + + var sections: Sections = .all + @State private var rangeMode = UnitSettingsStore.shared.timeInRangeMode @State private var glucoseUnit = UnitSettingsStore.shared.glucoseUnit @State private var lowValue = Storage.shared.lowLine.value @State private var highValue = Storage.shared.highLine.value + /// Formats a mg/dL threshold pair in the currently selected glucose unit, + /// e.g. "70–180 mg/dL" or "3.9–10.0 mmol/L". + private func rangeBounds(_ lowMgdl: Double, _ highMgdl: Double) -> String { + let factor = glucoseUnit == .mmolL ? GlucoseConversion.mgDlToMmolL : 1.0 + let digits = glucoseUnit.fractionDigits + let low = Localizer.formatToLocalizedString(lowMgdl * factor, maxFractionDigits: digits, minFractionDigits: digits) + let high = Localizer.formatToLocalizedString(highMgdl * factor, maxFractionDigits: digits, minFractionDigits: digits) + return "\(low)–\(high) \(glucoseUnit.rawValue)" + } + var body: some View { Group { - Section("Glucose") { - Picker("Glucose Unit", selection: $glucoseUnit) { - Text("mg/dL").tag(GlucoseDisplayUnit.mgdL) - Text("mmol/L").tag(GlucoseDisplayUnit.mmolL) - } - .pickerStyle(.segmented) - .onChange(of: glucoseUnit) { newValue in - UnitSettingsStore.shared.glucoseUnit = newValue - } + if sections.contains(.units) { + unitsSections + } + if sections.contains(.statistics) { + statisticsSections } + } + } + + @ViewBuilder + private var unitsSections: some View { + Section("Glucose") { + Picker("Glucose Unit", selection: $glucoseUnit) { + Text("mg/dL").tag(GlucoseDisplayUnit.mgdL) + Text("mmol/L").tag(GlucoseDisplayUnit.mmolL) + } + .pickerStyle(.segmented) + .onChange(of: glucoseUnit) { newValue in + UnitSettingsStore.shared.glucoseUnit = newValue + } + } + } - Section("Range") { + @ViewBuilder + private var statisticsSections: some View { + Group { + Section { Picker("Range Mode", selection: $rangeMode) { Text("TIR").tag(TimeInRangeDisplayMode.tir) Text("TITR").tag(TimeInRangeDisplayMode.titr) @@ -58,9 +101,13 @@ struct UnitsConfigurationView: View { Observable.shared.chartSettingsChanged.value = true } } + } header: { + Text("Range") + } footer: { + Text("TIR — Time in Range, the share of readings within \(rangeBounds(70, 180)).\nTITR — Time in Tight Range, within \(rangeBounds(70, 140)).\nCustom — set your own low and high.") } - Section("Glycemic Metrics") { + Section { Picker("Metric", selection: Binding( get: { UnitSettingsStore.shared.glycemicMetricMode }, set: { UnitSettingsStore.shared.glycemicMetricMode = $0 } @@ -78,9 +125,13 @@ struct UnitsConfigurationView: View { Text("mmol/mol").tag(GlycemicOutputUnit.mmolMol) } .pickerStyle(.segmented) + } header: { + Text("Glycemic Metrics") + } footer: { + Text("eHbA1c — an A1c estimate from your average glucose. GMI — Glucose Management Indicator, another A1c estimate from average glucose. % and mmol/mol (IFCC) are two scales for the result.") } - Section("Variability") { + Section { Picker("Metric", selection: Binding( get: { UnitSettingsStore.shared.variabilityMetricMode }, set: { UnitSettingsStore.shared.variabilityMetricMode = $0 } @@ -89,6 +140,10 @@ struct UnitsConfigurationView: View { Text("CV").tag(VariabilityMetricMode.cv) } .pickerStyle(.segmented) + } header: { + Text("Variability") + } footer: { + Text("Std Dev — Standard Deviation, how much glucose swings around the average. CV — Coefficient of Variation, that swing relative to the average (Std Dev ÷ mean).") } } } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 40d195372..b16a5994d 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -58,5 +58,9 @@ class Observable { /// Currently visible app-wide banner (nil = hidden). Managed by BannerManager. var activeBanner = ObservableValue(default: nil) + /// True while the onboarding cover is on screen; AlarmManager doesn't act + /// on alarms while set. In-memory, so background launches are unaffected. + var isOnboardingActive = ObservableValue(default: false) + private init() {} } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 0fd850f3f..4876924e2 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -201,6 +201,7 @@ class Storage { var token = StorageValue(key: "token", defaultValue: "") var units = StorageValue(key: "units", defaultValue: "mg/dL") var hasConfiguredUnits = StorageValue(key: "hasConfiguredUnits", defaultValue: false) + var hasCompletedOnboarding = StorageValue(key: "hasCompletedOnboarding", defaultValue: false) var infoDisplayItems = StorageValue<[InfoDisplayItem]>( key: "infoDisplayItems", diff --git a/Tests/AlarmConditions/BatteryConditionTests.swift b/Tests/AlarmConditions/BatteryConditionTests.swift index 804a7a728..580b51b2d 100644 --- a/Tests/AlarmConditions/BatteryConditionTests.swift +++ b/Tests/AlarmConditions/BatteryConditionTests.swift @@ -1,6 +1,7 @@ // LoopFollow // BatteryConditionTests.swift +import Foundation @testable import LoopFollow import Testing diff --git a/Tests/AlarmConditions/FastDropConditionTests.swift b/Tests/AlarmConditions/FastDropConditionTests.swift new file mode 100644 index 000000000..33b332702 --- /dev/null +++ b/Tests/AlarmConditions/FastDropConditionTests.swift @@ -0,0 +1,94 @@ +// LoopFollow +// FastDropConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +/// Fast drop fires only when **each** of the last `monitoringWindow` consecutive +/// intervals dropped by at least `delta` mg/dL. It is per-reading, not a single +/// cumulative drop — these tests pin that behaviour down. +struct FastDropConditionTests { + let cond = FastDropCondition() + + // MARK: - Fires + + @Test("fires when every one of N readings drops by at least delta") + func firesOnConsecutiveDrops() { + // 3 intervals, each falling exactly 15 → about 45 over ~15 minutes + let alarm = Alarm.fastDrop(delta: 15, window: 3) + let data = AlarmData.withReadings([150, 135, 120, 105]) + #expect(cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("a drop exactly equal to delta still counts") + func firesAtExactThreshold() { + let alarm = Alarm.fastDrop(delta: 15, window: 1) + let data = AlarmData.withReadings([120, 105]) + #expect(cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("only the most recent window+1 readings matter") + func ignoresOlderReadings() { + // Earlier readings rise sharply; the last 4 fall 15 each → fires + let alarm = Alarm.fastDrop(delta: 15, window: 3) + let data = AlarmData.withReadings([100, 200, 165, 150, 135, 120]) + #expect(cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + // MARK: - Does not fire + + @Test("does NOT fire when one interval falls short of delta") + func doesNotFireWhenOneIntervalTooSmall() { + let alarm = Alarm.fastDrop(delta: 15, window: 3) + // middle interval is only 10 + let data = AlarmData.withReadings([150, 135, 125, 110]) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("per-reading, not cumulative: 40+5+5 = 50 total but does NOT fire") + func doesNotFireOnCumulativeOnly() { + let alarm = Alarm.fastDrop(delta: 15, window: 3) + // total fall is 50 (> 45) but two intervals are only 5 each + let data = AlarmData.withReadings([150, 110, 105, 100]) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("does NOT fire when glucose is rising") + func doesNotFireWhenRising() { + let alarm = Alarm.fastDrop(delta: 15, window: 3) + let data = AlarmData.withReadings([100, 115, 130, 145]) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("does NOT fire without enough readings") + func doesNotFireWithTooFewReadings() { + // window 3 needs 4 readings; only 3 supplied + let alarm = Alarm.fastDrop(delta: 15, window: 3) + let data = AlarmData.withReadings([135, 120, 105]) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + // MARK: - Guard cases + + @Test("does NOT fire when delta is nil") + func ignoresNilDelta() { + let alarm = Alarm.fastDrop(delta: nil, window: 3) + let data = AlarmData.withReadings([150, 135, 120, 105]) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("does NOT fire when window is nil") + func ignoresNilWindow() { + let alarm = Alarm.fastDrop(delta: 15, window: nil) + let data = AlarmData.withReadings([150, 135, 120, 105]) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } + + @Test("does NOT fire when window is zero") + func ignoresZeroWindow() { + let alarm = Alarm.fastDrop(delta: 15, window: 0) + let data = AlarmData.withReadings([150, 135, 120, 105]) + #expect(!cond.evaluate(alarm: alarm, data: data, now: .init())) + } +} diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index 1fb91ba6c..f2b9fa869 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -28,6 +28,13 @@ extension Alarm { alarm.delta = delta return alarm } + + static func fastDrop(delta: Double?, window: Int?) -> Self { + var alarm = Alarm(type: .fastDrop) + alarm.delta = delta + alarm.monitoringWindow = window + return alarm + } } // MARK: - AlarmData helpers @@ -83,6 +90,38 @@ extension AlarmData { ) } + /// Builds an `AlarmData` from a list of glucose values ordered **oldest → + /// newest** (the same order the app stores them; `bgReadings.last` is the most + /// recent reading). Readings are spaced 5 minutes apart ending now. + static func withReadings(_ sgvs: [Int]) -> Self { + let now = Date() + let last = sgvs.count - 1 + let readings = sgvs.enumerated().map { offset, sgv in + GlucoseValue(sgv: sgv, date: now.addingTimeInterval(Double(offset - last) * 300)) + } + return AlarmData( + bgReadings: readings, + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: nil, + latestTempTargetStart: nil, + latestTempTargetEnd: nil, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: [] + ) + } + static func withCarbs(_ carbs: [CarbSample]) -> Self { AlarmData( bgReadings: [], diff --git a/Tests/AlarmConditions/SensorAgeConditionTests.swift b/Tests/AlarmConditions/SensorAgeConditionTests.swift index ec407a916..dba9c66b3 100644 --- a/Tests/AlarmConditions/SensorAgeConditionTests.swift +++ b/Tests/AlarmConditions/SensorAgeConditionTests.swift @@ -1,6 +1,7 @@ // LoopFollow // SensorAgeConditionTests.swift +import Foundation @testable import LoopFollow import Testing