AddTempTargetForm.swift 20 KB

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