polscm32 1 год назад
Родитель
Сommit
51e91612a3

+ 24 - 12
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -5485,6 +5485,9 @@
         }
       }
     },
+    "%.2f U/day" : {
+
+    },
     "%@" : {
       "comment" : "Info message\nunits",
       "localizations" : {
@@ -7437,9 +7440,6 @@
         }
       }
     },
-    "%lld:00" : {
-
-    },
     "%lld%%" : {
 
     },
@@ -11767,6 +11767,9 @@
         }
       }
     },
+    "• Lower rates are typically needed during sleep or periods of activity" : {
+
+    },
     "• Maximum Duration" : {
       "localizations" : {
         "bg" : {
@@ -12067,6 +12070,9 @@
         }
       }
     },
+    "• Morning hours may require more insulin due to 'dawn phenomenon'" : {
+
+    },
     "• Nightscout" : {
       "localizations" : {
         "bg" : {
@@ -12667,6 +12673,9 @@
         }
       }
     },
+    "• Rates should be adjusted based on your body's varying insulin needs" : {
+
+    },
     "• Secondary Font Size: Adjust text size for values in split layouts." : {
       "localizations" : {
         "bg" : {
@@ -13167,6 +13176,9 @@
         }
       }
     },
+    "• The basal profile provides background insulin throughout the day" : {
+
+    },
     "• xDrip4iOS" : {
       "localizations" : {
         "bg" : {
@@ -20422,9 +20434,6 @@
         }
       }
     },
-    "Add Basal Rate" : {
-
-    },
     "Add calibration" : {
       "localizations" : {
         "bg" : {
@@ -21741,6 +21750,9 @@
         }
       }
     },
+    "Add Initial Basal Rate" : {
+
+    },
     "Add Initial Carb Ratio" : {
 
     },
@@ -170721,9 +170733,6 @@
         }
       }
     },
-    "This chart shows your basal insulin delivery throughout a 24-hour day." : {
-
-    },
     "This could be useful for fast absorbing meals like sugary cereal." : {
       "localizations" : {
         "bg" : {
@@ -179994,6 +180003,9 @@
         }
       }
     },
+    "Total Daily Basal" : {
+
+    },
     "Total Daily Dose" : {
 
     },
@@ -185192,6 +185204,9 @@
         }
       }
     },
+    "Unable to Save Basal Profile" : {
+
+    },
     "Unable to set a temporary basal rate: %1$@" : {
       "comment" : "Alert format string for a failure to set temporary basal. (1: error description)",
       "extractionState" : "manual",
@@ -195755,9 +195770,6 @@
     "Your basal insulin profile determines how much background insulin you receive throughout the day." : {
 
     },
-    "Your Basal Profile" : {
-
-    },
     "Your carb ratio tells how many grams of carbohydrates one unit of insulin will cover." : {
 
     },

+ 336 - 98
Trio/Sources/Modules/Main/View/OnboardingSteps/BasalProfileStepView.swift

@@ -4,128 +4,242 @@
 //
 //  Created by Marvin Polscheit on 19.03.25.
 //
+import Charts
 import SwiftUI
+import UIKit
 
 /// Basal profile step view for setting basal insulin rates.
 struct BasalProfileStepView: View {
     @State var onboardingData: OnboardingData
     @State private var showTimeSelector = false
     @State private var selectedBasalIndex: Int?
-    @State private var newStartTime: Int = 0
+    @State private var showAlert = false
+    @State private var errorMessage = ""
+
+    // For chart scaling
+    private let chartScale = Calendar.current
+        .date(from: DateComponents(year: 2001, month: 01, day: 01, hour: 0, minute: 0, second: 0))
+
+    private var formatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.maximumFractionDigits = 2
+        return formatter
+    }
+
+    private var dateFormatter: DateFormatter {
+        let formatter = DateFormatter()
+        formatter.timeZone = TimeZone(secondsFromGMT: 0)
+        formatter.timeStyle = .short
+        return formatter
+    }
 
     var body: some View {
-        VStack(alignment: .leading, spacing: 20) {
-            Text("Your basal insulin profile determines how much background insulin you receive throughout the day.")
-                .font(.subheadline)
-                .foregroundColor(.secondary)
+        ScrollView {
+            VStack(alignment: .leading, spacing: 20) {
+                Text("Your basal insulin profile determines how much background insulin you receive throughout the day.")
+                    .font(.subheadline)
+                    .foregroundColor(.secondary)
+                    .padding(.horizontal)
 
-            // Basal rates list
-            VStack(alignment: .leading, spacing: 10) {
-                Text("Basal Rates")
-                    .font(.headline)
+                // Chart visualization
+                if !onboardingData.basalProfileItems.isEmpty {
+                    VStack(alignment: .leading) {
+                        Text("Basal Profile")
+                            .font(.headline)
+                            .padding(.horizontal)
+
+//                        basalProfileChart
+//                            .frame(height: 180)
+//                            .padding(.horizontal)
+                    }
+                    .padding(.vertical, 5)
+                    .background(Color.purple.opacity(0.05))
+                    .cornerRadius(10)
+                }
 
-                ForEach(Array(onboardingData.basalRates.enumerated()), id: \.element.id) { index, basalRate in
+                // Basal rates list
+                VStack(alignment: .leading, spacing: 10) {
                     HStack {
-                        Text(basalRate.timeFormatted)
-                            .frame(width: 80, alignment: .leading)
-
-                        Slider(
-                            value: Binding(
-                                get: { Double(truncating: onboardingData.basalRates[index].rate as NSNumber) },
-                                set: { onboardingData.basalRates[index].rate = Decimal($0) }
-                            ),
-                            in: 0 ... 5,
-                            step: 0.05
-                        )
-                        .accentColor(.purple)
-
-                        Text("\(String(format: "%.2f", Double(truncating: basalRate.rate as NSNumber))) U/h")
-                            .frame(width: 70, alignment: .trailing)
-
-                        // Delete button (not for the first entry at 00:00)
-                        if index > 0 {
+                        Text("Basal Rates")
+                            .font(.headline)
+
+                        Spacer()
+
+                        // Add new basal rate button
+                        if onboardingData.basalProfileItems.count < 24 {
                             Button(action: {
-                                onboardingData.basalRates.remove(at: index)
+                                showTimeSelector = true
                             }) {
-                                Image(systemName: "trash")
-                                    .foregroundColor(.red)
+                                HStack {
+                                    Image(systemName: "plus.circle.fill")
+                                    Text("Add Rate")
+                                }
+                                .foregroundColor(.purple)
                             }
+                            .disabled(!canAddBasalRate)
                         }
                     }
-                    .padding(.vertical, 8)
-                    .background(Color.purple.opacity(0.05))
-                    .cornerRadius(8)
-                }
-            }
+                    .padding(.horizontal)
 
-            // Add new basal rate button
-            if onboardingData.basalRates.count < 24 {
-                Button(action: {
-                    showTimeSelector = true
-                }) {
-                    HStack {
-                        Image(systemName: "plus.circle.fill")
-                        Text("Add Basal Rate")
-                    }
-                    .foregroundColor(.purple)
-                    .padding(.vertical, 8)
-                }
-            }
+                    if onboardingData.basalProfileItems.isEmpty {
+                        // Add default entry if no items exist
+                        Button("Add Initial Basal Rate") {
+                            addBasalRate()
+                        }
+                        .foregroundColor(.purple)
+                        .padding()
+                        .frame(maxWidth: .infinity)
+                        .background(Color.purple.opacity(0.1))
+                        .cornerRadius(8)
+                        .padding(.horizontal)
+                    } else {
+                        // List of basal rates
+                        VStack(spacing: 2) {
+                            ForEach(Array(onboardingData.basalProfileItems.enumerated()), id: \.element.id) { index, item in
+                                HStack {
+                                    // Time display
+                                    Text(
+                                        dateFormatter
+                                            .string(from: Date(
+                                                timeIntervalSince1970: onboardingData
+                                                    .basalProfileTimeValues[item.timeIndex]
+                                            ))
+                                    )
+                                    .frame(width: 80, alignment: .leading)
+                                    .padding(.leading)
+
+                                    // Rate slider
+                                    Slider(
+                                        value: Binding(
+                                            get: {
+                                                guard !onboardingData.basalProfileRateValues.isEmpty,
+                                                      item.rateIndex < onboardingData.basalProfileRateValues.count
+                                                else {
+                                                    return 0.0
+                                                }
+                                                return Double(
+                                                    truncating: onboardingData
+                                                        .basalProfileRateValues[item.rateIndex] as NSNumber
+                                                )
+                                            },
+                                            set: { newValue in
+                                                guard !onboardingData.basalProfileRateValues.isEmpty else { return }
+
+                                                // Find closest match in rateValues array
+                                                let newIndex = onboardingData.basalProfileRateValues
+                                                    .firstIndex { abs(Double($0) - newValue) < 0.005 } ?? item.rateIndex
+
+                                                // Ensure index is valid before updating
+                                                if newIndex < onboardingData.basalProfileRateValues.count,
+                                                   index < onboardingData.basalProfileItems.count
+                                                {
+                                                    onboardingData.basalProfileItems[index].rateIndex = newIndex
+                                                }
+                                            }
+                                        ),
+                                        in: onboardingData.basalProfileRateValues.isEmpty ? 0 ... 1 :
+                                            Double(truncating: onboardingData.basalProfileRateValues.first! as NSNumber) ...
+                                            Double(truncating: onboardingData.basalProfileRateValues.last! as NSNumber),
+                                        step: 0.05
+                                    )
+                                    .accentColor(.purple)
+                                    .padding(.horizontal, 5)
+                                    .onChange(of: onboardingData.basalProfileItems[index].rateIndex) { _, _ in
+                                        // Trigger immediate UI update when slider value changes
+                                        let impact = UIImpactFeedbackGenerator(style: .light)
+                                        impact.impactOccurred()
+                                    }
+
+                                    // Display the current value
+                                    Text(
+                                        "\(onboardingData.basalProfileRateValues.isEmpty || item.rateIndex >= onboardingData.basalProfileRateValues.count ? "--" : formatter.string(from: onboardingData.basalProfileRateValues[item.rateIndex] as NSNumber) ?? "--") U/h"
+                                    )
+                                    .frame(width: 60, alignment: .trailing)
 
-            Divider()
-
-            // Basal profile visualization
-            VStack(alignment: .leading, spacing: 8) {
-                Text("Your Basal Profile")
-                    .font(.headline)
-
-                // Simple chart representation
-                HStack(alignment: .bottom, spacing: 2) {
-                    ForEach(0 ..< 24) { hour in
-                        let rate = basalRateAt(hour: hour)
-                        let height = min(120, CGFloat(Double(rate) * 30))
-
-                        VStack {
-                            Rectangle()
-                                .fill(Color.purple.opacity(0.7))
-                                .frame(width: 10, height: height)
-
-                            if hour % 6 == 0 {
-                                Text("\(hour):00")
-                                    .font(.system(size: 8))
-                                    .frame(width: 20)
-                                    .rotationEffect(.degrees(-45))
-                                    .offset(y: 10)
+                                    // Delete button (not for the first entry at 00:00)
+                                    if index > 0 {
+                                        Button(action: {
+                                            onboardingData.basalProfileItems.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.purple.opacity(0.05) : Color.clear)
+                                .cornerRadius(8)
                             }
                         }
+                        .background(Color.purple.opacity(0.05))
+                        .cornerRadius(10)
+                        .padding(.horizontal)
                     }
                 }
-                .frame(height: 150)
-                .padding(.top)
 
-                Text("This chart shows your basal insulin delivery throughout a 24-hour day.")
-                    .font(.caption)
-                    .foregroundColor(.secondary)
+                // Total daily basal calculation
+                if !onboardingData.basalProfileItems.isEmpty {
+                    VStack(alignment: .leading, spacing: 8) {
+                        HStack {
+                            Text("Total Daily Basal")
+                                .font(.headline)
+                                .padding(.horizontal)
+
+                            Spacer()
+
+                            Text("\(calculateTotalDailyBasal(), specifier: "%.2f") U/day")
+                                .font(.headline)
+                                .padding(.horizontal)
+                        }
+                    }
+                    .padding(.top)
+
+                    // Information about basal rates
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("What This Means")
+                            .font(.headline)
+                            .padding(.horizontal)
+
+                        VStack(alignment: .leading, spacing: 4) {
+                            Text("• The basal profile provides background insulin throughout the day")
+                            Text("• Rates should be adjusted based on your body's varying insulin needs")
+                            Text("• Morning hours may require more insulin due to 'dawn phenomenon'")
+                            Text("• Lower rates are typically needed during sleep or periods of activity")
+                        }
+                        .font(.caption)
+                        .foregroundColor(.secondary)
+                        .padding(.horizontal)
+                    }
+                }
             }
+            .padding(.vertical)
         }
-        .padding()
         .actionSheet(isPresented: $showTimeSelector) {
             var buttons: [ActionSheet.Button] = []
 
             // Find available time slots in 1-hour increments
-            for hour in 1 ..< 24 {
+            for hour in 0 ..< 24 {
                 let hourInMinutes = hour * 60
+                // Calculate timeIndex for this hour
+                let timeIndex = onboardingData.basalProfileTimeValues
+                    .firstIndex { abs($0 - Double(hourInMinutes * 60)) < 10 } ?? 0
+
                 // Check if this hour is already in the profile
-                if !onboardingData.basalRates.contains(where: { $0.startTime == hourInMinutes }) {
+                if !onboardingData.basalProfileItems.contains(where: { $0.timeIndex == timeIndex }) {
                     buttons.append(.default(Text("\(String(format: "%02d:00", hour))")) {
-                        // Get the current basal rate active at this time
-                        let rate = basalRateAt(hour: hour)
-                        // Add new basal rate with the same value
-                        onboardingData.basalRates.append(
-                            OnboardingData.BasalRateEntry(startTime: hourInMinutes, rate: rate)
-                        )
-                        // Sort basal rates by time
-                        onboardingData.basalRates.sort(by: { $0.startTime < $1.startTime })
+                        // Get the current rate from the last item
+                        let rateIndex = onboardingData.basalProfileItems.last?.rateIndex ?? 20 // 1.0 U/h as default
+                        // Create new item with the specified time
+                        let newItem = BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
+                        // Add the new item and sort the list
+                        onboardingData.basalProfileItems.append(newItem)
+                        onboardingData.basalProfileItems.sort(by: { $0.timeIndex < $1.timeIndex })
                     })
                 }
             }
@@ -138,17 +252,141 @@ struct BasalProfileStepView: View {
                 buttons: buttons
             )
         }
+        .alert(isPresented: $showAlert) {
+            Alert(
+                title: Text("Unable to Save Basal Profile"),
+                message: Text(errorMessage),
+                dismissButton: .default(Text("OK"))
+            )
+        }
     }
 
-    /// Calculates the basal rate at a specific hour based on the profile.
-    private func basalRateAt(hour: Int) -> Decimal {
-        let minutes = hour * 60
-        // Find the most recent basal rate entry that starts before or at the given hour
-        let applicableRate = onboardingData.basalRates
-            .filter { $0.startTime <= minutes }
-            .sorted(by: { $0.startTime > $1.startTime })
-            .first
+    // Add initial basal rate
+    private func addBasalRate() {
+        // Default to midnight (00:00) and 1.0 U/h rate
+        let timeIndex = onboardingData.basalProfileTimeValues.firstIndex { abs($0 - 0) < 1 } ?? 0
+        let rateIndex = onboardingData.basalProfileRateValues.firstIndex { abs(Double($0) - 1.0) < 0.05 } ?? 20
+
+        let newItem = BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
+        onboardingData.basalProfileItems.append(newItem)
+    }
 
-        return applicableRate?.rate ?? Decimal(1.0)
+    // Computed property to check if we can add more basal rates
+    private var canAddBasalRate: Bool {
+        guard let lastItem = onboardingData.basalProfileItems.last else { return true }
+        return lastItem.timeIndex < onboardingData.basalProfileTimeValues.count - 1
     }
+
+    // Calculate the total daily basal insulin
+    private func calculateTotalDailyBasal() -> Double {
+        let items = onboardingData.basalProfileItems
+
+        // If there are no items, return 0
+        if items.isEmpty {
+            return 0.0
+        }
+
+        var total: Double = 0.0
+
+        // Safely create profile items with proper error checking
+        let profileItems = items.compactMap { item -> (timeIndex: Int, rate: Decimal)? in
+            // Safety check - make sure indices are within bounds
+            guard item.timeIndex >= 0 && item.timeIndex < onboardingData.basalProfileTimeValues.count,
+                  item.rateIndex >= 0 && item.rateIndex < onboardingData.basalProfileRateValues.count
+            else {
+                return nil
+            }
+
+            let timeValue = onboardingData.basalProfileTimeValues[item.timeIndex]
+            let rate = onboardingData.basalProfileRateValues[item.rateIndex]
+            return (Int(timeValue / 60), rate)
+        }.sorted(by: { $0.timeIndex < $1.timeIndex })
+
+        // If after safety checks we have no valid items, return 0
+        if profileItems.isEmpty {
+            return 0.0
+        }
+
+        // Create time points array safely
+        var timePoints = profileItems.map(\.timeIndex)
+
+        // Add the 24-hour mark to complete the cycle
+        timePoints.append(24 * 60) // Add 24 hours in minutes
+
+        // Calculate the total by multiplying each rate by its duration
+        for i in 0 ..< profileItems.count {
+            let rate = profileItems[i].rate
+            let currentTimeIndex = profileItems[i].timeIndex
+
+            // Calculate duration safely
+            let nextTimeIndex = i + 1 < timePoints.count ? timePoints[i + 1] : (24 * 60)
+            let duration = nextTimeIndex - currentTimeIndex
+
+            // Only add if duration is positive
+            if duration > 0 {
+                total += Double(rate) * Double(duration) / 60.0 // Convert to hours
+            }
+        }
+
+        return total
+    }
+
+    // Chart for visualizing basal profile
+//    private var basalProfileChart: some View {
+//        Chart {
+//            ForEach(Array(onboardingData.basalProfileItems.enumerated()), id: \.element.id) { index, item in
+//                let displayValue = onboardingData.basalProfileRateValues[item.rateIndex]
+//
+//                let tzOffset = TimeZone.current.secondsFromGMT() * -1
+//                let startDate = Date(timeIntervalSinceReferenceDate: onboardingData.basalProfileTimeValues[item.timeIndex])
+//                    .addingTimeInterval(TimeInterval(tzOffset))
+//                let endDate = onboardingData.basalProfileItems.count > index + 1 ?
+//                    Date(
+//                        timeIntervalSinceReferenceDate: onboardingData
+//                            .basalProfileTimeValues[onboardingData.basalProfileItems[index + 1].timeIndex]
+//                    )
+//                    .addingTimeInterval(TimeInterval(tzOffset)) :
+//                    Date(timeIntervalSinceReferenceDate: onboardingData.basalProfileTimeValues.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.purple.opacity(0.6),
+//                            Color.purple.opacity(0.1)
+//                        ],
+//                        startPoint: .bottom,
+//                        endPoint: .top
+//                    )
+//                ).alignsMarkStylesWithPlotArea()
+//
+//                LineMark(x: .value("End Date", startDate), y: .value("Ratio", displayValue))
+//                    .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.purple)
+//
+//                LineMark(x: .value("Start Date", endDate), y: .value("Ratio", displayValue))
+//                    .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.purple)
+//            }
+//        }
+//        .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]))
+//            }
+//        }
+//    }
 }

+ 27 - 0
Trio/Sources/Modules/Onboarding/Model.swift

@@ -242,6 +242,33 @@ extension OnboardingData {
 // MARK: - Setup Basal Profile
 
 extension OnboardingData {
+    var hasBasalProfileChanges: Bool {
+        if initialBasalProfileItems.count != basalProfileItems.count {
+            return true
+        }
+
+        for (initialItem, currentItem) in zip(initialBasalProfileItems, basalProfileItems) {
+            if initialItem.rateIndex != currentItem.rateIndex || initialItem.timeIndex != currentItem.timeIndex {
+                return true
+            }
+        }
+
+        return false
+    }
+
+    func addBasalRate() {
+        var time = 0
+        var rate = 20 // Default to 1.0 U/h (index 20 if basalProfileRateValues starts at 0.05 and increments by 0.05)
+
+        if let last = basalProfileItems.last {
+            time = last.timeIndex + 1
+            rate = last.rateIndex
+        }
+
+        let newItem = BasalProfileEditor.Item(rateIndex: rate, timeIndex: time)
+        basalProfileItems.append(newItem)
+    }
+
     func saveBasalProfile() -> AnyPublisher<Void, Error> {
         let profile = basalProfileItems.map { item -> BasalProfileEntry in
             let formatter = DateFormatter()