OnboardingViewModel.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. // LoopFollow
  2. // OnboardingViewModel.swift
  3. import Combine
  4. import SwiftUI
  5. import UserNotifications
  6. /// Drives the onboarding wizard: tracks the current phase, the chosen data
  7. /// source, the seeded alarms, and persists everything when the user finishes.
  8. ///
  9. /// The child connection view models are the same ones used by the regular
  10. /// settings screens, so URL/token/credential entry and validation behave
  11. /// identically here and there.
  12. @MainActor
  13. final class OnboardingViewModel: ObservableObject {
  14. enum DataSource: Hashable {
  15. case nightscout
  16. case dexcom
  17. case copyFromPhone
  18. }
  19. /// A single recommended alarm offered on the alarms phase. Display fields are
  20. /// carried explicitly (rather than derived from `AlarmType`) so two alarms of
  21. /// the same type — a warning Low and an Urgent Low — can read differently.
  22. struct SeedAlarm: Identifiable {
  23. let id = UUID()
  24. var alarm: Alarm
  25. var isEnabled: Bool
  26. var title: String
  27. var detail: String
  28. /// Needs Nightscout loop/uploader data, so it's hidden for Dexcom-only.
  29. var requiresNightscout: Bool
  30. var type: AlarmType { alarm.type }
  31. /// Use each alarm type's own icon rather than a bespoke one.
  32. var icon: String { alarm.type.icon }
  33. }
  34. /// Position within a multi-page phase, reported by that phase's view so the
  35. /// overall progress bar and label can reflect it.
  36. struct PhaseProgress: Equatable {
  37. var page: Int
  38. var count: Int
  39. }
  40. @Published var step: OnboardingStep = .welcome
  41. @Published var dataSource: DataSource?
  42. @Published var seedAlarms: [SeedAlarm]
  43. /// Within-phase position for phases that own internal pages (connect, alarms).
  44. /// Reset to `nil` whenever the phase changes.
  45. @Published var phaseProgress: PhaseProgress?
  46. /// Set once a QR settings import on the "copy from another phone" path
  47. /// succeeds, so the connect phase can be considered complete.
  48. @Published var didImportSettings = false
  49. /// Whether the notification permission is still undecided. The notifications
  50. /// phase is only shown when it is — there's no point prompting someone who has
  51. /// already granted or denied it (iOS won't show the prompt again anyway).
  52. /// Defaults to `true`; resolved asynchronously at launch, well before the user
  53. /// reaches that phase.
  54. @Published private var notificationsUndecided = true
  55. let nightscoutViewModel = NightscoutSettingsViewModel()
  56. let dexcomViewModel = DexcomSettingsViewModel()
  57. /// Called to dismiss the onboarding cover.
  58. private let onClose: () -> Void
  59. private var cancellables = Set<AnyCancellable>()
  60. /// Whether to show the in-flow telemetry consent phase. Captured once at
  61. /// launch so the decision the phase records doesn't remove it from under the
  62. /// navigation while the user is still on it.
  63. private let includeTelemetryStep: Bool
  64. /// Alarm types the user already has, captured at launch. Onboarding only
  65. /// offers recommended alarms whose type the user doesn't already own, so a
  66. /// returning user is helped to add new ones without touching their existing
  67. /// (possibly custom-named) alarms.
  68. private let existingAlarmTypes: Set<AlarmType>
  69. init(onClose: @escaping () -> Void) {
  70. self.onClose = onClose
  71. includeTelemetryStep = !Storage.shared.telemetryConsentDecisionMade.value
  72. existingAlarmTypes = Set(Storage.shared.alarms.value.map(\.type))
  73. let hasNightscout = !Storage.shared.url.value.isEmpty
  74. let hasDexcom = !Storage.shared.shareUserName.value.isEmpty
  75. && !Storage.shared.sharePassword.value.isEmpty
  76. isAlreadyConfigured = hasNightscout || hasDexcom
  77. seedAlarms = OnboardingViewModel.defaultSeedAlarms()
  78. // Re-publish child changes so the footer's `canProceed` stays in sync
  79. // with live connection validation.
  80. nightscoutViewModel.objectWillChange
  81. .sink { [weak self] in self?.objectWillChange.send() }
  82. .store(in: &cancellables)
  83. dexcomViewModel.objectWillChange
  84. .sink { [weak self] in self?.objectWillChange.send() }
  85. .store(in: &cancellables)
  86. UNUserNotificationCenter.current().getNotificationSettings { settings in
  87. let undecided = settings.authorizationStatus == .notDetermined
  88. Task { @MainActor [weak self] in
  89. self?.notificationsUndecided = undecided
  90. }
  91. }
  92. }
  93. // MARK: - Derived state
  94. /// True when the user already had a working data source at launch — used to
  95. /// make skipping prominent for returning users and to decide whether the
  96. /// data-source/connect phases are shown. Captured once in `init`, not read
  97. /// live: the connect step persists the URL/credentials as the user types, so
  98. /// a live read would flip this to `true` mid-`.connect`, drop those phases
  99. /// from `activeSteps`, and make `advance()` fall through to `finish()` —
  100. /// silently skipping units, alarms, and the rest of setup.
  101. let isAlreadyConfigured: Bool
  102. var canProceed: Bool {
  103. switch step {
  104. case .welcome, .overview, .units, .generalAlarms, .alarms,
  105. .tabOrder, .notifications, .telemetry, .completion:
  106. return true
  107. case .dataSource:
  108. return dataSource != nil
  109. case .connect:
  110. switch dataSource {
  111. case .nightscout: return nightscoutViewModel.isConnected || nightscoutViewModel.provisionedTokenPending
  112. case .dexcom: return dexcomViewModel.canVerifyProceed
  113. case .copyFromPhone: return didImportSettings
  114. case .none: return false
  115. }
  116. }
  117. }
  118. /// Whether Nightscout loop/uploader data is (or will be) available — used to
  119. /// gate alarms that depend on it. True for the Nightscout source, or any path
  120. /// that ends with a Nightscout URL configured (including a QR import).
  121. private var hasNightscoutData: Bool {
  122. dataSource == .nightscout || !Storage.shared.url.value.isEmpty
  123. }
  124. /// Whether a seeded alarm should be offered: its data source must be available
  125. /// and the user must not already have an alarm of that type.
  126. func isOffered(_ seed: SeedAlarm) -> Bool {
  127. guard !seed.requiresNightscout || hasNightscoutData else { return false }
  128. return !existingAlarmTypes.contains(seed.type)
  129. }
  130. var offeredSeedAlarms: [SeedAlarm] {
  131. seedAlarms.filter { isOffered($0) }
  132. }
  133. private var alarmsOffered: Bool { !offeredSeedAlarms.isEmpty }
  134. /// True when a returning user already has a working data source and there are
  135. /// no recommended alarms left to add — nothing essential to configure, so the
  136. /// overview becomes a short "you're all set" confirmation instead of a plan.
  137. var hasNothingToSetUp: Bool {
  138. isAlreadyConfigured && !alarmsOffered
  139. }
  140. /// The phases actually shown for the current configuration, in order. Optional
  141. /// phases are included only when relevant; a returning user skips the phases
  142. /// they've already completed.
  143. var activeSteps: [OnboardingStep] {
  144. var steps: [OnboardingStep] = [.welcome, .overview]
  145. // Already set up and nothing to add — the overview is the last stop, with
  146. // a "Done" that finishes. Notification/telemetry consent is still handled
  147. // after dismissal, the same as when skipping.
  148. if hasNothingToSetUp {
  149. return steps
  150. }
  151. // A returning user with a working data source skips source selection and
  152. // the connection screen; everyone else sets them up.
  153. if !isAlreadyConfigured {
  154. steps.append(contentsOf: [.dataSource, .connect])
  155. }
  156. steps.append(.units)
  157. if alarmsOffered {
  158. steps.append(contentsOf: [.generalAlarms, .alarms])
  159. }
  160. steps.append(.tabOrder)
  161. if notificationsUndecided {
  162. steps.append(.notifications)
  163. }
  164. if includeTelemetryStep {
  165. steps.append(.telemetry)
  166. }
  167. steps.append(.completion)
  168. return steps
  169. }
  170. /// Phases that show the progress header — the unit over which the bar fills.
  171. private var chromePhases: [OnboardingStep] {
  172. activeSteps.filter { $0.showsProgressHeader }
  173. }
  174. /// Progress fraction (0...1). Each phase is one slot; a multi-page phase fills
  175. /// its slot proportionally via `phaseProgress`, so the bar stays smooth no
  176. /// matter how many pages a phase turns out to have.
  177. var progress: Double {
  178. guard !chromePhases.isEmpty else { return 0 }
  179. guard let index = chromePhases.firstIndex(of: step) else {
  180. return step == .completion ? 1 : 0
  181. }
  182. let within: Double
  183. if let progress = phaseProgress, progress.count > 1 {
  184. within = Double(progress.page) / Double(progress.count)
  185. } else {
  186. within = 0
  187. }
  188. return (Double(index) + within) / Double(chromePhases.count)
  189. }
  190. /// The phase name shown in the header, with a local page count for multi-page
  191. /// phases (e.g. "Alarms · 3 of 13").
  192. var progressLabel: String {
  193. let title = step.phaseTitle
  194. if let progress = phaseProgress, progress.count > 1 {
  195. return "\(title) · \(min(progress.page + 1, progress.count)) of \(progress.count)"
  196. }
  197. return title
  198. }
  199. // MARK: - Navigation
  200. var canGoBack: Bool {
  201. guard let index = activeSteps.firstIndex(of: step) else { return false }
  202. return index > 0
  203. }
  204. func advance() {
  205. phaseProgress = nil
  206. let steps = activeSteps
  207. guard let index = steps.firstIndex(of: step) else {
  208. // Defensive: the current step should always be in `activeSteps`.
  209. // If it ever isn't (a state change removed it while we were on it),
  210. // continue to the next still-active step in canonical order rather
  211. // than silently ending setup via `finish()`.
  212. let canonical = OnboardingStep.allCases
  213. let currentRank = canonical.firstIndex(of: step) ?? canonical.count
  214. if let next = steps.first(where: { (canonical.firstIndex(of: $0) ?? 0) > currentRank }) {
  215. step = next
  216. } else {
  217. finish()
  218. }
  219. return
  220. }
  221. guard index + 1 < steps.count else {
  222. finish()
  223. return
  224. }
  225. step = steps[index + 1]
  226. }
  227. func goBack() {
  228. phaseProgress = nil
  229. guard let index = activeSteps.firstIndex(of: step), index > 0 else { return }
  230. step = activeSteps[index - 1]
  231. }
  232. /// Skip the rest of setup. Marks onboarding complete without seeding alarms
  233. /// or touching units, leaving any existing configuration untouched.
  234. func skip() {
  235. Storage.shared.hasCompletedOnboarding.value = true
  236. onClose()
  237. }
  238. /// Finish setup: seed selected alarms, mark units/onboarding complete, and
  239. /// kick a refresh so the home screen loads data immediately.
  240. func finish() {
  241. persistSeededAlarms()
  242. Storage.shared.hasConfiguredUnits.value = true
  243. Storage.shared.hasCompletedOnboarding.value = true
  244. onClose()
  245. NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
  246. }
  247. // MARK: - Alarm seeding
  248. private func persistSeededAlarms() {
  249. var alarms = Storage.shared.alarms.value
  250. let existingTypes = Set(alarms.map(\.type))
  251. for seed in offeredSeedAlarms where seed.isEnabled {
  252. // Don't re-add a type the user already configured. The two seeded Low
  253. // alarms (warning + urgent) are both added on a fresh install because
  254. // `existingTypes` is sampled once, before any are appended.
  255. guard !existingTypes.contains(seed.type) else { continue }
  256. alarms.append(seed.alarm)
  257. }
  258. Storage.shared.alarms.value = alarms
  259. }
  260. // MARK: - Default seed set
  261. /// The recommended alarms, in page order. Glucose and phone alarms are offered
  262. /// to everyone; loop/pump/insulin alarms require Nightscout data.
  263. private static func defaultSeedAlarms() -> [SeedAlarm] {
  264. func seed(
  265. _ type: AlarmType,
  266. title: String,
  267. detail: String,
  268. requiresNightscout: Bool,
  269. configure: (inout Alarm) -> Void = { _ in }
  270. ) -> SeedAlarm {
  271. var alarm = Alarm(type: type)
  272. configure(&alarm)
  273. return SeedAlarm(
  274. alarm: alarm,
  275. isEnabled: true,
  276. title: title,
  277. detail: detail,
  278. requiresNightscout: requiresNightscout
  279. )
  280. }
  281. return [
  282. seed(.low, title: "Low glucose",
  283. detail: "Warns when glucose is low, now or soon.",
  284. requiresNightscout: false)
  285. {
  286. $0.name = "Low glucose"
  287. $0.belowBG = 70
  288. $0.predictiveMinutes = 20
  289. },
  290. seed(.low, title: "Urgent low",
  291. detail: "A separate warning for when glucose is very low.",
  292. requiresNightscout: false)
  293. {
  294. $0.name = "Urgent low"
  295. $0.belowBG = 54
  296. $0.predictiveMinutes = 0
  297. $0.persistentMinutes = 0
  298. },
  299. seed(.high, title: "High glucose",
  300. detail: "Warns when glucose is high.",
  301. requiresNightscout: false)
  302. {
  303. $0.aboveBG = 180
  304. },
  305. seed(.fastDrop, title: "Fast drop",
  306. detail: "Warns when glucose is falling quickly.",
  307. requiresNightscout: false),
  308. seed(.missedReading, title: "Missed readings",
  309. detail: "Warns when glucose stops updating.",
  310. requiresNightscout: false),
  311. seed(.notLooping, title: "Not looping",
  312. detail: "Warns when the loop stops running.",
  313. requiresNightscout: true),
  314. seed(.battery, title: "Looping phone battery",
  315. detail: "Warns when the battery of the phone running Loop or Trio is low.",
  316. requiresNightscout: true),
  317. seed(.iob, title: "IOB",
  318. detail: "Warns when insulin on board is high.",
  319. requiresNightscout: true),
  320. seed(.cob, title: "COB",
  321. detail: "Warns when carbs on board are high.",
  322. requiresNightscout: true),
  323. seed(.sensorChange, title: "Sensor change",
  324. detail: "Reminds you when the CGM sensor is due.",
  325. requiresNightscout: true)
  326. {
  327. $0.threshold = 10
  328. $0.activeOption = .day
  329. },
  330. seed(.pumpChange, title: "Pump change",
  331. detail: "Reminds you when the pump site is due.",
  332. requiresNightscout: true)
  333. {
  334. $0.threshold = 3
  335. $0.activeOption = .day
  336. },
  337. seed(.pump, title: "Pump insulin",
  338. detail: "Warns when pump reservoir insulin is low.",
  339. requiresNightscout: true),
  340. ]
  341. }
  342. }