AddTempTargetForm.swift 20 KB

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