HomeRootView.swift 46 KB

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