BasalProfileStepView.swift 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. //
  2. // BasalProfileStepView.swift
  3. // Trio
  4. //
  5. // Created by Marvin Polscheit on 19.03.25.
  6. //
  7. import Charts
  8. import SwiftUI
  9. import UIKit
  10. /// Basal profile step view for setting basal insulin rates.
  11. struct BasalProfileStepView: View {
  12. @Bindable var state: Onboarding.StateModel
  13. @State private var refreshUI = UUID() // to update chart when slider value changes
  14. @State private var therapyItems: [TherapySettingItem] = []
  15. // For chart scaling
  16. private let chartScale = Calendar.current
  17. .date(from: DateComponents(year: 2001, month: 01, day: 01, hour: 0, minute: 0, second: 0))
  18. private var formatter: NumberFormatter {
  19. let formatter = NumberFormatter()
  20. formatter.numberStyle = .decimal
  21. formatter.maximumFractionDigits = 2
  22. return formatter
  23. }
  24. private var dateFormatter: DateFormatter {
  25. let formatter = DateFormatter()
  26. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  27. formatter.timeStyle = .short
  28. return formatter
  29. }
  30. var body: some View {
  31. LazyVStack {
  32. VStack(alignment: .leading, spacing: 0) {
  33. // Chart visualization
  34. if !state.basalProfileItems.isEmpty {
  35. VStack(alignment: .leading) {
  36. basalProfileChart
  37. .frame(height: 180)
  38. .padding(.horizontal)
  39. }
  40. .padding(.vertical)
  41. .background(Color.chart.opacity(0.65))
  42. .cornerRadius(10)
  43. }
  44. TimeValueEditorView(
  45. items: $therapyItems,
  46. unit: String(localized: "U/hr"),
  47. valueOptions: state.basalProfileRateValues
  48. )
  49. Spacer(minLength: 20)
  50. // Total daily basal calculation
  51. if !state.basalProfileItems.isEmpty {
  52. VStack(alignment: .leading, spacing: 0) {
  53. HStack {
  54. Text("Total")
  55. .bold()
  56. Spacer()
  57. HStack {
  58. Text("\(calculateTotalDailyBasal(), specifier: "%.2f")")
  59. Text("U/hr")
  60. .foregroundStyle(Color.secondary)
  61. }
  62. .id(refreshUI) // Erzwingt die Aktualisierung des Totals
  63. }
  64. }
  65. .padding()
  66. .background(Color.chart.opacity(0.65))
  67. .cornerRadius(10)
  68. }
  69. }
  70. }
  71. .onAppear {
  72. if state.basalProfileItems.isEmpty {
  73. state.addBasalRate()
  74. }
  75. therapyItems = state.getBasalTherapyItems(from: state.basalProfileItems)
  76. }.onChange(of: therapyItems) { _, newItems in
  77. state.updateBasalRates(from: newItems)
  78. refreshUI = UUID()
  79. }
  80. }
  81. // Add initial basal rate
  82. private func addBasalRate() {
  83. // Default to midnight (00:00) and 1.0 U/h rate
  84. let timeIndex = state.basalProfileTimeValues.firstIndex { abs($0 - 0) < 1 } ?? 0
  85. let rateIndex = state.basalProfileRateValues.firstIndex { abs(Double($0) - 1.0) < 0.05 } ?? 20
  86. let newItem = BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  87. state.basalProfileItems.append(newItem)
  88. }
  89. // Computed property to check if we can add more basal rates
  90. private var canAddBasalRate: Bool {
  91. guard let lastItem = state.basalProfileItems.last else { return true }
  92. return lastItem.timeIndex < state.basalProfileTimeValues.count - 1
  93. }
  94. // Calculate the total daily basal insulin
  95. private func calculateTotalDailyBasal() -> Double {
  96. let items = state.basalProfileItems
  97. // If there are no items, return 0
  98. if items.isEmpty {
  99. return 0.0
  100. }
  101. var total: Double = 0.0
  102. // Safely create profile items with proper error checking
  103. let profileItems = items.compactMap { item -> (timeIndex: Int, rate: Decimal)? in
  104. // Safety check - make sure indices are within bounds
  105. guard item.timeIndex >= 0 && item.timeIndex < state.basalProfileTimeValues.count,
  106. item.rateIndex >= 0 && item.rateIndex < state.basalProfileRateValues.count
  107. else {
  108. return nil
  109. }
  110. let timeValue = state.basalProfileTimeValues[item.timeIndex]
  111. let rate = state.basalProfileRateValues[item.rateIndex]
  112. return (Int(timeValue / 60), rate)
  113. }.sorted(by: { $0.timeIndex < $1.timeIndex })
  114. // If after safety checks we have no valid items, return 0
  115. if profileItems.isEmpty {
  116. return 0.0
  117. }
  118. // Create time points array safely
  119. var timePoints = profileItems.map(\.timeIndex)
  120. // Add the 24-hour mark to complete the cycle
  121. timePoints.append(24 * 60) // Add 24 hours in minutes
  122. // Calculate the total by multiplying each rate by its duration
  123. for i in 0 ..< profileItems.count {
  124. let rate = profileItems[i].rate
  125. let currentTimeIndex = profileItems[i].timeIndex
  126. // Calculate duration safely
  127. let nextTimeIndex = i + 1 < timePoints.count ? timePoints[i + 1] : (24 * 60)
  128. let duration = nextTimeIndex - currentTimeIndex
  129. // Only add if duration is positive
  130. if duration > 0 {
  131. total += Double(rate) * Double(duration) / 60.0 // Convert to hours
  132. }
  133. }
  134. return total
  135. }
  136. // Chart for visualizing basal profile
  137. private var basalProfileChart: some View {
  138. Chart {
  139. ForEach(Array(state.basalProfileItems.enumerated()), id: \.element.id) { index, item in
  140. let displayValue = state.basalProfileRateValues[item.rateIndex]
  141. let tzOffset = TimeZone.current.secondsFromGMT() * -1
  142. let startDate = Date(timeIntervalSinceReferenceDate: state.basalProfileTimeValues[item.timeIndex])
  143. .addingTimeInterval(TimeInterval(tzOffset))
  144. let endDate = state.basalProfileItems.count > index + 1 ?
  145. Date(
  146. timeIntervalSinceReferenceDate: state
  147. .basalProfileTimeValues[state.basalProfileItems[index + 1].timeIndex]
  148. )
  149. .addingTimeInterval(TimeInterval(tzOffset)) :
  150. Date(timeIntervalSinceReferenceDate: state.basalProfileTimeValues.last!).addingTimeInterval(30 * 60)
  151. .addingTimeInterval(TimeInterval(tzOffset))
  152. RectangleMark(
  153. xStart: .value("start", startDate),
  154. xEnd: .value("end", endDate),
  155. yStart: .value("rate-start", displayValue),
  156. yEnd: .value("rate-end", 0)
  157. ).foregroundStyle(
  158. .linearGradient(
  159. colors: [
  160. Color.purple.opacity(0.6),
  161. Color.purple.opacity(0.1)
  162. ],
  163. startPoint: .bottom,
  164. endPoint: .top
  165. )
  166. ).alignsMarkStylesWithPlotArea()
  167. LineMark(x: .value("End Date", startDate), y: .value("Rate", displayValue))
  168. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.purple)
  169. LineMark(x: .value("Start Date", endDate), y: .value("Rate", displayValue))
  170. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.purple)
  171. }
  172. }
  173. .id(refreshUI) // Force chart update
  174. .chartXAxis {
  175. AxisMarks(values: .automatic(desiredCount: 6)) { _ in
  176. AxisValueLabel(format: .dateTime.hour())
  177. AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1, dash: [2, 4]))
  178. }
  179. }
  180. .chartXScale(
  181. domain: Calendar.current.startOfDay(for: chartScale!) ... Calendar.current.startOfDay(for: chartScale!)
  182. .addingTimeInterval(60 * 60 * 24)
  183. )
  184. .chartYAxis {
  185. AxisMarks(values: .automatic(desiredCount: 4)) { _ in
  186. AxisValueLabel()
  187. AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1, dash: [2, 4]))
  188. }
  189. }
  190. }
  191. }