AddTempTargetForm.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import Foundation
  2. import SwiftUI
  3. struct AddTempTargetForm: View {
  4. init(state: OverrideConfig.StateModel) {
  5. _state = StateObject(wrappedValue: state)
  6. _targetStep = State(initialValue: state.units == .mgdL ? 5.0 : 9.0)
  7. }
  8. @StateObject var state: OverrideConfig.StateModel
  9. @Environment(\.presentationMode) var presentationMode
  10. @Environment(\.colorScheme) var colorScheme
  11. @Environment(\.dismiss) var dismiss
  12. @State private var targetStep: Double
  13. @State private var displayPickerTarget: Bool = false
  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 didPressSave =
  19. false // only used for fixing the Disclaimer showing up after pressing save (after the state was resetted), maybe refactor this...
  20. @State private var shouldDisplayHint = false
  21. @State var hintDetent = PresentationDetent.large
  22. @State var selectedVerboseHint: String?
  23. @State var hintLabel: String?
  24. var color: LinearGradient {
  25. colorScheme == .dark ? LinearGradient(
  26. gradient: Gradient(colors: [
  27. Color.bgDarkBlue,
  28. Color.bgDarkerDarkBlue
  29. ]),
  30. startPoint: .top,
  31. endPoint: .bottom
  32. )
  33. :
  34. LinearGradient(
  35. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  36. startPoint: .top,
  37. endPoint: .bottom
  38. )
  39. }
  40. private var formatter: NumberFormatter {
  41. let formatter = NumberFormatter()
  42. formatter.numberStyle = .decimal
  43. formatter.maximumFractionDigits = 0
  44. return formatter
  45. }
  46. private var glucoseFormatter: NumberFormatter {
  47. let formatter = NumberFormatter()
  48. formatter.numberStyle = .decimal
  49. formatter.maximumFractionDigits = 0
  50. if state.units == .mmolL {
  51. formatter.maximumFractionDigits = 1
  52. }
  53. formatter.roundingMode = .halfUp
  54. return formatter
  55. }
  56. var isSliderEnabled: Bool {
  57. state.computeSliderHigh() > state.computeSliderLow()
  58. }
  59. var body: some View {
  60. NavigationView {
  61. Form {
  62. addTempTarget()
  63. saveButton
  64. }.scrollContentBackground(.hidden).background(color)
  65. .navigationTitle("Add Temp Target")
  66. .navigationBarTitleDisplayMode(.inline)
  67. .navigationBarItems(leading: Button("Close") {
  68. presentationMode.wrappedValue.dismiss()
  69. })
  70. .sheet(isPresented: $shouldDisplayHint) {
  71. SettingInputHintView(
  72. hintDetent: $hintDetent,
  73. shouldDisplayHint: $shouldDisplayHint,
  74. hintLabel: hintLabel ?? "",
  75. hintText: selectedVerboseHint ?? "",
  76. sheetTitle: "Help"
  77. )
  78. }
  79. }
  80. }
  81. @ViewBuilder private func addTempTarget() -> some View {
  82. let pad: CGFloat = 3
  83. VStack {
  84. HStack {
  85. Text("Name")
  86. Spacer()
  87. TextField("(Optional)", text: $state.overrideName).multilineTextAlignment(.trailing)
  88. }
  89. .padding(.vertical, pad)
  90. }
  91. Section(
  92. header: Text("Configure Temp Target"),
  93. content: {
  94. HStack {
  95. Text("Name")
  96. Spacer()
  97. TextField("Enter Name (optional)", text: $state.tempTargetName)
  98. .multilineTextAlignment(.trailing)
  99. }
  100. HStack {
  101. Text("Duration")
  102. Spacer()
  103. TextFieldWithToolBar(text: $state.tempTargetDuration, placeholder: "0", numberFormatter: formatter)
  104. Text("minutes").foregroundColor(.secondary)
  105. }
  106. VStack {
  107. HStack {
  108. Text("Target Glucose")
  109. Spacer()
  110. Text(formattedGlucose(glucose: state.tempTargetTarget))
  111. .foregroundColor(!displayPickerTarget ? .primary : .accentColor)
  112. }
  113. .padding(.vertical, pad)
  114. .onTapGesture {
  115. displayPickerTarget.toggle()
  116. }
  117. if displayPickerTarget {
  118. HStack {
  119. // Radio buttons and text on the left side
  120. let steps = state.units == .mgdL ? [1.0, 5.0] : [1.8, 9.0]
  121. VStack(alignment: .leading) {
  122. // Radio buttons for step iteration
  123. ForEach(steps, id: \.self) { step in
  124. RadioButton(
  125. isSelected: targetStep == step,
  126. label: "\(formattedGlucose(glucose: Decimal(step)))"
  127. ) {
  128. targetStep = step
  129. roundTargetToStep()
  130. }
  131. .padding(.top, 10)
  132. }
  133. }
  134. .frame(maxWidth: .infinity)
  135. Spacer()
  136. // Picker on the right side
  137. Picker(
  138. selection: Binding(
  139. get: { Int(truncating: state.tempTargetTarget as NSNumber) },
  140. set: { state.tempTargetTarget = Decimal($0) }
  141. ), label: Text("")
  142. ) {
  143. ForEach(
  144. Array(stride(from: 80, through: 270, by: targetStep)),
  145. id: \.self
  146. ) { glucose in
  147. Text(formattedGlucose(glucose: Decimal(glucose)))
  148. .tag(Int(glucose))
  149. }
  150. }
  151. .pickerStyle(WheelPickerStyle())
  152. .frame(maxWidth: .infinity)
  153. }
  154. .frame(maxWidth: .infinity)
  155. }
  156. DatePicker("Date", selection: $state.date)
  157. }
  158. }
  159. ).listRowBackground(Color.chart)
  160. if isSliderEnabled && state.tempTargetTarget != 0 {
  161. if state.tempTargetTarget > 100 {
  162. Section {
  163. VStack(alignment: .leading) {
  164. Text("Raised Sensitivity:")
  165. .font(.footnote)
  166. .fontWeight(.bold)
  167. Text("Insulin reduced to \(formattedPercentage(state.percentage))% of regular amount.")
  168. .font(.footnote)
  169. .lineLimit(1)
  170. }
  171. }.listRowBackground(Color.tabBar)
  172. Section {
  173. VStack {
  174. Toggle("Adjust Sensitivity", isOn: $state.didAdjustSens).padding(.top)
  175. HStack(alignment: .top) {
  176. Text(
  177. "Temp Target raises Sensitivity. Further adjust if desired!"
  178. )
  179. .font(.footnote)
  180. .foregroundColor(.secondary)
  181. .lineLimit(nil)
  182. Spacer()
  183. Button(
  184. action: {
  185. hintLabel = "Adjust Sensitivity for high Temp Target "
  186. selectedVerboseHint =
  187. "You have enabled High TempTarget Raises Sensitivity in Target Behaviour settings. Therefore current high Temp Target of \(state.tempTargetTarget) would raise your sensitivity, hence reduce Insulin dosing to \(formattedPercentage(state.percentage)) % of regular amount. This can be adjusted to another desired Insulin percentage!"
  188. shouldDisplayHint.toggle()
  189. },
  190. label: {
  191. HStack {
  192. Image(systemName: "questionmark.circle")
  193. }
  194. }
  195. ).buttonStyle(BorderlessButtonStyle())
  196. }.padding(.top)
  197. }.padding(.bottom)
  198. }.listRowBackground(Color.chart)
  199. } else if state.tempTargetTarget < 100 {
  200. Section {
  201. VStack(alignment: .leading) {
  202. Text("Lowered Sensitivity:")
  203. .font(.footnote)
  204. .fontWeight(.bold)
  205. Text("Insulin increased to \(formattedPercentage(state.percentage))% of regular amount.")
  206. .font(.footnote)
  207. .lineLimit(1)
  208. }
  209. }.listRowBackground(Color.tabBar)
  210. Section {
  211. VStack {
  212. Toggle("Adjust Insulin %", isOn: $state.didAdjustSens).padding(.top)
  213. HStack(alignment: .top) {
  214. Text(
  215. "Temp Target lowers Sensitivity. Further adjust if desired!"
  216. )
  217. .font(.footnote)
  218. .foregroundColor(.secondary)
  219. .lineLimit(nil)
  220. Spacer()
  221. Button(
  222. action: {
  223. hintLabel = "Adjust Sensitivity for low Temp Target "
  224. selectedVerboseHint =
  225. "You have enabled Low TempTarget Lowers Sensitivity in Target Behaviour settings and set autosens Max > 1. Therefore current low Temp Target of \(state.tempTargetTarget) would lower your sensitivity, hence increase Insulin dosing to \(formattedPercentage(state.percentage)) % of regular amount. This can be adjusted to another desired Insulin percentage!"
  226. shouldDisplayHint.toggle()
  227. },
  228. label: {
  229. HStack {
  230. Image(systemName: "questionmark.circle")
  231. }
  232. }
  233. ).buttonStyle(BorderlessButtonStyle())
  234. }.padding(.top)
  235. }.padding(.bottom)
  236. }.listRowBackground(Color.chart)
  237. }
  238. if state.didAdjustSens && state.tempTargetTarget != 100 {
  239. Section {
  240. VStack {
  241. Text("\(Int(state.percentage)) % Insulin")
  242. .foregroundColor(isUsingSlider ? .orange : Color.tabBar)
  243. .font(.largeTitle)
  244. Slider(
  245. value: $state.percentage,
  246. in: state.computeSliderLow() ... state.computeSliderHigh(),
  247. step: 5
  248. ) {} minimumValueLabel: {
  249. Text("\(state.computeSliderLow(), specifier: "%.0f")%")
  250. } maximumValueLabel: {
  251. Text("\(state.computeSliderHigh(), specifier: "%.0f")%")
  252. } onEditingChanged: { editing in
  253. isUsingSlider = editing
  254. state.halfBasalTarget = Decimal(state.computeHalfBasalTarget())
  255. }
  256. .disabled(!isSliderEnabled)
  257. Divider()
  258. HStack {
  259. Text(
  260. state
  261. .units == .mgdL ?
  262. "Half Basal Exercise Target at: \(state.computeHalfBasalTarget().formatted(.number.precision(.fractionLength(0)))) mg/dl" :
  263. "Half Basal Exercise Target at: \(state.computeHalfBasalTarget().asMmolL.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))) mmol/L"
  264. )
  265. .lineLimit(1)
  266. .minimumScaleFactor(0.5)
  267. .foregroundColor(.secondary)
  268. Spacer()
  269. }
  270. }
  271. }.listRowBackground(Color.chart)
  272. }
  273. }
  274. // TODO: with iOS 17 we can change the body content wrapper from FORM to LIST and apply the .listSpacing modifier to make this all nice and small.
  275. // Section {
  276. // Button(action: {
  277. // showAlert.toggle()
  278. // }, label: {
  279. // Text("Enact Temp Target")
  280. //
  281. // })
  282. // .disabled(state.tempTargetDuration == 0)
  283. // .frame(maxWidth: .infinity, alignment: .center)
  284. // .tint(.white)
  285. // }.listRowBackground(state.tempTargetDuration == 0 ? Color(.systemGray4) : Color(.systemBlue))
  286. //
  287. // Section {
  288. // Button(action: {
  289. // Task {
  290. // didPressSave.toggle()
  291. // await state.saveTempTargetPreset()
  292. // dismiss()
  293. // }
  294. // }, label: {
  295. // Text("Save as Preset")
  296. //
  297. // })
  298. // .disabled(state.tempTargetDuration == 0)
  299. // .frame(maxWidth: .infinity, alignment: .center)
  300. // .tint(.white)
  301. // }.listRowBackground(state.tempTargetDuration == 0 ? Color(.systemGray4) : Color(.orange))
  302. }
  303. private func isTempTargetInvalid() -> (Bool, String?) {
  304. let noDurationSpecified = state.tempTargetDuration == 0
  305. let targetZero = state.tempTargetTarget < 80
  306. if noDurationSpecified {
  307. return (true, "Set a duration!")
  308. }
  309. if targetZero {
  310. return (
  311. true,
  312. "\(state.units == .mgdL ? "80 " : "4.4 ")" + state.units.rawValue + " needed as min. Glucose Target!"
  313. )
  314. }
  315. return (false, nil)
  316. }
  317. private var saveButton: some View {
  318. let (isInvalid, errorMessage) = isTempTargetInvalid()
  319. let noNameSpecified = state.tempTargetName == ""
  320. return Group {
  321. Section(
  322. header:
  323. HStack {
  324. Spacer()
  325. Text(errorMessage ?? "").textCase(nil)
  326. .foregroundColor(colorScheme == .dark ? .orange : .accentColor)
  327. Spacer()
  328. },
  329. content: {
  330. Button(action: {
  331. Task {
  332. if noNameSpecified { state.tempTargetName = "Custom Target" }
  333. didPressSave.toggle()
  334. state.isTempTargetEnabled.toggle()
  335. await state.saveCustomTempTarget()
  336. await state.resetTempTargetState()
  337. dismiss()
  338. }
  339. }, label: {
  340. Text("Enact Temp Target")
  341. })
  342. .disabled(isInvalid)
  343. .frame(maxWidth: .infinity, alignment: .center)
  344. .tint(.white)
  345. }
  346. ).listRowBackground(isInvalid ? Color(.systemGray4) : Color(.systemBlue))
  347. Section {
  348. Button(action: {
  349. Task {
  350. if noNameSpecified { state.tempTargetName = "Custom Target" }
  351. didPressSave.toggle()
  352. await state.saveTempTargetPreset()
  353. dismiss()
  354. }
  355. }, label: {
  356. Text("Save as Preset")
  357. })
  358. .disabled(isInvalid)
  359. .frame(maxWidth: .infinity, alignment: .center)
  360. .tint(.white)
  361. }
  362. .listRowBackground(
  363. isInvalid ? Color(.systemGray4) : Color.secondary
  364. )
  365. }
  366. }
  367. private func formattedPercentage(_ value: Double) -> String {
  368. let percentageNumber = NSNumber(value: value)
  369. return formatter.string(from: percentageNumber) ?? "\(value)"
  370. }
  371. private func formattedGlucose(glucose: Decimal) -> String {
  372. let formattedValue: String
  373. if state.units == .mgdL {
  374. formattedValue = glucoseFormatter.string(from: glucose as NSDecimalNumber) ?? "\(glucose)"
  375. } else {
  376. formattedValue = glucose.formattedAsMmolL
  377. }
  378. return "\(formattedValue) \(state.units.rawValue)"
  379. }
  380. private func roundTargetToStep() {
  381. // Check if tempTargetTarget is not divisible by the selected step
  382. if let tempTarget = state.tempTargetTarget as? Double,
  383. tempTarget.truncatingRemainder(dividingBy: targetStep) != 0
  384. {
  385. let roundedValue: Double
  386. if state.tempTargetTarget > 100 {
  387. // Round down to the nearest valid step away from 100
  388. let stepCount = (Double(state.tempTargetTarget) - 100) / targetStep
  389. roundedValue = 100 + floor(stepCount) * targetStep
  390. } else {
  391. // Round up to the nearest valid step away from 100
  392. let stepCount = (100 - Double(state.tempTargetTarget)) / targetStep
  393. roundedValue = 100 - floor(stepCount) * targetStep
  394. }
  395. // Ensure the value stays higher than 79
  396. state.tempTargetTarget = Decimal(max(80, roundedValue))
  397. }
  398. }
  399. }
  400. struct RadioButton: View {
  401. var isSelected: Bool
  402. var label: String
  403. var action: () -> Void
  404. var body: some View {
  405. Button(action: {
  406. action()
  407. }) {
  408. HStack {
  409. Image(systemName: isSelected ? "largecircle.fill.circle" : "circle")
  410. Text(label) // Add label inside the button to make it tappable
  411. }
  412. }
  413. .buttonStyle(PlainButtonStyle())
  414. }
  415. }