polscm32 1 rok temu
rodzic
commit
ea5a255602

+ 375 - 141
Trio/Sources/Modules/Main/View/OnboardingSteps/GlucoseTargetStepView.swift

@@ -4,12 +4,15 @@
 //
 //  Created by Marvin Polscheit on 19.03.25.
 //
+import Charts
 import SwiftUI
 
 /// Glucose target step view for setting target glucose range.
 struct GlucoseTargetStepView: View {
     @State var onboardingData: OnboardingData
     @State private var showUnitPicker = false
+    @State private var showTimeSelector = false
+    @State private var refreshUI = UUID() // to update chart when slider value changes
 
     // Formatter for glucose values
     private var numberFormatter: NumberFormatter {
@@ -19,177 +22,408 @@ struct GlucoseTargetStepView: View {
         return formatter
     }
 
+    private var dateFormatter: DateFormatter {
+        let formatter = DateFormatter()
+        formatter.timeZone = TimeZone(secondsFromGMT: 0)
+        formatter.timeStyle = .short
+        return formatter
+    }
+
+    // For chart scaling
+    private let chartScale = Calendar.current
+        .date(from: DateComponents(year: 2001, month: 01, day: 01, hour: 0, minute: 0, second: 0))
+
     var body: some View {
-        VStack(alignment: .leading, spacing: 20) {
-            // Unit selector
-            HStack {
-                Text("Blood Glucose Units")
-                    .font(.headline)
+        ScrollView {
+            VStack(alignment: .leading, spacing: 20) {
+                // Unit selector
+                HStack {
+                    Text("Blood Glucose Units")
+                        .font(.headline)
 
-                Spacer()
+                    Spacer()
 
-                Button(action: {
-                    showUnitPicker.toggle()
-                }) {
-                    HStack {
-                        Text(onboardingData.units == .mgdL ? "mg/dL" : "mmol/L")
-                        Image(systemName: "chevron.down")
+                    Button(action: {
+                        showUnitPicker.toggle()
+                    }) {
+                        HStack {
+                            Text(onboardingData.units == .mgdL ? "mg/dL" : "mmol/L")
+                            Image(systemName: "chevron.down")
+                        }
+                        .padding(.horizontal, 12)
+                        .padding(.vertical, 8)
+                        .background(Color.blue.opacity(0.1))
+                        .cornerRadius(8)
+                    }
+                    .actionSheet(isPresented: $showUnitPicker) {
+                        ActionSheet(
+                            title: Text("Select Blood Glucose Units"),
+                            buttons: [
+                                .default(Text("mg/dL")) {
+                                    onboardingData.units = .mgdL
+                                    // Adjust values for unit change
+                                    if onboardingData.units == .mgdL {
+                                        onboardingData.targetLow = max(70, onboardingData.targetLow * 18)
+                                        onboardingData.targetHigh = max(120, onboardingData.targetHigh * 18)
+                                        onboardingData.isf = max(30, onboardingData.isf * 18)
+                                    }
+                                },
+                                .default(Text("mmol/L")) {
+                                    onboardingData.units = .mmolL
+                                    // Adjust values for unit change
+                                    if onboardingData.units == .mmolL {
+                                        onboardingData.targetLow = max(3.9, onboardingData.targetLow / 18)
+                                        onboardingData.targetHigh = max(6.7, onboardingData.targetHigh / 18)
+                                        onboardingData.isf = max(1.7, onboardingData.isf / 18)
+                                    }
+                                },
+                                .cancel()
+                            ]
+                        )
                     }
-                    .padding(.horizontal, 12)
-                    .padding(.vertical, 8)
-                    .background(Color.blue.opacity(0.1))
-                    .cornerRadius(8)
-                }
-                .actionSheet(isPresented: $showUnitPicker) {
-                    ActionSheet(
-                        title: Text("Select Blood Glucose Units"),
-                        buttons: [
-                            .default(Text("mg/dL")) {
-                                onboardingData.units = .mgdL
-                                // Adjust values for unit change
-                                if onboardingData.units == .mgdL {
-                                    onboardingData.targetLow = max(70, onboardingData.targetLow * 18)
-                                    onboardingData.targetHigh = max(120, onboardingData.targetHigh * 18)
-                                    onboardingData.isf = max(30, onboardingData.isf * 18)
-                                }
-                            },
-                            .default(Text("mmol/L")) {
-                                onboardingData.units = .mmolL
-                                // Adjust values for unit change
-                                if onboardingData.units == .mmolL {
-                                    onboardingData.targetLow = max(3.9, onboardingData.targetLow / 18)
-                                    onboardingData.targetHigh = max(6.7, onboardingData.targetHigh / 18)
-                                    onboardingData.isf = max(1.7, onboardingData.isf / 18)
-                                }
-                            },
-                            .cancel()
-                        ]
-                    )
                 }
-            }
-
-            Divider()
 
-            // Target glucose range
-            VStack(alignment: .leading, spacing: 12) {
-                Text("Target Glucose Range")
-                    .font(.headline)
+                Divider()
 
-                Text("This range defines your ideal blood glucose values. Trio uses this to calculate insulin doses.")
-                    .font(.subheadline)
-                    .foregroundColor(.secondary)
+                // Target glucose range
+                VStack(alignment: .leading, spacing: 12) {
+                    Text("Target Glucose Range")
+                        .font(.headline)
 
-                // Low target
-                VStack(alignment: .leading) {
-                    Text("Low Target")
+                    Text("This range defines your ideal blood glucose values. Trio uses this to calculate insulin doses.")
                         .font(.subheadline)
+                        .foregroundColor(.secondary)
 
-                    HStack {
-                        Slider(
-                            value: Binding(
-                                get: { Double(truncating: onboardingData.targetLow as NSNumber) },
-                                set: { onboardingData.targetLow = Decimal($0) }
-                            ),
-                            in: onboardingData.units == .mgdL ? 70 ... 120 : 3.9 ... 6.7,
-                            step: onboardingData.units == .mgdL ? 1 : 0.1
-                        )
-                        .accentColor(.green)
+                    // Low target
+                    VStack(alignment: .leading) {
+                        Text("Low Target")
+                            .font(.subheadline)
 
-                        Text(
-                            "\(numberFormatter.string(from: onboardingData.targetLow as NSNumber) ?? "--") \(onboardingData.units == .mgdL ? "mg/dL" : "mmol/L")"
-                        )
-                        .frame(width: 80, alignment: .trailing)
+                        HStack {
+                            Slider(
+                                value: Binding(
+                                    get: { Double(truncating: onboardingData.targetLow as NSNumber) },
+                                    set: { onboardingData.targetLow = Decimal($0) }
+                                ),
+                                in: onboardingData.units == .mgdL ? 70 ... 120 : 3.9 ... 6.7,
+                                step: onboardingData.units == .mgdL ? 1 : 0.1
+                            )
+                            .accentColor(.green)
+
+                            Text(
+                                "\(numberFormatter.string(from: onboardingData.targetLow as NSNumber) ?? "--") \(onboardingData.units == .mgdL ? "mg/dL" : "mmol/L")"
+                            )
+                            .frame(width: 80, alignment: .trailing)
+                        }
                     }
+                    .padding(.vertical, 4)
+
+                    // High target
+                    VStack(alignment: .leading) {
+                        Text("High Target")
+                            .font(.subheadline)
+
+                        HStack {
+                            Slider(
+                                value: Binding(
+                                    get: { Double(truncating: onboardingData.targetHigh as NSNumber) },
+                                    set: { onboardingData.targetHigh = Decimal($0) }
+                                ),
+                                in: onboardingData.units == .mgdL ?
+                                    Double(truncating: onboardingData.targetLow as NSNumber) + 10 ... 200 :
+                                    Double(truncating: onboardingData.targetLow as NSNumber) + 0.6 ... 11.1,
+                                step: onboardingData.units == .mgdL ? 1 : 0.1
+                            )
+                            .accentColor(.green)
+
+                            Text(
+                                "\(numberFormatter.string(from: onboardingData.targetHigh as NSNumber) ?? "--") \(onboardingData.units == .mgdL ? "mg/dL" : "mmol/L")"
+                            )
+                            .frame(width: 80, alignment: .trailing)
+                        }
+                    }
+                    .padding(.vertical, 4)
                 }
-                .padding(.vertical, 4)
 
-                // High target
-                VStack(alignment: .leading) {
-                    Text("High Target")
-                        .font(.subheadline)
+                Divider()
 
-                    HStack {
-                        Slider(
-                            value: Binding(
-                                get: { Double(truncating: onboardingData.targetHigh as NSNumber) },
-                                set: { onboardingData.targetHigh = Decimal($0) }
-                            ),
-                            in: onboardingData.units == .mgdL ?
-                                Double(truncating: onboardingData.targetLow as NSNumber) + 10 ... 200 :
-                                Double(truncating: onboardingData.targetLow as NSNumber) + 0.6 ... 11.1,
-                            step: onboardingData.units == .mgdL ? 1 : 0.1
-                        )
-                        .accentColor(.green)
+                // Chart visualization
+                if !onboardingData.targetItems.isEmpty {
+                    VStack(alignment: .leading) {
+                        Text("Glucose Targets")
+                            .font(.headline)
+                            .padding(.horizontal)
 
-                        Text(
-                            "\(numberFormatter.string(from: onboardingData.targetHigh as NSNumber) ?? "--") \(onboardingData.units == .mgdL ? "mg/dL" : "mmol/L")"
-                        )
-                        .frame(width: 80, alignment: .trailing)
+                        glucoseTargetChart
+                            .frame(height: 180)
+                            .padding(.horizontal)
                     }
+                    .padding(.vertical, 5)
+                    .background(Color.orange.opacity(0.05))
+                    .cornerRadius(10)
                 }
-                .padding(.vertical, 4)
-            }
 
-            Divider()
-
-            // Target range visualization
-            VStack(alignment: .leading, spacing: 8) {
-                Text("Your Target Range")
-                    .font(.headline)
-
-                HStack(spacing: 0) {
-                    // Below range
-                    Rectangle()
-                        .fill(Color.red.opacity(0.3))
-                        .frame(width: 50, height: 30)
-                        .overlay(
-                            Text("Low")
-                                .font(.caption)
-                                .foregroundColor(.red)
-                        )
+                // Glucose target list
+                VStack(alignment: .leading, spacing: 10) {
+                    HStack {
+                        Text("Glucose Targets")
+                            .font(.headline)
 
-                    // Target range
-                    Rectangle()
-                        .fill(Color.green.opacity(0.3))
-                        .frame(width: 100, height: 30)
-                        .overlay(
-                            Text("Target")
-                                .font(.caption)
-                                .foregroundColor(.green)
-                        )
+                        Spacer()
 
-                    // Above range
-                    Rectangle()
-                        .fill(Color.yellow.opacity(0.3))
-                        .frame(width: 50, height: 30)
-                        .overlay(
-                            Text("High")
-                                .font(.caption)
+                        // Add new target button
+                        if onboardingData.targetItems.count < 24 {
+                            Button(action: {
+                                showTimeSelector = true
+                            }) {
+                                HStack {
+                                    Image(systemName: "plus.circle.fill")
+                                    Text("Add Ratio")
+                                }
                                 .foregroundColor(.orange)
-                        )
+                            }
+                            .disabled(!canAddRatio)
+                        }
+                    }
+                    .padding(.horizontal)
+
+                    // List of targets
+                    VStack(spacing: 2) {
+                        ForEach(Array(onboardingData.targetItems.enumerated()), id: \.element.id) { index, item in
+                            HStack {
+                                // Time display
+                                Text(
+                                    dateFormatter
+                                        .string(from: Date(
+                                            timeIntervalSince1970: onboardingData
+                                                .targetTimeValues[item.timeIndex]
+                                        ))
+                                )
+                                .frame(width: 80, alignment: .leading)
+                                .padding(.leading)
+
+                                // Ratio slider
+                                Slider(
+                                    value: Binding(
+                                        get: {
+                                            Double(
+                                                truncating: onboardingData
+                                                    .targetRateValues[item.rateIndex] as NSNumber
+                                            ) },
+                                        set: { newValue in
+                                            // Find closest match in rateValues array
+                                            let newIndex = onboardingData.targetRateValues
+                                                .firstIndex { abs(Double($0) - newValue) < 0.05 } ?? item.rateIndex
+                                            onboardingData.targetItems[index].rateIndex = newIndex
+                                            // Force refresh when slider changes
+                                            refreshUI = UUID()
+                                        }
+                                    ),
+                                    in: Double(truncating: onboardingData.targetRateValues.first! as NSNumber) ...
+                                        Double(truncating: onboardingData.targetRateValues.last! as NSNumber),
+                                    step: 0.5
+                                )
+                                .accentColor(.orange)
+                                .padding(.horizontal, 5)
+                                .onChange(of: onboardingData.targetItems[index].rateIndex) { _, _ in
+                                    let impact = UIImpactFeedbackGenerator(style: .light)
+                                    impact.impactOccurred()
+                                }
+
+                                // Display the current value
+                                Text(
+                                    "\(Formatters.decimalFormatterWithOneFractionDigit.string(from: onboardingData.targetRateValues[item.rateIndex] as NSNumber) ?? "--") g/U"
+                                )
+                                .frame(width: 80, alignment: .trailing)
+                                .lineLimit(1)
+                                .minimumScaleFactor(0.8)
+
+                                // Delete button (not for the first entry at 00:00)
+                                if index > 0 {
+                                    Button(action: {
+                                        onboardingData.targetItems.remove(at: index)
+                                    }) {
+                                        Image(systemName: "trash")
+                                            .foregroundColor(.red)
+                                            .padding(.horizontal, 5)
+                                    }
+                                } else {
+                                    // Spacer to maintain alignment
+                                    Spacer()
+                                        .frame(width: 30)
+                                }
+                            }
+                            .padding(.vertical, 12)
+                            .background(index % 2 == 0 ? Color.orange.opacity(0.05) : Color.clear)
+                            .cornerRadius(8)
+                        }
+                    }
+                    .background(Color.orange.opacity(0.05))
+                    .cornerRadius(10)
+                    .padding(.horizontal)
+                    .onAppear {
+                        if onboardingData.targetItems.isEmpty {
+                            onboardingData.addTarget()
+                        }
+                    }
                 }
-                .cornerRadius(8)
 
-                // Range values
-                HStack(spacing: 0) {
-                    Text("\(numberFormatter.string(from: onboardingData.targetLow as NSNumber) ?? "--")")
-                        .font(.caption)
-                        .frame(width: 50, alignment: .center)
+                // Target range visualization
+                VStack(alignment: .leading, spacing: 8) {
+                    Text("Your Target Range")
+                        .font(.headline)
 
-                    Spacer()
-                        .frame(width: 100)
+                    HStack(spacing: 0) {
+                        // Below range
+                        Rectangle()
+                            .fill(Color.red.opacity(0.3))
+                            .frame(width: 50, height: 30)
+                            .overlay(
+                                Text("Low")
+                                    .font(.caption)
+                                    .foregroundColor(.red)
+                            )
+
+                        // Target range
+                        Rectangle()
+                            .fill(Color.green.opacity(0.3))
+                            .frame(width: 100, height: 30)
+                            .overlay(
+                                Text("Target")
+                                    .font(.caption)
+                                    .foregroundColor(.green)
+                            )
 
-                    Text("\(numberFormatter.string(from: onboardingData.targetHigh as NSNumber) ?? "--")")
+                        // Above range
+                        Rectangle()
+                            .fill(Color.yellow.opacity(0.3))
+                            .frame(width: 50, height: 30)
+                            .overlay(
+                                Text("High")
+                                    .font(.caption)
+                                    .foregroundColor(.orange)
+                            )
+                    }
+                    .cornerRadius(8)
+
+                    // Range values
+                    HStack(spacing: 0) {
+                        Text("\(numberFormatter.string(from: onboardingData.targetLow as NSNumber) ?? "--")")
+                            .font(.caption)
+                            .frame(width: 50, alignment: .center)
+
+                        Spacer()
+                            .frame(width: 100)
+
+                        Text("\(numberFormatter.string(from: onboardingData.targetHigh as NSNumber) ?? "--")")
+                            .font(.caption)
+                            .frame(width: 50, alignment: .center)
+                    }
+
+                    Text("These values reflect your personal target range and can be adjusted at any time in the Settings.")
                         .font(.caption)
-                        .frame(width: 50, alignment: .center)
+                        .foregroundColor(.secondary)
+                        .padding(.top, 8)
                 }
+            }
+            .padding()
+        }
+        .actionSheet(isPresented: $showTimeSelector) {
+            var buttons: [ActionSheet.Button] = []
 
-                Text("These values reflect your personal target range and can be adjusted at any time in the Settings.")
-                    .font(.caption)
-                    .foregroundColor(.secondary)
-                    .padding(.top, 8)
+            // Find available time slots in 1-hour increments
+            for hour in 0 ..< 24 {
+                let hourInMinutes = hour * 60
+                // Calculate timeIndex for this hour
+                let timeIndex = onboardingData.targetTimeValues.firstIndex { abs($0 - Double(hourInMinutes * 60)) < 10 } ?? 0
+
+                // Check if this hour is already in the profile
+                if !onboardingData.targetItems.contains(where: { $0.timeIndex == timeIndex }) {
+                    buttons.append(.default(Text("\(String(format: "%02d:00", hour))")) {
+                        // Get the current ratio from the last item
+                        let rateIndex = onboardingData.targetItems.last?.rateIndex ?? 0
+                        // Create new item with the specified time
+                        let newItem = TargetsEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
+                        // Add the new item and sort the list
+                        onboardingData.targetItems.append(newItem)
+                        onboardingData.targetItems.sort(by: { $0.timeIndex < $1.timeIndex })
+                    })
+                }
+            }
+
+            buttons.append(.cancel())
+
+            return ActionSheet(
+                title: Text("Select Start Time"),
+                message: Text("Choose when this carb ratio should start"),
+                buttons: buttons
+            )
+        }
+    }
+
+    // Computed property to check if we can add more carb ratios
+    private var canAddRatio: Bool {
+        guard let lastItem = onboardingData.targetItems.last else { return true }
+        return lastItem.timeIndex < onboardingData.targetTimeValues.count - 1
+    }
+
+    // Chart for visualizing carb ratios
+    private var glucoseTargetChart: some View {
+        Chart {
+            ForEach(Array(onboardingData.targetItems.enumerated()), id: \.element.id) { index, item in
+                let displayValue = onboardingData.targetRateValues[item.timeIndex]
+
+                let tzOffset = TimeZone.current.secondsFromGMT() * -1
+                let startDate = Date(timeIntervalSinceReferenceDate: onboardingData.targetTimeValues[item.timeIndex])
+                    .addingTimeInterval(TimeInterval(tzOffset))
+                let endDate = onboardingData.targetItems.count > index + 1 ?
+                    Date(
+                        timeIntervalSinceReferenceDate: onboardingData
+                            .targetTimeValues[onboardingData.targetItems[index + 1].timeIndex]
+                    )
+                    .addingTimeInterval(TimeInterval(tzOffset)) :
+                    Date(timeIntervalSinceReferenceDate: onboardingData.targetTimeValues.last!).addingTimeInterval(30 * 60)
+                    .addingTimeInterval(TimeInterval(tzOffset))
+
+                RectangleMark(
+                    xStart: .value("start", startDate),
+                    xEnd: .value("end", endDate),
+                    yStart: .value("rate-start", displayValue),
+                    yEnd: .value("rate-end", 0)
+                ).foregroundStyle(
+                    .linearGradient(
+                        colors: [
+                            Color.orange.opacity(0.6),
+                            Color.orange.opacity(0.1)
+                        ],
+                        startPoint: .bottom,
+                        endPoint: .top
+                    )
+                ).alignsMarkStylesWithPlotArea()
+
+                LineMark(x: .value("End Date", startDate), y: .value("Ratio", displayValue))
+                    .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.orange)
+
+                LineMark(x: .value("Start Date", endDate), y: .value("Ratio", displayValue))
+                    .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.orange)
+            }
+        }
+        .id(refreshUI) // Force chart update
+        .chartXAxis {
+            AxisMarks(values: .automatic(desiredCount: 6)) { _ in
+                AxisValueLabel(format: .dateTime.hour())
+                AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1, dash: [2, 4]))
+            }
+        }
+        .chartXScale(
+            domain: Calendar.current.startOfDay(for: chartScale!) ... Calendar.current.startOfDay(for: chartScale!)
+                .addingTimeInterval(60 * 60 * 24)
+        )
+        .chartYAxis {
+            AxisMarks(values: .automatic(desiredCount: 4)) { _ in
+                AxisValueLabel()
+                AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1, dash: [2, 4]))
             }
         }
-        .padding()
     }
 }

+ 5 - 4
Trio/Sources/Modules/Onboarding/Model.swift

@@ -134,7 +134,7 @@ enum OnboardingStep: Int, CaseIterable, Identifiable {
 
         return values
     }
-    
+
     // Target related
     var targetItems: [TargetsEditor.Item] = []
     var initialTargetItems: [TargetsEditor.Item] = []
@@ -270,7 +270,7 @@ extension OnboardingData {
     var targetsHaveChanged: Bool {
         initialTargetItems != targetItems
     }
-    
+
     func addTarget() {
         var time = 0
         var low = 0
@@ -301,7 +301,8 @@ extension OnboardingData {
         }
         let profile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
         saveTargets(profile)
-        initialTargetItems = targetItems.map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
+        initialTargetItems = targetItems
+            .map { TargetsEditor.Item(lowIndex: $0.lowIndex, highIndex: $0.highIndex, timeIndex: $0.timeIndex) }
     }
 
 //    func validateTarget() {
@@ -319,7 +320,7 @@ extension OnboardingData {
 //            }
 //        }
 //    }
-    
+
     func saveTargets(_ profile: BGTargets) {
         storage.save(profile, as: OpenAPS.Settings.bgTargets)
     }