AddTempTargetForm.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import Foundation
  2. import SwiftUI
  3. struct AddTempTargetForm: View {
  4. @StateObject var state: OverrideConfig.StateModel
  5. @Environment(\.presentationMode) var presentationMode
  6. @Environment(\.colorScheme) var colorScheme
  7. @Environment(\.dismiss) var dismiss
  8. @State private var displayPickerDuration: Bool = false
  9. @State private var displayPickerTarget: Bool = false
  10. @State private var tempTargetSensitivityAdjustmentType: TempTargetSensitivityAdjustmentType = .standard
  11. @State private var durationHours = 0
  12. @State private var durationMinutes = 0
  13. @State private var targetStep: Decimal = 5
  14. @State private var showAlert = false
  15. @State private var showPresetAlert = false
  16. @State private var alertString = ""
  17. @State private var isUsingSlider = false
  18. @State private var hasChanges = false
  19. @State private var didPressSave =
  20. false // only used for fixing the Disclaimer showing up after pressing save (after the state was resetted), maybe refactor this...
  21. @State private var shouldDisplayHint = false
  22. @State var hintDetent = PresentationDetent.large
  23. @State var selectedVerboseHint: String?
  24. @State var hintLabel: String?
  25. var isCustomizedAdjustSens: Bool = false
  26. var color: LinearGradient {
  27. colorScheme == .dark ? LinearGradient(
  28. gradient: Gradient(colors: [
  29. Color.bgDarkBlue,
  30. Color.bgDarkerDarkBlue
  31. ]),
  32. startPoint: .top,
  33. endPoint: .bottom
  34. )
  35. :
  36. LinearGradient(
  37. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  38. startPoint: .top,
  39. endPoint: .bottom
  40. )
  41. }
  42. private var formatter: NumberFormatter {
  43. let formatter = NumberFormatter()
  44. formatter.numberStyle = .decimal
  45. formatter.maximumFractionDigits = 0
  46. return formatter
  47. }
  48. private var glucoseFormatter: NumberFormatter {
  49. let formatter = NumberFormatter()
  50. formatter.numberStyle = .decimal
  51. formatter.maximumFractionDigits = 0
  52. if state.units == .mmolL {
  53. formatter.maximumFractionDigits = 1
  54. }
  55. formatter.roundingMode = .halfUp
  56. return formatter
  57. }
  58. var body: some View {
  59. NavigationView {
  60. List {
  61. addTempTarget()
  62. saveButton
  63. }
  64. .listSectionSpacing(10)
  65. .listRowSpacing(10)
  66. .padding(.top, 30)
  67. .ignoresSafeArea(edges: .top)
  68. .scrollContentBackground(.hidden).background(color)
  69. .navigationTitle("Add Temp Target")
  70. .navigationBarTitleDisplayMode(.inline)
  71. .toolbar {
  72. ToolbarItem(placement: .topBarLeading) {
  73. Button(action: {
  74. presentationMode.wrappedValue.dismiss()
  75. }, label: {
  76. Text("Cancel")
  77. })
  78. }
  79. ToolbarItem(placement: .topBarTrailing) {
  80. Button(
  81. action: {
  82. state.isHelpSheetPresented.toggle()
  83. },
  84. label: {
  85. Image(systemName: "questionmark.circle")
  86. }
  87. )
  88. }
  89. }
  90. .onAppear {
  91. targetStep = state.units == .mgdL ? 5 : 9
  92. state.tempTargetTarget = state.normalTarget
  93. }
  94. .sheet(isPresented: $state.isHelpSheetPresented) {
  95. NavigationStack {
  96. List {
  97. Text(
  98. "A Temporary Target replaces the current Target Glucose specified in Therapy settings.\n\nDepending on the Algorithm > Target Behaviour settings these temporary glucose targets can also raise Insulin Sensitivity for high targets or lower sensitivity for low targets.\n\nFurthermore you could adjust that sensitivity change independently from the Half Basal Exercise Target specified in Algorithm > Target Behaviour settings by deliberatly setting a customized Insulin Percentage for a Temp Target."
  99. )
  100. }
  101. .padding(.trailing, 10)
  102. .navigationBarTitle("Help", displayMode: .inline)
  103. Button { state.isHelpSheetPresented.toggle() }
  104. label: { Text("Got it!").frame(maxWidth: .infinity, alignment: .center) }
  105. .buttonStyle(.bordered)
  106. .padding(.top)
  107. }
  108. .padding()
  109. .presentationDetents(
  110. [.fraction(0.9), .large],
  111. selection: $state.helpSheetDetent
  112. )
  113. }
  114. }
  115. }
  116. @ViewBuilder private func addTempTarget() -> some View {
  117. Group {
  118. Section {
  119. HStack {
  120. Text("Name")
  121. Spacer()
  122. TextField("(Optional)", text: $state.tempTargetName)
  123. .multilineTextAlignment(.trailing)
  124. }
  125. }.listRowBackground(Color.chart)
  126. Section {
  127. DatePicker("Date", selection: $state.date)
  128. }.listRowBackground(Color.chart)
  129. Section {
  130. VStack {
  131. HStack {
  132. Text("Duration")
  133. Spacer()
  134. Text(formatHrMin(Int(state.tempTargetDuration)))
  135. .foregroundColor(!displayPickerDuration ? .primary : .accentColor)
  136. }
  137. .onTapGesture {
  138. displayPickerDuration = toggleScrollWheel(displayPickerDuration)
  139. }
  140. if displayPickerDuration {
  141. HStack {
  142. Picker("Hours", selection: $durationHours) {
  143. ForEach(0 ..< 24) { hour in
  144. Text("\(hour) hr").tag(hour)
  145. }
  146. }
  147. .pickerStyle(WheelPickerStyle())
  148. .frame(maxWidth: .infinity)
  149. .onChange(of: durationHours) {
  150. state.tempTargetDuration = Decimal(totalDurationInMinutes())
  151. }
  152. Picker("Minutes", selection: $durationMinutes) {
  153. ForEach(Array(stride(from: 0, through: 55, by: 5)), id: \.self) { minute in
  154. Text("\(minute) min").tag(minute)
  155. }
  156. }
  157. .pickerStyle(WheelPickerStyle())
  158. .frame(maxWidth: .infinity)
  159. .onChange(of: durationMinutes) {
  160. state.tempTargetDuration = Decimal(totalDurationInMinutes())
  161. }
  162. }
  163. }
  164. }
  165. }.listRowBackground(Color.chart)
  166. Section {
  167. let settingsProvider = PickerSettingsProvider.shared
  168. let glucoseSetting = PickerSetting(value: 0, step: targetStep, min: 80, max: 270, type: .glucose)
  169. HStack {
  170. TargetPicker(
  171. label: "Target Glucose",
  172. selection: Binding(
  173. get: { state.tempTargetTarget },
  174. set: { state.tempTargetTarget = $0 }
  175. ),
  176. options: settingsProvider.generatePickerValues(
  177. from: glucoseSetting,
  178. units: state.units,
  179. roundMinToStep: true
  180. ),
  181. units: state.units,
  182. hasChanges: $hasChanges,
  183. targetStep: $targetStep,
  184. displayPickerTarget: $displayPickerTarget,
  185. toggleScrollWheel: toggleScrollWheel
  186. )
  187. }
  188. .onChange(of: state.tempTargetTarget) {
  189. state.percentage = Double(state.computeAdjustedPercentage() * 100)
  190. }
  191. }
  192. .listRowBackground(Color.chart)
  193. if state.tempTargetTarget != state.normalTarget {
  194. let computedHalfBasalTarget = Decimal(state.computeHalfBasalTarget())
  195. let sensHint = state.tempTargetTarget > state.normalTarget ?
  196. "Reducing all delivered insulin to \(formattedPercentage(state.percentage))%." :
  197. "Increasing all delivered insulin by \(formattedPercentage(state.percentage - 100))%."
  198. if state.isAdjustSensEnabled() {
  199. Section(
  200. header: Text(sensHint)
  201. .textCase(.none)
  202. .foregroundStyle(colorScheme == .dark ? Color.orange : Color.accentColor),
  203. content: {
  204. VStack {
  205. Picker("Sensitivity Adjustment", selection: $tempTargetSensitivityAdjustmentType) {
  206. ForEach(TempTargetSensitivityAdjustmentType.allCases, id: \.self) { option in
  207. Text(option.rawValue).tag(option)
  208. }
  209. .pickerStyle(MenuPickerStyle())
  210. .onChange(of: tempTargetSensitivityAdjustmentType) { newValue in
  211. if newValue == .standard {
  212. state.halfBasalTarget = state.settingHalfBasalTarget
  213. state.percentage = Double(state.computeAdjustedPercentage() * 100)
  214. }
  215. }
  216. }
  217. if tempTargetSensitivityAdjustmentType == .slider {
  218. Text("\(formattedPercentage(state.percentage)) % Insulin")
  219. .foregroundColor(isUsingSlider ? .orange : Color.tabBar)
  220. .font(.title3)
  221. .fontWeight(.bold)
  222. Slider(
  223. value: $state.percentage,
  224. in: state.computeSliderLow() ... state.computeSliderHigh(),
  225. step: 5
  226. ) {} minimumValueLabel: {
  227. Text("\(state.computeSliderLow(), specifier: "%.0f")%")
  228. } maximumValueLabel: {
  229. Text("\(state.computeSliderHigh(), specifier: "%.0f")%")
  230. } onEditingChanged: { editing in
  231. isUsingSlider = editing
  232. state.halfBasalTarget = Decimal(state.computeHalfBasalTarget())
  233. }
  234. Divider()
  235. HStack {
  236. Text(
  237. "Half Basal Exercise Target:"
  238. )
  239. Spacer()
  240. Text(formattedGlucose(glucose: computedHalfBasalTarget))
  241. }.foregroundStyle(.primary)
  242. }
  243. }.padding(.vertical, 10)
  244. }
  245. )
  246. .listRowBackground(Color.chart)
  247. .padding(.top, -10)
  248. }
  249. }
  250. }
  251. }
  252. private func isTempTargetInvalid() -> (Bool, String?) {
  253. let noDurationSpecified = state.tempTargetDuration == 0
  254. let targetZero = state.tempTargetTarget < 80
  255. if noDurationSpecified {
  256. return (true, "Set a duration!")
  257. }
  258. if targetZero {
  259. return (
  260. true,
  261. "\(state.units == .mgdL ? "80 " : "4.4 ")" + state.units.rawValue + " needed as min. Glucose Target!"
  262. )
  263. }
  264. return (false, nil)
  265. }
  266. private var saveButton: some View {
  267. let (isInvalid, errorMessage) = isTempTargetInvalid()
  268. let noNameSpecified = state.tempTargetName == ""
  269. return Group {
  270. Section(
  271. header:
  272. HStack {
  273. Spacer()
  274. Text(errorMessage ?? "").textCase(nil)
  275. .foregroundColor(colorScheme == .dark ? .orange : .accentColor)
  276. Spacer()
  277. },
  278. content: {
  279. Button(action: {
  280. Task {
  281. if noNameSpecified { state.tempTargetName = "Custom Target" }
  282. didPressSave.toggle()
  283. state.isTempTargetEnabled.toggle()
  284. await state.saveCustomTempTarget()
  285. await state.resetTempTargetState()
  286. dismiss()
  287. }
  288. }, label: {
  289. Text("Enact Temp Target")
  290. })
  291. .disabled(isInvalid)
  292. .frame(maxWidth: .infinity, alignment: .center)
  293. .tint(.white)
  294. }
  295. ).listRowBackground(isInvalid ? Color(.systemGray4) : Color(.systemBlue))
  296. Section {
  297. Button(action: {
  298. Task {
  299. if noNameSpecified { state.tempTargetName = "Custom Target" }
  300. didPressSave.toggle()
  301. await state.saveTempTargetPreset()
  302. dismiss()
  303. }
  304. }, label: {
  305. Text("Save as Preset")
  306. })
  307. .disabled(isInvalid)
  308. .frame(maxWidth: .infinity, alignment: .center)
  309. .tint(.white)
  310. }
  311. .listRowBackground(
  312. isInvalid ? Color(.systemGray4) : Color.secondary
  313. )
  314. }
  315. }
  316. private func totalDurationInMinutes() -> Int {
  317. let durationTotal = (durationHours * 60) + durationMinutes
  318. return max(0, durationTotal)
  319. }
  320. private func formattedPercentage(_ value: Double) -> String {
  321. let percentageNumber = NSNumber(value: value)
  322. return formatter.string(from: percentageNumber) ?? "\(value)"
  323. }
  324. private func formattedGlucose(glucose: Decimal) -> String {
  325. let formattedValue: String
  326. if state.units == .mgdL {
  327. formattedValue = glucoseFormatter.string(from: glucose as NSDecimalNumber) ?? "\(glucose)"
  328. } else {
  329. formattedValue = glucose.formattedAsMmolL
  330. }
  331. return "\(formattedValue) \(state.units.rawValue)"
  332. }
  333. private func roundTargetToStep(_ target: Decimal, _ step: Decimal) -> Decimal {
  334. // Convert target and step to NSDecimalNumber
  335. guard let targetValue = NSDecimalNumber(decimal: target).doubleValue as Double?,
  336. let stepValue = NSDecimalNumber(decimal: step).doubleValue as Double?
  337. else {
  338. print("Failed to unwrap target or step as NSDecimalNumber")
  339. return target
  340. }
  341. // Perform the remainder check using truncatingRemainder
  342. let remainder = Decimal(targetValue.truncatingRemainder(dividingBy: stepValue))
  343. if remainder != 0 {
  344. // Calculate how much to adjust (up or down) based on the remainder
  345. let adjustment = step - remainder
  346. return target + adjustment
  347. }
  348. // Return the original target if no adjustment is needed
  349. return target
  350. }
  351. private func toggleScrollWheel(_ toggle: Bool) -> Bool {
  352. displayPickerDuration = false
  353. displayPickerTarget = false
  354. return !toggle
  355. }
  356. func generateTargetPickerValues() -> [Decimal] {
  357. var values: [Decimal] = []
  358. var currentValue: Double = 80 // lowest allowed TT in oref
  359. let step = Double(targetStep)
  360. // Adjust currentValue to be divisible by targetStep
  361. let remainder = currentValue.truncatingRemainder(dividingBy: step)
  362. if remainder != 0 {
  363. // Move currentValue up to the next value divisible by targetStep
  364. currentValue += (step - remainder)
  365. }
  366. // Now generate the picker values starting from currentValue
  367. while currentValue <= 270 {
  368. values.append(Decimal(currentValue))
  369. currentValue += step
  370. }
  371. // Glucose values are stored as mg/dl values, so Integers.
  372. // Filter out duplicate values when rounded to 1 decimal place.
  373. if state.units == .mmolL {
  374. // Use a Set to track unique values rounded to 1 decimal
  375. var uniqueRoundedValues = Set<String>()
  376. values = values.filter { value in
  377. let roundedValue = String(format: "%.1f", NSDecimalNumber(decimal: value.asMmolL).doubleValue)
  378. return uniqueRoundedValues.insert(roundedValue).inserted
  379. }
  380. }
  381. return values
  382. }
  383. }