InsulinSensitivityStepView.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. //
  2. // InsulinSensitivityStepView.swift
  3. // Trio
  4. //
  5. // Created by Marvin Polscheit on 19.03.25.
  6. //
  7. import Charts
  8. import SwiftUI
  9. import UIKit
  10. /// Insulin sensitivity step view for setting insulin sensitivity factor.
  11. struct InsulinSensitivityStepView: View {
  12. @State var onboardingData: OnboardingData
  13. @State private var showTimeSelector = false
  14. @State private var selectedISFIndex: 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 numberFormatter: NumberFormatter {
  22. let formatter = NumberFormatter()
  23. formatter.numberStyle = .decimal
  24. formatter.maximumFractionDigits = onboardingData.units == .mmolL ? 1 : 0
  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(
  37. "Your insulin sensitivity factor (ISF) indicates how much one unit of insulin will lower your blood glucose."
  38. )
  39. .font(.subheadline)
  40. .foregroundColor(.secondary)
  41. .padding(.horizontal)
  42. // Chart visualization
  43. if !onboardingData.isfItems.isEmpty {
  44. VStack(alignment: .leading) {
  45. Text("ISF Profile")
  46. .font(.headline)
  47. .padding(.horizontal)
  48. isfChart
  49. .frame(height: 180)
  50. .padding(.horizontal)
  51. }
  52. .padding(.vertical, 5)
  53. .background(Color.red.opacity(0.05))
  54. .cornerRadius(10)
  55. }
  56. // ISF values list
  57. VStack(alignment: .leading, spacing: 10) {
  58. HStack {
  59. Text("Sensitivity Factors")
  60. .font(.headline)
  61. Spacer()
  62. // Add new ISF button
  63. if onboardingData.isfItems.count < 24 {
  64. Button(action: {
  65. showTimeSelector = true
  66. }) {
  67. HStack {
  68. Image(systemName: "plus.circle.fill")
  69. Text("Add ISF")
  70. }
  71. .foregroundColor(.red)
  72. }
  73. .disabled(!canAddISF)
  74. }
  75. }
  76. .padding(.horizontal)
  77. if onboardingData.isfItems.isEmpty {
  78. // Add default entry if no items exist
  79. Button("Add Initial ISF Value") {
  80. onboardingData.addISFValue()
  81. }
  82. .foregroundColor(.red)
  83. .padding()
  84. .frame(maxWidth: .infinity)
  85. .background(Color.red.opacity(0.1))
  86. .cornerRadius(8)
  87. .padding(.horizontal)
  88. } else {
  89. // List of ISF values
  90. VStack(spacing: 2) {
  91. ForEach(Array(onboardingData.isfItems.enumerated()), id: \.element.id) { index, item in
  92. HStack {
  93. // Time display
  94. Text(
  95. dateFormatter
  96. .string(from: Date(
  97. timeIntervalSince1970: onboardingData
  98. .isfTimeValues[item.timeIndex]
  99. ))
  100. )
  101. .frame(width: 80, alignment: .leading)
  102. .padding(.leading)
  103. // ISF slider
  104. Slider(
  105. value: Binding(
  106. get: {
  107. guard !onboardingData.rateValues.isEmpty,
  108. item.rateIndex < onboardingData.rateValues.count
  109. else {
  110. return 0.0
  111. }
  112. return Double(
  113. truncating: onboardingData
  114. .rateValues[item.rateIndex] as NSNumber
  115. )
  116. },
  117. set: { newValue in
  118. guard !onboardingData.rateValues.isEmpty else { return }
  119. // Find closest match in rateValues array
  120. let newIndex = onboardingData.rateValues
  121. .firstIndex { abs(Double($0) - newValue) < 0.5 } ?? item.rateIndex
  122. // Ensure index is valid before updating
  123. if newIndex < onboardingData.rateValues.count,
  124. index < onboardingData.isfItems.count
  125. {
  126. onboardingData.isfItems[index].rateIndex = newIndex
  127. // Force refresh when slider changes
  128. refreshUI = UUID()
  129. }
  130. }
  131. ),
  132. in: onboardingData.rateValues.isEmpty ? 0 ... 1 :
  133. Double(truncating: onboardingData.rateValues.first! as NSNumber) ...
  134. Double(truncating: onboardingData.rateValues.last! as NSNumber),
  135. step: onboardingData.units == .mgdL ? 1 : 0.1
  136. )
  137. .accentColor(.red)
  138. .padding(.horizontal, 5)
  139. .onChange(of: onboardingData.isfItems[index].rateIndex) { _, _ in
  140. // Trigger immediate UI update when slider value changes
  141. let impact = UIImpactFeedbackGenerator(style: .light)
  142. impact.impactOccurred()
  143. }
  144. // Display the current value
  145. Text(
  146. "\(onboardingData.rateValues.isEmpty || item.rateIndex >= onboardingData.rateValues.count ? "--" : numberFormatter.string(from: onboardingData.rateValues[item.rateIndex] as NSNumber) ?? "--") \(onboardingData.units == .mgdL ? "mg/dL" : "mmol/L")"
  147. )
  148. .frame(width: 90, alignment: .trailing)
  149. .lineLimit(1)
  150. .minimumScaleFactor(0.8)
  151. // Delete button (not for the first entry at 00:00)
  152. if index > 0 {
  153. Button(action: {
  154. onboardingData.isfItems.remove(at: index)
  155. }) {
  156. Image(systemName: "trash")
  157. .foregroundColor(.red)
  158. .padding(.horizontal, 5)
  159. }
  160. } else {
  161. // Spacer to maintain alignment
  162. Spacer()
  163. .frame(width: 30)
  164. }
  165. }
  166. .padding(.vertical, 12)
  167. .background(index % 2 == 0 ? Color.red.opacity(0.05) : Color.clear)
  168. .cornerRadius(8)
  169. }
  170. }
  171. .background(Color.red.opacity(0.05))
  172. .cornerRadius(10)
  173. .padding(.horizontal)
  174. }
  175. }
  176. // Example calculation based on first ISF
  177. if !onboardingData.isfItems.isEmpty {
  178. Divider()
  179. .padding(.horizontal)
  180. VStack(alignment: .leading, spacing: 8) {
  181. Text("Example Calculation")
  182. .font(.headline)
  183. .padding(.horizontal)
  184. VStack(alignment: .leading, spacing: 4) {
  185. // Current glucose is 40 mg/dL or 2.2 mmol/L above target
  186. let aboveTarget = onboardingData.units == .mgdL ? 40.0 : 2.2
  187. let isfValue = onboardingData.rateValues.isEmpty || onboardingData.isfItems.isEmpty ?
  188. Double(truncating: onboardingData.isf as NSNumber) :
  189. Double(
  190. truncating: onboardingData
  191. .rateValues[onboardingData.isfItems.first!.rateIndex] as NSNumber
  192. )
  193. let insulinNeeded = aboveTarget / isfValue
  194. Text(
  195. "If you are \(numberFormatter.string(from: NSNumber(value: aboveTarget)) ?? "--") \(onboardingData.units == .mgdL ? "mg/dL" : "mmol/L") above target:"
  196. )
  197. .font(.subheadline)
  198. .padding(.horizontal)
  199. Text(
  200. "\(numberFormatter.string(from: NSNumber(value: aboveTarget)) ?? "--") ÷ \(numberFormatter.string(from: isfValue as NSNumber) ?? "--") = \(String(format: "%.1f", insulinNeeded)) units of insulin"
  201. )
  202. .font(.system(.body, design: .monospaced))
  203. .foregroundColor(.red)
  204. .padding(.vertical, 8)
  205. .padding(.horizontal, 12)
  206. .frame(maxWidth: .infinity, alignment: .leading)
  207. .background(Color.red.opacity(0.1))
  208. .cornerRadius(8)
  209. .padding(.horizontal)
  210. }
  211. .padding(.vertical, 4)
  212. }
  213. // Information about ISF
  214. VStack(alignment: .leading, spacing: 8) {
  215. Text("What This Means")
  216. .font(.headline)
  217. .padding(.horizontal)
  218. VStack(alignment: .leading, spacing: 4) {
  219. if onboardingData.units == .mgdL {
  220. Text("• An ISF of 50 mg/dL means 1 unit of insulin lowers your BG by 50 mg/dL")
  221. Text("• A lower number means you're more sensitive to insulin")
  222. Text("• A higher number means you're less sensitive to insulin")
  223. Text("• ISF may vary throughout the day")
  224. } else {
  225. Text("• An ISF of 2.8 mmol/L means 1 unit of insulin lowers your BG by 2.8 mmol/L")
  226. Text("• A lower number means you're more sensitive to insulin")
  227. Text("• A higher number means you're less sensitive to insulin")
  228. Text("• ISF may vary throughout the day")
  229. }
  230. }
  231. .font(.caption)
  232. .foregroundColor(.secondary)
  233. .padding(.horizontal)
  234. }
  235. }
  236. }
  237. .padding(.vertical)
  238. }
  239. .actionSheet(isPresented: $showTimeSelector) {
  240. var buttons: [ActionSheet.Button] = []
  241. // Find available time slots in 1-hour increments
  242. for hour in 0 ..< 24 {
  243. let hourInMinutes = hour * 60
  244. // Calculate timeIndex for this hour
  245. let timeIndex = onboardingData.isfTimeValues
  246. .firstIndex { abs($0 - Double(hourInMinutes * 60)) < 10 } ?? 0
  247. // Check if this hour is already in the profile
  248. if !onboardingData.isfItems.contains(where: { $0.timeIndex == timeIndex }) {
  249. buttons.append(.default(Text("\(String(format: "%02d:00", hour))")) {
  250. // Get the current rate from the last item
  251. let rateIndex = onboardingData.isfItems.last?.rateIndex ?? 45 // Default to 45 mg/dL
  252. // Create new item with the specified time
  253. let newItem = ISFEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  254. // Add the new item and sort the list
  255. onboardingData.isfItems.append(newItem)
  256. onboardingData.isfItems.sort(by: { $0.timeIndex < $1.timeIndex })
  257. })
  258. }
  259. }
  260. buttons.append(.cancel())
  261. return ActionSheet(
  262. title: Text("Select Start Time"),
  263. message: Text("Choose when this sensitivity factor should start"),
  264. buttons: buttons
  265. )
  266. }
  267. .alert(isPresented: $showAlert) {
  268. Alert(
  269. title: Text("Unable to Save ISF Profile"),
  270. message: Text(errorMessage),
  271. dismissButton: .default(Text("OK"))
  272. )
  273. }
  274. }
  275. // Add initial ISF value
  276. private func addInitialISF() {
  277. // Default to midnight (00:00) and 50 mg/dL (or 2.8 mmol/L)
  278. let timeIndex = onboardingData.isfTimeValues.firstIndex { abs($0 - 0) < 1 } ?? 0
  279. let defaultISF = onboardingData.units == .mgdL ? 50.0 : 2.8
  280. let rateIndex = onboardingData.rateValues.firstIndex { abs(Double($0) - defaultISF) < 0.5 } ?? 45
  281. let newItem = ISFEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  282. onboardingData.isfItems.append(newItem)
  283. }
  284. // Computed property to check if we can add more ISF values
  285. private var canAddISF: Bool {
  286. guard let lastItem = onboardingData.isfItems.last else { return true }
  287. return lastItem.timeIndex < onboardingData.isfTimeValues.count - 1
  288. }
  289. // Chart for visualizing ISF profile
  290. private var isfChart: some View {
  291. Chart {
  292. ForEach(Array(onboardingData.isfItems.enumerated()), id: \.element.id) { index, item in
  293. let displayValue = onboardingData.rateValues[item.rateIndex]
  294. let tzOffset = TimeZone.current.secondsFromGMT() * -1
  295. let startDate = Date(timeIntervalSinceReferenceDate: onboardingData.isfTimeValues[item.timeIndex])
  296. .addingTimeInterval(TimeInterval(tzOffset))
  297. let endDate = onboardingData.isfItems.count > index + 1 ?
  298. Date(
  299. timeIntervalSinceReferenceDate: onboardingData
  300. .isfTimeValues[onboardingData.isfItems[index + 1].timeIndex]
  301. )
  302. .addingTimeInterval(TimeInterval(tzOffset)) :
  303. Date(timeIntervalSinceReferenceDate: onboardingData.isfTimeValues.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.red.opacity(0.6),
  314. Color.red.opacity(0.1)
  315. ],
  316. startPoint: .bottom,
  317. endPoint: .top
  318. )
  319. ).alignsMarkStylesWithPlotArea()
  320. LineMark(x: .value("End Date", startDate), y: .value("ISF", displayValue))
  321. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.red)
  322. LineMark(x: .value("Start Date", endDate), y: .value("ISF", displayValue))
  323. .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.red)
  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. }