BasalProfileStepView.swift 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  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. @State private var now = Date()
  16. private var rateFormatter: NumberFormatter {
  17. let formatter = NumberFormatter()
  18. formatter.numberStyle = .decimal
  19. return formatter
  20. }
  21. private var dateFormatter: DateFormatter {
  22. let formatter = DateFormatter()
  23. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  24. formatter.timeStyle = .short
  25. return formatter
  26. }
  27. var body: some View {
  28. LazyVStack {
  29. VStack(alignment: .leading, spacing: 0) {
  30. // Chart visualization
  31. if !state.basalProfileItems.isEmpty {
  32. VStack(alignment: .leading) {
  33. basalProfileChart
  34. .frame(height: 180)
  35. .padding(.horizontal)
  36. }
  37. .padding(.vertical)
  38. .background(Color.chart.opacity(0.65))
  39. .clipShape(
  40. .rect(
  41. topLeadingRadius: 10,
  42. bottomLeadingRadius: 0,
  43. bottomTrailingRadius: 0,
  44. topTrailingRadius: 10
  45. )
  46. )
  47. }
  48. TherapySettingEditorView(
  49. items: $therapyItems,
  50. unit: .unitPerHour,
  51. timeOptions: state.basalProfileTimeValues,
  52. valueOptions: state.basalProfileRateValues,
  53. validateOnDelete: state.validateBasal
  54. )
  55. Spacer(minLength: 20)
  56. // Total daily basal calculation
  57. if !state.basalProfileItems.isEmpty {
  58. VStack(alignment: .leading, spacing: 0) {
  59. HStack {
  60. Text("Total")
  61. .bold()
  62. Spacer()
  63. HStack {
  64. Text(rateFormatter.string(from: calculateTotalDailyBasal() as NSNumber) ?? "0")
  65. Text("U/day")
  66. .foregroundStyle(Color.secondary)
  67. }
  68. .id(refreshUI) // Erzwingt die Aktualisierung des Totals
  69. }
  70. }
  71. .padding()
  72. .background(Color.chart.opacity(0.65))
  73. .cornerRadius(10)
  74. }
  75. }
  76. }
  77. .onAppear {
  78. if state.basalProfileItems.isEmpty {
  79. state.addInitialBasalRate()
  80. }
  81. state.validateBasal()
  82. therapyItems = state.getBasalTherapyItems()
  83. }.onChange(of: therapyItems) { _, newItems in
  84. state.updateBasal(from: newItems)
  85. refreshUI = UUID()
  86. }
  87. }
  88. // Calculate the total daily basal insulin
  89. private func calculateTotalDailyBasal() -> Double {
  90. let items = state.basalProfileItems
  91. // If there are no items, return 0
  92. if items.isEmpty {
  93. return 0.0
  94. }
  95. var total: Double = 0.0
  96. // Safely create profile items with proper error checking
  97. let profileItems = items.compactMap { item -> (timeIndex: Int, rate: Decimal)? in
  98. // Safety check - make sure indices are within bounds
  99. guard item.timeIndex >= 0 && item.timeIndex < state.basalProfileTimeValues.count,
  100. item.rateIndex >= 0 && item.rateIndex < state.basalProfileRateValues.count
  101. else {
  102. return nil
  103. }
  104. let timeValue = state.basalProfileTimeValues[item.timeIndex]
  105. let rate = state.basalProfileRateValues[item.rateIndex]
  106. return (Int(timeValue / 60), rate)
  107. }.sorted(by: { $0.timeIndex < $1.timeIndex })
  108. // If after safety checks we have no valid items, return 0
  109. if profileItems.isEmpty {
  110. return 0.0
  111. }
  112. // Create time points array safely
  113. var timePoints = profileItems.map(\.timeIndex)
  114. // Add the 24-hour mark to complete the cycle
  115. timePoints.append(24 * 60) // Add 24 hours in minutes
  116. // Calculate the total by multiplying each rate by its duration
  117. for i in 0 ..< profileItems.count {
  118. let rate = profileItems[i].rate
  119. let currentTimeIndex = profileItems[i].timeIndex
  120. // Calculate duration safely
  121. let nextTimeIndex = i + 1 < timePoints.count ? timePoints[i + 1] : (24 * 60)
  122. let duration = nextTimeIndex - currentTimeIndex
  123. // Only add if duration is positive
  124. if duration > 0 {
  125. total += Double(rate) * Double(duration) / 60.0 // Convert to hours
  126. }
  127. }
  128. return total
  129. }
  130. // Chart for visualizing basal profile
  131. private var basalProfileChart: some View {
  132. Chart {
  133. ForEach(Array(state.basalProfileItems.enumerated()), id: \.element.id) { index, item in
  134. let displayValue = state.basalProfileRateValues[item.rateIndex]
  135. let startDate = Calendar.current
  136. .startOfDay(for: now)
  137. .addingTimeInterval(state.basalProfileTimeValues[item.timeIndex])
  138. var offset: TimeInterval {
  139. if state.basalProfileItems.count > index + 1 {
  140. return state.basalProfileTimeValues[state.basalProfileItems[index + 1].timeIndex]
  141. } else {
  142. return state.basalProfileTimeValues.last! + 30 * 60
  143. }
  144. }
  145. let endDate = Calendar.current.startOfDay(for: now).addingTimeInterval(offset)
  146. RectangleMark(
  147. xStart: .value("start", startDate),
  148. xEnd: .value("end", endDate),
  149. yStart: .value("rate-start", displayValue),
  150. yEnd: .value("rate-end", 0)
  151. ).foregroundStyle(
  152. .linearGradient(
  153. colors: [
  154. Color.purple.opacity(0.6),
  155. Color.purple.opacity(0.1)
  156. ],
  157. startPoint: .bottom,
  158. endPoint: .top
  159. )
  160. ).alignsMarkStylesWithPlotArea()
  161. LineMark(x: .value("End Date", startDate), y: .value("Rate", displayValue))
  162. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.purple)
  163. LineMark(x: .value("Start Date", endDate), y: .value("Rate", displayValue))
  164. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.purple)
  165. }
  166. }
  167. .id(refreshUI) // Force chart update
  168. .chartXAxis {
  169. AxisMarks(values: .automatic(desiredCount: 6)) { _ in
  170. AxisValueLabel(format: .dateTime.hour())
  171. AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1, dash: [2, 4]))
  172. }
  173. }
  174. .chartXScale(
  175. domain: Calendar.current.startOfDay(for: now) ... Calendar.current.startOfDay(for: now)
  176. .addingTimeInterval(60 * 60 * 24)
  177. )
  178. .chartYAxis {
  179. AxisMarks(values: .automatic(desiredCount: 4)) { _ in
  180. AxisValueLabel()
  181. AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1, dash: [2, 4]))
  182. }
  183. }
  184. }
  185. }