|
|
@@ -8,6 +8,7 @@ extension Onboarding {
|
|
|
@State var state = StateModel()
|
|
|
let onboardingManager: OnboardingManager
|
|
|
@State private var currentStep: OnboardingStep = .welcome
|
|
|
+ @State private var currentDeliverySubstep: DeliveryLimitSubstep = .maxIOB
|
|
|
|
|
|
// Animation states
|
|
|
@State private var animationScale: CGFloat = 1.0
|
|
|
@@ -28,16 +29,18 @@ extension Onboarding {
|
|
|
VStack(spacing: 0) {
|
|
|
// Progress bar
|
|
|
OnboardingProgressBar(
|
|
|
- currentStep: OnboardingStep.allCases.firstIndex(of: currentStep) ?? 0,
|
|
|
- totalSteps: OnboardingStep.allCases.count - 1
|
|
|
+ currentStep: currentStep,
|
|
|
+ currentSubstep: currentStep == .deliveryLimits ? currentDeliverySubstep.rawValue : nil,
|
|
|
+ stepsWithSubsteps: [.deliveryLimits: DeliveryLimitSubstep.allCases.count]
|
|
|
)
|
|
|
+
|
|
|
.padding(.top)
|
|
|
|
|
|
// Step content
|
|
|
ScrollView {
|
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
|
// Header
|
|
|
- if currentStep != .welcome {
|
|
|
+ if currentStep != .welcome && currentStep != .completed {
|
|
|
HStack {
|
|
|
Image(systemName: currentStep.iconName)
|
|
|
.font(.system(size: 40))
|
|
|
@@ -93,7 +96,7 @@ extension Onboarding {
|
|
|
case .insulinSensitivity:
|
|
|
InsulinSensitivityStepView(state: state)
|
|
|
case .deliveryLimits:
|
|
|
- DeliveryLimitsStepView(state: state)
|
|
|
+ DeliveryLimitsStepView(state: state, substep: currentDeliverySubstep)
|
|
|
case .completed:
|
|
|
CompletedStepView()
|
|
|
}
|
|
|
@@ -113,7 +116,20 @@ extension Onboarding {
|
|
|
if currentStep != .welcome {
|
|
|
Button(action: {
|
|
|
withAnimation {
|
|
|
- if let previous = currentStep.previous {
|
|
|
+ if currentStep == .completed {
|
|
|
+ currentStep = .deliveryLimits
|
|
|
+ currentDeliverySubstep = .maxCOB // ensure we land on the last substep visually
|
|
|
+ } else if currentStep == .deliveryLimits {
|
|
|
+ if let previousSub = DeliveryLimitSubstep(
|
|
|
+ rawValue: currentDeliverySubstep
|
|
|
+ .rawValue - 1
|
|
|
+ ) {
|
|
|
+ currentDeliverySubstep = previousSub
|
|
|
+ } else if let previousMainStep = currentStep.previous {
|
|
|
+ currentStep = previousMainStep
|
|
|
+ currentDeliverySubstep = .maxIOB // reset to first substep for later return
|
|
|
+ }
|
|
|
+ } else if let previous = currentStep.previous {
|
|
|
currentStep = previous
|
|
|
}
|
|
|
}
|
|
|
@@ -133,10 +149,16 @@ extension Onboarding {
|
|
|
Button(action: {
|
|
|
withAnimation {
|
|
|
if currentStep == .completed {
|
|
|
- // Apply settings and complete onboarding
|
|
|
- state.applyToSettings()
|
|
|
+ state.saveOnboardingData()
|
|
|
onboardingManager.completeOnboarding()
|
|
|
Foundation.NotificationCenter.default.post(name: .onboardingCompleted, object: nil)
|
|
|
+ } else if currentStep == .deliveryLimits {
|
|
|
+ if let nextSub = DeliveryLimitSubstep(rawValue: currentDeliverySubstep.rawValue + 1) {
|
|
|
+ currentDeliverySubstep = nextSub
|
|
|
+ } else if let next = currentStep.next {
|
|
|
+ currentStep = next
|
|
|
+ currentDeliverySubstep = .maxIOB
|
|
|
+ }
|
|
|
} else if let next = currentStep.next {
|
|
|
currentStep = next
|
|
|
}
|
|
|
@@ -148,11 +170,7 @@ extension Onboarding {
|
|
|
}
|
|
|
.padding()
|
|
|
.foregroundColor(.white)
|
|
|
- .background(
|
|
|
- Capsule()
|
|
|
-// .fill(currentStep.accentColor)
|
|
|
- .fill(Color.blue)
|
|
|
- )
|
|
|
+ .background(Capsule().fill(Color.blue))
|
|
|
}
|
|
|
}
|
|
|
.padding(.horizontal)
|
|
|
@@ -183,254 +201,88 @@ extension Onboarding {
|
|
|
|
|
|
/// A progress bar that shows the user's progress through the onboarding process.
|
|
|
struct OnboardingProgressBar: View {
|
|
|
- let currentStep: Int
|
|
|
- let totalSteps: Int
|
|
|
+ let currentStep: OnboardingStep
|
|
|
+ let currentSubstep: Int?
|
|
|
+ let stepsWithSubsteps: [OnboardingStep: Int] // e.g. [.deliveryLimits: 4]
|
|
|
|
|
|
var body: some View {
|
|
|
HStack(spacing: 4) {
|
|
|
- ForEach(0 ..< totalSteps, id: \.self) { step in
|
|
|
- Rectangle()
|
|
|
- .fill(step <= currentStep ? Color.blue : Color.gray.opacity(0.3))
|
|
|
- .frame(height: 4)
|
|
|
- .cornerRadius(2)
|
|
|
+ ForEach(renderedSteps, id: \.self.id) { element in
|
|
|
+ if let substeps = element.substeps {
|
|
|
+ HStack(spacing: 2) {
|
|
|
+ ForEach(0 ..< substeps, id: \.self) { i in
|
|
|
+ Rectangle()
|
|
|
+ .fill(isSubstepActive(for: element.step, index: i) ? Color.blue : Color.gray.opacity(0.3))
|
|
|
+ .frame(height: 4)
|
|
|
+ .cornerRadius(2)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ Rectangle()
|
|
|
+ .fill(isStepActive(element.step) ? Color.blue : Color.gray.opacity(0.3))
|
|
|
+ .frame(height: 4)
|
|
|
+ .cornerRadius(2)
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
.padding(.horizontal)
|
|
|
}
|
|
|
-}
|
|
|
|
|
|
-///// A simple animated placeholder for each step
|
|
|
-// struct AnimationPlaceholder: View {
|
|
|
-// let step: OnboardingStep
|
|
|
-// @State private var animationValue: Double = 0
|
|
|
-//
|
|
|
-// init(for step: OnboardingStep) {
|
|
|
-// self.step = step
|
|
|
-// }
|
|
|
-//
|
|
|
-// var body: some View {
|
|
|
-// VStack {
|
|
|
-// Group {
|
|
|
-// switch step {
|
|
|
-// case .welcome:
|
|
|
-// welcomeAnimation
|
|
|
-// case .glucoseTarget:
|
|
|
-// glucoseTargetAnimation
|
|
|
-// case .basalProfile:
|
|
|
-// basalProfileAnimation
|
|
|
-// case .carbRatio:
|
|
|
-// carbRatioAnimation
|
|
|
-// case .insulinSensitivity:
|
|
|
-// insulinSensitivityAnimation
|
|
|
-// case .completed:
|
|
|
-// completedAnimation
|
|
|
-// }
|
|
|
-// }
|
|
|
-// .frame(height: 180)
|
|
|
-// }
|
|
|
-// .onAppear {
|
|
|
-// withAnimation(Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
|
|
|
-// animationValue = 1.0
|
|
|
-// }
|
|
|
-// }
|
|
|
-// }
|
|
|
-//
|
|
|
-// // Custom animated views for each step
|
|
|
-// var welcomeAnimation: some View {
|
|
|
-// ZStack {
|
|
|
-// ForEach(0 ..< 5) { index in
|
|
|
-// Image(systemName: "heart.fill")
|
|
|
-// .font(.system(size: 40))
|
|
|
-// .foregroundColor(step.accentColor.opacity(0.8 - Double(index) * 0.15))
|
|
|
-// .offset(x: CGFloat.random(in: -100 ... 100), y: CGFloat.random(in: -60 ... 60))
|
|
|
-// .scaleEffect(1.0 + animationValue * 0.3)
|
|
|
-// .rotationEffect(.degrees(animationValue * Double.random(in: -30 ... 30)))
|
|
|
-// }
|
|
|
-//
|
|
|
-// Image(systemName: "syringe.fill")
|
|
|
-// .font(.system(size: 80))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .scaleEffect(1.0 + animationValue * 0.2)
|
|
|
-// .shadow(color: step.accentColor.opacity(0.5), radius: 10 * animationValue, x: 0, y: 0)
|
|
|
-// }
|
|
|
-// }
|
|
|
-//
|
|
|
-// var glucoseTargetAnimation: some View {
|
|
|
-// ZStack {
|
|
|
-// // Target rings
|
|
|
-// ForEach(0 ..< 3) { index in
|
|
|
-// Circle()
|
|
|
-// .stroke(step.accentColor.opacity(Double(3 - index) * 0.3), lineWidth: 8)
|
|
|
-// .frame(width: 120 + CGFloat(index * 40))
|
|
|
-// .scaleEffect(1.0 + animationValue * 0.05)
|
|
|
-// }
|
|
|
-//
|
|
|
-// // Arrow
|
|
|
-// Image(systemName: "arrow.down.to.line")
|
|
|
-// .font(.system(size: 50))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .offset(y: -10 + animationValue * 20)
|
|
|
-// .rotationEffect(.degrees(animationValue * 360))
|
|
|
-// }
|
|
|
-// }
|
|
|
-//
|
|
|
-// var basalProfileAnimation: some View {
|
|
|
-// ZStack {
|
|
|
-// // Line graph representation
|
|
|
-// Path { path in
|
|
|
-// let width: CGFloat = 300
|
|
|
-// let height: CGFloat = 100
|
|
|
-//
|
|
|
-// path.move(to: CGPoint(x: 0, y: height * 0.5))
|
|
|
-//
|
|
|
-// for i in 0 ..< 8 {
|
|
|
-// let x = width * CGFloat(i) / 7
|
|
|
-// let y = height * (0.5 + (sin(Double(i) * .pi / 3) * 0.4))
|
|
|
-//
|
|
|
-// if i == 0 {
|
|
|
-// path.move(to: CGPoint(x: x, y: y))
|
|
|
-// } else {
|
|
|
-// path.addLine(to: CGPoint(x: x, y: y))
|
|
|
-// }
|
|
|
-// }
|
|
|
-// }
|
|
|
-// .trim(from: 0, to: animationValue)
|
|
|
-// .stroke(step.accentColor, style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round))
|
|
|
-// .frame(width: 300, height: 100)
|
|
|
-//
|
|
|
-// // Clock symbols to represent time
|
|
|
-// HStack(spacing: 50) {
|
|
|
-// Image(systemName: "clock")
|
|
|
-// .font(.system(size: 20))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .opacity(animationValue)
|
|
|
-//
|
|
|
-// Image(systemName: "clock.fill")
|
|
|
-// .font(.system(size: 20))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .opacity(animationValue)
|
|
|
-//
|
|
|
-// Image(systemName: "clock")
|
|
|
-// .font(.system(size: 20))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .opacity(animationValue)
|
|
|
-// }
|
|
|
-// .offset(y: 70)
|
|
|
-// }
|
|
|
-// }
|
|
|
-//
|
|
|
-// var carbRatioAnimation: some View {
|
|
|
-// ZStack {
|
|
|
-// // Plate
|
|
|
-// Circle()
|
|
|
-// .fill(Color.gray.opacity(0.1))
|
|
|
-// .frame(width: 150)
|
|
|
-//
|
|
|
-// // Food items
|
|
|
-// ForEach(0 ..< 5) { index in
|
|
|
-// Image(systemName: [
|
|
|
-// "carrot.fill",
|
|
|
-// "fork.knife",
|
|
|
-// "takeoutbag.and.cup.and.straw.fill",
|
|
|
-// "wallet.pass.fill",
|
|
|
-// "cup.and.saucer.fill"
|
|
|
-// ][index % 5])
|
|
|
-// .font(.system(size: 25))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .offset(
|
|
|
-// x: cos(Double(index) * .pi * 2 / 5) * 50 * animationValue,
|
|
|
-// y: sin(Double(index) * .pi * 2 / 5) * 50 * animationValue
|
|
|
-// )
|
|
|
-// .rotationEffect(.degrees(animationValue * 360))
|
|
|
-// }
|
|
|
-//
|
|
|
-// // Insulin
|
|
|
-// Image(systemName: "drop.fill")
|
|
|
-// .font(.system(size: 40))
|
|
|
-// .foregroundColor(.blue)
|
|
|
-// .scaleEffect(0.8 + animationValue * 0.3)
|
|
|
-// .shadow(color: .blue.opacity(0.5), radius: 5, x: 0, y: 0)
|
|
|
-// }
|
|
|
-// }
|
|
|
-//
|
|
|
-// var insulinSensitivityAnimation: some View {
|
|
|
-// ZStack {
|
|
|
-// // Glucose meter
|
|
|
-// RoundedRectangle(cornerRadius: 20)
|
|
|
-// .fill(Color.gray.opacity(0.1))
|
|
|
-// .frame(width: 120, height: 200)
|
|
|
-//
|
|
|
-// // Display screen
|
|
|
-// RoundedRectangle(cornerRadius: 10)
|
|
|
-// .fill(Color.black.opacity(0.1))
|
|
|
-// .frame(width: 100, height: 60)
|
|
|
-// .offset(y: -60)
|
|
|
-//
|
|
|
-// // Value on screen
|
|
|
-// Text("120")
|
|
|
-// .font(.system(size: 24, weight: .bold, design: .monospaced))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .offset(y: -60)
|
|
|
-// .opacity(animationValue)
|
|
|
-//
|
|
|
-// // Insulin drop
|
|
|
-// Image(systemName: "drop.fill")
|
|
|
-// .font(.system(size: 30))
|
|
|
-// .foregroundColor(.blue)
|
|
|
-// .offset(y: 20)
|
|
|
-// .opacity(1)
|
|
|
-//
|
|
|
-// // Arrow showing decrease
|
|
|
-// Image(systemName: "arrow.down")
|
|
|
-// .font(.system(size: 30))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .offset(y: 60)
|
|
|
-// .opacity(animationValue)
|
|
|
-// .scaleEffect(1.0 + animationValue * 0.5)
|
|
|
-//
|
|
|
-// // Lower value
|
|
|
-// Text("80")
|
|
|
-// .font(.system(size: 24, weight: .bold, design: .monospaced))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .offset(y: 100)
|
|
|
-// .opacity(animationValue)
|
|
|
-// }
|
|
|
-// }
|
|
|
-//
|
|
|
-// var completedAnimation: some View {
|
|
|
-// ZStack {
|
|
|
-// // Success checkmark
|
|
|
-// Circle()
|
|
|
-// .fill(step.accentColor.opacity(0.2))
|
|
|
-// .frame(width: 150)
|
|
|
-// .scaleEffect(animationValue)
|
|
|
-//
|
|
|
-// Circle()
|
|
|
-// .stroke(step.accentColor, lineWidth: 5)
|
|
|
-// .frame(width: 150)
|
|
|
-// .scaleEffect(animationValue)
|
|
|
-//
|
|
|
-// Image(systemName: "checkmark")
|
|
|
-// .font(.system(size: 80, weight: .bold))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .offset(y: animationValue * 5)
|
|
|
-// .scaleEffect(animationValue)
|
|
|
-//
|
|
|
-// // Celebrate particles
|
|
|
-// ForEach(0 ..< 8) { index in
|
|
|
-// Image(systemName: "star.fill")
|
|
|
-// .font(.system(size: 20))
|
|
|
-// .foregroundColor(step.accentColor)
|
|
|
-// .offset(
|
|
|
-// x: cos(Double(index) * .pi / 4) * 100 * animationValue,
|
|
|
-// y: sin(Double(index) * .pi / 4) * 100 * animationValue
|
|
|
-// )
|
|
|
-// .opacity(animationValue)
|
|
|
-// .scaleEffect(animationValue)
|
|
|
-// }
|
|
|
-// }
|
|
|
-// }
|
|
|
-// }
|
|
|
+ // Filter only the visible steps (exclude welcome and completed)
|
|
|
+ private var visibleSteps: [OnboardingStep] {
|
|
|
+ OnboardingStep.allCases.filter { $0 != .welcome && $0 != .completed }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Combine steps with info on whether they have substeps
|
|
|
+ private var renderedSteps: [(id: String, step: OnboardingStep, substeps: Int?)] {
|
|
|
+ visibleSteps.map {
|
|
|
+ let sub = stepsWithSubsteps[$0]
|
|
|
+ return (id: "\($0.id)", step: $0, substeps: sub)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func isStepActive(_ step: OnboardingStep) -> Bool {
|
|
|
+ // If we’re at .completed, everything should be filled
|
|
|
+ if currentStep == .completed { return true }
|
|
|
+
|
|
|
+ // Current step should be filled
|
|
|
+ if step == currentStep { return true }
|
|
|
+
|
|
|
+ // Steps before the current one should be filled
|
|
|
+ if let currentIndex = visibleSteps.firstIndex(of: currentStep),
|
|
|
+ let stepIndex = visibleSteps.firstIndex(of: step),
|
|
|
+ stepIndex < currentIndex
|
|
|
+ {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ private func isSubstepActive(for step: OnboardingStep, index: Int) -> Bool {
|
|
|
+ guard let current = currentSubstep else {
|
|
|
+ // Special case: if currentStep is `.completed`, show all substeps as filled
|
|
|
+ if currentStep == .completed && step == .deliveryLimits {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ if step == currentStep {
|
|
|
+ return index <= current
|
|
|
+ }
|
|
|
+
|
|
|
+ // If step comes before currentStep, mark all substeps filled
|
|
|
+ if let currentIndex = visibleSteps.firstIndex(of: currentStep),
|
|
|
+ let stepIndex = visibleSteps.firstIndex(of: step),
|
|
|
+ stepIndex < currentIndex
|
|
|
+ {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
struct Onboarding_Preview: PreviewProvider {
|
|
|
static var previews: some View {
|