|
|
@@ -4,6 +4,7 @@
|
|
|
//
|
|
|
// Created by Marvin Polscheit on 19.03.25.
|
|
|
//
|
|
|
+import Charts
|
|
|
import SwiftUI
|
|
|
|
|
|
/// Carb ratio step view for setting insulin-to-carb ratio.
|
|
|
@@ -12,6 +13,10 @@ struct CarbRatioStepView: View {
|
|
|
@State private var showTimeSelector = false
|
|
|
@State private var selectedRatioIndex: Int?
|
|
|
|
|
|
+ // 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
|
|
|
@@ -27,171 +32,179 @@ struct CarbRatioStepView: View {
|
|
|
}
|
|
|
|
|
|
var body: some View {
|
|
|
- VStack(alignment: .leading, spacing: 20) {
|
|
|
- Text("Your carb ratio tells how many grams of carbohydrates one unit of insulin will cover.")
|
|
|
- .font(.subheadline)
|
|
|
- .foregroundColor(.secondary)
|
|
|
-
|
|
|
- // Carb ratios list
|
|
|
- VStack(alignment: .leading, spacing: 10) {
|
|
|
- Text("Carb Ratios")
|
|
|
- .font(.headline)
|
|
|
-
|
|
|
- if onboardingData.items.isEmpty {
|
|
|
- // Add default entry if no items exist
|
|
|
- Button("Add Initial Carb Ratio") {
|
|
|
- onboardingData.addCarbRatio()
|
|
|
- }
|
|
|
- .foregroundColor(.orange)
|
|
|
- .padding(.vertical, 8)
|
|
|
- } else {
|
|
|
- ForEach(Array(onboardingData.items.enumerated()), id: \.element.id) { index, item in
|
|
|
- HStack {
|
|
|
- // Time display
|
|
|
- Text(
|
|
|
- dateFormatter
|
|
|
- .string(from: Date(timeIntervalSince1970: onboardingData.timeValues[item.timeIndex]))
|
|
|
- )
|
|
|
- .frame(width: 80, alignment: .leading)
|
|
|
-
|
|
|
- // Ratio slider
|
|
|
- Slider(
|
|
|
- value: Binding(
|
|
|
- get: { Double(truncating: onboardingData.rateValues[item.rateIndex] as NSNumber) },
|
|
|
- set: { newValue in
|
|
|
- // Find closest match in rateValues array
|
|
|
- let newIndex = onboardingData.rateValues
|
|
|
- .firstIndex { abs(Double($0) - newValue) < 0.05 } ?? item.rateIndex
|
|
|
- onboardingData.items[index].rateIndex = newIndex
|
|
|
- }
|
|
|
- ),
|
|
|
- in: Double(truncating: onboardingData.rateValues.first! as NSNumber) ...
|
|
|
- Double(truncating: onboardingData.rateValues.last! as NSNumber),
|
|
|
- step: 0.5
|
|
|
- )
|
|
|
- .accentColor(.orange)
|
|
|
-
|
|
|
- // Display the current value
|
|
|
- Text("\(formatter.string(from: onboardingData.rateValues[item.rateIndex] as NSNumber) ?? "--") g/U")
|
|
|
- .frame(width: 70, alignment: .trailing)
|
|
|
-
|
|
|
- // Delete button (not for the first entry at 00:00)
|
|
|
- if index > 0 {
|
|
|
- Button(action: {
|
|
|
- onboardingData.items.remove(at: index)
|
|
|
- }) {
|
|
|
- Image(systemName: "trash")
|
|
|
- .foregroundColor(.red)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- .padding(.vertical, 8)
|
|
|
- .background(Color.orange.opacity(0.05))
|
|
|
- .cornerRadius(8)
|
|
|
+ ScrollView {
|
|
|
+ VStack(alignment: .leading, spacing: 20) {
|
|
|
+ Text("Your carb ratio tells how many grams of carbohydrates one unit of insulin will cover.")
|
|
|
+ .font(.subheadline)
|
|
|
+ .foregroundColor(.secondary)
|
|
|
+ .padding(.horizontal)
|
|
|
+
|
|
|
+ // Chart visualization
|
|
|
+ if !onboardingData.items.isEmpty {
|
|
|
+ VStack(alignment: .leading) {
|
|
|
+ Text("Carb Ratio Profile")
|
|
|
+ .font(.headline)
|
|
|
+ .padding(.horizontal)
|
|
|
+
|
|
|
+ carbRatioChart
|
|
|
+ .frame(height: 180)
|
|
|
+ .padding(.horizontal)
|
|
|
}
|
|
|
+ .padding(.vertical, 5)
|
|
|
+ .background(Color.orange.opacity(0.05))
|
|
|
+ .cornerRadius(10)
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- // Add new carb ratio button
|
|
|
- if !onboardingData.items.isEmpty && onboardingData.items.count < 24 {
|
|
|
- Button(action: {
|
|
|
- showTimeSelector = true
|
|
|
- }) {
|
|
|
+ // Carb ratios list
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
HStack {
|
|
|
- Image(systemName: "plus.circle.fill")
|
|
|
- Text("Add Carb Ratio")
|
|
|
+ Text("Carb Ratios")
|
|
|
+ .font(.headline)
|
|
|
+
|
|
|
+ Spacer()
|
|
|
+
|
|
|
+ // Add new carb ratio button
|
|
|
+ if onboardingData.items.count < 24 {
|
|
|
+ Button(action: {
|
|
|
+ showTimeSelector = true
|
|
|
+ }) {
|
|
|
+ HStack {
|
|
|
+ Image(systemName: "plus.circle.fill")
|
|
|
+ Text("Add Ratio")
|
|
|
+ }
|
|
|
+ .foregroundColor(.orange)
|
|
|
+ }
|
|
|
+ .disabled(!canAddRatio)
|
|
|
+ }
|
|
|
}
|
|
|
- .foregroundColor(.orange)
|
|
|
- .padding(.vertical, 8)
|
|
|
- }
|
|
|
- }
|
|
|
+ .padding(.horizontal)
|
|
|
|
|
|
- Divider()
|
|
|
-
|
|
|
- // Example calculation based on first carb ratio
|
|
|
- if !onboardingData.items.isEmpty {
|
|
|
- VStack(alignment: .leading, spacing: 8) {
|
|
|
- Text("Example Calculation")
|
|
|
- .font(.headline)
|
|
|
- .padding(.top)
|
|
|
-
|
|
|
- VStack(alignment: .leading, spacing: 4) {
|
|
|
- Text("For 45g of carbs, you would need:")
|
|
|
- .font(.subheadline)
|
|
|
-
|
|
|
- let insulinNeeded = 45 /
|
|
|
- Double(truncating: onboardingData.rateValues[onboardingData.items.first!.rateIndex] as NSNumber)
|
|
|
- Text(
|
|
|
- "45g ÷ \(formatter.string(from: onboardingData.rateValues[onboardingData.items.first!.rateIndex] as NSNumber) ?? "--") = \(String(format: "%.1f", insulinNeeded)) units of insulin"
|
|
|
- )
|
|
|
- .font(.system(.body, design: .monospaced))
|
|
|
+ if onboardingData.items.isEmpty {
|
|
|
+ // Add default entry if no items exist
|
|
|
+ Button("Add Initial Carb Ratio") {
|
|
|
+ onboardingData.addCarbRatio()
|
|
|
+ }
|
|
|
.foregroundColor(.orange)
|
|
|
- .padding(.vertical, 8)
|
|
|
- .padding(.horizontal, 12)
|
|
|
+ .padding()
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
.background(Color.orange.opacity(0.1))
|
|
|
.cornerRadius(8)
|
|
|
- }
|
|
|
- .padding(.vertical, 4)
|
|
|
- }
|
|
|
+ .padding(.horizontal)
|
|
|
+ } else {
|
|
|
+ // List of carb ratios
|
|
|
+ VStack(spacing: 2) {
|
|
|
+ ForEach(Array(onboardingData.items.enumerated()), id: \.element.id) { index, item in
|
|
|
+ HStack {
|
|
|
+ // Time display
|
|
|
+ Text(
|
|
|
+ dateFormatter
|
|
|
+ .string(from: Date(timeIntervalSince1970: onboardingData.timeValues[item.timeIndex]))
|
|
|
+ )
|
|
|
+ .frame(width: 80, alignment: .leading)
|
|
|
+ .padding(.leading)
|
|
|
+
|
|
|
+ // Ratio slider
|
|
|
+ Slider(
|
|
|
+ value: Binding(
|
|
|
+ get: { Double(truncating: onboardingData.rateValues[item.rateIndex] as NSNumber) },
|
|
|
+ set: { newValue in
|
|
|
+ // Find closest match in rateValues array
|
|
|
+ let newIndex = onboardingData.rateValues
|
|
|
+ .firstIndex { abs(Double($0) - newValue) < 0.05 } ?? item.rateIndex
|
|
|
+ onboardingData.items[index].rateIndex = newIndex
|
|
|
+ }
|
|
|
+ ),
|
|
|
+ in: Double(truncating: onboardingData.rateValues.first! as NSNumber) ...
|
|
|
+ Double(truncating: onboardingData.rateValues.last! as NSNumber),
|
|
|
+ step: 0.5
|
|
|
+ )
|
|
|
+ .accentColor(.orange)
|
|
|
+ .padding(.horizontal, 5)
|
|
|
+
|
|
|
+ // Display the current value
|
|
|
+ Text(
|
|
|
+ "\(formatter.string(from: onboardingData.rateValues[item.rateIndex] as NSNumber) ?? "--") g/U"
|
|
|
+ )
|
|
|
+ .frame(width: 60, alignment: .trailing)
|
|
|
|
|
|
- // Information about the carb ratio
|
|
|
- VStack(alignment: .leading, spacing: 8) {
|
|
|
- Text("What This Means")
|
|
|
- .font(.headline)
|
|
|
- .padding(.top, 8)
|
|
|
-
|
|
|
- VStack(alignment: .leading, spacing: 4) {
|
|
|
- Text("• A ratio of 10 g/U means 1 unit of insulin covers 10g of carbs")
|
|
|
- Text("• A lower number means you need more insulin for the same amount of carbs")
|
|
|
- Text("• A higher number means you need less insulin for the same amount of carbs")
|
|
|
- Text("• Different times of day may require different ratios")
|
|
|
+ // Delete button (not for the first entry at 00:00)
|
|
|
+ if index > 0 {
|
|
|
+ Button(action: {
|
|
|
+ onboardingData.items.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)
|
|
|
}
|
|
|
- .font(.caption)
|
|
|
- .foregroundColor(.secondary)
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- // Visualization of carb ratio if we have ratios defined
|
|
|
- if !onboardingData.items.isEmpty {
|
|
|
- VStack(alignment: .leading, spacing: 8) {
|
|
|
- Text("Visual Reference")
|
|
|
- .font(.headline)
|
|
|
- .padding(.top)
|
|
|
-
|
|
|
- HStack(spacing: 20) {
|
|
|
- VStack {
|
|
|
- Image(systemName: "fork.knife")
|
|
|
- .font(.system(size: 40))
|
|
|
- .foregroundColor(.orange)
|
|
|
+ // Example calculation based on first carb ratio
|
|
|
+ if !onboardingData.items.isEmpty {
|
|
|
+ Divider()
|
|
|
+ .padding(.horizontal)
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ Text("Example Calculation")
|
|
|
+ .font(.headline)
|
|
|
+ .padding(.horizontal)
|
|
|
+
|
|
|
+ VStack(alignment: .leading, spacing: 4) {
|
|
|
+ Text("For 45g of carbs, you would need:")
|
|
|
+ .font(.subheadline)
|
|
|
+ .padding(.horizontal)
|
|
|
+
|
|
|
+ let insulinNeeded = 45 /
|
|
|
+ Double(truncating: onboardingData.rateValues[onboardingData.items.first!.rateIndex] as NSNumber)
|
|
|
Text(
|
|
|
- "\(formatter.string(from: onboardingData.rateValues[onboardingData.items.first!.rateIndex] as NSNumber) ?? "--")g"
|
|
|
+ "45g ÷ \(formatter.string(from: onboardingData.rateValues[onboardingData.items.first!.rateIndex] as NSNumber) ?? "--") = \(String(format: "%.1f", insulinNeeded)) units of insulin"
|
|
|
)
|
|
|
- .font(.headline)
|
|
|
- Text("Carbs")
|
|
|
- .font(.caption)
|
|
|
+ .font(.system(.body, design: .monospaced))
|
|
|
+ .foregroundColor(.orange)
|
|
|
+ .padding(.vertical, 8)
|
|
|
+ .padding(.horizontal, 12)
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
+ .background(Color.orange.opacity(0.1))
|
|
|
+ .cornerRadius(8)
|
|
|
+ .padding(.horizontal)
|
|
|
}
|
|
|
+ .padding(.vertical, 4)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Information about the carb ratio
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
+ Text("What This Means")
|
|
|
+ .font(.headline)
|
|
|
+ .padding(.horizontal)
|
|
|
|
|
|
- Text("=")
|
|
|
- .font(.title)
|
|
|
-
|
|
|
- VStack {
|
|
|
- Image(systemName: "drop.fill")
|
|
|
- .font(.system(size: 40))
|
|
|
- .foregroundColor(.blue)
|
|
|
- Text("1U")
|
|
|
- .font(.headline)
|
|
|
- Text("Insulin")
|
|
|
- .font(.caption)
|
|
|
+ VStack(alignment: .leading, spacing: 4) {
|
|
|
+ Text("• A ratio of 10 g/U means 1 unit of insulin covers 10g of carbs")
|
|
|
+ Text("• A lower number means you need more insulin for the same amount of carbs")
|
|
|
+ Text("• A higher number means you need less insulin for the same amount of carbs")
|
|
|
+ Text("• Different times of day may require different ratios")
|
|
|
}
|
|
|
+ .font(.caption)
|
|
|
+ .foregroundColor(.secondary)
|
|
|
+ .padding(.horizontal)
|
|
|
}
|
|
|
- .frame(maxWidth: .infinity)
|
|
|
- .padding()
|
|
|
- .background(Color.orange.opacity(0.1))
|
|
|
- .cornerRadius(12)
|
|
|
}
|
|
|
}
|
|
|
+ .padding(.vertical)
|
|
|
}
|
|
|
- .padding()
|
|
|
.actionSheet(isPresented: $showTimeSelector) {
|
|
|
var buttons: [ActionSheet.Button] = []
|
|
|
|
|
|
@@ -224,4 +237,66 @@ struct CarbRatioStepView: View {
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ // Computed property to check if we can add more carb ratios
|
|
|
+ private var canAddRatio: Bool {
|
|
|
+ guard let lastItem = onboardingData.items.last else { return true }
|
|
|
+ return lastItem.timeIndex < onboardingData.timeValues.count - 1
|
|
|
+ }
|
|
|
+
|
|
|
+ // Chart for visualizing carb ratios
|
|
|
+ private var carbRatioChart: some View {
|
|
|
+ Chart {
|
|
|
+ ForEach(Array(onboardingData.items.enumerated()), id: \.element.id) { index, item in
|
|
|
+ let displayValue = onboardingData.rateValues[item.rateIndex]
|
|
|
+
|
|
|
+ let tzOffset = TimeZone.current.secondsFromGMT() * -1
|
|
|
+ let startDate = Date(timeIntervalSinceReferenceDate: onboardingData.timeValues[item.timeIndex])
|
|
|
+ .addingTimeInterval(TimeInterval(tzOffset))
|
|
|
+ let endDate = onboardingData.items.count > index + 1 ?
|
|
|
+ Date(timeIntervalSinceReferenceDate: onboardingData.timeValues[onboardingData.items[index + 1].timeIndex])
|
|
|
+ .addingTimeInterval(TimeInterval(tzOffset)) :
|
|
|
+ Date(timeIntervalSinceReferenceDate: onboardingData.timeValues.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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .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]))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|