AddTempTargetForm.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  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 durationHours = 0
  10. @State private var durationMinutes = 0
  11. @State private var targetStep: Decimal = 5
  12. @State private var displayPickerTarget: Bool = false
  13. @State private var showAlert = false
  14. @State private var showPresetAlert = false
  15. @State private var alertString = ""
  16. @State private var isUsingSlider = false
  17. @State private var didPressSave =
  18. false // only used for fixing the Disclaimer showing up after pressing save (after the state was resetted), maybe refactor this...
  19. @State private var shouldDisplayHint = false
  20. @State var hintDetent = PresentationDetent.large
  21. @State var selectedVerboseHint: String?
  22. @State var hintLabel: String?
  23. var color: LinearGradient {
  24. colorScheme == .dark ? LinearGradient(
  25. gradient: Gradient(colors: [
  26. Color.bgDarkBlue,
  27. Color.bgDarkerDarkBlue
  28. ]),
  29. startPoint: .top,
  30. endPoint: .bottom
  31. )
  32. :
  33. LinearGradient(
  34. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  35. startPoint: .top,
  36. endPoint: .bottom
  37. )
  38. }
  39. private var formatter: NumberFormatter {
  40. let formatter = NumberFormatter()
  41. formatter.numberStyle = .decimal
  42. formatter.maximumFractionDigits = 0
  43. return formatter
  44. }
  45. private var glucoseFormatter: NumberFormatter {
  46. let formatter = NumberFormatter()
  47. formatter.numberStyle = .decimal
  48. formatter.maximumFractionDigits = 0
  49. if state.units == .mmolL {
  50. formatter.maximumFractionDigits = 1
  51. }
  52. formatter.roundingMode = .halfUp
  53. return formatter
  54. }
  55. var isSliderEnabled: Bool {
  56. state.computeSliderHigh() > state.computeSliderLow()
  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. }
  80. .sheet(isPresented: $shouldDisplayHint) {
  81. SettingInputHintView(
  82. hintDetent: $hintDetent,
  83. shouldDisplayHint: $shouldDisplayHint,
  84. hintLabel: hintLabel ?? "",
  85. hintText: selectedVerboseHint ?? "",
  86. sheetTitle: "Help"
  87. )
  88. }
  89. .onAppear {
  90. targetStep = state.units == .mgdL ? 5 : 9
  91. Task {
  92. await state.getCurrentGlucoseTarget()
  93. }
  94. }
  95. }
  96. }
  97. @ViewBuilder private func addTempTarget() -> some View {
  98. Group {
  99. Section {
  100. HStack {
  101. Text("Name")
  102. Spacer()
  103. TextField("Enter Name (optional)", text: $state.tempTargetName)
  104. .multilineTextAlignment(.trailing)
  105. }
  106. }.listRowBackground(Color.chart)
  107. Section {
  108. DatePicker("Date", selection: $state.date)
  109. }.listRowBackground(Color.chart)
  110. Section {
  111. VStack {
  112. HStack {
  113. Text("Duration")
  114. Spacer()
  115. Text(formatHrMin(Int(state.tempTargetDuration)))
  116. .foregroundColor(!displayPickerDuration ? .primary : .accentColor)
  117. }
  118. .onTapGesture {
  119. displayPickerDuration.toggle()
  120. }
  121. if displayPickerDuration {
  122. HStack {
  123. Picker("Hours", selection: $durationHours) {
  124. ForEach(0 ..< 24) { hour in
  125. Text("\(hour) hr").tag(hour)
  126. }
  127. }
  128. .pickerStyle(WheelPickerStyle())
  129. .frame(maxWidth: .infinity)
  130. .onChange(of: durationHours) {
  131. state.tempTargetDuration = Decimal(totalDurationInMinutes())
  132. }
  133. Picker("Minutes", selection: $durationMinutes) {
  134. ForEach(Array(stride(from: 0, through: 55, by: 5)), id: \.self) { minute in
  135. Text("\(minute) min").tag(minute)
  136. }
  137. }
  138. .pickerStyle(WheelPickerStyle())
  139. .frame(maxWidth: .infinity)
  140. .onChange(of: durationMinutes) {
  141. state.tempTargetDuration = Decimal(totalDurationInMinutes())
  142. }
  143. }
  144. }
  145. }
  146. }.listRowBackground(Color.chart)
  147. Section {
  148. HStack {
  149. Text("Target Glucose")
  150. Spacer()
  151. Text(formattedGlucose(glucose: state.tempTargetTarget))
  152. .foregroundColor(!displayPickerTarget ? .primary : .accentColor)
  153. }
  154. .onTapGesture {
  155. displayPickerTarget.toggle()
  156. }
  157. if displayPickerTarget {
  158. HStack {
  159. // Radio buttons and text on the left side
  160. VStack(alignment: .leading) {
  161. // Radio buttons for step iteration
  162. let stepChoices: [Decimal] = state.units == .mgdL ? [1, 5] : [1, 9]
  163. ForEach(stepChoices, id: \.self) { step in
  164. RadioButton(
  165. isSelected: targetStep == step,
  166. label: "\(state.units == .mgdL ? step : step.asMmolL) \(state.units.rawValue)"
  167. ) {
  168. targetStep = step
  169. state.tempTargetTarget = roundTargetToStep(state.tempTargetTarget, targetStep)
  170. }
  171. .padding(.top, 10)
  172. }
  173. }
  174. .frame(maxWidth: .infinity)
  175. Spacer()
  176. // Picker on the right side
  177. Picker(selection: Binding(
  178. get: { roundTargetToStep(state.tempTargetTarget, targetStep) },
  179. set: { state.tempTargetTarget = $0 }
  180. ), label: Text("")) {
  181. ForEach(
  182. generateTargetPickerValues(),
  183. id: \.self
  184. ) { glucose in
  185. Text(formattedGlucose(glucose: glucose))
  186. .tag(glucose)
  187. }
  188. }
  189. .pickerStyle(WheelPickerStyle())
  190. .frame(maxWidth: .infinity)
  191. .onChange(of: state.tempTargetTarget) {
  192. state.percentage = Double(state.computeAdjustedPercentage() * 100)
  193. }
  194. }
  195. }
  196. }.listRowBackground(Color.chart)
  197. if state.tempTargetTarget != state.currentGlucoseTarget {
  198. let computedHalfBasalTarget = state.computeHalfBasalTarget()
  199. Section(
  200. header: HStack {
  201. if state
  202. .tempTargetTarget > state.currentGlucoseTarget
  203. {
  204. HStack(spacing: 5) {
  205. Text("Sensitivity")
  206. Image(systemName: "arrow.up.circle")
  207. Text("Insulin")
  208. Image(systemName: "arrow.down.circle")
  209. Text("using \(formattedPercentage(state.percentage))% of default.")
  210. }
  211. } else {
  212. HStack(spacing: 5) {
  213. Text("Sensitivity")
  214. Image(systemName: "arrow.down.circle")
  215. Text("Insulin")
  216. Image(systemName: "arrow.up.circle")
  217. Text("using \(formattedPercentage(state.percentage))% of default.")
  218. }
  219. }
  220. }
  221. .textCase(.none)
  222. .foregroundStyle(colorScheme == .dark ? Color.orange : Color.accentColor),
  223. content: {
  224. VStack {
  225. Text("\(Int(state.percentage)) % Insulin")
  226. .foregroundColor(isUsingSlider ? .orange : Color.tabBar)
  227. .font(.title3)
  228. .fontWeight(.bold)
  229. Slider(
  230. value: $state.percentage,
  231. in: state.computeSliderLow() ... state.computeSliderHigh(),
  232. step: 5
  233. ) {} minimumValueLabel: {
  234. Text("\(state.computeSliderLow(), specifier: "%.0f")%")
  235. } maximumValueLabel: {
  236. Text("\(state.computeSliderHigh(), specifier: "%.0f")%")
  237. } onEditingChanged: { editing in
  238. isUsingSlider = editing
  239. state.halfBasalTarget = Decimal(state.computeHalfBasalTarget())
  240. }
  241. .disabled(!isSliderEnabled)
  242. Divider()
  243. HStack {
  244. Text(
  245. "Half Basal Exercise Target:"
  246. )
  247. Spacer()
  248. Text(
  249. (
  250. state.units == .mgdL ? computedHalfBasalTarget.description : computedHalfBasalTarget
  251. .formattedAsMmolL
  252. ) + " " + state.units.rawValue
  253. )
  254. }.foregroundStyle(.primary)
  255. }.padding(.vertical, 10)
  256. }
  257. )
  258. .listRowBackground(Color.chart)
  259. .padding(.top, -10)
  260. }
  261. }
  262. }
  263. private func isTempTargetInvalid() -> (Bool, String?) {
  264. let noDurationSpecified = state.tempTargetDuration == 0
  265. let targetZero = state.tempTargetTarget < 80
  266. if noDurationSpecified {
  267. return (true, "Set a duration!")
  268. }
  269. if targetZero {
  270. return (
  271. true,
  272. "\(state.units == .mgdL ? "80 " : "4.4 ")" + state.units.rawValue + " needed as min. Glucose Target!"
  273. )
  274. }
  275. return (false, nil)
  276. }
  277. private var saveButton: some View {
  278. let (isInvalid, errorMessage) = isTempTargetInvalid()
  279. let noNameSpecified = state.tempTargetName == ""
  280. return Group {
  281. Section(
  282. header:
  283. HStack {
  284. Spacer()
  285. Text(errorMessage ?? "").textCase(nil)
  286. .foregroundColor(colorScheme == .dark ? .orange : .accentColor)
  287. Spacer()
  288. },
  289. content: {
  290. Button(action: {
  291. Task {
  292. if noNameSpecified { state.tempTargetName = "Custom Target" }
  293. didPressSave.toggle()
  294. state.isTempTargetEnabled.toggle()
  295. await state.saveCustomTempTarget()
  296. await state.resetTempTargetState()
  297. dismiss()
  298. }
  299. }, label: {
  300. Text("Enact Temp Target")
  301. })
  302. .disabled(isInvalid)
  303. .frame(maxWidth: .infinity, alignment: .center)
  304. .tint(.white)
  305. }
  306. ).listRowBackground(isInvalid ? Color(.systemGray4) : Color(.systemBlue))
  307. Section {
  308. Button(action: {
  309. Task {
  310. if noNameSpecified { state.tempTargetName = "Custom Target" }
  311. didPressSave.toggle()
  312. await state.saveTempTargetPreset()
  313. dismiss()
  314. }
  315. }, label: {
  316. Text("Save as Preset")
  317. })
  318. .disabled(isInvalid)
  319. .frame(maxWidth: .infinity, alignment: .center)
  320. .tint(.white)
  321. }
  322. .listRowBackground(
  323. isInvalid ? Color(.systemGray4) : Color.secondary
  324. )
  325. }
  326. }
  327. private func totalDurationInMinutes() -> Int {
  328. let durationTotal = (durationHours * 60) + durationMinutes
  329. return max(0, durationTotal)
  330. }
  331. private func formattedPercentage(_ value: Double) -> String {
  332. let percentageNumber = NSNumber(value: value)
  333. return formatter.string(from: percentageNumber) ?? "\(value)"
  334. }
  335. private func formattedGlucose(glucose: Decimal) -> String {
  336. let formattedValue: String
  337. if state.units == .mgdL {
  338. formattedValue = glucoseFormatter.string(from: glucose as NSDecimalNumber) ?? "\(glucose)"
  339. } else {
  340. formattedValue = glucose.formattedAsMmolL
  341. }
  342. return "\(formattedValue) \(state.units.rawValue)"
  343. }
  344. private func roundTargetToStep(_ target: Decimal, _ step: Decimal) -> Decimal {
  345. // Convert target and step to NSDecimalNumber
  346. guard let targetValue = NSDecimalNumber(decimal: target).doubleValue as Double?,
  347. let stepValue = NSDecimalNumber(decimal: step).doubleValue as Double?
  348. else {
  349. print("Failed to unwrap target or step as NSDecimalNumber")
  350. return target
  351. }
  352. // Perform the remainder check using truncatingRemainder
  353. let remainder = Decimal(targetValue.truncatingRemainder(dividingBy: stepValue))
  354. if remainder != 0 {
  355. // Calculate how much to adjust (up or down) based on the remainder
  356. let adjustment = step - remainder
  357. return target + adjustment
  358. }
  359. // Return the original target if no adjustment is needed
  360. return target
  361. }
  362. func generateTargetPickerValues() -> [Decimal] {
  363. var values: [Decimal] = []
  364. var currentValue: Double = 80 // lowest allowed TT in oref
  365. let step = Double(targetStep)
  366. // Adjust currentValue to be divisible by targetStep
  367. let remainder = currentValue.truncatingRemainder(dividingBy: step)
  368. if remainder != 0 {
  369. // Move currentValue up to the next value divisible by targetStep
  370. currentValue += (step - remainder)
  371. }
  372. // Now generate the picker values starting from currentValue
  373. while currentValue <= 270 {
  374. values.append(Decimal(currentValue))
  375. currentValue += step
  376. }
  377. // Glucose values are stored as mg/dl values, so Integers.
  378. // Filter out duplicate values when rounded to 1 decimal place.
  379. if state.units == .mmolL {
  380. // Use a Set to track unique values rounded to 1 decimal
  381. var uniqueRoundedValues = Set<String>()
  382. values = values.filter { value in
  383. let roundedValue = String(format: "%.1f", NSDecimalNumber(decimal: value.asMmolL).doubleValue)
  384. return uniqueRoundedValues.insert(roundedValue).inserted
  385. }
  386. }
  387. return values
  388. }
  389. }
  390. func formatHrMin(_ durationInMinutes: Int) -> String {
  391. let hours = durationInMinutes / 60
  392. let minutes = durationInMinutes % 60
  393. switch (hours, minutes) {
  394. case let (0, m):
  395. return "\(m) min"
  396. case let (h, 0):
  397. return "\(h) hr"
  398. default:
  399. return "\(hours) hr \(minutes) min"
  400. }
  401. }
  402. struct RadioButton: View {
  403. var isSelected: Bool
  404. var label: String
  405. var action: () -> Void
  406. var body: some View {
  407. Button(action: {
  408. action()
  409. }) {
  410. HStack {
  411. Image(systemName: isSelected ? "largecircle.fill.circle" : "circle")
  412. Text(label) // Add label inside the button to make it tappable
  413. }
  414. }
  415. .buttonStyle(PlainButtonStyle())
  416. }
  417. }