BasalProfileStepView.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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. @State var onboardingData: OnboardingData
  13. @State private var showTimeSelector = false
  14. @State private var selectedBasalIndex: Int?
  15. @State private var showAlert = false
  16. @State private var errorMessage = ""
  17. @State private var refreshUI = UUID() // to update chart when slider value changes
  18. // For chart scaling
  19. private let chartScale = Calendar.current
  20. .date(from: DateComponents(year: 2001, month: 01, day: 01, hour: 0, minute: 0, second: 0))
  21. private var formatter: NumberFormatter {
  22. let formatter = NumberFormatter()
  23. formatter.numberStyle = .decimal
  24. formatter.maximumFractionDigits = 2
  25. return formatter
  26. }
  27. private var dateFormatter: DateFormatter {
  28. let formatter = DateFormatter()
  29. formatter.timeZone = TimeZone(secondsFromGMT: 0)
  30. formatter.timeStyle = .short
  31. return formatter
  32. }
  33. var body: some View {
  34. ScrollView {
  35. VStack(alignment: .leading, spacing: 20) {
  36. Text("Your basal insulin profile determines how much background insulin you receive throughout the day.")
  37. .font(.subheadline)
  38. .foregroundColor(.secondary)
  39. .padding(.horizontal)
  40. // Chart visualization
  41. if !onboardingData.basalProfileItems.isEmpty {
  42. VStack(alignment: .leading) {
  43. Text("Basal Profile")
  44. .font(.headline)
  45. .padding(.horizontal)
  46. basalProfileChart
  47. .frame(height: 180)
  48. .padding(.horizontal)
  49. }
  50. .padding(.vertical, 5)
  51. .background(Color.purple.opacity(0.05))
  52. .cornerRadius(10)
  53. }
  54. // Basal rates list
  55. VStack(alignment: .leading, spacing: 10) {
  56. HStack {
  57. Text("Basal Rates")
  58. .font(.headline)
  59. Spacer()
  60. // Add new basal rate button
  61. if onboardingData.basalProfileItems.count < 24 {
  62. Button(action: {
  63. showTimeSelector = true
  64. }) {
  65. HStack {
  66. Image(systemName: "plus.circle.fill")
  67. Text("Add Rate")
  68. }
  69. .foregroundColor(.purple)
  70. }
  71. .disabled(!canAddBasalRate)
  72. }
  73. }
  74. .padding(.horizontal)
  75. // List of basal rates
  76. VStack(spacing: 2) {
  77. ForEach(Array(onboardingData.basalProfileItems.enumerated()), id: \.element.id) { index, item in
  78. HStack {
  79. // Time display
  80. Text(
  81. dateFormatter
  82. .string(from: Date(
  83. timeIntervalSince1970: onboardingData
  84. .basalProfileTimeValues[item.timeIndex]
  85. ))
  86. )
  87. .frame(width: 80, alignment: .leading)
  88. .padding(.leading)
  89. // Rate slider
  90. Slider(
  91. value: Binding(
  92. get: {
  93. guard !onboardingData.basalProfileRateValues.isEmpty,
  94. item.rateIndex < onboardingData.basalProfileRateValues.count
  95. else {
  96. return 0.0
  97. }
  98. return Double(
  99. truncating: onboardingData
  100. .basalProfileRateValues[item.rateIndex] as NSNumber
  101. )
  102. },
  103. set: { newValue in
  104. guard !onboardingData.basalProfileRateValues.isEmpty else { return }
  105. // Find closest match in rateValues array
  106. let newIndex = onboardingData.basalProfileRateValues
  107. .firstIndex { abs(Double($0) - newValue) < 0.005 } ?? item.rateIndex
  108. // Ensure index is valid before updating
  109. if newIndex < onboardingData.basalProfileRateValues.count,
  110. index < onboardingData.basalProfileItems.count
  111. {
  112. onboardingData.basalProfileItems[index].rateIndex = newIndex
  113. // Force refresh when slider changes
  114. refreshUI = UUID()
  115. }
  116. }
  117. ),
  118. in: onboardingData.basalProfileRateValues.isEmpty ? 0 ... 1 :
  119. Double(truncating: onboardingData.basalProfileRateValues.first! as NSNumber) ...
  120. Double(truncating: onboardingData.basalProfileRateValues.last! as NSNumber),
  121. step: 0.05
  122. )
  123. .accentColor(.purple)
  124. .padding(.horizontal, 5)
  125. .onChange(of: onboardingData.basalProfileItems[index].rateIndex) { _, _ in
  126. // Trigger immediate UI update when slider value changes
  127. let impact = UIImpactFeedbackGenerator(style: .light)
  128. impact.impactOccurred()
  129. }
  130. // Display the current value
  131. Text(
  132. "\(onboardingData.basalProfileRateValues.isEmpty || item.rateIndex >= onboardingData.basalProfileRateValues.count ? "--" : formatter.string(from: onboardingData.basalProfileRateValues[item.rateIndex] as NSNumber) ?? "--") U/h"
  133. )
  134. .frame(width: 80, alignment: .trailing)
  135. .lineLimit(1)
  136. .minimumScaleFactor(0.8)
  137. // Delete button (not for the first entry at 00:00)
  138. if index > 0 {
  139. Button(action: {
  140. onboardingData.basalProfileItems.remove(at: index)
  141. }) {
  142. Image(systemName: "trash")
  143. .foregroundColor(.red)
  144. .padding(.horizontal, 5)
  145. }
  146. } else {
  147. // Spacer to maintain alignment
  148. Spacer()
  149. .frame(width: 30)
  150. }
  151. }
  152. .padding(.vertical, 12)
  153. .background(index % 2 == 0 ? Color.purple.opacity(0.05) : Color.clear)
  154. .cornerRadius(8)
  155. }
  156. }
  157. .background(Color.purple.opacity(0.05))
  158. .cornerRadius(10)
  159. .padding(.horizontal)
  160. .onAppear {
  161. addBasalRate()
  162. }
  163. }
  164. // Total daily basal calculation
  165. if !onboardingData.basalProfileItems.isEmpty {
  166. VStack(alignment: .leading, spacing: 8) {
  167. HStack {
  168. Text("Total Daily Basal")
  169. .font(.headline)
  170. .padding(.horizontal)
  171. Spacer()
  172. Text("\(calculateTotalDailyBasal(), specifier: "%.2f") U/day")
  173. .font(.headline)
  174. .padding(.horizontal)
  175. .id(refreshUI) // Erzwingt die Aktualisierung des Totals
  176. }
  177. }
  178. .padding(.top)
  179. // Information about basal rates
  180. VStack(alignment: .leading, spacing: 8) {
  181. Text("What This Means")
  182. .font(.headline)
  183. .padding(.horizontal)
  184. VStack(alignment: .leading, spacing: 4) {
  185. Text("• The basal profile provides background insulin throughout the day")
  186. Text("• Rates should be adjusted based on your body's varying insulin needs")
  187. Text("• Morning hours may require more insulin due to 'dawn phenomenon'")
  188. Text("• Lower rates are typically needed during sleep or periods of activity")
  189. }
  190. .font(.caption)
  191. .foregroundColor(.secondary)
  192. .padding(.horizontal)
  193. }
  194. }
  195. }
  196. .padding(.vertical)
  197. }
  198. .actionSheet(isPresented: $showTimeSelector) {
  199. var buttons: [ActionSheet.Button] = []
  200. // Find available time slots in 1-hour increments
  201. for hour in 0 ..< 24 {
  202. let hourInMinutes = hour * 60
  203. // Calculate timeIndex for this hour
  204. let timeIndex = onboardingData.basalProfileTimeValues
  205. .firstIndex { abs($0 - Double(hourInMinutes * 60)) < 10 } ?? 0
  206. // Check if this hour is already in the profile
  207. if !onboardingData.basalProfileItems.contains(where: { $0.timeIndex == timeIndex }) {
  208. buttons.append(.default(Text("\(String(format: "%02d:00", hour))")) {
  209. // Get the current rate from the last item
  210. let rateIndex = onboardingData.basalProfileItems.last?.rateIndex ?? 20 // 1.0 U/h as default
  211. // Create new item with the specified time
  212. let newItem = BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  213. // Add the new item and sort the list
  214. onboardingData.basalProfileItems.append(newItem)
  215. onboardingData.basalProfileItems.sort(by: { $0.timeIndex < $1.timeIndex })
  216. })
  217. }
  218. }
  219. buttons.append(.cancel())
  220. return ActionSheet(
  221. title: Text("Select Start Time"),
  222. message: Text("Choose when this basal rate should start"),
  223. buttons: buttons
  224. )
  225. }
  226. .alert(isPresented: $showAlert) {
  227. Alert(
  228. title: Text("Unable to Save Basal Profile"),
  229. message: Text(errorMessage),
  230. dismissButton: .default(Text("OK"))
  231. )
  232. }
  233. }
  234. // Add initial basal rate
  235. private func addBasalRate() {
  236. // Default to midnight (00:00) and 1.0 U/h rate
  237. let timeIndex = onboardingData.basalProfileTimeValues.firstIndex { abs($0 - 0) < 1 } ?? 0
  238. let rateIndex = onboardingData.basalProfileRateValues.firstIndex { abs(Double($0) - 1.0) < 0.05 } ?? 20
  239. let newItem = BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  240. onboardingData.basalProfileItems.append(newItem)
  241. }
  242. // Computed property to check if we can add more basal rates
  243. private var canAddBasalRate: Bool {
  244. guard let lastItem = onboardingData.basalProfileItems.last else { return true }
  245. return lastItem.timeIndex < onboardingData.basalProfileTimeValues.count - 1
  246. }
  247. // Calculate the total daily basal insulin
  248. private func calculateTotalDailyBasal() -> Double {
  249. let items = onboardingData.basalProfileItems
  250. // If there are no items, return 0
  251. if items.isEmpty {
  252. return 0.0
  253. }
  254. var total: Double = 0.0
  255. // Safely create profile items with proper error checking
  256. let profileItems = items.compactMap { item -> (timeIndex: Int, rate: Decimal)? in
  257. // Safety check - make sure indices are within bounds
  258. guard item.timeIndex >= 0 && item.timeIndex < onboardingData.basalProfileTimeValues.count,
  259. item.rateIndex >= 0 && item.rateIndex < onboardingData.basalProfileRateValues.count
  260. else {
  261. return nil
  262. }
  263. let timeValue = onboardingData.basalProfileTimeValues[item.timeIndex]
  264. let rate = onboardingData.basalProfileRateValues[item.rateIndex]
  265. return (Int(timeValue / 60), rate)
  266. }.sorted(by: { $0.timeIndex < $1.timeIndex })
  267. // If after safety checks we have no valid items, return 0
  268. if profileItems.isEmpty {
  269. return 0.0
  270. }
  271. // Create time points array safely
  272. var timePoints = profileItems.map(\.timeIndex)
  273. // Add the 24-hour mark to complete the cycle
  274. timePoints.append(24 * 60) // Add 24 hours in minutes
  275. // Calculate the total by multiplying each rate by its duration
  276. for i in 0 ..< profileItems.count {
  277. let rate = profileItems[i].rate
  278. let currentTimeIndex = profileItems[i].timeIndex
  279. // Calculate duration safely
  280. let nextTimeIndex = i + 1 < timePoints.count ? timePoints[i + 1] : (24 * 60)
  281. let duration = nextTimeIndex - currentTimeIndex
  282. // Only add if duration is positive
  283. if duration > 0 {
  284. total += Double(rate) * Double(duration) / 60.0 // Convert to hours
  285. }
  286. }
  287. return total
  288. }
  289. // Chart for visualizing basal profile
  290. private var basalProfileChart: some View {
  291. Chart {
  292. ForEach(Array(onboardingData.basalProfileItems.enumerated()), id: \.element.id) { index, item in
  293. let displayValue = onboardingData.basalProfileRateValues[item.rateIndex]
  294. let tzOffset = TimeZone.current.secondsFromGMT() * -1
  295. let startDate = Date(timeIntervalSinceReferenceDate: onboardingData.basalProfileTimeValues[item.timeIndex])
  296. .addingTimeInterval(TimeInterval(tzOffset))
  297. let endDate = onboardingData.basalProfileItems.count > index + 1 ?
  298. Date(
  299. timeIntervalSinceReferenceDate: onboardingData
  300. .basalProfileTimeValues[onboardingData.basalProfileItems[index + 1].timeIndex]
  301. )
  302. .addingTimeInterval(TimeInterval(tzOffset)) :
  303. Date(timeIntervalSinceReferenceDate: onboardingData.basalProfileTimeValues.last!).addingTimeInterval(30 * 60)
  304. .addingTimeInterval(TimeInterval(tzOffset))
  305. RectangleMark(
  306. xStart: .value("start", startDate),
  307. xEnd: .value("end", endDate),
  308. yStart: .value("rate-start", displayValue),
  309. yEnd: .value("rate-end", 0)
  310. ).foregroundStyle(
  311. .linearGradient(
  312. colors: [
  313. Color.purple.opacity(0.6),
  314. Color.purple.opacity(0.1)
  315. ],
  316. startPoint: .bottom,
  317. endPoint: .top
  318. )
  319. ).alignsMarkStylesWithPlotArea()
  320. LineMark(x: .value("End Date", startDate), y: .value("Rate", displayValue))
  321. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.purple)
  322. LineMark(x: .value("Start Date", endDate), y: .value("Rate", displayValue))
  323. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.purple)
  324. }
  325. }
  326. .id(refreshUI) // Force chart update
  327. .chartXAxis {
  328. AxisMarks(values: .automatic(desiredCount: 6)) { _ in
  329. AxisValueLabel(format: .dateTime.hour())
  330. AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1, dash: [2, 4]))
  331. }
  332. }
  333. .chartXScale(
  334. domain: Calendar.current.startOfDay(for: chartScale!) ... Calendar.current.startOfDay(for: chartScale!)
  335. .addingTimeInterval(60 * 60 * 24)
  336. )
  337. .chartYAxis {
  338. AxisMarks(values: .automatic(desiredCount: 4)) { _ in
  339. AxisValueLabel()
  340. AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1, dash: [2, 4]))
  341. }
  342. }
  343. }
  344. }