AddTempTargetForm.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  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 { targetStep = state.units == .mgdL ? 5 : 9 }
  90. }
  91. }
  92. @ViewBuilder private func addTempTarget() -> some View {
  93. Section {
  94. let pad: CGFloat = 3
  95. HStack {
  96. Text("Name")
  97. Spacer()
  98. TextField("Enter Name (optional)", text: $state.tempTargetName)
  99. .multilineTextAlignment(.trailing)
  100. }
  101. .padding(.vertical, pad)
  102. DatePicker("Date", selection: $state.date)
  103. VStack {
  104. HStack {
  105. Text("Duration")
  106. Spacer()
  107. Text(formatHrMin(Int(state.tempTargetDuration)))
  108. .foregroundColor(!displayPickerDuration ? .primary : .accentColor)
  109. }
  110. .padding(.vertical, pad)
  111. .onTapGesture {
  112. displayPickerDuration.toggle()
  113. }
  114. if displayPickerDuration {
  115. HStack {
  116. Picker("Hours", selection: $durationHours) {
  117. ForEach(0 ..< 24) { hour in
  118. Text("\(hour) hr").tag(hour)
  119. }
  120. }
  121. .pickerStyle(WheelPickerStyle())
  122. .frame(maxWidth: .infinity)
  123. .onChange(of: durationHours) {
  124. state.tempTargetDuration = Decimal(totalDurationInMinutes())
  125. }
  126. Picker("Minutes", selection: $durationMinutes) {
  127. ForEach(Array(stride(from: 0, through: 55, by: 5)), id: \.self) { minute in
  128. Text("\(minute) min").tag(minute)
  129. }
  130. }
  131. .pickerStyle(WheelPickerStyle())
  132. .frame(maxWidth: .infinity)
  133. .onChange(of: durationMinutes) {
  134. state.tempTargetDuration = Decimal(totalDurationInMinutes())
  135. }
  136. }
  137. }
  138. }
  139. VStack {
  140. HStack {
  141. Text("Target Glucose")
  142. Spacer()
  143. Text(formattedGlucose(glucose: state.tempTargetTarget))
  144. .foregroundColor(!displayPickerTarget ? .primary : .accentColor)
  145. }
  146. .padding(.vertical, pad)
  147. .onTapGesture {
  148. displayPickerTarget.toggle()
  149. }
  150. if displayPickerTarget {
  151. HStack {
  152. // Radio buttons and text on the left side
  153. VStack(alignment: .leading) {
  154. // Radio buttons for step iteration
  155. let stepChoices: [Decimal] = state.units == .mgdL ? [1, 5] : [1, 9]
  156. ForEach(stepChoices, id: \.self) { step in
  157. RadioButton(
  158. isSelected: targetStep == step,
  159. label: "\(state.units == .mgdL ? step : step.asMmolL) \(state.units.rawValue)"
  160. ) {
  161. targetStep = step
  162. state.tempTargetTarget = roundTargetToStep(state.tempTargetTarget, targetStep)
  163. }
  164. .padding(.top, 10)
  165. }
  166. }
  167. .frame(maxWidth: .infinity)
  168. Spacer()
  169. // Picker on the right side
  170. Picker(selection: Binding(
  171. get: { roundTargetToStep(state.tempTargetTarget, targetStep) },
  172. set: { state.tempTargetTarget = $0 }
  173. ), label: Text("")) {
  174. ForEach(
  175. generateTargetPickerValues(),
  176. id: \.self
  177. ) { glucose in
  178. Text(formattedGlucose(glucose: glucose))
  179. .tag(glucose)
  180. }
  181. }
  182. .pickerStyle(WheelPickerStyle())
  183. .frame(maxWidth: .infinity)
  184. .onChange(of: state.tempTargetTarget) { _ in
  185. state.percentage = Double(state.computeAdjustedPercentage() * 100)
  186. }
  187. }
  188. }
  189. }
  190. if isSliderEnabled && state.tempTargetTarget != 0 {
  191. if state.tempTargetTarget > 100 {
  192. Section {
  193. VStack(alignment: .leading) {
  194. Text("Raised Sensitivity:")
  195. .font(.footnote)
  196. .fontWeight(.bold)
  197. Text("Insulin reduced to \(formattedPercentage(state.percentage))% of regular amount.")
  198. .font(.footnote)
  199. .lineLimit(1)
  200. .minimumScaleFactor(0.8)
  201. }
  202. .padding(.vertical, pad)
  203. }.listRowBackground(Color.tabBar)
  204. Section {
  205. VStack {
  206. Toggle("Adjust Sensitivity", isOn: $state.didAdjustSens).padding(.top)
  207. HStack(alignment: .top) {
  208. Text(
  209. "Temp Target raises Sensitivity. Further adjust if desired!"
  210. )
  211. .font(.footnote)
  212. .foregroundColor(.secondary)
  213. .lineLimit(nil)
  214. Spacer()
  215. Button(
  216. action: {
  217. hintLabel = "Adjust Sensitivity for high Temp Target "
  218. selectedVerboseHint =
  219. "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!"
  220. shouldDisplayHint.toggle()
  221. },
  222. label: {
  223. HStack {
  224. Image(systemName: "questionmark.circle")
  225. }
  226. }
  227. ).buttonStyle(BorderlessButtonStyle())
  228. }.padding(.top)
  229. }
  230. .padding(.vertical, pad)
  231. }.listRowBackground(Color.chart)
  232. } else if state.tempTargetTarget < 100 {
  233. Section {
  234. VStack(alignment: .leading) {
  235. Text("Lowered Sensitivity:")
  236. .font(.footnote)
  237. .fontWeight(.bold)
  238. Text("Insulin increased to \(formattedPercentage(state.percentage))% of regular amount.")
  239. .font(.footnote)
  240. .lineLimit(1)
  241. .minimumScaleFactor(0.8)
  242. }
  243. .padding(.vertical, pad)
  244. }.listRowBackground(Color.tabBar)
  245. Section {
  246. VStack {
  247. Toggle("Adjust Insulin %", isOn: $state.didAdjustSens).padding(.top)
  248. HStack(alignment: .top) {
  249. Text(
  250. "Temp Target lowers Sensitivity. Further adjust if desired!"
  251. )
  252. .font(.footnote)
  253. .foregroundColor(.secondary)
  254. .lineLimit(nil)
  255. Spacer()
  256. Button(
  257. action: {
  258. hintLabel = "Adjust Sensitivity for low Temp Target "
  259. selectedVerboseHint =
  260. "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!"
  261. shouldDisplayHint.toggle()
  262. },
  263. label: {
  264. HStack {
  265. Image(systemName: "questionmark.circle")
  266. }
  267. }
  268. ).buttonStyle(BorderlessButtonStyle())
  269. }.padding(.top)
  270. }
  271. .padding(.vertical, pad)
  272. }.listRowBackground(Color.chart)
  273. }
  274. if state.didAdjustSens && state.tempTargetTarget != 100 {
  275. Section {
  276. VStack {
  277. Text("\(Int(state.percentage)) % Insulin")
  278. .foregroundColor(isUsingSlider ? .orange : Color.tabBar)
  279. .font(.title3)
  280. .fontWeight(.bold)
  281. Slider(
  282. value: $state.percentage,
  283. in: state.computeSliderLow() ... state.computeSliderHigh(),
  284. step: 5
  285. ) {} minimumValueLabel: {
  286. Text("\(state.computeSliderLow(), specifier: "%.0f")%")
  287. } maximumValueLabel: {
  288. Text("\(state.computeSliderHigh(), specifier: "%.0f")%")
  289. } onEditingChanged: { editing in
  290. isUsingSlider = editing
  291. state.halfBasalTarget = Decimal(state.computeHalfBasalTarget())
  292. }
  293. .disabled(!isSliderEnabled)
  294. Divider()
  295. HStack {
  296. Text(
  297. "Half Basal Exercise Target at: \(formattedGlucose(glucose: Decimal(state.computeHalfBasalTarget())))"
  298. )
  299. .lineLimit(1)
  300. .minimumScaleFactor(0.8)
  301. .foregroundColor(.secondary)
  302. Spacer()
  303. }
  304. }
  305. .padding(.vertical, pad)
  306. }.listRowBackground(Color.chart)
  307. }
  308. }
  309. }.listRowBackground(Color.chart)
  310. }
  311. private func isTempTargetInvalid() -> (Bool, String?) {
  312. let noDurationSpecified = state.tempTargetDuration == 0
  313. let targetZero = state.tempTargetTarget < 80
  314. if noDurationSpecified {
  315. return (true, "Set a duration!")
  316. }
  317. if targetZero {
  318. return (
  319. true,
  320. "\(state.units == .mgdL ? "80 " : "4.4 ")" + state.units.rawValue + " needed as min. Glucose Target!"
  321. )
  322. }
  323. return (false, nil)
  324. }
  325. private var saveButton: some View {
  326. let (isInvalid, errorMessage) = isTempTargetInvalid()
  327. let noNameSpecified = state.tempTargetName == ""
  328. return Group {
  329. Section(
  330. header:
  331. HStack {
  332. Spacer()
  333. Text(errorMessage ?? "").textCase(nil)
  334. .foregroundColor(colorScheme == .dark ? .orange : .accentColor)
  335. Spacer()
  336. },
  337. content: {
  338. Button(action: {
  339. Task {
  340. if noNameSpecified { state.tempTargetName = "Custom Target" }
  341. didPressSave.toggle()
  342. state.isTempTargetEnabled.toggle()
  343. await state.saveCustomTempTarget()
  344. await state.resetTempTargetState()
  345. dismiss()
  346. }
  347. }, label: {
  348. Text("Enact Temp Target")
  349. })
  350. .disabled(isInvalid)
  351. .frame(maxWidth: .infinity, alignment: .center)
  352. .tint(.white)
  353. }
  354. ).listRowBackground(isInvalid ? Color(.systemGray4) : Color(.systemBlue))
  355. Section {
  356. Button(action: {
  357. Task {
  358. if noNameSpecified { state.tempTargetName = "Custom Target" }
  359. didPressSave.toggle()
  360. await state.saveTempTargetPreset()
  361. dismiss()
  362. }
  363. }, label: {
  364. Text("Save as Preset")
  365. })
  366. .disabled(isInvalid)
  367. .frame(maxWidth: .infinity, alignment: .center)
  368. .tint(.white)
  369. }
  370. .listRowBackground(
  371. isInvalid ? Color(.systemGray4) : Color.secondary
  372. )
  373. }
  374. }
  375. private func totalDurationInMinutes() -> Int {
  376. let durationTotal = (durationHours * 60) + durationMinutes
  377. return max(0, durationTotal)
  378. }
  379. private func formattedPercentage(_ value: Double) -> String {
  380. let percentageNumber = NSNumber(value: value)
  381. return formatter.string(from: percentageNumber) ?? "\(value)"
  382. }
  383. private func formattedGlucose(glucose: Decimal) -> String {
  384. let formattedValue: String
  385. if state.units == .mgdL {
  386. formattedValue = glucoseFormatter.string(from: glucose as NSDecimalNumber) ?? "\(glucose)"
  387. } else {
  388. formattedValue = glucose.formattedAsMmolL
  389. }
  390. return "\(formattedValue) \(state.units.rawValue)"
  391. }
  392. private func roundTargetToStep(_ target: Decimal, _ step: Decimal) -> Decimal {
  393. // Convert target and step to NSDecimalNumber
  394. guard let targetValue = NSDecimalNumber(decimal: target).doubleValue as Double?,
  395. let stepValue = NSDecimalNumber(decimal: step).doubleValue as Double?
  396. else {
  397. print("Failed to unwrap target or step as NSDecimalNumber")
  398. return target
  399. }
  400. // Perform the remainder check using truncatingRemainder
  401. let remainder = Decimal(targetValue.truncatingRemainder(dividingBy: stepValue))
  402. if remainder != 0 {
  403. // Calculate how much to adjust (up or down) based on the remainder
  404. let adjustment = step - remainder
  405. return target + adjustment
  406. }
  407. // Return the original target if no adjustment is needed
  408. return target
  409. }
  410. func generateTargetPickerValues() -> [Decimal] {
  411. var values: [Decimal] = []
  412. var currentValue: Double = 72
  413. let step = Double(targetStep)
  414. // Adjust currentValue to be divisible by targetStep
  415. let remainder = currentValue.truncatingRemainder(dividingBy: step)
  416. if remainder != 0 {
  417. // Move currentValue up to the next value divisible by targetStep
  418. currentValue += (step - remainder)
  419. }
  420. // Now generate the picker values starting from currentValue
  421. while currentValue <= 270 {
  422. values.append(Decimal(currentValue))
  423. currentValue += step
  424. }
  425. // Glucose values are stored as mg/dl values, so Integers.
  426. // Filter out duplicate values when rounded to 1 decimal place.
  427. if state.units == .mmolL {
  428. // Use a Set to track unique values rounded to 1 decimal
  429. var uniqueRoundedValues = Set<String>()
  430. values = values.filter { value in
  431. let roundedValue = String(format: "%.1f", NSDecimalNumber(decimal: value.asMmolL).doubleValue)
  432. return uniqueRoundedValues.insert(roundedValue).inserted
  433. }
  434. }
  435. return values
  436. }
  437. }
  438. func formatHrMin(_ durationInMinutes: Int) -> String {
  439. let hours = durationInMinutes / 60
  440. let minutes = durationInMinutes % 60
  441. switch (hours, minutes) {
  442. case let (0, m):
  443. return "\(m) min"
  444. case let (h, 0):
  445. return "\(h) hr"
  446. default:
  447. return "\(hours) hr \(minutes) min"
  448. }
  449. }
  450. struct RadioButton: View {
  451. var isSelected: Bool
  452. var label: String
  453. var action: () -> Void
  454. var body: some View {
  455. Button(action: {
  456. action()
  457. }) {
  458. HStack {
  459. Image(systemName: isSelected ? "largecircle.fill.circle" : "circle")
  460. Text(label) // Add label inside the button to make it tappable
  461. }
  462. }
  463. .buttonStyle(PlainButtonStyle())
  464. }
  465. }