Просмотр исходного кода

Add Nightscout handling (setup, import, etc.) WIP

Deniz Cengiz 1 год назад
Родитель
Сommit
bff05a8ccc

+ 24 - 0
Trio.xcodeproj/project.pbxproj

@@ -553,6 +553,10 @@
 		DD3F1F832D9DC78800DCE7B3 /* UnitSelectionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F822D9DC78300DCE7B3 /* UnitSelectionStepView.swift */; };
 		DD3F1F852D9DD84000DCE7B3 /* DeliveryLimitsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F842D9DD83B00DCE7B3 /* DeliveryLimitsStepView.swift */; };
 		DD3F1F872D9DDB1200DCE7B3 /* AnimationPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F862D9DDB1200DCE7B3 /* AnimationPlaceholder.swift */; };
+		DD3F1F892D9E078D00DCE7B3 /* TimeValueEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F882D9E078300DCE7B3 /* TimeValueEditorView.swift */; };
+		DD3F1F8B2D9E08B600DCE7B3 /* NightscoutLoginStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F8A2D9E08B200DCE7B3 /* NightscoutLoginStepView.swift */; };
+		DD3F1F8D2D9E0E0600DCE7B3 /* NightscoutStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F8C2D9E0E0000DCE7B3 /* NightscoutStepView.swift */; };
+		DD3F1F902D9E153F00DCE7B3 /* NightscoutImportStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F8F2D9E153A00DCE7B3 /* NightscoutImportStepView.swift */; };
 		DD498F2B2D692BEA00AAEA30 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 8A9134292D63D9A1007F8874 /* Localizable.xcstrings */; };
 		DD498F2C2D692BEA00AAEA30 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 8A9134292D63D9A1007F8874 /* Localizable.xcstrings */; };
 		DD498F2D2D692BEA00AAEA30 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 8A9134292D63D9A1007F8874 /* Localizable.xcstrings */; };
@@ -1333,6 +1337,10 @@
 		DD3F1F822D9DC78300DCE7B3 /* UnitSelectionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitSelectionStepView.swift; sourceTree = "<group>"; };
 		DD3F1F842D9DD83B00DCE7B3 /* DeliveryLimitsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryLimitsStepView.swift; sourceTree = "<group>"; };
 		DD3F1F862D9DDB1200DCE7B3 /* AnimationPlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationPlaceholder.swift; sourceTree = "<group>"; };
+		DD3F1F882D9E078300DCE7B3 /* TimeValueEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeValueEditorView.swift; sourceTree = "<group>"; };
+		DD3F1F8A2D9E08B200DCE7B3 /* NightscoutLoginStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutLoginStepView.swift; sourceTree = "<group>"; };
+		DD3F1F8C2D9E0E0000DCE7B3 /* NightscoutStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutStepView.swift; sourceTree = "<group>"; };
+		DD3F1F8F2D9E153A00DCE7B3 /* NightscoutImportStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutImportStepView.swift; sourceTree = "<group>"; };
 		DD4C57A72D73ADEA001BFF2C /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = "<group>"; };
 		DD4C57A92D73B3D9001BFF2C /* RestartLiveActivityIntentRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntentRequest.swift; sourceTree = "<group>"; };
 		DD4C581E2D73C43D001BFF2C /* LoopStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatsView.swift; sourceTree = "<group>"; };
@@ -2729,6 +2737,7 @@
 		BD47FD152D88AAD80043966B /* View */ = {
 			isa = PBXGroup;
 			children = (
+				DD3F1F882D9E078300DCE7B3 /* TimeValueEditorView.swift */,
 				DD3F1F862D9DDB1200DCE7B3 /* AnimationPlaceholder.swift */,
 				DDC38E0F2D9B376900ADCB46 /* OnboardingView+Util.swift */,
 				BD47FD182D88AAF90043966B /* OnboardingView.swift */,
@@ -2740,6 +2749,7 @@
 		BD47FDD52D8B64AE0043966B /* OnboardingSteps */ = {
 			isa = PBXGroup;
 			children = (
+				DD3F1F8E2D9E151200DCE7B3 /* Nightscout */,
 				DD3F1F842D9DD83B00DCE7B3 /* DeliveryLimitsStepView.swift */,
 				DD3F1F822D9DC78300DCE7B3 /* UnitSelectionStepView.swift */,
 				BD47FD162D88AAEF0043966B /* OnboardingStepViews.swift */,
@@ -3195,6 +3205,16 @@
 			path = Helper;
 			sourceTree = "<group>";
 		};
+		DD3F1F8E2D9E151200DCE7B3 /* Nightscout */ = {
+			isa = PBXGroup;
+			children = (
+				DD3F1F8F2D9E153A00DCE7B3 /* NightscoutImportStepView.swift */,
+				DD3F1F8C2D9E0E0000DCE7B3 /* NightscoutStepView.swift */,
+				DD3F1F8A2D9E08B200DCE7B3 /* NightscoutLoginStepView.swift */,
+			);
+			path = Nightscout;
+			sourceTree = "<group>";
+		};
 		DD4C57A42D73ADDA001BFF2C /* LiveActivity */ = {
 			isa = PBXGroup;
 			children = (
@@ -3871,6 +3891,7 @@
 				58645BA72CA2D390008AFCE7 /* ChartAxisSetup.swift in Sources */,
 				38D0B3B625EBE24900CB6E88 /* Battery.swift in Sources */,
 				38C4D33725E9A1A300D30B77 /* DispatchQueue+Extensions.swift in Sources */,
+				DD3F1F8D2D9E0E0600DCE7B3 /* NightscoutStepView.swift in Sources */,
 				F90692CF274B999A0037068D /* HealthKitDataFlow.swift in Sources */,
 				CE7CA3552A064973004BE681 /* ListStateIntent.swift in Sources */,
 				BDF530D82B40F8AC002CAF43 /* LockScreenView.swift in Sources */,
@@ -3972,6 +3993,7 @@
 				19D466A729AA2C22004D5F33 /* MealSettingsStateModel.swift in Sources */,
 				DD17452B2C556E8100211FAC /* SettingInputHintView.swift in Sources */,
 				38E44528274E401C00EC9A94 /* Protected.swift in Sources */,
+				DD3F1F8B2D9E08B600DCE7B3 /* NightscoutLoginStepView.swift in Sources */,
 				3811DEB625C9D88300A708ED /* UnlockManager.swift in Sources */,
 				581516A42BCED84A00BF67D7 /* DebuggingIdentifiers.swift in Sources */,
 				E00EEC0827368630002FF094 /* NetworkAssembly.swift in Sources */,
@@ -4073,6 +4095,7 @@
 				3811DE5F25C9D4D500A708ED /* ProgressBar.swift in Sources */,
 				BD249D8C2D42FC2C00412DEB /* GlucoseDistributionChart.swift in Sources */,
 				38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */,
+				DD3F1F902D9E153F00DCE7B3 /* NightscoutImportStepView.swift in Sources */,
 				CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */,
 				DD1745372C55B74200211FAC /* AlgorithmSettings.swift in Sources */,
 				38BF021D25E7E3AF00579895 /* Reservoir.swift in Sources */,
@@ -4319,6 +4342,7 @@
 				DDD163122C4C689900CD525A /* AdjustmentsStateModel.swift in Sources */,
 				BD47FDD72D8B64D20043966B /* CarbRatioStepView.swift in Sources */,
 				3B2F77862D7E52ED005ED9FA /* TDD.swift in Sources */,
+				DD3F1F892D9E078D00DCE7B3 /* TimeValueEditorView.swift in Sources */,
 				DD1745132C54169400211FAC /* DevicesView.swift in Sources */,
 				7F7B756BE8543965D9FDF1A2 /* DataTableDataFlow.swift in Sources */,
 				1D845DF2E3324130E1D95E67 /* DataTableProvider.swift in Sources */,

+ 21 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -39137,6 +39137,9 @@
         }
       }
     },
+    "Before you proceed, please decide if you want to import existing therapy settings from Nightscout (your \"default profile\"), or if you would like to start from scratch." : {
+
+    },
     "Beta (TestFlight) Expires" : {
       "localizations" : {
         "bg" : {
@@ -121865,6 +121868,9 @@
         }
       }
     },
+    "Nightscout use is entirely optional. You can also setup Nightscout at a later time." : {
+
+    },
     "No" : {
       "comment" : "Button",
       "localizations" : {
@@ -134759,6 +134765,9 @@
     "Please choose from the options below." : {
 
     },
+    "Please enter your credentials:" : {
+
+    },
     "Please make sure that your Libre 2 sensor is already activated and finished warming up. If you have other apps connecting to the sensor via bluetooth, these need to be shut down or uninstalled. \n\n You can only have one app communicating with the sensor via bluetooth. Then press the \"pariring and connection\" button below to start the process. Please note that the bluetooth connection might take up to a couple of minutes before it starts working." : {
       "extractionState" : "manual",
       "localizations" : {
@@ -152056,6 +152065,9 @@
         }
       }
     },
+    "Setup Nightscout for Trio" : {
+
+    },
     "Shape" : {
       "extractionState" : "stale",
       "localizations" : {
@@ -155298,6 +155310,9 @@
         }
       }
     },
+    "Skip Nightscout Setup" : {
+
+    },
     "Slope" : {
       "comment" : "v",
       "localizations" : {
@@ -163452,6 +163467,9 @@
         }
       }
     },
+    "Tap \"Import Settings\" to begin, or \"Next\" to skip." : {
+
+    },
     "Tap and hold a bar to reveal more details." : {
 
     },
@@ -183966,6 +183984,9 @@
         }
       }
     },
+    "Trio will import the following therapy settings from Nightscout:" : {
+
+    },
     "Trio will use the larger of the default setting calculation below and the value entered here." : {
       "localizations" : {
         "bg" : {

+ 1 - 0
Trio/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift

@@ -95,6 +95,7 @@ extension NightscoutConfig {
             if let CheckURL = url.last, CheckURL == "/" {
                 let fixedURL = url.dropLast()
                 url = String(fixedURL)
+                url = String(fixedURL)
             }
 
             guard let url = URL(string: url), self.url.hasPrefix("https://") else {

+ 58 - 217
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -4,232 +4,25 @@ import LoopKit
 import Observation
 import SwiftUI
 
-/// Represents the different steps in the onboarding process.
-enum OnboardingStep: Int, CaseIterable, Identifiable {
-    case welcome
-    case unitSelection
-    case glucoseTarget
-    case basalProfile
-    case carbRatio
-    case insulinSensitivity
-    case deliveryLimits
-    case completed
-
-    var id: Int { rawValue }
-
-    var hasSubsteps: Bool {
-        self == .deliveryLimits
-    }
-
-    var substeps: [DeliveryLimitSubstep] {
-        guard hasSubsteps else { return [] }
-        return DeliveryLimitSubstep.allCases
-    }
-
-    /// The title to display for this onboarding step.
-    var title: String {
-        switch self {
-        case .welcome:
-            return "Welcome to Trio"
-        case .unitSelection:
-            return "Units & Pump"
-        case .glucoseTarget:
-            return "Glucose Target"
-        case .basalProfile:
-            return "Basal Profile"
-        case .carbRatio:
-            return "Carbohydrate Ratio"
-        case .insulinSensitivity:
-            return "Insulin Sensitivity"
-        case .deliveryLimits:
-            return "Delivery Limits"
-        case .completed:
-            return "All Set!"
-        }
-    }
-
-    /// A detailed description of what this onboarding step is about.
-    var description: String {
-        switch self {
-        case .welcome:
-            return "Trio is a powerful app that helps you manage your diabetes. Let's get started by setting up a few important parameters that will help Trio work effectively for you."
-        case .unitSelection:
-            return "Before you can begin with configuring your therapy settigns, Trio needs to know which units you use for your glucose and insulin measurements (based on your pump model)."
-        case .glucoseTarget:
-            return "Your glucose target is the blood glucose level you aim to maintain. Trio will use this to calculate insulin doses and provide recommendations."
-        case .basalProfile:
-            return "Your basal profile represents the amount of background insulin you need throughout the day. This helps Trio calculate your insulin needs."
-        case .carbRatio:
-            return "Your carb ratio tells how many grams of carbohydrates one unit of insulin will cover. This is essential for accurate meal bolus calculations."
-        case .insulinSensitivity:
-            return "Your insulin sensitivity factor (ISF) indicates how much one unit of insulin will lower your blood glucose. This helps calculate correction boluses."
-        case .deliveryLimits:
-            return "Trio offers various delivery limits which represent the maximum amount of insulin it can deliver at a time. This helps ensure safe and effective experience."
-        case .completed:
-            return "Great job! You've completed the initial setup of Trio. You can always adjust these settings later in the app."
-        }
-    }
-
-    /// The system icon name associated with this step.
-    var iconName: String {
-        switch self {
-        case .welcome:
-            return "hand.wave.fill"
-        case .unitSelection:
-            return "numbers.rectangle"
-        case .glucoseTarget:
-            return "target"
-        case .basalProfile:
-            return "chart.xyaxis.line"
-        case .carbRatio:
-            return "fork.knife"
-        case .insulinSensitivity:
-            return "drop.fill"
-        case .deliveryLimits:
-            return "slider.horizontal.3"
-        case .completed:
-            return "checkmark.circle.fill"
-        }
-    }
-
-    /// Returns the next step in the onboarding process, or nil if this is the last step.
-    var next: OnboardingStep? {
-        let allCases = OnboardingStep.allCases
-        let currentIndex = allCases.firstIndex(of: self) ?? 0
-        let nextIndex = currentIndex + 1
-        return nextIndex < allCases.count ? allCases[nextIndex] : nil
-    }
-
-    /// Returns the previous step in the onboarding process, or nil if this is the first step.
-    var previous: OnboardingStep? {
-        let allCases = OnboardingStep.allCases
-        let currentIndex = allCases.firstIndex(of: self) ?? 0
-        let previousIndex = currentIndex - 1
-        return previousIndex >= 0 ? allCases[previousIndex] : nil
-    }
-
-    /// The accent color to use for this step.
-    var accentColor: Color {
-        switch self {
-        case .completed,
-             .deliveryLimits,
-             .unitSelection,
-             .welcome:
-            return Color.blue
-        case .glucoseTarget:
-            return Color.green
-        case .basalProfile:
-            return Color.purple
-        case .carbRatio:
-            return Color.orange
-        case .insulinSensitivity:
-            return Color.red
-        }
-    }
-}
-
-enum DeliveryLimitSubstep: Int, CaseIterable, Identifiable {
-    case maxIOB
-    case maxBolus
-    case maxBasal
-    case maxCOB
-
-    var id: Int { rawValue }
-
-    var title: String {
-        switch self {
-        case .maxIOB: return String(localized: "Max IOB", comment: "Max IOB")
-        case .maxBolus: return String(localized: "Max Bolus")
-        case .maxBasal: return String(localized: "Max Basal")
-        case .maxCOB: return String(localized: "Max COB", comment: "Max COB")
-        }
-    }
-
-    var hint: String {
-        switch self {
-        case .maxIOB: return String(localized: "Maximum units of insulin allowed to be active.")
-        case .maxBolus: return String(localized: "Largest bolus of insulin allowed.")
-        case .maxBasal: return String(localized: "Largest basal rate allowed.")
-        case .maxCOB: return String(localized: "Maximum Carbs On Board (COB) allowed.")
-        }
-    }
-
-    var description: any View {
-        switch self {
-        case .maxIOB:
-            return VStack(alignment: .leading, spacing: 8) {
-                Text(
-                    "This is the maximum amount of Insulin On Board (IOB) above profile basal rates from all sources - positive temporary basal rates, manual or meal boluses, and SMBs - that Trio is allowed to accumulate to address an above target glucose."
-                )
-                Text(
-                    "If a calculated amount exceeds this limit, the suggested and / or delivered amount will be reduced so that active insulin on board (IOB) will not exceed this safety limit."
-                )
-                Text(
-                    "Note: You can still manually bolus above this limit, but the suggested bolus amount will never exceed this in the bolus calculator."
-                )
-            }
-        case .maxBolus:
-            return VStack(alignment: .leading, spacing: 8) {
-                Text(
-                    "This is the maximum bolus allowed to be delivered at one time. This limits manual and automatic bolus."
-                )
-                Text("Most set this to their largest meal bolus. Then, adjust if needed.")
-                Text("If you attempt to request a bolus larger than this, the bolus will not be accepted.")
-            }
-        case .maxBasal:
-            return VStack(alignment: .leading, spacing: 8) {
-                Text(
-                    "This is the maximum basal rate allowed to be set or scheduled. This applies to both automatic and manual basal rates."
-                )
-                Text(
-                    "Note to Medtronic Pump Users: You must also manually set the max basal rate on the pump to this value or higher."
-                )
-            }
-        case .maxCOB:
-            return VStack(alignment: .leading, spacing: 8) {
-                Text(
-                    "This setting defines the maximum amount of Carbs On Board (COB) at any given time for Trio to use in dosing calculations. If more carbs are entered than allowed by this limit, Trio will cap the current COB in calculations to Max COB and remain at max until all remaining carbs have shown to be absorbed."
-                )
-                Text(
-                    "For example, if Max COB is 120 g and you enter a meal containing 150 g of carbs, your COB will remain at 120 g until the remaining 30 g have been absorbed."
-                )
-                Text("This is an important limit when UAM is ON.")
-            }
-        }
-    }
-}
-
-enum PumpOptionsForOnboardingUnits: String, Equatable, CaseIterable, Identifiable {
-    case minimed
-    case omnipodEros
-    case omnipodDash
-    case dana
-
-    var id: String { rawValue }
-
-    var displayName: String {
-        switch self {
-        case .minimed:
-            return "Medtronic 5xx / 7xx"
-        case .omnipodEros:
-            return "Omnipod Eros"
-        case .omnipodDash:
-            return "Omnipod Dash"
-        case .dana:
-            return "Dana (RS/-i)"
-        }
-    }
-}
-
 /// Model that holds the data collected during onboarding.
 extension Onboarding {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() var fileStorage: FileStorage!
         @ObservationIgnored @Injected() var deviceManager: DeviceDataManager!
         @ObservationIgnored @Injected() private var broadcaster: Broadcaster!
+        @ObservationIgnored @Injected() private var keychain: Keychain!
+        @ObservationIgnored @Injected() private var nightscoutManager: NightscoutManager!
 
         private let settingsProvider = PickerSettingsProvider.shared
 
+        // Nightscout Setup
+        var nightscoutSetupOption: NightscoutSetupOption = .noSelection
+        var url = ""
+        var secret = ""
+        var message = ""
+        var isValidURL: Bool = false
+        var connecting = false
+
         // Carb Ratio related
         var carbRatioItems: [CarbRatioEditor.Item] = []
         var initialCarbRatioItems: [CarbRatioEditor.Item] = []
@@ -446,6 +239,54 @@ extension Onboarding {
     }
 }
 
+// MARK: - Setup Nightscout Connection
+
+extension Onboarding.StateModel {
+    func connectToNightscout() {
+        if let CheckURL = url.last, CheckURL == "/" {
+            let fixedURL = url.dropLast()
+            url = String(fixedURL)
+        }
+
+        guard let url = URL(string: url), self.url.hasPrefix("https://") else {
+            message = "Invalid URL"
+            isValidURL = false
+            return
+        }
+
+        connecting = true
+        isValidURL = true
+        message = ""
+
+        NightscoutAPI(url: url, secret: secret).checkConnection()
+            .receive(on: DispatchQueue.main)
+            .sink { completion in
+                switch completion {
+                case .finished: break
+                case let .failure(error):
+                    self.message = "Error: \(error.localizedDescription)"
+                }
+                DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+                    self.connecting = false
+                }
+            } receiveValue: {
+                self.keychain.setValue(self.url, forKey: NightscoutConfig.Config.urlKey)
+                self.keychain.setValue(self.secret, forKey: NightscoutConfig.Config.secretKey)
+            }
+            .store(in: &lifetime)
+    }
+
+    private var nightscoutAPI: NightscoutAPI? {
+        guard let urlString = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey),
+              let url = URL(string: urlString),
+              let secret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)
+        else {
+            return nil
+        }
+        return NightscoutAPI(url: url, secret: secret)
+    }
+}
+
 // MARK: - Setup Carb Ratios
 
 extension Onboarding.StateModel {

+ 45 - 0
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/Nightscout/NightscoutImportStepView.swift

@@ -0,0 +1,45 @@
+import SwiftUI
+
+struct NightscoutImportStepView: View {
+    @Bindable var state: Onboarding.StateModel
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            Text(
+                "Before you proceed, please decide if you want to import existing therapy settings from Nightscout (your \"default profile\"), or if you would like to start from scratch."
+            )
+
+            Text("Tap \"Import Settings\" to begin, or \"Next\" to skip.")
+                .foregroundStyle(Color.secondary)
+
+            Button(action: {
+                let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                impactHeavy.impactOccurred()
+
+                // TODO: handle import
+            }) {
+                HStack {
+                    Text("Import Settings").bold()
+                }
+                .frame(maxWidth: .infinity, alignment: .center)
+                .padding(.vertical, 8)
+            }
+            .disabled(state.url.isEmpty || state.secret.isEmpty)
+            .buttonStyle(.borderedProminent)
+
+            VStack(alignment: .leading, spacing: 10) {
+                Text(
+                    "Trio will import the following therapy settings from Nightscout:"
+                )
+                VStack(alignment: .leading) {
+                    Text("• Basal Rates")
+                    Text("• Insulin Sensitivities")
+                    Text("• Carb Ratios")
+                    Text("• Glucose Targets")
+                }
+            }
+            .font(.footnote)
+            .foregroundStyle(Color.secondary)
+        }
+    }
+}

+ 65 - 0
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/Nightscout/NightscoutLoginStepView.swift

@@ -0,0 +1,65 @@
+import SwiftUI
+
+struct NightscoutLoginStepView: View {
+    @Bindable var state: Onboarding.StateModel
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 20) {
+            Text("Please enter your credentials:")
+                .font(.headline)
+                .padding(.horizontal)
+
+            HStack {
+                TextField("URL", text: $state.url)
+                    .disableAutocorrection(true)
+                    .textContentType(.URL)
+                    .autocapitalization(.none)
+                    .keyboardType(.URL)
+                if state.message.isNotEmpty && !state.isValidURL {
+                    Image(systemName: "exclamationmark.triangle.fill")
+                        .foregroundStyle(.orange)
+                }
+            }.padding()
+                .background(Color.chart.opacity(0.45))
+                .cornerRadius(10)
+
+            HStack {
+                SecureField("API secret", text: $state.secret)
+                    .disableAutocorrection(true)
+                    .autocapitalization(.none)
+                    .textContentType(.password)
+                    .keyboardType(.asciiCapable)
+            }.padding()
+                .background(Color.chart.opacity(0.45))
+                .cornerRadius(10)
+
+            Spacer(minLength: 10)
+
+            if state.message.isNotEmpty {
+                VStack(alignment: .center) {
+                    Text(state.message)
+                        .font(.subheadline)
+                        .foregroundStyle(Color.orange)
+                }
+            }
+
+            Button(action: {
+                let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                impactHeavy.impactOccurred()
+                state.connectToNightscout()
+            }) {
+                HStack {
+                    if state.connecting {
+                        ProgressView().padding(.trailing, 10)
+                    }
+                    Text(state.connecting ? "Connecting..." : "Connect to Nightscout")
+                        .bold()
+                }
+                .frame(maxWidth: .infinity, alignment: .center)
+                .padding(.vertical, 8)
+            }
+            .disabled(state.url.isEmpty || state.secret.isEmpty)
+            .buttonStyle(.borderedProminent)
+        }
+    }
+}

+ 34 - 0
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/Nightscout/NightscoutStepView.swift

@@ -0,0 +1,34 @@
+import SwiftUI
+
+struct NightscoutStepView: View {
+    @Bindable var state: Onboarding.StateModel
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 20) {
+            Text("Nightscout use is entirely optional. You can also setup Nightscout at a later time.")
+                .font(.headline)
+                .padding(.horizontal)
+
+            ForEach([NightscoutSetupOption.setupNightscout, NightscoutSetupOption.skipNightscoutSetup], id: \.self) { option in
+                Button(action: {
+                    state.nightscoutSetupOption = option
+                }) {
+                    HStack {
+                        Image(systemName: state.nightscoutSetupOption == option ? "largecircle.fill.circle" : "circle")
+                            .foregroundColor(state.nightscoutSetupOption == option ? .accentColor : .secondary)
+                            .imageScale(.large)
+
+                        Text(option.displayName)
+                            .foregroundColor(.primary)
+
+                        Spacer()
+                    }
+                    .padding()
+                    .background(Color.chart.opacity(0.45))
+                    .cornerRadius(10)
+                }
+                .buttonStyle(.plain)
+            }
+        }
+    }
+}

+ 234 - 149
Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift

@@ -1,168 +1,253 @@
 import SwiftUI
 
-struct TherapySettingItem: Identifiable, Equatable {
-    var id = UUID()
-    var time: TimeInterval // seconds since start of day
-    var value: Double
-}
+/// Represents the different steps in the onboarding process.
+enum OnboardingStep: Int, CaseIterable, Identifiable {
+    case welcome
+    case nightscout
+    case unitSelection
+    case glucoseTarget
+    case basalProfile
+    case carbRatio
+    case insulinSensitivity
+    case deliveryLimits
+    case completed
+
+    var id: Int { rawValue }
+
+    var hasSubsteps: Bool {
+        self == .deliveryLimits
+    }
 
-struct TimeValuePickerRow: View {
-    @Binding var item: TherapySettingItem
-    var valueOptions: [Decimal]
-    var unit: String
-
-    var body: some View {
-        VStack(spacing: 8) {
-            HStack {
-                Picker("Time", selection: Binding(
-                    get: { item.time },
-                    set: { item.time = $0 }
-                )) {
-                    ForEach(0 ..< 48) { i in
-                        let seconds = Double(i * 30 * 60)
-                        Text(timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: seconds)))
-                            .tag(seconds)
-                    }
-                }
-                .frame(maxWidth: .infinity)
-                .clipped()
-
-                Picker("Value", selection: Binding(
-                    get: { item.value },
-                    set: { item.value = $0 }
-                )) {
-                    ForEach(valueOptions, id: \.self) { value in
-                        Text("\(Double(value), specifier: "%.1f") \(unit)").tag(Double(value))
-                    }
-                }
-                .frame(maxWidth: .infinity)
-                .clipped()
-            }
-            .pickerStyle(.wheel)
+    var substeps: [DeliveryLimitSubstep] {
+        guard hasSubsteps else { return [] }
+        return DeliveryLimitSubstep.allCases
+    }
+
+    /// The title to display for this onboarding step.
+    var title: String {
+        switch self {
+        case .welcome:
+            return "Welcome to Trio"
+        case .nightscout:
+            return "Nightscout"
+        case .unitSelection:
+            return "Units & Pump"
+        case .glucoseTarget:
+            return "Glucose Target"
+        case .basalProfile:
+            return "Basal Profile"
+        case .carbRatio:
+            return "Carbohydrate Ratio"
+        case .insulinSensitivity:
+            return "Insulin Sensitivity"
+        case .deliveryLimits:
+            return "Delivery Limits"
+        case .completed:
+            return "All Set!"
+        }
+    }
+
+    /// A detailed description of what this onboarding step is about.
+    var description: String {
+        switch self {
+        case .welcome:
+            return "Trio is a powerful app that helps you manage your diabetes. Let's get started by setting up a few important parameters that will help Trio work effectively for you."
+        case .nightscout:
+            return "Nightscout is a cloud-based platform that allows you to store your diabetes data. It's often used by caregivers to remotely monitor what Trio is doing."
+        case .unitSelection:
+            return "Before you can begin with configuring your therapy settigns, Trio needs to know which units you use for your glucose and insulin measurements (based on your pump model)."
+        case .glucoseTarget:
+            return "Your glucose target is the blood glucose level you aim to maintain. Trio will use this to calculate insulin doses and provide recommendations."
+        case .basalProfile:
+            return "Your basal profile represents the amount of background insulin you need throughout the day. This helps Trio calculate your insulin needs."
+        case .carbRatio:
+            return "Your carb ratio tells how many grams of carbohydrates one unit of insulin will cover. This is essential for accurate meal bolus calculations."
+        case .insulinSensitivity:
+            return "Your insulin sensitivity factor (ISF) indicates how much one unit of insulin will lower your blood glucose. This helps calculate correction boluses."
+        case .deliveryLimits:
+            return "Trio offers various delivery limits which represent the maximum amount of insulin it can deliver at a time. This helps ensure safe and effective experience."
+        case .completed:
+            return "Great job! You've completed the initial setup of Trio. You can always adjust these settings later in the app."
+        }
+    }
+
+    /// The system icon name associated with this step.
+    var iconName: String {
+        switch self {
+        case .welcome:
+            return "hand.wave.fill"
+        case .nightscout:
+            return "owl"
+        case .unitSelection:
+            return "numbers.rectangle"
+        case .glucoseTarget:
+            return "target"
+        case .basalProfile:
+            return "chart.xyaxis.line"
+        case .carbRatio:
+            return "fork.knife"
+        case .insulinSensitivity:
+            return "drop.fill"
+        case .deliveryLimits:
+            return "slider.horizontal.3"
+        case .completed:
+            return "checkmark.circle.fill"
         }
-        .padding(.vertical, 8)
     }
 
-    private var timeFormatter: DateFormatter {
-        let formatter = DateFormatter()
-        formatter.dateFormat = "HH:mm"
-        formatter.timeZone = TimeZone.current
-        return formatter
+    /// Returns the next step in the onboarding process, or nil if this is the last step.
+    var next: OnboardingStep? {
+        let allCases = OnboardingStep.allCases
+        let currentIndex = allCases.firstIndex(of: self) ?? 0
+        let nextIndex = currentIndex + 1
+        return nextIndex < allCases.count ? allCases[nextIndex] : nil
+    }
+
+    /// Returns the previous step in the onboarding process, or nil if this is the first step.
+    var previous: OnboardingStep? {
+        let allCases = OnboardingStep.allCases
+        let currentIndex = allCases.firstIndex(of: self) ?? 0
+        let previousIndex = currentIndex - 1
+        return previousIndex >= 0 ? allCases[previousIndex] : nil
+    }
+
+    /// The accent color to use for this step.
+    var accentColor: Color {
+        switch self {
+        case .completed,
+             .deliveryLimits,
+             .nightscout,
+             .unitSelection,
+             .welcome:
+            return Color.blue
+        case .glucoseTarget:
+            return Color.green
+        case .basalProfile:
+            return Color.purple
+        case .carbRatio:
+            return Color.orange
+        case .insulinSensitivity:
+            return Color.red
+        }
     }
 }
 
-struct TimeValueEditorView: View {
-    @Binding var items: [TherapySettingItem]
-    var unit: String
-    var valueOptions: [Decimal]
-
-    @State private var selectedItemID: UUID?
-
-    var body: some View {
-        List {
-            HStack {
-                Text("Entries").bold()
-                Spacer()
-                Button {
-                    let lastTime = items.last?.time ?? 0
-                    let newTime = min(lastTime + 1800, 23 * 3600 + 1800)
-                    let newValue = items.last?.value ?? 1.0
-                    items.append(TherapySettingItem(time: newTime, value: newValue))
-                } label: {
-                    HStack {
-                        Image(systemName: "plus.circle.fill")
-                        Text("Add")
-                    }.foregroundColor(.accentColor)
-                }
-                .disabled(items.count >= 48)
+enum DeliveryLimitSubstep: Int, CaseIterable, Identifiable {
+    case maxIOB
+    case maxBolus
+    case maxBasal
+    case maxCOB
+
+    var id: Int { rawValue }
+
+    var title: String {
+        switch self {
+        case .maxIOB: return String(localized: "Max IOB", comment: "Max IOB")
+        case .maxBolus: return String(localized: "Max Bolus")
+        case .maxBasal: return String(localized: "Max Basal")
+        case .maxCOB: return String(localized: "Max COB", comment: "Max COB")
+        }
+    }
+
+    var hint: String {
+        switch self {
+        case .maxIOB: return String(localized: "Maximum units of insulin allowed to be active.")
+        case .maxBolus: return String(localized: "Largest bolus of insulin allowed.")
+        case .maxBasal: return String(localized: "Largest basal rate allowed.")
+        case .maxCOB: return String(localized: "Maximum Carbs On Board (COB) allowed.")
+        }
+    }
+
+    var description: any View {
+        switch self {
+        case .maxIOB:
+            return VStack(alignment: .leading, spacing: 8) {
+                Text(
+                    "This is the maximum amount of Insulin On Board (IOB) above profile basal rates from all sources - positive temporary basal rates, manual or meal boluses, and SMBs - that Trio is allowed to accumulate to address an above target glucose."
+                )
+                Text(
+                    "If a calculated amount exceeds this limit, the suggested and / or delivered amount will be reduced so that active insulin on board (IOB) will not exceed this safety limit."
+                )
+                Text(
+                    "Note: You can still manually bolus above this limit, but the suggested bolus amount will never exceed this in the bolus calculator."
+                )
             }
-            .listRowBackground(Color.chart.opacity(0.45))
-            .padding(.vertical, 5)
-
-            ForEach($items) { $item in
-                VStack(spacing: 0) {
-                    Button {
-                        selectedItemID = selectedItemID == item.id ? nil : item.id
-                    } label: {
-                        HStack {
-                            HStack {
-                                Text("\(item.value, specifier: "%.1f")")
-                                    .foregroundStyle(selectedItemID == item.id ? Color.accentColor : Color.primary)
-                                Text(unit.description)
-                                    .foregroundStyle(Color.secondary)
-                            }
-
-                            Spacer()
-
-                            HStack {
-                                Text("starts at").foregroundStyle(Color.secondary)
-
-                                let startDate = Date(timeIntervalSinceReferenceDate: item.time)
-                                Text(timeFormatter.string(from: startDate))
-                                    .foregroundStyle(selectedItemID == item.id ? Color.accentColor : Color.primary)
-                            }
-                        }.contentShape(Rectangle())
-                    }
-                    .buttonStyle(.plain)
-
-                    if selectedItemID == item.id {
-                        TimeValuePickerRow(
-                            item: $item,
-                            valueOptions: valueOptions,
-                            unit: unit
-                        )
-                        .transition(.slide)
-                    }
-                }
-                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
-                    if let index = items.firstIndex(where: { $0.id == item.id }), items.count > 1 {
-                        Button(role: .destructive) {
-                            items.remove(at: index)
-                            selectedItemID = nil
-                        } label: {
-                            Label("Delete", systemImage: "trash")
-                        }
-                    }
-                }
+        case .maxBolus:
+            return VStack(alignment: .leading, spacing: 8) {
+                Text(
+                    "This is the maximum bolus allowed to be delivered at one time. This limits manual and automatic bolus."
+                )
+                Text("Most set this to their largest meal bolus. Then, adjust if needed.")
+                Text("If you attempt to request a bolus larger than this, the bolus will not be accepted.")
+            }
+        case .maxBasal:
+            return VStack(alignment: .leading, spacing: 8) {
+                Text(
+                    "This is the maximum basal rate allowed to be set or scheduled. This applies to both automatic and manual basal rates."
+                )
+                Text(
+                    "Note to Medtronic Pump Users: You must also manually set the max basal rate on the pump to this value or higher."
+                )
             }
-            .listRowBackground(Color.chart.opacity(0.45))
-
-            Rectangle().fill(Color.chart.opacity(0.45)).frame(height: 10)
-                .clipShape(
-                    .rect(
-                        topLeadingRadius: 0,
-                        bottomLeadingRadius: 10,
-                        bottomTrailingRadius: 10,
-                        topTrailingRadius: 0
-                    )
+        case .maxCOB:
+            return VStack(alignment: .leading, spacing: 8) {
+                Text(
+                    "This setting defines the maximum amount of Carbs On Board (COB) at any given time for Trio to use in dosing calculations. If more carbs are entered than allowed by this limit, Trio will cap the current COB in calculations to Max COB and remain at max until all remaining carbs have shown to be absorbed."
+                )
+                Text(
+                    "For example, if Max COB is 120 g and you enter a meal containing 150 g of carbs, your COB will remain at 120 g until the remaining 30 g have been absorbed."
                 )
-                .listRowBackground(Color.clear)
-                .listRowInsets(EdgeInsets(top: -22, leading: 0, bottom: 0, trailing: 0))
-                .listRowSeparator(.hidden)
+                Text("This is an important limit when UAM is ON.")
+            }
+        }
+    }
+}
+
+enum PumpOptionsForOnboardingUnits: String, Equatable, CaseIterable, Identifiable {
+    case minimed
+    case omnipodEros
+    case omnipodDash
+    case dana
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .minimed:
+            return "Medtronic 5xx / 7xx"
+        case .omnipodEros:
+            return "Omnipod Eros"
+        case .omnipodDash:
+            return "Omnipod Dash"
+        case .dana:
+            return "Dana (RS/-i)"
         }
-        .listStyle(.plain)
-        .scrollDisabled(true)
-        .scrollContentBackground(.hidden)
-        .frame(height: 55 + CGFloat(items.count) * 45 + (items.contains(where: { $0.id == selectedItemID }) ? 230 : 0))
-        // 55 for header row, item counts x 45 for every entry row + 230 for a visible picker row
     }
+}
 
-    private var timeFormatter: DateFormatter {
-        let formatter = DateFormatter()
-        formatter.dateFormat = "HH:mm"
-        return formatter
+enum NightscoutSetupOption: String, Equatable, CaseIterable, Identifiable {
+    case setupNightscout
+    case skipNightscoutSetup
+    case noSelection
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .setupNightscout:
+            return String(localized: "Setup Nightscout for Trio")
+        case .skipNightscoutSetup:
+            return String(localized: "Skip Nightscout Setup")
+        case .noSelection:
+            return ""
+        }
     }
 }
 
-#Preview {
-    @Previewable @State var previewItems = [
-        TherapySettingItem(time: 0, value: 1.0),
-        TherapySettingItem(time: 1800, value: 1.2)
-    ]
-
-    TimeValueEditorView(
-        items: $previewItems,
-        unit: "U/h",
-        valueOptions: stride(from: 0.0, through: 10.0, by: 0.05).map { Decimal(round(100 * $0) / 100) }
-    )
+enum NightscoutSubstep: Int, CaseIterable, Identifiable {
+    case setupSelection
+    case connectToNightscout
+    case importFromNightscout
+
+    var id: Int { rawValue }
 }

+ 137 - 21
Trio/Sources/Modules/Onboarding/View/OnboardingView.swift

@@ -9,12 +9,24 @@ extension Onboarding {
         let onboardingManager: OnboardingManager
         @State private var currentStep: OnboardingStep = .welcome
         @State private var currentDeliverySubstep: DeliveryLimitSubstep = .maxIOB
+        @State private var currentNightscoutSubstep: NightscoutSubstep = .setupSelection
 
         // Animation states
         @State private var animationScale: CGFloat = 1.0
         @State private var animationOpacity: Double = 0
         @State private var isAnimating = false
 
+        private var shouldDisableNextButton: Bool {
+            (
+                currentStep == .nightscout && currentNightscoutSubstep == .setupSelection && state
+                    .nightscoutSetupOption == .noSelection
+            ) ||
+                (
+                    currentStep == .nightscout && currentNightscoutSubstep == .connectToNightscout && state.url.isEmpty && !state
+                        .isValidURL && state.secret.isEmpty
+                )
+        }
+
         var body: some View {
             NavigationView {
                 ZStack {
@@ -30,8 +42,18 @@ extension Onboarding {
                         // Progress bar
                         OnboardingProgressBar(
                             currentStep: currentStep,
-                            currentSubstep: currentStep == .deliveryLimits ? currentDeliverySubstep.rawValue : nil,
-                            stepsWithSubsteps: [.deliveryLimits: DeliveryLimitSubstep.allCases.count]
+                            currentSubstep: {
+                                switch currentStep {
+                                case .deliveryLimits: return currentDeliverySubstep.rawValue
+                                case .nightscout: return currentNightscoutSubstep.rawValue
+                                default: return nil
+                                }
+                            }(),
+                            stepsWithSubsteps: [
+                                .nightscout: NightscoutSubstep.allCases.count,
+                                .deliveryLimits: DeliveryLimitSubstep.allCases.count
+                            ],
+                            nightscoutSetupOption: state.nightscoutSetupOption
                         )
 
                         .padding(.top)
@@ -42,14 +64,22 @@ extension Onboarding {
                                 // Header
                                 if currentStep != .welcome && currentStep != .completed {
                                     HStack {
-                                        Image(systemName: currentStep.iconName)
-                                            .font(.system(size: 40))
-                                            .foregroundColor(currentStep.accentColor)
-                                            .frame(width: 60, height: 60)
-                                            .background(
-                                                Circle()
-                                                    .fill(currentStep.accentColor.opacity(0.2))
-                                            )
+                                        if currentStep == .nightscout {
+                                            Image(currentStep.iconName)
+                                                .resizable()
+                                                .scaledToFit()
+                                                .frame(width: 60, height: 60)
+
+                                        } else {
+                                            Image(systemName: currentStep.iconName)
+                                                .font(.system(size: 40))
+                                                .foregroundColor(currentStep.accentColor)
+                                                .frame(width: 60, height: 60)
+                                                .background(
+                                                    Circle()
+                                                        .fill(currentStep.accentColor.opacity(0.2))
+                                                )
+                                        }
 
                                         VStack(alignment: .leading) {
                                             Text(currentStep.title)
@@ -85,6 +115,15 @@ extension Onboarding {
                                     switch currentStep {
                                     case .welcome:
                                         WelcomeStepView()
+                                    case .nightscout:
+                                        switch currentNightscoutSubstep {
+                                        case .setupSelection:
+                                            NightscoutStepView(state: state)
+                                        case .connectToNightscout:
+                                            NightscoutLoginStepView(state: state)
+                                        case .importFromNightscout:
+                                            NightscoutImportStepView(state: state)
+                                        }
                                     case .unitSelection:
                                         UnitSelectionStepView(state: state)
                                     case .glucoseTarget:
@@ -119,7 +158,33 @@ extension Onboarding {
                                         if currentStep == .completed {
                                             currentStep = .deliveryLimits
                                             currentDeliverySubstep = .maxCOB // ensure we land on the last substep visually
-                                        } else if currentStep == .deliveryLimits {
+                                        } else if currentStep == .nightscout {
+                                            if currentNightscoutSubstep == .setupSelection {
+                                                // First substep: go to previous main step
+                                                if let previousMainStep = currentStep.previous {
+                                                    currentStep = previousMainStep
+                                                    currentNightscoutSubstep = .setupSelection // reset substep
+                                                }
+                                            } else {
+                                                // Go back one substep
+                                                currentNightscoutSubstep = NightscoutSubstep(
+                                                    rawValue: currentNightscoutSubstep
+                                                        .rawValue - 1
+                                                )!
+                                            }
+                                        }
+
+//                                        else if currentStep == .nightscout {
+//                                            if currentNightscoutSubstep != .setupSelection,
+//                                               state.nightscoutSetupOption == .skipNightscoutSetup
+//                                            {
+//                                                currentNightscoutSubstep = .setupSelection
+//                                            } else {
+//                                                currentNightscoutSubstep =
+//                                                    NightscoutSubstep(rawValue: currentNightscoutSubstep.rawValue - 1)!
+//                                            }
+//                                        }
+                                        else if currentStep == .deliveryLimits {
                                             if let previousSub = DeliveryLimitSubstep(
                                                 rawValue: currentDeliverySubstep
                                                     .rawValue - 1
@@ -152,6 +217,23 @@ extension Onboarding {
                                         state.saveOnboardingData()
                                         onboardingManager.completeOnboarding()
                                         Foundation.NotificationCenter.default.post(name: .onboardingCompleted, object: nil)
+                                    } else if currentStep == .nightscout {
+                                        if currentNightscoutSubstep != .importFromNightscout {
+                                            // Handle conditional skip
+                                            if currentNightscoutSubstep == .setupSelection,
+                                               state.nightscoutSetupOption == .skipNightscoutSetup,
+                                               let next = currentStep.next
+                                            {
+                                                currentStep = next
+                                            } else {
+                                                currentNightscoutSubstep = NightscoutSubstep(
+                                                    rawValue: currentNightscoutSubstep
+                                                        .rawValue + 1
+                                                )!
+                                            }
+                                        } else if let next = currentStep.next {
+                                            currentStep = next
+                                        }
                                     } else if currentStep == .deliveryLimits {
                                         if let nextSub = DeliveryLimitSubstep(rawValue: currentDeliverySubstep.rawValue + 1) {
                                             currentDeliverySubstep = nextSub
@@ -170,8 +252,8 @@ extension Onboarding {
                                 }
                                 .padding()
                                 .foregroundColor(.white)
-                                .background(Capsule().fill(Color.blue))
-                            }
+                                .background(Capsule().fill(!shouldDisableNextButton ? Color.blue : Color(.systemGray)))
+                            }.disabled(shouldDisableNextButton)
                         }
                         .padding(.horizontal)
                         .padding(.bottom)
@@ -204,6 +286,7 @@ struct OnboardingProgressBar: View {
     let currentStep: OnboardingStep
     let currentSubstep: Int?
     let stepsWithSubsteps: [OnboardingStep: Int] // e.g. [.deliveryLimits: 4]
+    let nightscoutSetupOption: NightscoutSetupOption
 
     var body: some View {
         HStack(spacing: 4) {
@@ -259,20 +342,53 @@ struct OnboardingProgressBar: View {
         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 &&
+//                stepsWithSubsteps[step] != nil
+//            {
+//                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
+//    }
     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
+        // Case 1: We're on the completed screen → show everything as done
+        if currentStep == .completed && stepsWithSubsteps[step] != nil {
+            return true
+        }
+
+        // Case 2: Nightscout was skipped, and we're past it → mark all its substeps active
+        if step == .nightscout,
+           nightscoutSetupOption == .skipNightscoutSetup,
+           let currentIndex = visibleSteps.firstIndex(of: currentStep),
+           let stepIndex = visibleSteps.firstIndex(of: .nightscout),
+           currentIndex > stepIndex
+        {
+            return true
         }
 
+        // Case 3: We're currently on the same step → check substep index
         if step == currentStep {
-            return index <= current
+            return index <= (currentSubstep ?? 0)
         }
 
-        // If step comes before currentStep, mark all substeps filled
+        // Case 4: We're past this step → all substeps active
         if let currentIndex = visibleSteps.firstIndex(of: currentStep),
            let stepIndex = visibleSteps.firstIndex(of: step),
            stepIndex < currentIndex

+ 168 - 0
Trio/Sources/Modules/Onboarding/View/TimeValueEditorView.swift

@@ -0,0 +1,168 @@
+import SwiftUI
+
+struct TimeValueEditorView: View {
+    @Binding var items: [TherapySettingItem]
+    var unit: String
+    var valueOptions: [Decimal]
+
+    @State private var selectedItemID: UUID?
+
+    var body: some View {
+        List {
+            HStack {
+                Text("Entries").bold()
+                Spacer()
+                Button {
+                    let lastTime = items.last?.time ?? 0
+                    let newTime = min(lastTime + 1800, 23 * 3600 + 1800)
+                    let newValue = items.last?.value ?? 1.0
+                    items.append(TherapySettingItem(time: newTime, value: newValue))
+                } label: {
+                    HStack {
+                        Image(systemName: "plus.circle.fill")
+                        Text("Add")
+                    }.foregroundColor(.accentColor)
+                }
+                .disabled(items.count >= 48)
+            }
+            .listRowBackground(Color.chart.opacity(0.45))
+            .padding(.vertical, 5)
+
+            ForEach($items) { $item in
+                VStack(spacing: 0) {
+                    Button {
+                        selectedItemID = selectedItemID == item.id ? nil : item.id
+                    } label: {
+                        HStack {
+                            HStack {
+                                Text("\(item.value, specifier: "%.1f")")
+                                    .foregroundStyle(selectedItemID == item.id ? Color.accentColor : Color.primary)
+                                Text(unit.description)
+                                    .foregroundStyle(Color.secondary)
+                            }
+
+                            Spacer()
+
+                            HStack {
+                                Text("starts at").foregroundStyle(Color.secondary)
+
+                                let startDate = Date(timeIntervalSinceReferenceDate: item.time)
+                                Text(timeFormatter.string(from: startDate))
+                                    .foregroundStyle(selectedItemID == item.id ? Color.accentColor : Color.primary)
+                            }
+                        }.contentShape(Rectangle())
+                    }
+                    .buttonStyle(.plain)
+
+                    if selectedItemID == item.id {
+                        TimeValuePickerRow(
+                            item: $item,
+                            valueOptions: valueOptions,
+                            unit: unit
+                        )
+                        .transition(.slide)
+                    }
+                }
+                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+                    if let index = items.firstIndex(where: { $0.id == item.id }), items.count > 1 {
+                        Button(role: .destructive) {
+                            items.remove(at: index)
+                            selectedItemID = nil
+                        } label: {
+                            Label("Delete", systemImage: "trash")
+                        }
+                    }
+                }
+            }
+            .listRowBackground(Color.chart.opacity(0.45))
+
+            Rectangle().fill(Color.chart.opacity(0.45)).frame(height: 10)
+                .clipShape(
+                    .rect(
+                        topLeadingRadius: 0,
+                        bottomLeadingRadius: 10,
+                        bottomTrailingRadius: 10,
+                        topTrailingRadius: 0
+                    )
+                )
+                .listRowBackground(Color.clear)
+                .listRowInsets(EdgeInsets(top: -22, leading: 0, bottom: 0, trailing: 0))
+                .listRowSeparator(.hidden)
+        }
+        .listStyle(.plain)
+        .scrollDisabled(true)
+        .scrollContentBackground(.hidden)
+        .frame(height: 55 + CGFloat(items.count) * 45 + (items.contains(where: { $0.id == selectedItemID }) ? 230 : 0))
+        // 55 for header row, item counts x 45 for every entry row + 230 for a visible picker row
+    }
+
+    private var timeFormatter: DateFormatter {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "HH:mm"
+        return formatter
+    }
+}
+
+struct TherapySettingItem: Identifiable, Equatable {
+    var id = UUID()
+    var time: TimeInterval // seconds since start of day
+    var value: Double
+}
+
+struct TimeValuePickerRow: View {
+    @Binding var item: TherapySettingItem
+    var valueOptions: [Decimal]
+    var unit: String
+
+    var body: some View {
+        VStack(spacing: 8) {
+            HStack {
+                Picker("Time", selection: Binding(
+                    get: { item.time },
+                    set: { item.time = $0 }
+                )) {
+                    ForEach(0 ..< 48) { i in
+                        let seconds = Double(i * 30 * 60)
+                        Text(timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: seconds)))
+                            .tag(seconds)
+                    }
+                }
+                .frame(maxWidth: .infinity)
+                .clipped()
+
+                Picker("Value", selection: Binding(
+                    get: { item.value },
+                    set: { item.value = $0 }
+                )) {
+                    ForEach(valueOptions, id: \.self) { value in
+                        Text("\(Double(value), specifier: "%.1f") \(unit)").tag(Double(value))
+                    }
+                }
+                .frame(maxWidth: .infinity)
+                .clipped()
+            }
+            .pickerStyle(.wheel)
+        }
+        .padding(.vertical, 8)
+    }
+
+    private var timeFormatter: DateFormatter {
+        let formatter = DateFormatter()
+        formatter.dateFormat = "HH:mm"
+        formatter.timeZone = TimeZone.current
+        return formatter
+    }
+}
+
+#Preview {
+    @Previewable @State var previewItems = [
+        TherapySettingItem(time: 0, value: 1.0),
+        TherapySettingItem(time: 1800, value: 1.2)
+    ]
+
+    TimeValueEditorView(
+        items: $previewItems,
+        unit: "U/h",
+        valueOptions: stride(from: 0.0, through: 10.0, by: 0.05).map { Decimal(round(100 * $0) / 100) }
+    )
+}