HomeRootView.swift 48 KB

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