OnboardingRootView.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import SwiftUI
  2. import Swinject
  3. /// The main onboarding view that manages navigation between onboarding steps.
  4. extension Onboarding {
  5. struct RootView: BaseView {
  6. let resolver: Resolver
  7. @State var state = StateModel()
  8. @State private var navigationDirection: OnboardingNavigationDirection = .forward
  9. let onboardingManager: OnboardingManager
  10. @State private var currentStep: OnboardingStep = .welcome
  11. @State private var currentDeliverySubstep: DeliveryLimitSubstep = .maxIOB
  12. @State private var currentNightscoutSubstep: NightscoutSubstep = .setupSelection
  13. // Animation states
  14. @State private var animationScale: CGFloat = 1.0
  15. @State private var animationOpacity: Double = 0
  16. @State private var isAnimating = false
  17. // Conditional button states for Nightscout substeps
  18. private var didSelectNightscoutSetupOption: Bool {
  19. currentNightscoutSubstep == .setupSelection && state
  20. .nightscoutSetupOption == .noSelection
  21. }
  22. private var hasValidNightscoutConnection: Bool {
  23. currentNightscoutSubstep == .connectToNightscout && !state.isConnectedToNS
  24. }
  25. private var didSelectNightscoutImportOption: Bool {
  26. currentNightscoutSubstep == .importFromNightscout && state.nightscoutImportOption == .noSelection
  27. }
  28. private var shouldDisableNextButton: Bool {
  29. (currentStep == .nightscout && didSelectNightscoutSetupOption)
  30. ||
  31. (currentStep == .nightscout && hasValidNightscoutConnection)
  32. ||
  33. (currentStep == .nightscout && didSelectNightscoutImportOption)
  34. }
  35. var body: some View {
  36. NavigationView {
  37. ZStack {
  38. // Background gradient
  39. LinearGradient(
  40. gradient: Gradient(colors: [Color.bgDarkBlue, Color.bgDarkerDarkBlue]),
  41. startPoint: .top,
  42. endPoint: .bottom
  43. )
  44. .ignoresSafeArea()
  45. VStack(spacing: 0) {
  46. if (nonInfoOnboardingSteps + [OnboardingStep.overview, OnboardingStep.completed]).contains(currentStep) {
  47. // Progress bar
  48. OnboardingProgressBar(
  49. currentStep: currentStep,
  50. currentSubstep: {
  51. switch currentStep {
  52. case .deliveryLimits: return currentDeliverySubstep.rawValue
  53. case .nightscout: return currentNightscoutSubstep.rawValue
  54. default: return nil
  55. }
  56. }(),
  57. stepsWithSubsteps: [
  58. .nightscout: NightscoutSubstep.allCases.count,
  59. .deliveryLimits: DeliveryLimitSubstep.allCases.count
  60. ],
  61. nightscoutSetupOption: state.nightscoutSetupOption
  62. )
  63. .padding(.top)
  64. }
  65. // Step content
  66. ScrollViewReader { scrollProxy in
  67. ScrollView {
  68. VStack(alignment: .leading, spacing: 20) {
  69. // Scroll position marker at top
  70. Color.clear.frame(height: 0).id("top")
  71. // Header
  72. if currentStep != .welcome && currentStep != .completed {
  73. HStack {
  74. if currentStep == .nightscout {
  75. Image(currentStep.iconName)
  76. .resizable()
  77. .scaledToFit()
  78. .frame(width: 60, height: 60)
  79. } else {
  80. Image(systemName: currentStep.iconName)
  81. .font(.system(size: 40))
  82. .foregroundColor(currentStep.accentColor)
  83. .frame(width: 60, height: 60)
  84. .background(
  85. Circle()
  86. .fill(currentStep.accentColor.opacity(0.2))
  87. )
  88. }
  89. VStack(alignment: .leading) {
  90. Text(currentStep.title)
  91. .font(.largeTitle)
  92. .fontWeight(.bold)
  93. .foregroundColor(.primary)
  94. Text(currentStep.description)
  95. .font(.subheadline)
  96. .foregroundColor(.secondary)
  97. .fixedSize(horizontal: false, vertical: true)
  98. }
  99. }
  100. .padding([.horizontal, .top])
  101. }
  102. // Step-specific content
  103. Group {
  104. switch currentStep {
  105. case .welcome:
  106. WelcomeStepView()
  107. case .startupGuide:
  108. StartupGuideStepView()
  109. case .overview:
  110. OverviewStepView()
  111. case .diagnostics:
  112. DiagnosticsStepView(state: state)
  113. case .nightscout:
  114. switch currentNightscoutSubstep {
  115. case .setupSelection:
  116. NightscoutSetupStepView(state: state)
  117. case .connectToNightscout:
  118. NightscoutLoginStepView(state: state)
  119. case .importFromNightscout:
  120. NightscoutImportStepView(state: state)
  121. }
  122. case .unitSelection:
  123. UnitSelectionStepView(state: state)
  124. case .glucoseTarget:
  125. GlucoseTargetStepView(state: state)
  126. case .basalRates:
  127. BasalProfileStepView(state: state)
  128. case .carbRatio:
  129. CarbRatioStepView(state: state)
  130. case .insulinSensitivity:
  131. InsulinSensitivityStepView(state: state)
  132. case .deliveryLimits:
  133. DeliveryLimitsStepView(state: state, substep: currentDeliverySubstep)
  134. case .completed:
  135. CompletedStepView()
  136. }
  137. }
  138. .transition(
  139. navigationDirection == .forward
  140. ? .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
  141. : .asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
  142. )
  143. .padding(.horizontal)
  144. .id(currentStep.id) // Force view recreation when step changes
  145. }
  146. .padding(.bottom, 80) // Make room for buttons at bottom
  147. }
  148. .onChange(of: currentStep) { _, _ in
  149. scrollProxy.scrollTo("top", anchor: .top)
  150. }
  151. .onChange(of: currentNightscoutSubstep) { _, _ in
  152. scrollProxy.scrollTo("top", anchor: .top)
  153. }
  154. .onChange(of: currentDeliverySubstep) { _, _ in
  155. scrollProxy.scrollTo("top", anchor: .top)
  156. }
  157. }
  158. Spacer()
  159. // Navigation buttons
  160. HStack {
  161. // Back button
  162. if currentStep != .welcome {
  163. Button(action: {
  164. navigationDirection = .backward
  165. withAnimation {
  166. if currentStep == .completed {
  167. currentStep = .deliveryLimits
  168. currentDeliverySubstep =
  169. .minimumSafetyThreshold // ensure we land on the last substep visually
  170. } else if currentStep == .nightscout {
  171. if currentNightscoutSubstep == .setupSelection {
  172. // First substep: go to previous main step
  173. if let previousMainStep = currentStep.previous {
  174. currentStep = previousMainStep
  175. currentNightscoutSubstep = .setupSelection // reset substep
  176. }
  177. } else {
  178. // Go back one substep
  179. currentNightscoutSubstep = NightscoutSubstep(
  180. rawValue: currentNightscoutSubstep
  181. .rawValue - 1
  182. )!
  183. }
  184. } else if currentStep == .deliveryLimits {
  185. if let previousSub = DeliveryLimitSubstep(
  186. rawValue: currentDeliverySubstep
  187. .rawValue - 1
  188. ) {
  189. currentDeliverySubstep = previousSub
  190. } else if let previousMainStep = currentStep.previous {
  191. currentStep = previousMainStep
  192. currentDeliverySubstep = .maxIOB // reset to first substep for later return
  193. }
  194. } else if let previous = currentStep.previous {
  195. currentStep = previous
  196. }
  197. }
  198. }) {
  199. HStack {
  200. Image(systemName: "chevron.left")
  201. Text("Back")
  202. }
  203. .padding()
  204. .foregroundColor(.primary)
  205. }
  206. }
  207. Spacer()
  208. // Next/Finish button
  209. Button(action: {
  210. navigationDirection = .forward
  211. withAnimation {
  212. if currentStep == .completed {
  213. state.saveOnboardingData()
  214. onboardingManager.completeOnboarding()
  215. Foundation.NotificationCenter.default.post(name: .onboardingCompleted, object: nil)
  216. } else if currentStep == .nightscout {
  217. if currentNightscoutSubstep != .importFromNightscout {
  218. // Handle conditional skip
  219. if currentNightscoutSubstep == .setupSelection,
  220. state.nightscoutSetupOption == .skipNightscoutSetup,
  221. let next = currentStep.next
  222. {
  223. currentStep = next
  224. } else {
  225. currentNightscoutSubstep = NightscoutSubstep(
  226. rawValue: currentNightscoutSubstep
  227. .rawValue + 1
  228. )!
  229. }
  230. } else if currentNightscoutSubstep == .importFromNightscout,
  231. state.nightscoutImportOption == .useImport
  232. {
  233. // TODO: trigger import, show animation, then proceed to next step
  234. Task {
  235. await state.importSettingsFromNightscout(currentStep: $currentStep)
  236. }
  237. } else if let next = currentStep.next {
  238. currentStep = next
  239. }
  240. } else if currentStep == .deliveryLimits {
  241. if let nextSub = DeliveryLimitSubstep(rawValue: currentDeliverySubstep.rawValue + 1) {
  242. currentDeliverySubstep = nextSub
  243. } else if let next = currentStep.next {
  244. currentStep = next
  245. currentDeliverySubstep = .maxIOB
  246. }
  247. } else if let next = currentStep.next {
  248. currentStep = next
  249. }
  250. }
  251. }) {
  252. HStack {
  253. Text(currentStep == .completed ? "Get Started" : "Next")
  254. Image(systemName: "chevron.right")
  255. }
  256. .padding()
  257. .foregroundColor(.white)
  258. .background(Capsule().fill(!shouldDisableNextButton ? Color.blue : Color(.systemGray)))
  259. }.disabled(shouldDisableNextButton)
  260. }
  261. .padding(.horizontal)
  262. .padding(.bottom)
  263. }
  264. }
  265. .navigationBarHidden(true)
  266. }
  267. .onChange(of: currentStep) { _, _ in
  268. // Reset animation when step changes
  269. animationScale = 0.9
  270. animationOpacity = 0
  271. isAnimating = false
  272. // Start new animation
  273. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  274. withAnimation(.easeInOut(duration: 0.7)) {
  275. animationOpacity = 1
  276. animationScale = 1.0
  277. }
  278. isAnimating = true
  279. }
  280. }
  281. .onAppear(perform: configureView)
  282. }
  283. }
  284. }
  285. /// A progress bar that shows the user's progress through the onboarding process.
  286. struct OnboardingProgressBar: View {
  287. let currentStep: OnboardingStep
  288. let currentSubstep: Int?
  289. let stepsWithSubsteps: [OnboardingStep: Int]
  290. let nightscoutSetupOption: NightscoutSetupOption
  291. var body: some View {
  292. HStack(spacing: 4) {
  293. ForEach(renderedSteps, id: \.id) { step in
  294. ZStack(alignment: .leading) {
  295. Rectangle()
  296. .fill(Color.gray.opacity(0.3))
  297. .frame(height: 4)
  298. .cornerRadius(2)
  299. GeometryReader { geo in
  300. Rectangle()
  301. .fill(Color.blue)
  302. .frame(
  303. width: geo.size.width * fillFraction(for: step.step, totalSubsteps: step.substeps),
  304. height: 4
  305. )
  306. .cornerRadius(2)
  307. }
  308. }
  309. .frame(height: 4)
  310. }
  311. }
  312. .padding(.horizontal)
  313. }
  314. private var renderedSteps: [(id: String, step: OnboardingStep, substeps: Int?)] {
  315. nonInfoOnboardingSteps.map {
  316. (id: "\($0.rawValue)", step: $0, substeps: stepsWithSubsteps[$0])
  317. }
  318. }
  319. private func fillFraction(for step: OnboardingStep, totalSubsteps: Int?) -> CGFloat {
  320. // If currentStep is .completed, fill everything
  321. if currentStep == .completed { return 1.0 }
  322. if let currentIndex = nonInfoOnboardingSteps.firstIndex(of: currentStep),
  323. let stepIndex = nonInfoOnboardingSteps.firstIndex(of: step),
  324. stepIndex < currentIndex
  325. {
  326. return 1.0
  327. }
  328. if step == currentStep {
  329. if let total = totalSubsteps, let current = currentSubstep {
  330. return CGFloat(current + 1) / CGFloat(total)
  331. } else {
  332. return 1.0
  333. }
  334. }
  335. // Handle special case: Nightscout was skipped
  336. if step == .nightscout,
  337. nightscoutSetupOption == .skipNightscoutSetup,
  338. let currentIndex = nonInfoOnboardingSteps.firstIndex(of: currentStep),
  339. let nightscoutIndex = nonInfoOnboardingSteps.firstIndex(of: .nightscout),
  340. currentIndex > nightscoutIndex
  341. {
  342. return 1.0
  343. }
  344. return 0.0
  345. }
  346. }
  347. struct Onboarding_Preview: PreviewProvider {
  348. static var previews: some View {
  349. Group {
  350. let resolver = TrioApp.resolver
  351. let onboardingManager = OnboardingManager()
  352. Onboarding.RootView(resolver: resolver, onboardingManager: onboardingManager)
  353. .previewDisplayName("Onboarding Flow")
  354. }
  355. }
  356. }