HomeRootView+BottomControls.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import CoreData
  2. import SwiftDate
  3. import SwiftUI
  4. // MARK: - Zone E: bottom controls (adjustment panel / bolus progress)
  5. extension Home.RootView {
  6. var bolusProgressFormatter: NumberFormatter {
  7. let fractionDigits: Int = switch state.settingsManager.preferences.bolusIncrement {
  8. case 0.1: 1
  9. case 0.025: 3
  10. default: 2
  11. }
  12. let formatter = NumberFormatter()
  13. formatter.numberStyle = .decimal
  14. formatter.minimum = 0
  15. formatter.maximumFractionDigits = fractionDigits
  16. formatter.minimumFractionDigits = fractionDigits
  17. formatter.allowsFloats = true
  18. formatter.roundingIncrement = Double(state.settingsManager.preferences.bolusIncrement) as NSNumber
  19. return formatter
  20. }
  21. private var fetchedTargetFormatter: NumberFormatter {
  22. let formatter = NumberFormatter()
  23. formatter.numberStyle = .decimal
  24. if state.units == .mmolL {
  25. formatter.maximumFractionDigits = 1
  26. } else { formatter.maximumFractionDigits = 0 }
  27. return formatter
  28. }
  29. var overrideString: String? {
  30. guard let latestOverride = latestOverride.first else {
  31. return nil
  32. }
  33. guard let settingsManager = state.settingsManager else {
  34. return nil
  35. }
  36. let percent = latestOverride.percentage
  37. let percentString = percent == 100 ? "" : "\(percent.formatted(.number)) %"
  38. let unit = state.units
  39. var target = (latestOverride.target ?? 0) as Decimal
  40. target = unit == .mmolL ? target.asMmolL : target
  41. var targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + unit
  42. .rawValue
  43. if tempTargetString != nil {
  44. targetString = ""
  45. }
  46. let duration = latestOverride.duration ?? 0
  47. let addedMinutes = Int(truncating: duration)
  48. let date = latestOverride.date ?? Date()
  49. let newDuration = max(
  50. Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes),
  51. 0
  52. )
  53. let indefinite = latestOverride.indefinite
  54. var durationString = ""
  55. if !indefinite {
  56. if newDuration >= 1 {
  57. durationString = formatHrMin(Int(newDuration))
  58. } else if newDuration > 0 {
  59. durationString = "\(Int(newDuration * 60)) s"
  60. } else {
  61. /// Do not show the Override anymore
  62. Task {
  63. guard let objectID = self.latestOverride.first?.objectID else { return }
  64. await state.cancelOverride(withID: objectID)
  65. }
  66. }
  67. }
  68. let smbScheduleString = latestOverride
  69. .smbIsScheduledOff && ((latestOverride.start?.stringValue ?? "") != (latestOverride.end?.stringValue ?? ""))
  70. ? " \(formatTimeRange(start: latestOverride.start?.stringValue, end: latestOverride.end?.stringValue))"
  71. : ""
  72. let smbToggleString = latestOverride.smbIsOff || latestOverride
  73. .smbIsScheduledOff ? String(localized: "SMBs Off\(smbScheduleString)") : ""
  74. var smbMinuteString: String = ""
  75. var uamMinuteString: String = ""
  76. if !latestOverride.smbIsOff, latestOverride.advancedSettings {
  77. if let smbMinutes = latestOverride.smbMinutes,
  78. smbMinutes.decimalValue != settingsManager.preferences.maxSMBBasalMinutes
  79. {
  80. smbMinuteString = "SMB\u{00A0}\(smbMinutes)\u{00A0}" +
  81. String(localized: "m", comment: "Abbreviation for Minutes")
  82. }
  83. if let uamMinutes = latestOverride.uamMinutes,
  84. uamMinutes.decimalValue != settingsManager.preferences.maxUAMSMBBasalMinutes
  85. {
  86. uamMinuteString = "UAM\u{00A0}\(uamMinutes)\u{00A0}" +
  87. String(localized: "m", comment: "Abbreviation for Minutes")
  88. }
  89. }
  90. let components = [durationString, percentString, targetString, smbToggleString, smbMinuteString, uamMinuteString]
  91. .filter { !$0.isEmpty }
  92. return components.isEmpty ? nil : components.joined(separator: ", ")
  93. }
  94. var tempTargetString: String? {
  95. guard let latestTempTarget = latestTempTarget.first else {
  96. return nil
  97. }
  98. let duration = latestTempTarget.duration
  99. let addedMinutes = Int(truncating: duration ?? 0)
  100. let date = latestTempTarget.date ?? Date()
  101. let newDuration = max(
  102. Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes),
  103. 0
  104. )
  105. var durationString = ""
  106. var percentageString = ""
  107. var target = (latestTempTarget.target ?? 100) as Decimal
  108. // Use TempTargetCalculations to get effective HBT (handles both custom and auto-adjusted standard TT)
  109. let effectiveHBT = TempTargetCalculations.computeEffectiveHBT(
  110. tempTargetHalfBasalTarget: latestTempTarget.halfBasalTarget?.decimalValue,
  111. settingHalfBasalTarget: state.settingHalfBasalTarget,
  112. target: target,
  113. autosensMax: state.autosensMax
  114. ) ?? state.settingHalfBasalTarget
  115. var showPercentage = false
  116. if target > 100, state.isExerciseModeActive || state.highTTraisesSens { showPercentage = true }
  117. if target < 100, state.lowTTlowersSens, state.autosensMax > 1 { showPercentage = true }
  118. if showPercentage {
  119. percentageString =
  120. " \(Int(TempTargetCalculations.computeAdjustedPercentage(halfBasalTarget: effectiveHBT, target: target, autosensMax: state.autosensMax)))%"
  121. }
  122. target = state.units == .mmolL ? target.asMmolL : target
  123. let targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " +
  124. state.units.rawValue + percentageString
  125. if newDuration >= 1 {
  126. durationString =
  127. "\(newDuration.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) min"
  128. } else if newDuration > 0 {
  129. durationString =
  130. "\((newDuration * 60).formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) s"
  131. } else {
  132. /// Do not show the Temp Target anymore
  133. Task {
  134. guard let objectID = self.latestTempTarget.first?.objectID else { return }
  135. await state.cancelTempTarget(withID: objectID)
  136. }
  137. }
  138. let components = [targetString, durationString].filter { !$0.isEmpty }
  139. return components.isEmpty ? nil : components.joined(separator: ", ")
  140. }
  141. @ViewBuilder func adjustmentsOverrideView(_ overrideString: String) -> some View {
  142. Group {
  143. Image(systemName: "clock.arrow.2.circlepath")
  144. .font(.title2)
  145. .foregroundStyle(Color.primary, Color.purple)
  146. VStack(alignment: .leading) {
  147. Text(latestOverride.first?.name ?? String(localized: "Custom Override"))
  148. .font(.subheadline)
  149. .frame(alignment: .leading)
  150. Text(overrideString)
  151. .font(.caption)
  152. }
  153. }
  154. .onTapGesture {
  155. selectedTab = 2
  156. }
  157. }
  158. @ViewBuilder func adjustmentsTempTargetView(_ tempTargetString: String) -> some View {
  159. Group {
  160. Image(systemName: "target")
  161. .font(.title2)
  162. .foregroundStyle(Color.loopGreen)
  163. VStack(alignment: .leading) {
  164. Text(latestTempTarget.first?.name ?? String(localized: "Temp Target"))
  165. .font(.subheadline)
  166. Text(tempTargetString)
  167. .font(.caption)
  168. }
  169. }
  170. .onTapGesture {
  171. selectedTab = 2
  172. }
  173. }
  174. @ViewBuilder func adjustmentsCancelView(_ cancelAction: @escaping () -> Void) -> some View {
  175. Image(systemName: "xmark.app")
  176. .font(.title)
  177. .onTapGesture {
  178. cancelAction()
  179. }
  180. }
  181. @ViewBuilder func adjustmentsCancelTempTargetView() -> some View {
  182. Image(systemName: "xmark.app")
  183. .font(.title)
  184. .confirmationDialog(
  185. "Stop the Temp Target \"\(latestTempTarget.first?.name ?? "")\"?",
  186. isPresented: $isConfirmStopTempTargetShown,
  187. titleVisibility: .visible
  188. ) {
  189. Button("Stop", role: .destructive) {
  190. Task {
  191. guard let objectID = latestTempTarget.first?.objectID else { return }
  192. await state.cancelTempTarget(withID: objectID)
  193. }
  194. }
  195. Button("Cancel", role: .cancel) {}
  196. }
  197. .padding(.trailing, 8)
  198. .onTapGesture {
  199. if !latestTempTarget.isEmpty {
  200. isConfirmStopTempTargetShown = true
  201. }
  202. }
  203. }
  204. @ViewBuilder func adjustmentsCancelOverrideView() -> some View {
  205. Image(systemName: "xmark.app")
  206. .font(.title)
  207. .confirmationDialog(
  208. "Stop the Override \"\(latestOverride.first?.name ?? "")\"?",
  209. isPresented: $isConfirmStopOverridePresented,
  210. titleVisibility: .visible
  211. ) {
  212. Button("Stop", role: .destructive) {
  213. Task {
  214. guard let objectID = latestOverride.first?.objectID else { return }
  215. await state.cancelOverride(withID: objectID)
  216. }
  217. }
  218. Button("Cancel", role: .cancel) {}
  219. }
  220. .padding(.trailing, 8)
  221. .onTapGesture {
  222. if !latestOverride.isEmpty {
  223. isConfirmStopOverridePresented = true
  224. }
  225. }
  226. }
  227. @ViewBuilder func noActiveAdjustmentsView() -> some View {
  228. Group {
  229. VStack {
  230. Text("No Active Adjustment")
  231. .font(.subheadline)
  232. .frame(maxWidth: .infinity, alignment: .leading)
  233. Text("Profile at 100 %")
  234. .font(.caption)
  235. .frame(maxWidth: .infinity, alignment: .leading)
  236. }.padding(.leading, 10)
  237. Spacer()
  238. /// to ensure the same position....
  239. Image(systemName: "xmark.app")
  240. .font(.title)
  241. // clear color for the icon
  242. .foregroundStyle(Color.clear)
  243. }.onTapGesture {
  244. selectedTab = 2
  245. }
  246. }
  247. @ViewBuilder func adjustmentView() -> some View {
  248. // let background = colorScheme == .dark ? Material.ultraThinMaterial.opacity(0.5) : Color.black.opacity(0.2)
  249. ZStack {
  250. /// rectangle as background
  251. RoundedRectangle(cornerRadius: 15)
  252. .fill(
  253. (overrideString != nil || tempTargetString != nil) ?
  254. (
  255. colorScheme == .dark ?
  256. Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) :
  257. Color.insulin.opacity(0.1)
  258. ) : Color.clear // Use clear and add the Material in the background
  259. )
  260. .background(colorScheme == .dark ? Color.chart.opacity(0.25) : Color.black.opacity(0.075))
  261. .clipShape(RoundedRectangle(cornerRadius: 15))
  262. .frame(height: HomeLayout.bottomPanelHeight)
  263. .shadow(
  264. color: (overrideString != nil || tempTargetString != nil) ?
  265. (
  266. colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  267. Color.black.opacity(0.33)
  268. ) : Color.clear,
  269. radius: 3
  270. )
  271. HStack {
  272. if let overrideString = overrideString, let tempTargetString = tempTargetString {
  273. HStack {
  274. adjustmentsOverrideView(overrideString)
  275. Spacer()
  276. Divider()
  277. .frame(height: HomeLayout.bottomPanelHeight - 24)
  278. .padding(.horizontal, 2)
  279. adjustmentsTempTargetView(tempTargetString)
  280. Spacer()
  281. adjustmentsCancelView({
  282. if !latestTempTarget.isEmpty, !latestOverride.isEmpty {
  283. showCancelConfirmDialog = true
  284. } else if !latestOverride.isEmpty {
  285. showCancelAlert = true
  286. } else if !latestTempTarget.isEmpty {
  287. showCancelAlert = true
  288. }
  289. })
  290. }
  291. } else if let overrideString = overrideString {
  292. adjustmentsOverrideView(overrideString)
  293. Spacer()
  294. adjustmentsCancelOverrideView()
  295. } else if let tempTargetString = tempTargetString {
  296. HStack {
  297. adjustmentsTempTargetView(tempTargetString)
  298. Spacer()
  299. adjustmentsCancelTempTargetView()
  300. }
  301. } else {
  302. noActiveAdjustmentsView()
  303. }
  304. }.padding(.horizontal, 10)
  305. .confirmationDialog("Adjustment to Stop", isPresented: $showCancelConfirmDialog) {
  306. Button("Stop Override", role: .destructive) {
  307. Task {
  308. guard let objectID = latestOverride.first?.objectID else { return }
  309. await state.cancelOverride(withID: objectID)
  310. }
  311. }
  312. Button("Stop Temp Target", role: .destructive) {
  313. Task {
  314. guard let objectID = latestTempTarget.first?.objectID else { return }
  315. await state.cancelTempTarget(withID: objectID)
  316. }
  317. }
  318. Button("Stop All Adjustments", role: .destructive) {
  319. Task {
  320. guard let overrideObjectID = latestOverride.first?.objectID else { return }
  321. await state.cancelOverride(withID: overrideObjectID)
  322. guard let tempTargetObjectID = latestTempTarget.first?.objectID else { return }
  323. await state.cancelTempTarget(withID: tempTargetObjectID)
  324. }
  325. }
  326. } message: {
  327. Text("Select Adjustment")
  328. }
  329. }.padding(.horizontal, 10)
  330. }
  331. @ViewBuilder func bolusView(_ progress: Decimal) -> some View {
  332. /// ensure that state.lastPumpBolus has a value, i.e. there is a last bolus done by the pump and not an external bolus
  333. /// - TRUE: show the pump bolus
  334. /// - FALSE: do not show a progress bar at all
  335. if let bolusTotal = state.lastPumpBolus?.bolus?.amount {
  336. let bolusFraction = progress * (bolusTotal as Decimal)
  337. let bolusString =
  338. (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0")
  339. + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
  340. (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
  341. + String(localized: " U", comment: "Insulin unit")
  342. let bolusLabel = state
  343. .bolusStatus == .inProgress ? String(localized: "Bolusing") : String(localized: "Initiating…")
  344. ZStack {
  345. /// rectangle as background
  346. RoundedRectangle(cornerRadius: 15)
  347. .fill(
  348. colorScheme == .dark ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) : Color
  349. .insulin
  350. .opacity(0.2)
  351. )
  352. .clipShape(RoundedRectangle(cornerRadius: 15))
  353. .frame(height: HomeLayout.bottomPanelHeight)
  354. .shadow(
  355. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  356. Color.black.opacity(0.33),
  357. radius: 3
  358. )
  359. /// actual bolus view
  360. HStack {
  361. Image(systemName: "cross.vial.fill")
  362. .font(.system(size: 25))
  363. Spacer()
  364. VStack {
  365. Text(bolusLabel)
  366. .font(.subheadline)
  367. .frame(maxWidth: .infinity, alignment: .leading)
  368. Text(bolusString)
  369. .font(.caption)
  370. .frame(maxWidth: .infinity, alignment: .leading)
  371. }.padding(.leading, 5)
  372. Spacer()
  373. if state.bolusStatus == .inProgress {
  374. Button {
  375. state.showProgressView()
  376. state.cancelBolus()
  377. } label: {
  378. Image(systemName: "xmark.app")
  379. .font(.system(size: 25))
  380. }
  381. } else if state.bolusStatus == .initiating {
  382. ProgressView()
  383. }
  384. }.padding(.horizontal, 10)
  385. .padding(.trailing, 8)
  386. }
  387. .padding(.horizontal, 10)
  388. .overlay(alignment: .bottom) {
  389. // bar hugs the panel's bottom edge (the slot no longer has outer bottom padding)
  390. BolusProgressBar(progress: progress)
  391. .padding(.horizontal, 18)
  392. .padding(.bottom, 1)
  393. }.clipShape(RoundedRectangle(cornerRadius: 15))
  394. }
  395. }
  396. /// Bottom-anchored fixed slot shared by adjustment panel and bolus progress.
  397. @ViewBuilder func bottomControls() -> some View {
  398. Group {
  399. if let progress = state.bolusProgress {
  400. bolusView(progress)
  401. } else {
  402. adjustmentView()
  403. }
  404. }
  405. .frame(height: HomeLayout.bottomPanelHeight)
  406. .animation(.easeInOut(duration: 0.2), value: state.bolusProgress != nil)
  407. // clear air below the chart's x-axis labels and above the tab bar
  408. .padding(.vertical, HomeLayout.bottomZonePadding)
  409. }
  410. }