浏览代码

Add first-run onboarding guide (#673)

* Add first-run onboarding guide

Introduce a step-based onboarding wizard shown once on launch (skippable,
with a prominent Skip for already-configured users): welcome, data source
choice, Nightscout/Dexcom connection, units, and a few useful alarms seeded
with sensible defaults.

For Nightscout, add the option to create a read-only access token from the
site's API secret. The secret is used only to authorize the request and is
never stored. The token is derived locally from the created subject's id and
the secret, since Nightscout's subjects list is served from a cache that does
not reflect a freshly created subject right away.

Replace the launch-time permission prompts with deferred requests so a fresh
install is not fronted with them before onboarding:
- notifications are requested when alarms are set up or first added
- calendar is requested from the Calendar settings screen
- Bluetooth is initialised only when a BLE background refresh mode is selected

Welcome screen uses the LoopFollow mark with a coin-landing animation.

* Gate device/system onboarding alarms to Nightscout

Not Looping and Low Battery rely on loop and uploader data that only a
Nightscout site provides, so they are meaningless for a Dexcom-only follower.
Offer the device/system alarm group during onboarding only when following
Nightscout (or a Nightscout URL is already configured), and don't seed them
otherwise.

* Refine onboarding copy and align Dexcom labels

Polish the onboarding wording (data source, Nightscout token, Dexcom sign-in,
units, alarms, and completion screens) and modernise the Dexcom field labels:
"Username" instead of "User Name" and "Outside US" instead of "NON-US" (display
only; stored server value is unchanged). Apply the same Dexcom label changes to
the standalone Dexcom settings screen so the two stay consistent.

* Expand onboarding flow and harden Nightscout token setup

Onboarding
- Track progress by phase with a smooth bar and a per-phase counter
- Add overview, general alarm settings, tab order, notification and
  telemetry steps, plus a "Copy from another phone" QR data source
- Left-align text and tidy copy throughout

Nightscout
- Split connect into an address page and a token page shown only when needed
- Add a live status pill; show "needs a token" as a positive state
- Accept both array and object shapes from the subject-create response,
  fixing the create-token-fails-first-time case
- Treat a freshly created but not-yet-accepted token as pending instead
  of an error so the user can continue

Dexcom
- Enable keychain autofill, verify credentials with a real login, and
  use a CGM-style icon

Alarms
- Offer more alarms one per page, on by default, with a few settings each
- Skip alarm types the user already has and use each type's own icon

Other
- Defer notification, calendar and Bluetooth prompts away from launch
- Add units explainer footers with correct values that follow the unit

* Refine onboarding layout, units, and alarm defaults

- Unify the Nightscout and Dexcom connect screens on a shared, color-coded
  status pill pinned at the top.
- Give every onboarding header one shared leading edge with its content by
  moving horizontal margins out of OnboardingStepHeader to the call sites.
- Top-align the Notifications and Privacy screens to match the rest of the flow.
- Split the Units phase into two lighter pages: Units and Statistics.
- State a concrete setup time on the Overview screen and add a short
  "things to explore" tease on the completion screen.
- Format the Fast drop alarm's threshold in the selected glucose unit.
- Show the day/night setting on the Sensor change and Pump change alarms,
  defaulting to Day.
- Derive the onboarding alarm control fallbacks from Alarm(type:) so they
  share the one source of truth for per-type defaults.

* Tune recommended alarm defaults and clarify Fast drop

- Update recommended thresholds: Low 70 / 20 min predictive, Urgent low 54,
  Fast drop 15 over 3 readings (~45 in 15 minutes), Not looping 16 (aligned
  with Missed readings).
- Add a "Readings in a row" control and a plain-language footer to the Fast
  drop onboarding page so its per-reading behaviour is clear.
- Derive the onboarding alarm control fallbacks from Alarm(type:) so they
  share one source of truth with the normal per-type defaults.
- Add FastDropCondition tests covering per-reading vs cumulative behaviour,
  window and threshold edges, and guard cases.

* Create read-only tokens from the API secret in Nightscout settings

Bring the onboarding's token-from-secret flow to the regular Nightscout
settings screen: paste a token, or paste the API secret and LoopFollow
creates (or reuses) a read-only token for you.

When the token field holds something that isn't a token and the site
rejects it — most often a pasted API secret — a one-tap "create a token
from this" action is offered.

The provisioning logic, secret detection, and error mapping now live on
NightscoutSettingsViewModel so onboarding and settings share one
implementation.

* Verify a pasted API secret and offer token creation in onboarding too

Detect a pasted API secret by actually verifying it against the site
(an auth probe on an admin-gated endpoint), rather than guessing from its
shape — so the "create a read-only token from this" suggestion only appears
when creating one will genuinely work.

Show the suggestion on the onboarding token page as well, not just the
Nightscout settings screen.

* Skip already-completed onboarding steps for returning users

A user who already has a data source no longer walks through the data-source
picker and connection screens, and the overview now lists only the phases
that will actually run rather than a fixed checklist.

When such a user also has no recommended alarms left to add, the overview
becomes a short "you're all set" confirmation with a Done button — there's
nothing essential to configure.

* Rebuild onboarding logo from the icon artwork and split the range footer onto separate lines

* Cover newly missed StorageValues in reloadAll after BFU launches

Several StorageValues were missing from reloadAll(), so after a
Before-First-Unlock background launch they stayed cached at their
defaults for the life of the process. Most visibly, onboarding and the
telemetry consent sheet could re-appear for users who had already
completed them. Add the missing contact, graph, telemetry, setup-flag,
and websocket keys; migrationStep and the SecureStorageValue properties
remain intentionally excluded.

* Name seeded low alarms distinctly and keep alarms silent during onboarding

* Fix onboarding step-skip, Dexcom autofill, and Skip label

* Rebuild Dexcom Share client when credentials change
Jonas Björkert 1 周之前
父节点
当前提交
411c7afb69
共有 44 个文件被更改,包括 3272 次插入86 次删除
  1. 3 3
      LoopFollow/Alarm/Alarm.swift
  2. 3 0
      LoopFollow/Alarm/AlarmListView.swift
  3. 9 0
      LoopFollow/Alarm/AlarmManager.swift
  4. 13 18
      LoopFollow/Application/AppDelegate.swift
  5. 48 7
      LoopFollow/Application/MainTabView.swift
  6. 7 0
      LoopFollow/BackgroundRefresh/BT/BLEManager.swift
  7. 7 1
      LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsViewModel.swift
  8. 4 1
      LoopFollow/Controllers/BackgroundAlertManager.swift
  9. 158 0
      LoopFollow/Helpers/NightscoutUtils.swift
  10. 30 0
      LoopFollow/Helpers/NotificationAuthorization.swift
  11. 1 1
      LoopFollow/Helpers/Telemetry.swift
  12. 17 20
      LoopFollow/Helpers/Views/TogglableSecureInput.swift
  13. 29 2
      LoopFollow/Nightscout/NightscoutSettingsView.swift
  14. 236 0
      LoopFollow/Nightscout/NightscoutSettingsViewModel.swift
  15. 49 0
      LoopFollow/Onboarding/ConnectionStatusPill.swift
  16. 176 0
      LoopFollow/Onboarding/LoopFollowLogo.swift
  17. 138 0
      LoopFollow/Onboarding/OnboardingContainerView.swift
  18. 39 0
      LoopFollow/Onboarding/OnboardingNavFooter.swift
  19. 69 0
      LoopFollow/Onboarding/OnboardingStep.swift
  20. 38 0
      LoopFollow/Onboarding/OnboardingStepHeader.swift
  21. 387 0
      LoopFollow/Onboarding/OnboardingViewModel.swift
  22. 260 0
      LoopFollow/Onboarding/Steps/AlarmsStepView.swift
  23. 108 0
      LoopFollow/Onboarding/Steps/CompletionStepView.swift
  24. 84 0
      LoopFollow/Onboarding/Steps/ConnectImportStepView.swift
  25. 84 0
      LoopFollow/Onboarding/Steps/DataSourceChoiceStepView.swift
  26. 97 0
      LoopFollow/Onboarding/Steps/DexcomConnectStepView.swift
  27. 83 0
      LoopFollow/Onboarding/Steps/GeneralAlarmsStepView.swift
  28. 293 0
      LoopFollow/Onboarding/Steps/NightscoutConnectStepView.swift
  29. 66 0
      LoopFollow/Onboarding/Steps/NotificationsStepView.swift
  30. 132 0
      LoopFollow/Onboarding/Steps/OverviewStepView.swift
  31. 23 0
      LoopFollow/Onboarding/Steps/TabOrderStepView.swift
  32. 79 0
      LoopFollow/Onboarding/Steps/TelemetryStepView.swift
  33. 86 0
      LoopFollow/Onboarding/Steps/UnitsStepView.swift
  34. 84 0
      LoopFollow/Onboarding/Steps/WelcomeStepView.swift
  35. 6 4
      LoopFollow/Settings/DexcomSettingsView.swift
  36. 98 0
      LoopFollow/Settings/DexcomSettingsViewModel.swift
  37. 67 12
      LoopFollow/Settings/UnitsConfigurationView.swift
  38. 4 0
      LoopFollow/Storage/Observable.swift
  39. 1 0
      LoopFollow/Storage/Storage.swift
  40. 21 17
      LoopFollow/ViewControllers/MainViewController.swift
  41. 1 0
      Tests/AlarmConditions/BatteryConditionTests.swift
  42. 94 0
      Tests/AlarmConditions/FastDropConditionTests.swift
  43. 39 0
      Tests/AlarmConditions/Helpers.swift
  44. 1 0
      Tests/AlarmConditions/SensorAgeConditionTests.swift

+ 3 - 3
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

+ 3 - 0
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)
             }
 

+ 9 - 0
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.

+ 13 - 18
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
 

+ 48 - 7
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 {

+ 7 - 0
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,

+ 7 - 1
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()
+        }
     }
 }

+ 4 - 1
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] = [

+ 158 - 0
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) {

+ 30 - 0
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() }
+            }
+        }
+    }
+}

+ 1 - 1
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)
 

+ 17 - 20
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
             }
         }
     }

+ 29 - 2
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.")
         }
     }
 

+ 236 - 0
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<Void, Never>()
     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)

+ 49 - 0
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)
+        )
+    }
+}

+ 176 - 0
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
+                    }
+                }
+            }
+    }
+}

+ 138 - 0
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)
+    }
+}

+ 39 - 0
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)
+    }
+}

+ 69 - 0
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"
+        }
+    }
+}

+ 38 - 0
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)
+    }
+}

+ 387 - 0
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<AnyCancellable>()
+
+    /// 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<AlarmType>
+
+    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),
+        ]
+    }
+}

+ 260 - 0
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<OnboardingViewModel.SeedAlarm>) -> 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<OnboardingViewModel.SeedAlarm>,
+        title: String,
+        range: ClosedRange<Double>,
+        keyPath: WritableKeyPath<Alarm, Double?>
+    ) -> some View {
+        BGPicker(title: title, range: range, value: doubleBinding(seed, keyPath: keyPath))
+    }
+
+    private func doubleStepper(
+        _ seed: Binding<OnboardingViewModel.SeedAlarm>,
+        label: String,
+        range: ClosedRange<Double>,
+        step: Double,
+        unit: String,
+        keyPath: WritableKeyPath<Alarm, Double?>
+    ) -> 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<OnboardingViewModel.SeedAlarm>,
+        label: String,
+        range: ClosedRange<Int>,
+        step: Int,
+        unit: String,
+        keyPath: WritableKeyPath<Alarm, Int?>
+    ) -> 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<OnboardingViewModel.SeedAlarm>) -> some View {
+        let binding = Binding<ActiveOption>(
+            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<OnboardingViewModel.SeedAlarm>,
+        keyPath: WritableKeyPath<Alarm, Double?>
+    ) -> Binding<Double> {
+        Binding(
+            get: { seed.wrappedValue.alarm[keyPath: keyPath] ?? typeDefault(seed, keyPath) },
+            set: { seed.wrappedValue.alarm[keyPath: keyPath] = $0 }
+        )
+    }
+
+    private func intBinding(
+        _ seed: Binding<OnboardingViewModel.SeedAlarm>,
+        keyPath: WritableKeyPath<Alarm, Int?>
+    ) -> Binding<Int> {
+        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<OnboardingViewModel.SeedAlarm>, _ keyPath: KeyPath<Alarm, Double?>) -> Double {
+        Alarm(type: seed.wrappedValue.type)[keyPath: keyPath] ?? 0
+    }
+
+    private func typeDefault(_ seed: Binding<OnboardingViewModel.SeedAlarm>, _ keyPath: KeyPath<Alarm, Int?>) -> Int {
+        Alarm(type: seed.wrappedValue.type)[keyPath: keyPath] ?? 0
+    }
+}
+
+private extension Array {
+    subscript(safe index: Int) -> Element? {
+        indices.contains(index) ? self[index] : nil
+    }
+}

+ 108 - 0
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))
+        )
+    }
+}

+ 84 - 0
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
+            }
+        }
+    }
+}

+ 84 - 0
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)
+    }
+}

+ 97 - 0
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"
+        }
+    }
+}

+ 83 - 0
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<Date> {
+        timeBinding(\.dayStart)
+    }
+
+    private var nightBinding: Binding<Date> {
+        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<AlarmConfiguration, TimeOfDay>) -> Binding<Date> {
+        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)
+            }
+        )
+    }
+}

+ 293 - 0
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"
+        }
+    }
+}

+ 66 - 0
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)
+    }
+}

+ 132 - 0
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()
+                }
+            }
+        )
+    }
+}

+ 23 - 0
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()
+        }
+    }
+}

+ 79 - 0
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()
+    }
+}

+ 86 - 0
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() }
+            )
+        }
+    }
+}

+ 84 - 0
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() }
+        }
+    }
+}

+ 6 - 4
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())
             }

+ 98 - 0
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<AnyCancellable>()
+    private let verifySubject = PassthroughSubject<Void, Never>()
+
     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
+                }
+            }
+        }
     }
 }

+ 67 - 12
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).")
             }
         }
     }

+ 4 - 0
LoopFollow/Storage/Observable.swift

@@ -58,5 +58,9 @@ class Observable {
     /// Currently visible app-wide banner (nil = hidden). Managed by BannerManager.
     var activeBanner = ObservableValue<BannerMessage?>(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<Bool>(default: false)
+
     private init() {}
 }

+ 1 - 0
LoopFollow/Storage/Storage.swift

@@ -201,6 +201,7 @@ class Storage {
     var token = StorageValue<String>(key: "token", defaultValue: "")
     var units = StorageValue<String>(key: "units", defaultValue: "mg/dL")
     var hasConfiguredUnits = StorageValue<Bool>(key: "hasConfiguredUnits", defaultValue: false)
+    var hasCompletedOnboarding = StorageValue<Bool>(key: "hasCompletedOnboarding", defaultValue: false)
 
     var infoDisplayItems = StorageValue<[InfoDisplayItem]>(
         key: "infoDisplayItems",

+ 21 - 17
LoopFollow/ViewControllers/MainViewController.swift

@@ -199,10 +199,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
         // Synchronize info types to ensure arrays are the correct size
         synchronizeInfoTypes()
 
-        let shareUserName = Storage.shared.shareUserName.value
-        let sharePassword = Storage.shared.sharePassword.value
-        let shareServer = Storage.shared.shareServer.value == "US" ?KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue
-        dexShare = ShareClient(username: shareUserName, password: sharePassword, shareServer: shareServer)
+        configureDexShareClient()
 
         // setup show/hide graphs (first-time setup check)
         updateGraphVisibility()
@@ -323,19 +320,18 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
             }
             .store(in: &cancellables)
 
-        Storage.shared.shareUserName.$value
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] _ in
-                self?.checkAndShowImportButtonIfNeeded()
-            }
-            .store(in: &cancellables)
-
-        Storage.shared.sharePassword.$value
-            .receive(on: DispatchQueue.main)
-            .sink { [weak self] _ in
-                self?.checkAndShowImportButtonIfNeeded()
-            }
-            .store(in: &cancellables)
+        // ShareClient captures the credentials at init, so it's rebuilt on any change.
+        Publishers.MergeMany(
+            Storage.shared.shareUserName.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.sharePassword.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.shareServer.$value.map { _ in () }.eraseToAnyPublisher()
+        )
+        .receive(on: DispatchQueue.main)
+        .sink { [weak self] in
+            self?.configureDexShareClient()
+            self?.checkAndShowImportButtonIfNeeded()
+        }
+        .store(in: &cancellables)
 
         Publishers.CombineLatest4(
             Storage.shared.remoteApnsKey.$value,
@@ -411,6 +407,14 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
         checkAndShowImportButtonIfNeeded()
     }
 
+    /// Builds the Dexcom Share client from the stored credentials.
+    private func configureDexShareClient() {
+        let shareUserName = Storage.shared.shareUserName.value
+        let sharePassword = Storage.shared.sharePassword.value
+        let shareServer = Storage.shared.shareServer.value == "US" ? KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue
+        dexShare = ShareClient(username: shareUserName, password: sharePassword, shareServer: shareServer)
+    }
+
     // MARK: - Loading Overlay
 
     private func isDataSourceConfigured() -> Bool {

+ 1 - 0
Tests/AlarmConditions/BatteryConditionTests.swift

@@ -1,6 +1,7 @@
 // LoopFollow
 // BatteryConditionTests.swift
 
+import Foundation
 @testable import LoopFollow
 import Testing
 

+ 94 - 0
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()))
+    }
+}

+ 39 - 0
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: [],

+ 1 - 0
Tests/AlarmConditions/SensorAgeConditionTests.swift

@@ -1,6 +1,7 @@
 // LoopFollow
 // SensorAgeConditionTests.swift
 
+import Foundation
 @testable import LoopFollow
 import Testing