AddTempTargetForm.swift 16 KB

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