HomeRootView.swift 51 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214
  1. import CoreData
  2. import SpriteKit
  3. import SwiftDate
  4. import SwiftUI
  5. import Swinject
  6. struct TimePicker: Identifiable {
  7. var active: Bool
  8. let hours: Int16
  9. var id: String { hours.description }
  10. }
  11. extension Home {
  12. struct RootView: BaseView {
  13. let resolver: Resolver
  14. let safeAreaSize: CGFloat = 0.08
  15. @Environment(\.managedObjectContext) var moc
  16. @Environment(\.colorScheme) var colorScheme
  17. @Environment(AppState.self) var appState
  18. @State var state = StateModel()
  19. @State var settingsPath = NavigationPath()
  20. @State var isStatusPopupPresented = false
  21. @State var showCancelAlert = false
  22. @State var showCancelConfirmDialog = false
  23. @State var isConfirmStopOverrideShown = false
  24. @State var isConfirmStopOverridePresented = false
  25. @State var isConfirmStopTempTargetShown = false
  26. @State var isMenuPresented = false
  27. @State var showTreatments = false
  28. @State var selectedTab: Int = 0
  29. @State var showPumpSelection: Bool = false
  30. @State var showCGMSelection: Bool = false
  31. @State var notificationsDisabled = false
  32. @State var timeButtons: [TimePicker] = [
  33. TimePicker(active: false, hours: 4),
  34. TimePicker(active: false, hours: 6),
  35. TimePicker(active: false, hours: 12),
  36. TimePicker(active: false, hours: 24)
  37. ]
  38. @FetchRequest(fetchRequest: OverrideStored.fetch(
  39. NSPredicate.lastActiveOverride,
  40. ascending: false,
  41. fetchLimit: 1
  42. )) var latestOverride: FetchedResults<OverrideStored>
  43. @FetchRequest(fetchRequest: TempTargetStored.fetch(
  44. NSPredicate.lastActiveTempTarget,
  45. ascending: false,
  46. fetchLimit: 1
  47. )) var latestTempTarget: FetchedResults<TempTargetStored>
  48. var bolusProgressFormatter: NumberFormatter {
  49. let formatter = NumberFormatter()
  50. formatter.numberStyle = .decimal
  51. formatter.minimum = 0
  52. formatter.maximumFractionDigits = state.settingsManager.preferences.bolusIncrement > 0.05 ? 1 : 2
  53. formatter.minimumFractionDigits = state.settingsManager.preferences.bolusIncrement > 0.05 ? 1 : 2
  54. formatter.allowsFloats = true
  55. formatter.roundingIncrement = Double(state.settingsManager.preferences.bolusIncrement) as NSNumber
  56. return formatter
  57. }
  58. private var fetchedTargetFormatter: NumberFormatter {
  59. let formatter = NumberFormatter()
  60. formatter.numberStyle = .decimal
  61. if state.units == .mmolL {
  62. formatter.maximumFractionDigits = 1
  63. } else { formatter.maximumFractionDigits = 0 }
  64. return formatter
  65. }
  66. private var historySFSymbol: String {
  67. if #available(iOS 17.0, *) {
  68. return "book.pages"
  69. } else {
  70. return "book"
  71. }
  72. }
  73. @ViewBuilder func pumpTimezoneView(_ badgeImage: UIImage, _ badgeColor: Color) -> some View {
  74. HStack {
  75. Image(uiImage: badgeImage.withRenderingMode(.alwaysTemplate))
  76. .font(.system(size: 14))
  77. .colorMultiply(badgeColor)
  78. Text(String(localized: "Time Change Detected", comment: ""))
  79. .bold()
  80. .font(.system(size: 14))
  81. .foregroundStyle(badgeColor)
  82. }
  83. .onTapGesture {
  84. if state.pumpDisplayState != nil {
  85. // sends user to pump settings
  86. state.shouldDisplayPumpSetupSheet.toggle()
  87. }
  88. }
  89. .frame(maxWidth: .infinity, alignment: .center)
  90. .padding(.vertical, 5)
  91. .padding(.horizontal, 10)
  92. .overlay(
  93. Capsule()
  94. .stroke(badgeColor.opacity(0.4), lineWidth: 2)
  95. )
  96. }
  97. var cgmSelectionButtons: some View {
  98. ForEach(cgmOptions, id: \.name) { option in
  99. if let cgm = state.listOfCGM.first(where: option.predicate) {
  100. Button(option.name) {
  101. state.addCGM(cgm: cgm)
  102. }
  103. }
  104. }
  105. }
  106. var glucoseView: some View {
  107. CurrentGlucoseView(
  108. timerDate: state.timerDate,
  109. units: state.units,
  110. alarm: state.alarm,
  111. lowGlucose: state.lowGlucose,
  112. highGlucose: state.highGlucose,
  113. cgmAvailable: state.cgmAvailable,
  114. currentGlucoseTarget: state.currentGlucoseTarget,
  115. glucoseColorScheme: state.glucoseColorScheme,
  116. glucose: state.latestTwoGlucoseValues
  117. ).scaleEffect(0.9)
  118. .onTapGesture {
  119. if !state.cgmAvailable {
  120. showCGMSelection.toggle()
  121. } else {
  122. state.shouldDisplayCGMSetupSheet.toggle()
  123. }
  124. }
  125. .onLongPressGesture {
  126. let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
  127. impactHeavy.impactOccurred()
  128. state.showModal(for: .snooze)
  129. }
  130. }
  131. var pumpView: some View {
  132. PumpView(
  133. reservoir: state.reservoir,
  134. name: state.pumpName,
  135. expiresAtDate: state.pumpExpiresAtDate,
  136. timerDate: state.timerDate,
  137. pumpStatusHighlightMessage: state.pumpStatusHighlightMessage,
  138. battery: state.batteryFromPersistence
  139. )
  140. .onTapGesture {
  141. if state.pumpDisplayState == nil {
  142. // shows user confirmation dialog with pump model choices, then proceeds to setup
  143. showPumpSelection.toggle()
  144. } else {
  145. // sends user to pump settings
  146. state.shouldDisplayPumpSetupSheet.toggle()
  147. }
  148. }
  149. }
  150. var basalString: String? {
  151. var rate: NSNumber = 0
  152. var manualBasalString = ""
  153. guard let apsManager = state.apsManager else {
  154. return nil
  155. }
  156. if apsManager.isScheduledBasal == true {
  157. guard let scheduledRate = scheduledBasalDeliveryRate(at: Date()) else {
  158. return nil
  159. }
  160. rate = scheduledRate
  161. } else {
  162. guard let lastTempBasal = state.tempBasals.last?.tempBasal, let tempRate = lastTempBasal.rate else {
  163. return nil
  164. }
  165. if apsManager.isManualTempBasal {
  166. manualBasalString = String(
  167. localized: " - Manual Basal ⚠️",
  168. comment: "Manual Temp basal"
  169. )
  170. }
  171. rate = tempRate
  172. }
  173. let rateString = Formatter.decimalFormatterWithTwoFractionDigits.string(from: rate) ?? "0"
  174. return rateString + String(localized: " U/hr", comment: "Unit per hour with space") +
  175. manualBasalString
  176. }
  177. // Returns the scheduled basal rate for the current time based on the saved basal scheduled.
  178. // Would be better if in the future BasalDeliveryStatus could be updated to include this info.
  179. func scheduledBasalDeliveryRate(at when: Date) -> NSNumber? {
  180. let calendar = Calendar(identifier: .gregorian)
  181. // calendar.timeZone = timeZone /// should come from pumpManager in case it's different!
  182. let hours = calendar.component(.hour, from: when)
  183. let minutes = calendar.component(.minute, from: when)
  184. let totalMinutes = hours * 60 + minutes
  185. if let rate = findBasalRateForOffset(for: totalMinutes, in: state.basalProfile) {
  186. return NSDecimalNumber(decimal: rate)
  187. }
  188. return nil
  189. }
  190. var overrideString: String? {
  191. guard let latestOverride = latestOverride.first else {
  192. return nil
  193. }
  194. let percent = latestOverride.percentage
  195. let percentString = percent == 100 ? "" : "\(percent.formatted(.number)) %"
  196. let unit = state.units
  197. var target = (latestOverride.target ?? 100) as Decimal
  198. target = unit == .mmolL ? target.asMmolL : target
  199. var targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + unit
  200. .rawValue
  201. if tempTargetString != nil {
  202. targetString = ""
  203. }
  204. let duration = latestOverride.duration ?? 0
  205. let addedMinutes = Int(truncating: duration)
  206. let date = latestOverride.date ?? Date()
  207. let newDuration = max(
  208. Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes),
  209. 0
  210. )
  211. let indefinite = latestOverride.indefinite
  212. var durationString = ""
  213. if !indefinite {
  214. if newDuration >= 1 {
  215. durationString = formatHrMin(Int(newDuration))
  216. } else if newDuration > 0 {
  217. durationString = "\(Int(newDuration * 60)) s"
  218. } else {
  219. /// Do not show the Override anymore
  220. Task {
  221. guard let objectID = self.latestOverride.first?.objectID else { return }
  222. await state.cancelOverride(withID: objectID)
  223. }
  224. }
  225. }
  226. let smbScheduleString = latestOverride
  227. .smbIsScheduledOff && ((latestOverride.start?.stringValue ?? "") != (latestOverride.end?.stringValue ?? ""))
  228. ? " \(formatTimeRange(start: latestOverride.start?.stringValue, end: latestOverride.end?.stringValue))"
  229. : ""
  230. let smbToggleString = latestOverride.smbIsOff || latestOverride
  231. .smbIsScheduledOff ? "SMBs Off\(smbScheduleString)" : ""
  232. let components = [durationString, percentString, targetString, smbToggleString].filter { !$0.isEmpty }
  233. return components.isEmpty ? nil : components.joined(separator: ", ")
  234. }
  235. var tempTargetString: String? {
  236. guard let latestTempTarget = latestTempTarget.first else {
  237. return nil
  238. }
  239. let duration = latestTempTarget.duration
  240. let addedMinutes = Int(truncating: duration ?? 0)
  241. let date = latestTempTarget.date ?? Date()
  242. let newDuration = max(
  243. Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes),
  244. 0
  245. )
  246. var durationString = ""
  247. var percentageString = ""
  248. var target = (latestTempTarget.target ?? 100) as Decimal
  249. var halfBasalTarget: Decimal = 160
  250. if latestTempTarget.halfBasalTarget != nil {
  251. halfBasalTarget = latestTempTarget.halfBasalTarget! as Decimal
  252. } else { halfBasalTarget = state.settingHalfBasalTarget }
  253. var showPercentage = false
  254. if target > 100, state.isExerciseModeActive || state.highTTraisesSens { showPercentage = true }
  255. if target < 100, state.lowTTlowersSens, state.autosensMax > 1 { showPercentage = true }
  256. if showPercentage {
  257. percentageString =
  258. " \(state.computeAdjustedPercentage(halfBasalTargetValue: halfBasalTarget, tempTargetValue: target))%" }
  259. target = state.units == .mmolL ? target.asMmolL : target
  260. let targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " +
  261. state.units.rawValue + percentageString
  262. if newDuration >= 1 {
  263. durationString =
  264. "\(newDuration.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) min"
  265. } else if newDuration > 0 {
  266. durationString =
  267. "\((newDuration * 60).formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) s"
  268. } else {
  269. /// Do not show the Temp Target anymore
  270. Task {
  271. guard let objectID = self.latestTempTarget.first?.objectID else { return }
  272. await state.cancelTempTarget(withID: objectID)
  273. }
  274. }
  275. let components = [targetString, durationString].filter { !$0.isEmpty }
  276. return components.isEmpty ? nil : components.joined(separator: ", ")
  277. }
  278. var timeIntervalButtons: some View {
  279. let buttonColor = (colorScheme == .dark ? Color.white : Color.black).opacity(0.8)
  280. return HStack(alignment: .center) {
  281. ForEach(timeButtons) { button in
  282. Button(action: {
  283. state.hours = button.hours
  284. }) {
  285. Group {
  286. if button.active {
  287. Text(
  288. button.hours.description + "\u{00A0}" +
  289. String(localized: "h", comment: "h")
  290. )
  291. } else {
  292. Text(button.hours.description)
  293. }
  294. }
  295. .font(.footnote)
  296. .fontWeight(button.active ? .semibold : .regular)
  297. .padding(.vertical, 5)
  298. .padding(.horizontal, 10)
  299. .foregroundColor(
  300. button
  301. .active ? (colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) : buttonColor
  302. )
  303. .background(button.active ? buttonColor.opacity(colorScheme == .dark ? 1 : 0.8) : Color.clear)
  304. .clipShape(Capsule())
  305. .overlay(
  306. Capsule()
  307. .stroke(button.active ? buttonColor.opacity(0.4) : Color.clear, lineWidth: 2)
  308. )
  309. }
  310. }
  311. }
  312. }
  313. var statsIconString: String {
  314. if #available(iOS 18, *) {
  315. return "chart.line.text.clipboard"
  316. } else {
  317. return "list.clipboard"
  318. }
  319. }
  320. @ViewBuilder private func tappableButton(
  321. buttonColor: Color,
  322. label: String,
  323. iconString: String,
  324. action: @escaping () -> Void
  325. ) -> some View {
  326. Button(action: {
  327. action()
  328. }) {
  329. HStack {
  330. Image(systemName: iconString)
  331. Text(label)
  332. }
  333. .font(.footnote)
  334. .padding(.vertical, 5)
  335. .padding(.horizontal, 10)
  336. .foregroundStyle(buttonColor)
  337. .overlay(
  338. Capsule()
  339. .stroke(buttonColor.opacity(0.4), lineWidth: 2)
  340. )
  341. }
  342. }
  343. @ViewBuilder func mainChart(geo: GeometryProxy) -> some View {
  344. ZStack {
  345. MainChartView(
  346. geo: geo,
  347. safeAreaSize: notificationsDisabled == true ? safeAreaSize : 0,
  348. units: state.units,
  349. hours: state.filteredHours,
  350. highGlucose: state.highGlucose,
  351. lowGlucose: state.lowGlucose,
  352. currentGlucoseTarget: state.currentGlucoseTarget,
  353. glucoseColorScheme: state.glucoseColorScheme,
  354. screenHours: state.hours,
  355. displayXgridLines: state.displayXgridLines,
  356. displayYgridLines: state.displayYgridLines,
  357. thresholdLines: state.thresholdLines,
  358. state: state
  359. )
  360. }
  361. .padding(.bottom, UIDevice.adjustPadding(min: 0, max: nil))
  362. }
  363. func highlightButtons() {
  364. for i in 0 ..< timeButtons.count {
  365. timeButtons[i].active = timeButtons[i].hours == state.hours
  366. }
  367. }
  368. @ViewBuilder func rightHeaderPanel(_: GeometryProxy) -> some View {
  369. VStack(alignment: .leading, spacing: 20) {
  370. /// Loop view at bottomLeading
  371. LoopView(
  372. closedLoop: state.closedLoop,
  373. timerDate: state.timerDate,
  374. isLooping: state.isLooping,
  375. lastLoopDate: state.lastLoopDate,
  376. manualTempBasal: state.manualTempBasal,
  377. determination: state.determinationsFromPersistence
  378. )
  379. .onTapGesture {
  380. state.isLoopStatusPresented = true
  381. }
  382. .onLongPressGesture {
  383. let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
  384. impactHeavy.impactOccurred()
  385. state.runLoop()
  386. }
  387. /// eventualBG string at bottomTrailing
  388. if let eventualBG = state.enactedAndNonEnactedDeterminations.first?.eventualBG {
  389. let eventualGlucose = eventualBG as Decimal
  390. HStack {
  391. Image(systemName: "arrow.right.circle")
  392. .font(.callout)
  393. .fontWeight(.bold)
  394. Text(state.units == .mgdL ? eventualGlucose.description : eventualGlucose.formattedAsMmolL)
  395. .font(.callout)
  396. .fontWeight(.bold)
  397. .fontDesign(.rounded)
  398. }
  399. // aligns the evBG icon exactly with the first pixel of loop status icon
  400. .padding(.leading, 12)
  401. } else {
  402. HStack {
  403. Image(systemName: "arrow.right.circle")
  404. .font(.callout).fontWeight(.bold)
  405. Text("--")
  406. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  407. }
  408. }
  409. }
  410. }
  411. @ViewBuilder func mealPanel(_: GeometryProxy) -> some View {
  412. HStack {
  413. HStack {
  414. Image(systemName: "syringe.fill")
  415. .font(.callout)
  416. .foregroundColor(Color.insulin)
  417. Text(
  418. (
  419. Formatter.decimalFormatterWithTwoFractionDigits
  420. .string(from: state.currentIOB as NSNumber) ?? "0"
  421. ) +
  422. String(localized: " U", comment: "Insulin unit")
  423. )
  424. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  425. }
  426. Spacer()
  427. HStack {
  428. Image(systemName: "fork.knife")
  429. .font(.callout)
  430. .foregroundColor(.loopYellow)
  431. Text(
  432. (
  433. Formatter.decimalFormatterWithTwoFractionDigits.string(
  434. from: NSNumber(value: state.enactedAndNonEnactedDeterminations.first?.cob ?? 0)
  435. ) ?? "0"
  436. ) +
  437. String(localized: " g", comment: "gram of carbs")
  438. )
  439. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  440. }
  441. Spacer()
  442. if state.maxIOB == 0.0 {
  443. HStack {
  444. Image(systemName: "exclamationmark.circle.fill")
  445. Text("MaxIOB: 0 U")
  446. }.bold()
  447. .foregroundStyle(Color.red)
  448. .font(.callout)
  449. } else {
  450. HStack {
  451. /// Only display the insulin delivery rate info if the pump is not
  452. /// suspended and is available (e.g., pod is paired & not faulted).
  453. let pumpAvailable = state.apsManager.isScheduledBasal != nil
  454. if !state.apsManager.isSuspended && pumpAvailable {
  455. Image(systemName: "drop.circle")
  456. .font(.callout)
  457. .foregroundColor(.insulinTintColor)
  458. if let basalString = self.basalString {
  459. /// Adjust opacity when displaying a scheduled basal rate
  460. let opacity = state.apsManager?.isScheduledBasal == true ? 0.6 : 1.0
  461. if basalString.count > 5 {
  462. Text(basalString)
  463. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  464. .lineLimit(1)
  465. .minimumScaleFactor(0.85)
  466. .truncationMode(.tail)
  467. .allowsTightening(true)
  468. .opacity(opacity)
  469. } else {
  470. // Short strings can just display normally
  471. Text(basalString)
  472. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  473. .opacity(opacity)
  474. }
  475. } else {
  476. Text("No Data")
  477. .font(.callout).fontWeight(.bold).fontDesign(.rounded)
  478. }
  479. }
  480. }
  481. }
  482. }.padding(.horizontal)
  483. }
  484. @ViewBuilder func adjustmentsOverrideView(_ overrideString: String) -> some View {
  485. Group {
  486. Image(systemName: "clock.arrow.2.circlepath")
  487. .font(.title2)
  488. .foregroundStyle(Color.primary, Color.purple)
  489. VStack(alignment: .leading) {
  490. Text(latestOverride.first?.name ?? String(localized: "Custom Override"))
  491. .font(.subheadline)
  492. .frame(alignment: .leading)
  493. Text(overrideString)
  494. .font(.caption)
  495. }
  496. }
  497. .onTapGesture {
  498. selectedTab = 2
  499. }
  500. }
  501. @ViewBuilder func adjustmentsTempTargetView(_ tempTargetString: String) -> some View {
  502. Group {
  503. Image(systemName: "target")
  504. .font(.title2)
  505. .foregroundStyle(Color.loopGreen)
  506. VStack(alignment: .leading) {
  507. Text(latestTempTarget.first?.name ?? String(localized: "Temp Target"))
  508. .font(.subheadline)
  509. Text(tempTargetString)
  510. .font(.caption)
  511. }
  512. }
  513. .onTapGesture {
  514. selectedTab = 2
  515. }
  516. }
  517. @ViewBuilder func adjustmentsCancelView(_ cancelAction: @escaping () -> Void) -> some View {
  518. Image(systemName: "xmark.app")
  519. .font(.title)
  520. .onTapGesture {
  521. cancelAction()
  522. }
  523. }
  524. @ViewBuilder func adjustmentsCancelTempTargetView() -> some View {
  525. Image(systemName: "xmark.app")
  526. .font(.title)
  527. .confirmationDialog(
  528. "Stop the Temp Target \"\(latestTempTarget.first?.name ?? "")\"?",
  529. isPresented: $isConfirmStopTempTargetShown,
  530. titleVisibility: .visible
  531. ) {
  532. Button("Stop", role: .destructive) {
  533. Task {
  534. guard let objectID = latestTempTarget.first?.objectID else { return }
  535. await state.cancelTempTarget(withID: objectID)
  536. }
  537. }
  538. Button("Cancel", role: .cancel) {}
  539. }
  540. .padding(.trailing, 8)
  541. .onTapGesture {
  542. if !latestTempTarget.isEmpty {
  543. isConfirmStopTempTargetShown = true
  544. }
  545. }
  546. }
  547. @ViewBuilder func adjustmentsCancelOverrideView() -> some View {
  548. Image(systemName: "xmark.app")
  549. .font(.title)
  550. .confirmationDialog(
  551. "Stop the Override \"\(latestOverride.first?.name ?? "")\"?",
  552. isPresented: $isConfirmStopOverridePresented,
  553. titleVisibility: .visible
  554. ) {
  555. Button("Stop", role: .destructive) {
  556. Task {
  557. guard let objectID = latestOverride.first?.objectID else { return }
  558. await state.cancelOverride(withID: objectID)
  559. }
  560. }
  561. Button("Cancel", role: .cancel) {}
  562. }
  563. .padding(.trailing, 8)
  564. .onTapGesture {
  565. if !latestOverride.isEmpty {
  566. isConfirmStopOverridePresented = true
  567. }
  568. }
  569. }
  570. @ViewBuilder func noActiveAdjustmentsView() -> some View {
  571. Group {
  572. VStack {
  573. Text("No Active Adjustment")
  574. .font(.subheadline)
  575. .frame(maxWidth: .infinity, alignment: .leading)
  576. Text("Profile at 100 %")
  577. .font(.caption)
  578. .frame(maxWidth: .infinity, alignment: .leading)
  579. }.padding(.leading, 10)
  580. Spacer()
  581. /// to ensure the same position....
  582. Image(systemName: "xmark.app")
  583. .font(.title)
  584. // clear color for the icon
  585. .foregroundStyle(Color.clear)
  586. }.onTapGesture {
  587. selectedTab = 2
  588. }
  589. }
  590. @ViewBuilder func adjustmentView(geo: GeometryProxy) -> some View {
  591. // let background = colorScheme == .dark ? Material.ultraThinMaterial.opacity(0.5) : Color.black.opacity(0.2)
  592. ZStack {
  593. /// rectangle as background
  594. RoundedRectangle(cornerRadius: 15)
  595. .fill(
  596. (overrideString != nil || tempTargetString != nil) ?
  597. (
  598. colorScheme == .dark ?
  599. Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) :
  600. Color.insulin.opacity(0.1)
  601. ) : Color.clear // Use clear and add the Material in the background
  602. )
  603. .background(colorScheme == .dark ? Color.chart.opacity(0.25) : Color.black.opacity(0.075))
  604. .clipShape(RoundedRectangle(cornerRadius: 15))
  605. .frame(height: geo.size.height * 0.08)
  606. .shadow(
  607. color: (overrideString != nil || tempTargetString != nil) ?
  608. (
  609. colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  610. Color.black.opacity(0.33)
  611. ) : Color.clear,
  612. radius: 3
  613. )
  614. HStack {
  615. if let overrideString = overrideString, let tempTargetString = tempTargetString {
  616. HStack {
  617. adjustmentsOverrideView(overrideString)
  618. Spacer()
  619. Divider()
  620. .frame(height: geo.size.height * 0.05)
  621. .padding(.horizontal, 2)
  622. adjustmentsTempTargetView(tempTargetString)
  623. Spacer()
  624. adjustmentsCancelView({
  625. if !latestTempTarget.isEmpty, !latestOverride.isEmpty {
  626. showCancelConfirmDialog = true
  627. } else if !latestOverride.isEmpty {
  628. showCancelAlert = true
  629. } else if !latestTempTarget.isEmpty {
  630. showCancelAlert = true
  631. }
  632. })
  633. }
  634. } else if let overrideString = overrideString {
  635. adjustmentsOverrideView(overrideString)
  636. Spacer()
  637. adjustmentsCancelOverrideView()
  638. } else if let tempTargetString = tempTargetString {
  639. HStack {
  640. adjustmentsTempTargetView(tempTargetString)
  641. Spacer()
  642. adjustmentsCancelTempTargetView()
  643. }
  644. } else {
  645. noActiveAdjustmentsView()
  646. }
  647. }.padding(.horizontal, 10)
  648. .confirmationDialog("Adjustment to Stop", isPresented: $showCancelConfirmDialog) {
  649. Button("Stop Override", role: .destructive) {
  650. Task {
  651. guard let objectID = latestOverride.first?.objectID else { return }
  652. await state.cancelOverride(withID: objectID)
  653. }
  654. }
  655. Button("Stop Temp Target", role: .destructive) {
  656. Task {
  657. guard let objectID = latestTempTarget.first?.objectID else { return }
  658. await state.cancelTempTarget(withID: objectID)
  659. }
  660. }
  661. Button("Stop All Adjustments", role: .destructive) {
  662. Task {
  663. guard let overrideObjectID = latestOverride.first?.objectID else { return }
  664. await state.cancelOverride(withID: overrideObjectID)
  665. guard let tempTargetObjectID = latestTempTarget.first?.objectID else { return }
  666. await state.cancelTempTarget(withID: tempTargetObjectID)
  667. }
  668. }
  669. } message: {
  670. Text("Select Adjustment")
  671. }
  672. }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
  673. }
  674. @ViewBuilder func bolusProgressBar(_ progress: Decimal) -> some View {
  675. GeometryReader { geo in
  676. RoundedRectangle(cornerRadius: 15)
  677. .frame(height: 6)
  678. .foregroundColor(.clear)
  679. .background(
  680. LinearGradient(colors: [
  681. Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
  682. Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569),
  683. Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765),
  684. Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961),
  685. Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
  686. ], startPoint: .leading, endPoint: .trailing)
  687. .mask(alignment: .leading) {
  688. RoundedRectangle(cornerRadius: 15)
  689. .frame(width: geo.size.width * CGFloat(progress))
  690. }
  691. )
  692. }
  693. }
  694. @ViewBuilder func bolusView(geo: GeometryProxy, _ progress: Decimal) -> some View {
  695. /// ensure that state.lastPumpBolus has a value, i.e. there is a last bolus done by the pump and not an external bolus
  696. /// - TRUE: show the pump bolus
  697. /// - FALSE: do not show a progress bar at all
  698. if let bolusTotal = state.lastPumpBolus?.bolus?.amount {
  699. let bolusFraction = progress * (bolusTotal as Decimal)
  700. let bolusString =
  701. (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0")
  702. + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
  703. (Formatter.decimalFormatterWithTwoFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
  704. + String(localized: " U", comment: "Insulin unit")
  705. ZStack {
  706. /// rectangle as background
  707. RoundedRectangle(cornerRadius: 15)
  708. .fill(
  709. colorScheme == .dark ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) : Color
  710. .insulin
  711. .opacity(0.2)
  712. )
  713. .clipShape(RoundedRectangle(cornerRadius: 15))
  714. .frame(height: geo.size.height * 0.08)
  715. .shadow(
  716. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  717. Color.black.opacity(0.33),
  718. radius: 3
  719. )
  720. /// actual bolus view
  721. HStack {
  722. Image(systemName: "cross.vial.fill")
  723. .font(.system(size: 25))
  724. Spacer()
  725. VStack {
  726. Text("Bolusing")
  727. .font(.subheadline)
  728. .frame(maxWidth: .infinity, alignment: .leading)
  729. Text(bolusString)
  730. .font(.caption)
  731. .frame(maxWidth: .infinity, alignment: .leading)
  732. }.padding(.leading, 5)
  733. Spacer()
  734. Button {
  735. state.showProgressView()
  736. state.cancelBolus()
  737. } label: {
  738. Image(systemName: "xmark.app")
  739. .font(.system(size: 25))
  740. }
  741. }.padding(.horizontal, 10)
  742. .padding(.trailing, 8)
  743. }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
  744. .overlay(alignment: .bottom) {
  745. // Use a geo-based offset here to position progress bar independent of device size
  746. let offset = geo.size.height * 0.0725
  747. bolusProgressBar(progress).padding(.horizontal, 18)
  748. .offset(y: offset)
  749. }.clipShape(RoundedRectangle(cornerRadius: 15))
  750. }
  751. }
  752. @ViewBuilder func alertSafetyNotificationsView(geo: GeometryProxy) -> some View {
  753. ZStack {
  754. /// rectangle as background
  755. RoundedRectangle(cornerRadius: 15)
  756. .fill(
  757. Color(
  758. red: 0.9,
  759. green: 0.133333333,
  760. blue: 0.2156862745
  761. )
  762. )
  763. .clipShape(RoundedRectangle(cornerRadius: 15))
  764. .frame(height: geo.size.height * safeAreaSize)
  765. .coordinateSpace(name: "alertSafetyNotificationsView")
  766. .shadow(
  767. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  768. Color.black.opacity(0.33),
  769. radius: 3
  770. )
  771. HStack {
  772. Spacer()
  773. VStack {
  774. Text("⚠️ Safety Notifications are OFF")
  775. .font(.headline)
  776. .fontWeight(.bold)
  777. .fontDesign(.rounded)
  778. .foregroundStyle(.white.gradient)
  779. .frame(maxWidth: .infinity, alignment: .leading)
  780. Text("Fix now by turning Notifications ON.")
  781. .font(.footnote)
  782. .fontDesign(.rounded)
  783. .foregroundStyle(.white.gradient)
  784. .frame(maxWidth: .infinity, alignment: .leading)
  785. }.padding(.leading, 5)
  786. Spacer()
  787. Image(systemName: "chevron.right").foregroundColor(.white)
  788. .font(.headline)
  789. }.padding(.horizontal, 10)
  790. .padding(.trailing, 8)
  791. .onTapGesture {
  792. UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
  793. }
  794. }.padding(.horizontal, 10)
  795. .padding(.top, 0)
  796. }
  797. @ViewBuilder func mainViewElements(_ geo: GeometryProxy) -> some View {
  798. VStack(spacing: 0) {
  799. ZStack {
  800. if let apsManager = state.apsManager, let bluetoothManager = apsManager.bluetoothManager,
  801. bluetoothManager.bluetoothAuthorization != .authorized
  802. {
  803. BluetoothRequiredView()
  804. } else {
  805. /// right panel with loop status and evBG
  806. HStack {
  807. Spacer()
  808. rightHeaderPanel(geo)
  809. }.padding(.trailing, 20)
  810. /// glucose bobble
  811. glucoseView
  812. /// left panel with pump related info
  813. HStack {
  814. pumpView
  815. Spacer()
  816. }.padding(.leading, 20)
  817. }
  818. }
  819. .padding(.top, 10)
  820. .safeAreaInset(edge: .top, spacing: 0) {
  821. if notificationsDisabled {
  822. alertSafetyNotificationsView(geo: geo)
  823. }
  824. if let badgeImage = state.pumpStatusBadgeImage, let badgeColor = state.pumpStatusBadgeColor {
  825. pumpTimezoneView(badgeImage, badgeColor)
  826. .padding(.horizontal, 20)
  827. }
  828. }
  829. mealPanel(geo).padding(.top, UIDevice.adjustPadding(min: nil, max: 30))
  830. .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 20))
  831. mainChart(geo: geo)
  832. HStack {
  833. tappableButton(
  834. buttonColor: (colorScheme == .dark ? Color.white : Color.black).opacity(0.8),
  835. label: String(localized: "Stats", comment: "Stats icon in main view"),
  836. iconString: statsIconString,
  837. action: { state.showModal(for: .statistics) }
  838. )
  839. Spacer()
  840. timeIntervalButtons.padding(.top, UIDevice.adjustPadding(min: 0, max: 10))
  841. .padding(.bottom, UIDevice.adjustPadding(min: 0, max: 10))
  842. Spacer()
  843. tappableButton(
  844. buttonColor: (colorScheme == .dark ? Color.white : Color.black).opacity(0.8),
  845. label: String(localized: "Info", comment: "Info icon in main view"),
  846. iconString: "info",
  847. action: { state.isLegendPresented.toggle() }
  848. )
  849. }.padding([.horizontal, .bottom])
  850. if let progress = state.bolusProgress {
  851. bolusView(geo: geo, progress)
  852. .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40))
  853. } else {
  854. adjustmentView(geo: geo).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40))
  855. }
  856. }
  857. .background(appState.trioBackgroundColor(for: colorScheme))
  858. .onReceive(
  859. resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled,
  860. perform: {
  861. if notificationsDisabled != $0 {
  862. notificationsDisabled = $0
  863. if notificationsDisabled {
  864. debug(.default, "notificationsDisabled")
  865. }
  866. }
  867. }
  868. )
  869. }
  870. @ViewBuilder func mainView() -> some View {
  871. GeometryReader { geo in
  872. mainViewElements(geo)
  873. }
  874. .onChange(of: state.hours) {
  875. highlightButtons()
  876. }
  877. .onAppear {
  878. configureView {
  879. highlightButtons()
  880. }
  881. }
  882. .navigationTitle("Home")
  883. .navigationBarHidden(true)
  884. .ignoresSafeArea(.keyboard)
  885. .blur(radius: state.isLoopStatusPresented ? 3 : 0)
  886. .sheet(isPresented: $state.isLoopStatusPresented) {
  887. LoopStatusView(state: state)
  888. }
  889. .sheet(isPresented: $state.isLegendPresented) {
  890. ChartLegendView(state: state)
  891. }
  892. // PUMP RELATED
  893. .confirmationDialog("Pump Model", isPresented: $showPumpSelection) {
  894. Button("Medtronic") { state.addPump(.minimed) }
  895. Button("Omnipod Eros") { state.addPump(.omnipod) }
  896. Button("Omnipod DASH") { state.addPump(.omnipodBLE) }
  897. Button("Dana(RS/-i)") { state.addPump(.dana) }
  898. Button("Pump Simulator") { state.addPump(.simulator) }
  899. } message: { Text("Select Pump Model") }
  900. .sheet(isPresented: $state.shouldDisplayPumpSetupSheet) {
  901. if let pumpManager = state.provider.apsManager.pumpManager {
  902. PumpConfig.PumpSettingsView(
  903. pumpManager: pumpManager,
  904. bluetoothManager: state.provider.apsManager.bluetoothManager!,
  905. completionDelegate: state,
  906. setupDelegate: state
  907. )
  908. } else {
  909. PumpConfig.PumpSetupView(
  910. pumpType: state.setupPumpType,
  911. pumpInitialSettings: state.pumpInitialSettings,
  912. bluetoothManager: state.provider.apsManager.bluetoothManager!,
  913. completionDelegate: state,
  914. setupDelegate: state
  915. )
  916. }
  917. }
  918. // CGM RELATED
  919. .confirmationDialog("CGM Model", isPresented: $showCGMSelection) {
  920. cgmSelectionButtons
  921. } message: {
  922. Text("Select CGM Model")
  923. }
  924. .sheet(isPresented: $state.shouldDisplayCGMSetupSheet) {
  925. switch state.cgmCurrent.type {
  926. case .enlite,
  927. .nightscout,
  928. .none,
  929. .simulator,
  930. .xdrip:
  931. CGMSettings.CustomCGMOptionsView(
  932. resolver: self.resolver,
  933. state: state.cgmStateModel,
  934. cgmCurrent: state.cgmCurrent,
  935. deleteCGM: state.deleteCGM
  936. )
  937. case .plugin:
  938. if let fetchGlucoseManager = state.fetchGlucoseManager,
  939. let cgmManager = fetchGlucoseManager.cgmManager,
  940. state.cgmCurrent.type == fetchGlucoseManager.cgmGlucoseSourceType,
  941. state.cgmCurrent.id == fetchGlucoseManager.cgmGlucosePluginId
  942. {
  943. CGMSettings.CGMSettingsView(
  944. cgmManager: cgmManager,
  945. bluetoothManager: state.provider.apsManager.bluetoothManager!,
  946. unit: state.settingsManager.settings.units,
  947. completionDelegate: state
  948. )
  949. } else {
  950. CGMSettings.CGMSetupView(
  951. CGMType: state.cgmCurrent,
  952. bluetoothManager: state.provider.apsManager.bluetoothManager!,
  953. unit: state.settingsManager.settings.units,
  954. completionDelegate: state,
  955. setupDelegate: state,
  956. pluginCGMManager: self.state.pluginCGMManager
  957. )
  958. }
  959. }
  960. }
  961. }
  962. @ViewBuilder func tabBar() -> some View {
  963. ZStack(alignment: .bottom) {
  964. TabView(selection: $selectedTab) {
  965. let carbsRequiredBadge: String? = {
  966. guard let carbsRequired = state.enactedAndNonEnactedDeterminations.first?.carbsRequired,
  967. state.showCarbsRequiredBadge
  968. else {
  969. return nil
  970. }
  971. let carbsRequiredDecimal = Decimal(carbsRequired)
  972. if carbsRequiredDecimal > state.settingsManager.settings.carbsRequiredThreshold {
  973. let numberAsNSNumber = NSDecimalNumber(decimal: carbsRequiredDecimal)
  974. return (Formatter.decimalFormatterWithTwoFractionDigits.string(from: numberAsNSNumber) ?? "") + " g"
  975. }
  976. return nil
  977. }()
  978. NavigationStack { mainView() }
  979. .tabItem { Label("Main", systemImage: "chart.xyaxis.line") }
  980. .badge(carbsRequiredBadge).tag(0)
  981. NavigationStack { DataTable.RootView(resolver: resolver) }
  982. .tabItem { Label("History", systemImage: historySFSymbol) }.tag(1)
  983. Spacer()
  984. NavigationStack { Adjustments.RootView(resolver: resolver) }
  985. .tabItem {
  986. Label(
  987. "Adjustments",
  988. systemImage: "slider.horizontal.2.gobackward"
  989. ) }.tag(2)
  990. NavigationStack(path: self.$settingsPath) {
  991. Settings.RootView(resolver: resolver) }
  992. .tabItem { Label(
  993. "Settings",
  994. systemImage: "gear"
  995. ) }.tag(3)
  996. }
  997. .tint(Color.tabBar)
  998. Button(
  999. action: {
  1000. state.showModal(for: .treatmentView) },
  1001. label: {
  1002. Image(systemName: "plus.circle.fill")
  1003. .font(.system(size: 40))
  1004. .foregroundStyle(Color.tabBar)
  1005. .padding(.vertical, 2)
  1006. .padding(.horizontal, 24)
  1007. }
  1008. )
  1009. }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0)
  1010. .onChange(of: selectedTab) {
  1011. if !settingsPath.isEmpty {
  1012. settingsPath = NavigationPath()
  1013. }
  1014. }
  1015. }
  1016. var body: some View {
  1017. ZStack(alignment: .center) {
  1018. tabBar()
  1019. if state.waitForSuggestion {
  1020. CustomProgressView(text: String(localized: "Updating IOB...", comment: "Progress text when updating IOB"))
  1021. }
  1022. }
  1023. }
  1024. }
  1025. }
  1026. extension UIDevice {
  1027. public enum DeviceSize: CGFloat {
  1028. case smallDevice = 667 // Height for 4" iPhone SE
  1029. case largeDevice = 852 // Height for 6.1" iPhone 15 Pro
  1030. }
  1031. @usableFromInline static func adjustPadding(
  1032. min: CGFloat? = nil,
  1033. max: CGFloat? = nil
  1034. ) -> CGFloat? {
  1035. if UIScreen.screenHeight > UIDevice.DeviceSize.smallDevice.rawValue {
  1036. if UIScreen.screenHeight >= UIDevice.DeviceSize.largeDevice.rawValue {
  1037. return max
  1038. } else {
  1039. return min != nil ?
  1040. (max != nil ? max! * (UIScreen.screenHeight / UIDevice.DeviceSize.largeDevice.rawValue) : nil) : nil
  1041. }
  1042. } else {
  1043. return min
  1044. }
  1045. }
  1046. }
  1047. extension UIScreen {
  1048. static var screenHeight: CGFloat {
  1049. UIScreen.main.bounds.height
  1050. }
  1051. static var screenWidth: CGFloat {
  1052. UIScreen.main.bounds.width
  1053. }
  1054. }
  1055. /// Checks if the device is using a 24-hour time format.
  1056. func is24HourFormat() -> Bool {
  1057. let formatter = DateFormatter()
  1058. formatter.locale = Locale.current
  1059. formatter.dateStyle = .none
  1060. formatter.timeStyle = .short
  1061. let dateString = formatter.string(from: Date())
  1062. return !dateString.contains("AM") && !dateString.contains("PM")
  1063. }
  1064. /// Converts a duration in minutes to a formatted string (e.g., "1 h 30 m").
  1065. func formatHrMin(_ durationInMinutes: Int) -> String {
  1066. let hours = durationInMinutes / 60
  1067. let minutes = durationInMinutes % 60
  1068. switch (hours, minutes) {
  1069. case let (0, m):
  1070. return "\(m)\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes")
  1071. case let (h, 0):
  1072. return "\(h)\u{00A0}" + String(localized: "h", comment: "h")
  1073. default:
  1074. return hours.description + "\u{00A0}" + String(localized: "h", comment: "h") + "\u{00A0}" + minutes
  1075. .description + "\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes")
  1076. }
  1077. }
  1078. // Helper function to convert a start and end hour to either 24-hour or AM/PM format
  1079. func formatTimeRange(start: String?, end: String?) -> String {
  1080. guard let start = start, let end = end else {
  1081. return ""
  1082. }
  1083. // Check if the format is 24-hour or AM/PM
  1084. if is24HourFormat() {
  1085. // Return the original 24-hour format
  1086. return "\(start)-\(end)"
  1087. } else {
  1088. // Convert to AM/PM format using DateFormatter
  1089. let formatter = DateFormatter()
  1090. formatter.dateFormat = "HH"
  1091. if let startHour = Int(start), let endHour = Int(end) {
  1092. let startDate = Calendar.current.date(bySettingHour: startHour, minute: 0, second: 0, of: Date()) ?? Date()
  1093. let endDate = Calendar.current.date(bySettingHour: endHour, minute: 0, second: 0, of: Date()) ?? Date()
  1094. // Customize the format to "2p" or "2a"
  1095. formatter.dateFormat = "ha"
  1096. let startFormatted = formatter.string(from: startDate).lowercased().replacingOccurrences(of: "m", with: "")
  1097. let endFormatted = formatter.string(from: endDate).lowercased().replacingOccurrences(of: "m", with: "")
  1098. return "\(startFormatted)-\(endFormatted)"
  1099. } else {
  1100. return ""
  1101. }
  1102. }
  1103. }