HomeRootView.swift 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027
  1. import CoreData
  2. import SpriteKit
  3. import SwiftDate
  4. import SwiftUI
  5. import Swinject
  6. extension Home {
  7. struct RootView: BaseView {
  8. let resolver: Resolver
  9. @State var state = StateModel()
  10. @State var isStatusPopupPresented = false
  11. @State var showCancelAlert = false
  12. @State var isMenuPresented = false
  13. @State var showTreatments = false
  14. @State var selectedTab: Int = 0
  15. @State private var statusTitle: String = ""
  16. @State var showPumpSelection: Bool = false
  17. struct Buttons: Identifiable {
  18. let label: String
  19. let number: String
  20. var active: Bool
  21. let hours: Int16
  22. var id: String { label }
  23. }
  24. @State var timeButtons: [Buttons] = [
  25. Buttons(label: "2 hours", number: "2", active: false, hours: 2),
  26. Buttons(label: "4 hours", number: "4", active: false, hours: 4),
  27. Buttons(label: "6 hours", number: "6", active: false, hours: 6),
  28. Buttons(label: "12 hours", number: "12", active: false, hours: 12),
  29. Buttons(label: "24 hours", number: "24", active: false, hours: 24)
  30. ]
  31. let buttonFont = Font.custom("TimeButtonFont", size: 14)
  32. @Environment(\.managedObjectContext) var moc
  33. @Environment(\.colorScheme) var colorScheme
  34. @FetchRequest(fetchRequest: OverrideStored.fetch(
  35. NSPredicate.lastActiveOverride,
  36. ascending: false,
  37. fetchLimit: 1
  38. )) var latestOverride: FetchedResults<OverrideStored>
  39. @FetchRequest(
  40. entity: TempTargets.entity(),
  41. sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)]
  42. ) var sliderTTpresets: FetchedResults<TempTargets>
  43. @FetchRequest(
  44. entity: TempTargetsSlider.entity(),
  45. sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)]
  46. ) var enactedSliderTT: FetchedResults<TempTargetsSlider>
  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 numberFormatter: NumberFormatter {
  58. let formatter = NumberFormatter()
  59. formatter.numberStyle = .decimal
  60. formatter.maximumFractionDigits = 2
  61. return formatter
  62. }
  63. private var fetchedTargetFormatter: NumberFormatter {
  64. let formatter = NumberFormatter()
  65. formatter.numberStyle = .decimal
  66. if state.units == .mmolL {
  67. formatter.maximumFractionDigits = 1
  68. } else { formatter.maximumFractionDigits = 0 }
  69. return formatter
  70. }
  71. private var targetFormatter: NumberFormatter {
  72. let formatter = NumberFormatter()
  73. formatter.numberStyle = .decimal
  74. formatter.maximumFractionDigits = 1
  75. return formatter
  76. }
  77. private var tirFormatter: NumberFormatter {
  78. let formatter = NumberFormatter()
  79. formatter.numberStyle = .decimal
  80. formatter.maximumFractionDigits = 0
  81. return formatter
  82. }
  83. private var dateFormatter: DateFormatter {
  84. let dateFormatter = DateFormatter()
  85. dateFormatter.timeStyle = .short
  86. return dateFormatter
  87. }
  88. private var color: LinearGradient {
  89. colorScheme == .dark ? LinearGradient(
  90. gradient: Gradient(colors: [
  91. Color.bgDarkBlue,
  92. Color.bgDarkerDarkBlue
  93. ]),
  94. startPoint: .top,
  95. endPoint: .bottom
  96. )
  97. :
  98. LinearGradient(
  99. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  100. startPoint: .top,
  101. endPoint: .bottom
  102. )
  103. }
  104. private var historySFSymbol: String {
  105. if #available(iOS 17.0, *) {
  106. return "book.pages"
  107. } else {
  108. return "book"
  109. }
  110. }
  111. var glucoseView: some View {
  112. CurrentGlucoseView(
  113. timerDate: state.timerDate,
  114. units: state.units,
  115. alarm: state.alarm,
  116. lowGlucose: state.lowGlucose,
  117. highGlucose: state.highGlucose,
  118. cgmAvailable: state.cgmAvailable,
  119. glucose: state.latestTwoGlucoseValues
  120. ).scaleEffect(0.9)
  121. .onTapGesture {
  122. state.openCGM()
  123. }
  124. .onLongPressGesture {
  125. let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
  126. impactHeavy.impactOccurred()
  127. state.showModal(for: .snooze)
  128. }
  129. }
  130. var pumpView: some View {
  131. PumpView(
  132. reservoir: state.reservoir,
  133. name: state.pumpName,
  134. expiresAtDate: state.pumpExpiresAtDate,
  135. timerDate: state.timerDate,
  136. timeZone: state.timeZone,
  137. pumpStatusHighlightMessage: state.pumpStatusHighlightMessage,
  138. battery: state.batteryFromPersistence
  139. ).onTapGesture {
  140. if state.pumpDisplayState == nil {
  141. // shows user confirmation dialog with pump model choices, then proceeds to setup
  142. showPumpSelection.toggle()
  143. } else {
  144. // sends user to pump settings
  145. state.setupPump.toggle()
  146. }
  147. }
  148. }
  149. var tempBasalString: String? {
  150. guard let lastTempBasal = state.tempBasals.last?.tempBasal, let tempRate = lastTempBasal.rate else {
  151. return nil
  152. }
  153. let rateString = numberFormatter.string(from: tempRate as NSNumber) ?? "0"
  154. var manualBasalString = ""
  155. if let apsManager = state.apsManager, apsManager.isManualTempBasal {
  156. manualBasalString = NSLocalizedString(
  157. " - Manual Basal ⚠️",
  158. comment: "Manual Temp basal"
  159. )
  160. }
  161. return rateString + " " + NSLocalizedString(" U/hr", comment: "Unit per hour with space") + manualBasalString
  162. }
  163. var overrideString: String? {
  164. guard let latestOverride = latestOverride.first else {
  165. return nil
  166. }
  167. let percent = latestOverride.percentage
  168. let percentString = percent == 100 ? "" : "\(percent.formatted(.number)) %"
  169. let unit = state.units
  170. var target = (latestOverride.target ?? 100) as Decimal
  171. target = unit == .mmolL ? target.asMmolL : target
  172. var targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + unit
  173. .rawValue
  174. if tempTargetString != nil {
  175. targetString = ""
  176. }
  177. let duration = latestOverride.duration ?? 0
  178. let addedMinutes = Int(truncating: duration)
  179. let date = latestOverride.date ?? Date()
  180. let newDuration = max(
  181. Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes),
  182. 0
  183. )
  184. let indefinite = latestOverride.indefinite
  185. var durationString = ""
  186. if !indefinite {
  187. if newDuration >= 1 {
  188. durationString =
  189. "\(newDuration.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) min"
  190. } else if newDuration > 0 {
  191. durationString =
  192. "\((newDuration * 60).formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))) s"
  193. } else {
  194. /// Do not show the Override anymore
  195. Task {
  196. guard let objectID = self.latestOverride.first?.objectID else { return }
  197. await state.cancelOverride(withID: objectID)
  198. }
  199. }
  200. }
  201. let smbToggleString = latestOverride.smbIsOff ? " \u{20e0}" : ""
  202. let components = [percentString, targetString, durationString, smbToggleString].filter { !$0.isEmpty }
  203. return components.isEmpty ? nil : components.joined(separator: ", ")
  204. }
  205. var tempTargetString: String? {
  206. guard let tempTarget = state.tempTarget else {
  207. return nil
  208. }
  209. let target = tempTarget.targetBottom ?? 0
  210. let unitString = targetFormatter.string(from: (tempTarget.targetBottom?.asMmolL ?? 0) as NSNumber) ?? ""
  211. let rawString = (tirFormatter.string(from: (tempTarget.targetBottom ?? 0) as NSNumber) ?? "") + " " + state.units
  212. .rawValue
  213. var string = ""
  214. if sliderTTpresets.first?.active ?? false {
  215. let hbt = sliderTTpresets.first?.hbt ?? 0
  216. string = ", " + (tirFormatter.string(from: state.infoPanelTTPercentage(hbt, target) as NSNumber) ?? "") + " %"
  217. }
  218. let percentString = state
  219. .units == .mmolL ? (unitString + " mmol/L" + string) : (rawString + (string == "0" ? "" : string))
  220. return tempTarget.displayName + " " + percentString
  221. }
  222. var infoPanel: some View {
  223. HStack(alignment: .center) {
  224. if state.pumpSuspended {
  225. Text("Pump suspended")
  226. .font(.system(size: 15, weight: .bold)).foregroundColor(.loopGray)
  227. .padding(.leading, 8)
  228. } else if let tempBasalString = tempBasalString {
  229. Text(tempBasalString)
  230. .font(.system(size: 15, weight: .bold))
  231. .foregroundColor(.insulin)
  232. .padding(.leading, 8)
  233. }
  234. if state.totalInsulinDisplayType == .totalInsulinInScope {
  235. Text(
  236. "TINS: \(state.calculateTINS())" +
  237. NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)")
  238. )
  239. .font(.system(size: 15, weight: .bold))
  240. .foregroundColor(.insulin)
  241. }
  242. if let tempTargetString = tempTargetString {
  243. Text(tempTargetString)
  244. .font(.caption)
  245. .foregroundColor(.secondary)
  246. }
  247. Spacer()
  248. if state.closedLoop, state.settingsManager.preferences.maxIOB == 0 {
  249. Text("Max IOB: 0").font(.callout).foregroundColor(.orange).padding(.trailing, 20)
  250. }
  251. }
  252. .frame(maxWidth: .infinity, maxHeight: 30)
  253. }
  254. var timeInterval: some View {
  255. HStack(alignment: .center) {
  256. ForEach(timeButtons) { button in
  257. Text(button.active ? NSLocalizedString(button.label, comment: "") : button.number).onTapGesture {
  258. state.hours = button.hours
  259. }
  260. .foregroundStyle(button.active ? (colorScheme == .dark ? Color.white : Color.black).opacity(0.9) : .secondary)
  261. .frame(maxHeight: 30).padding(.horizontal, 8)
  262. .background(
  263. button.active ?
  264. // RGB(30, 60, 95)
  265. (
  266. colorScheme == .dark ? Color(red: 0.1176470588, green: 0.2352941176, blue: 0.3725490196) :
  267. Color.white
  268. ) :
  269. Color
  270. .clear
  271. )
  272. .cornerRadius(20)
  273. }
  274. Button(action: {
  275. state.isLegendPresented.toggle()
  276. }) {
  277. Image(systemName: "info")
  278. .foregroundColor(colorScheme == .dark ? Color.white : Color.black).opacity(0.9)
  279. .frame(width: 20, height: 20)
  280. .background(
  281. colorScheme == .dark ? Color(red: 0.1176470588, green: 0.2352941176, blue: 0.3725490196) :
  282. Color.white
  283. )
  284. .clipShape(Circle())
  285. }
  286. .padding([.top, .bottom])
  287. }
  288. .shadow(
  289. color: Color.black.opacity(colorScheme == .dark ? 0.75 : 0.33),
  290. radius: colorScheme == .dark ? 5 : 3
  291. )
  292. .font(buttonFont)
  293. }
  294. @ViewBuilder func mainChart(geo: GeometryProxy) -> some View {
  295. ZStack {
  296. MainChartView(
  297. geo: geo,
  298. units: state.units,
  299. hours: state.filteredHours,
  300. tempTargets: state.tempTargets,
  301. highGlucose: state.highGlucose,
  302. lowGlucose: state.lowGlucose,
  303. screenHours: state.hours,
  304. displayXgridLines: state.displayXgridLines,
  305. displayYgridLines: state.displayYgridLines,
  306. thresholdLines: state.thresholdLines,
  307. state: state
  308. )
  309. }
  310. .padding(.bottom, UIDevice.adjustPadding(min: 0, max: nil))
  311. }
  312. func highlightButtons() {
  313. for i in 0 ..< timeButtons.count {
  314. timeButtons[i].active = timeButtons[i].hours == state.hours
  315. }
  316. }
  317. @ViewBuilder func rightHeaderPanel(_: GeometryProxy) -> some View {
  318. VStack(alignment: .leading, spacing: 20) {
  319. /// Loop view at bottomLeading
  320. LoopView(
  321. closedLoop: state.closedLoop,
  322. timerDate: state.timerDate,
  323. isLooping: state.isLooping,
  324. lastLoopDate: state.lastLoopDate,
  325. manualTempBasal: state.manualTempBasal,
  326. determination: state.determinationsFromPersistence
  327. ).onTapGesture {
  328. state.isStatusPopupPresented = true
  329. setStatusTitle()
  330. }.onLongPressGesture {
  331. let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
  332. impactHeavy.impactOccurred()
  333. state.runLoop()
  334. }
  335. /// eventualBG string at bottomTrailing
  336. if let eventualBG = state.enactedAndNonEnactedDeterminations.first?.eventualBG {
  337. let bg = eventualBG as Decimal
  338. HStack {
  339. Image(systemName: "arrow.right.circle")
  340. .font(.system(size: 16, weight: .bold))
  341. Text(
  342. numberFormatter.string(
  343. from: (
  344. state.units == .mmolL ? bg
  345. .asMmolL : bg
  346. ) as NSNumber
  347. )!
  348. )
  349. .font(.system(size: 16))
  350. }
  351. } else {
  352. HStack {
  353. Image(systemName: "arrow.right.circle")
  354. .font(.system(size: 16, weight: .bold))
  355. Text("--")
  356. .font(.system(size: 16))
  357. }
  358. }
  359. }
  360. }
  361. @ViewBuilder func mealPanel(_: GeometryProxy) -> some View {
  362. HStack {
  363. HStack {
  364. Image(systemName: "syringe.fill")
  365. .font(.system(size: 16))
  366. .foregroundColor(Color.insulin)
  367. Text(
  368. (
  369. numberFormatter
  370. .string(from: (state.enactedAndNonEnactedDeterminations.first?.iob ?? 0) as NSNumber) ?? "0"
  371. ) +
  372. NSLocalizedString(" U", comment: "Insulin unit")
  373. )
  374. .font(.system(size: 16, weight: .bold, design: .rounded))
  375. }
  376. Spacer()
  377. HStack {
  378. Image(systemName: "fork.knife")
  379. .font(.system(size: 16))
  380. .foregroundColor(.loopYellow)
  381. Text(
  382. (
  383. numberFormatter
  384. .string(from: (state.enactedAndNonEnactedDeterminations.first?.cob ?? 0) as NSNumber) ?? "0"
  385. ) +
  386. NSLocalizedString(" g", comment: "gram of carbs")
  387. )
  388. .font(.system(size: 16, weight: .bold, design: .rounded))
  389. }
  390. Spacer()
  391. HStack {
  392. if state.pumpSuspended {
  393. Text("Pump suspended")
  394. .font(.system(size: 12, weight: .bold, design: .rounded)).foregroundColor(.loopGray)
  395. } else if let tempBasalString = tempBasalString {
  396. Image(systemName: "drop.circle")
  397. .font(.system(size: 16))
  398. .foregroundColor(.insulinTintColor)
  399. Text(tempBasalString)
  400. .font(.system(size: 16, weight: .bold, design: .rounded))
  401. } else {
  402. Image(systemName: "drop.circle")
  403. .font(.system(size: 16))
  404. .foregroundColor(.insulinTintColor)
  405. Text("No Data")
  406. .font(.system(size: 16, weight: .bold, design: .rounded))
  407. }
  408. }
  409. if state.totalInsulinDisplayType == .totalDailyDose {
  410. Spacer()
  411. Text(
  412. "TDD: " +
  413. (
  414. numberFormatter
  415. .string(from: (state.determinationsFromPersistence.first?.totalDailyDose ?? 0) as NSNumber) ??
  416. "0"
  417. ) +
  418. NSLocalizedString(" U", comment: "Insulin unit")
  419. )
  420. .font(.system(size: 16, weight: .bold, design: .rounded))
  421. } else {
  422. Spacer()
  423. HStack {
  424. Text(
  425. "TINS: \(state.roundedTotalBolus)" +
  426. NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)")
  427. )
  428. .font(.system(size: 16, weight: .bold, design: .rounded))
  429. .onChange(of: state.hours) { _ in
  430. state.roundedTotalBolus = state.calculateTINS()
  431. }
  432. .onAppear {
  433. DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
  434. state.roundedTotalBolus = state.calculateTINS()
  435. }
  436. }
  437. }
  438. }
  439. }.padding(.horizontal, 10)
  440. }
  441. @ViewBuilder func profileView(geo: GeometryProxy) -> some View {
  442. ZStack {
  443. /// rectangle as background
  444. RoundedRectangle(cornerRadius: 15)
  445. .fill(
  446. colorScheme == .dark ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) : Color.insulin
  447. .opacity(0.1)
  448. )
  449. .clipShape(RoundedRectangle(cornerRadius: 15))
  450. .frame(height: geo.size.height * 0.08)
  451. .shadow(
  452. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  453. Color.black.opacity(0.33),
  454. radius: 3
  455. )
  456. HStack {
  457. /// actual profile view
  458. Image(systemName: "person.fill")
  459. .font(.system(size: 25))
  460. Spacer()
  461. if let overrideString = overrideString {
  462. VStack {
  463. Text(latestOverride.first?.name ?? "Custom Override")
  464. .font(.subheadline)
  465. .frame(maxWidth: .infinity, alignment: .leading)
  466. Text("\(overrideString)")
  467. .font(.caption)
  468. .frame(maxWidth: .infinity, alignment: .leading)
  469. }.padding(.leading, 5)
  470. Spacer()
  471. Image(systemName: "xmark.app")
  472. .font(.system(size: 25))
  473. } else {
  474. if tempTargetString == nil {
  475. VStack {
  476. Text("Normal Profile")
  477. .font(.subheadline)
  478. .frame(maxWidth: .infinity, alignment: .leading)
  479. Text("100 %")
  480. .font(.caption)
  481. .frame(maxWidth: .infinity, alignment: .leading)
  482. }.padding(.leading, 5)
  483. Spacer()
  484. /// to ensure the same position....
  485. Image(systemName: "xmark.app")
  486. .font(.system(size: 25))
  487. .foregroundStyle(Color.clear)
  488. }
  489. }
  490. }.padding(.horizontal, 10)
  491. .alert(
  492. "Return to Normal?", isPresented: $showCancelAlert,
  493. actions: {
  494. Button("No", role: .cancel) {}
  495. Button("Yes", role: .destructive) {
  496. Task {
  497. guard let objectID = latestOverride.first?.objectID else { return }
  498. await state.cancelOverride(withID: objectID)
  499. }
  500. }
  501. }, message: { Text("This will change settings back to your normal profile.") }
  502. )
  503. .padding(.trailing, 8)
  504. .onTapGesture {
  505. if !latestOverride.isEmpty {
  506. showCancelAlert = true
  507. }
  508. }
  509. }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
  510. .overlay {
  511. /// just show temp target if no profile is already active
  512. if overrideString == nil, let tempTargetString = tempTargetString {
  513. ZStack {
  514. /// rectangle as background
  515. RoundedRectangle(cornerRadius: 15)
  516. .fill(
  517. colorScheme == .dark ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) :
  518. Color
  519. .insulin
  520. .opacity(0.2)
  521. )
  522. .clipShape(RoundedRectangle(cornerRadius: 15))
  523. .frame(height: UIScreen.main.bounds.height / 18)
  524. .shadow(
  525. color: colorScheme == .dark ? Color(
  526. red: 0.02745098039,
  527. green: 0.1098039216,
  528. blue: 0.1411764706
  529. ) :
  530. Color.black.opacity(0.33),
  531. radius: 3
  532. )
  533. HStack {
  534. Image(systemName: "person.fill")
  535. .font(.system(size: 25))
  536. Spacer()
  537. Text(tempTargetString)
  538. .font(.subheadline)
  539. Spacer()
  540. }.padding(.horizontal, 10)
  541. }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
  542. }
  543. }
  544. }
  545. @ViewBuilder func bolusProgressBar(_ progress: Decimal) -> some View {
  546. GeometryReader { geo in
  547. RoundedRectangle(cornerRadius: 15)
  548. .frame(height: 6)
  549. .foregroundColor(.clear)
  550. .background(
  551. LinearGradient(colors: [
  552. Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
  553. Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569),
  554. Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765),
  555. Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961),
  556. Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
  557. ], startPoint: .leading, endPoint: .trailing)
  558. .mask(alignment: .leading) {
  559. RoundedRectangle(cornerRadius: 15)
  560. .frame(width: geo.size.width * CGFloat(progress))
  561. }
  562. )
  563. }
  564. }
  565. @ViewBuilder func bolusView(geo: GeometryProxy, _ progress: Decimal) -> some View {
  566. /// ensure that state.lastPumpBolus has a value, i.e. there is a last bolus done by the pump and not an external bolus
  567. /// - TRUE: show the pump bolus
  568. /// - FALSE: do not show a progress bar at all
  569. if let bolusTotal = state.lastPumpBolus?.bolus?.amount {
  570. let bolusFraction = progress * (bolusTotal as Decimal)
  571. let bolusString =
  572. (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0")
  573. + " of " +
  574. (numberFormatter.string(from: bolusTotal as NSNumber) ?? "0")
  575. + NSLocalizedString(" U", comment: "Insulin unit")
  576. ZStack {
  577. /// rectangle as background
  578. RoundedRectangle(cornerRadius: 15)
  579. .fill(
  580. colorScheme == .dark ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) : Color
  581. .insulin
  582. .opacity(0.2)
  583. )
  584. .clipShape(RoundedRectangle(cornerRadius: 15))
  585. .frame(height: geo.size.height * 0.08)
  586. .shadow(
  587. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  588. Color.black.opacity(0.33),
  589. radius: 3
  590. )
  591. /// actual bolus view
  592. HStack {
  593. Image(systemName: "cross.vial.fill")
  594. .font(.system(size: 25))
  595. Spacer()
  596. VStack {
  597. Text("Bolusing")
  598. .font(.subheadline)
  599. .frame(maxWidth: .infinity, alignment: .leading)
  600. Text(bolusString)
  601. .font(.caption)
  602. .frame(maxWidth: .infinity, alignment: .leading)
  603. }.padding(.leading, 5)
  604. Spacer()
  605. Button {
  606. state.showProgressView()
  607. state.cancelBolus()
  608. } label: {
  609. Image(systemName: "xmark.app")
  610. .font(.system(size: 25))
  611. }
  612. }.padding(.horizontal, 10)
  613. .padding(.trailing, 8)
  614. }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
  615. .overlay(alignment: .bottom) {
  616. bolusProgressBar(progress).padding(.horizontal, 18).offset(y: 48)
  617. }.clipShape(RoundedRectangle(cornerRadius: 15))
  618. }
  619. }
  620. @ViewBuilder func mainView() -> some View {
  621. GeometryReader { geo in
  622. VStack(spacing: 0) {
  623. ZStack {
  624. /// glucose bobble
  625. glucoseView
  626. /// right panel with loop status and evBG
  627. HStack {
  628. Spacer()
  629. rightHeaderPanel(geo)
  630. }.padding(.trailing, 20)
  631. /// left panel with pump related info
  632. HStack {
  633. pumpView
  634. Spacer()
  635. }.padding(.leading, 20)
  636. }.padding(.top, 10)
  637. mealPanel(geo).padding(.top, UIDevice.adjustPadding(min: nil, max: 30))
  638. .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 20))
  639. mainChart(geo: geo)
  640. timeInterval.padding(.top, UIDevice.adjustPadding(min: 0, max: 12))
  641. .padding(.bottom, UIDevice.adjustPadding(min: 0, max: 12))
  642. if let progress = state.bolusProgress {
  643. bolusView(geo: geo, progress).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40))
  644. } else {
  645. profileView(geo: geo).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40))
  646. }
  647. }
  648. .background(color)
  649. }
  650. .onChange(of: state.hours) { _ in
  651. highlightButtons()
  652. }
  653. .onAppear {
  654. configureView {
  655. highlightButtons()
  656. }
  657. }
  658. .navigationTitle("Home")
  659. .navigationBarHidden(true)
  660. .ignoresSafeArea(.keyboard)
  661. .popup(isPresented: state.isStatusPopupPresented, alignment: .top, direction: .top) {
  662. popup
  663. .padding()
  664. .background(
  665. RoundedRectangle(cornerRadius: 8, style: .continuous)
  666. .fill(colorScheme == .dark ? Color(
  667. "Chart"
  668. ) : Color(UIColor.darkGray))
  669. )
  670. .onTapGesture {
  671. state.isStatusPopupPresented = false
  672. }
  673. .gesture(
  674. DragGesture(minimumDistance: 10, coordinateSpace: .local)
  675. .onEnded { value in
  676. if value.translation.height < 0 {
  677. state.isStatusPopupPresented = false
  678. }
  679. }
  680. )
  681. }
  682. .confirmationDialog("Pump Model", isPresented: $showPumpSelection) {
  683. Button("Medtronic") { state.addPump(.minimed) }
  684. Button("Omnipod Eros") { state.addPump(.omnipod) }
  685. Button("Omnipod Dash") { state.addPump(.omnipodBLE) }
  686. Button("Pump Simulator") { state.addPump(.simulator) }
  687. } message: { Text("Select Pump Model") }
  688. .sheet(isPresented: $state.setupPump) {
  689. if let pumpManager = state.provider.apsManager.pumpManager {
  690. PumpConfig.PumpSettingsView(
  691. pumpManager: pumpManager,
  692. bluetoothManager: state.provider.apsManager.bluetoothManager!,
  693. completionDelegate: state,
  694. setupDelegate: state
  695. )
  696. } else {
  697. PumpConfig.PumpSetupView(
  698. pumpType: state.setupPumpType,
  699. pumpInitialSettings: PumpConfig.PumpInitialSettings.default,
  700. bluetoothManager: state.provider.apsManager.bluetoothManager!,
  701. completionDelegate: state,
  702. setupDelegate: state
  703. )
  704. }
  705. }
  706. .sheet(isPresented: $state.isLegendPresented) {
  707. NavigationStack {
  708. Text(
  709. "The oref algorithm determines insulin dosing based on a number of scenarios that it estimates with different types of forecasts."
  710. )
  711. .font(.subheadline)
  712. .foregroundColor(.secondary)
  713. if state.forecastDisplayType == .lines {
  714. List {
  715. DefinitionRow(
  716. term: "IOB (Insulin on Board)",
  717. definition: "Forecasts BG based on the amount of insulin still active in the body.",
  718. color: .insulin
  719. )
  720. DefinitionRow(
  721. term: "ZT (Zero-Temp)",
  722. definition: "Forecasts the worst-case blood glucose (BG) scenario if no carbs are absorbed and insulin delivery is stopped until BG starts rising.",
  723. color: .zt
  724. )
  725. DefinitionRow(
  726. term: "COB (Carbs on Board)",
  727. definition: "Forecasts BG changes by considering the amount of carbohydrates still being absorbed in the body.",
  728. color: .loopYellow
  729. )
  730. DefinitionRow(
  731. term: "UAM (Unannounced Meal)",
  732. definition: "Forecasts BG levels and insulin dosing needs for unexpected meals or other causes of BG rises without prior notice.",
  733. color: .uam
  734. )
  735. }
  736. .padding(.trailing, 10)
  737. .navigationBarTitle("Legend", displayMode: .inline)
  738. } else {
  739. List {
  740. DefinitionRow(
  741. term: "Cone of Uncertainty",
  742. definition: "For simplicity reasons, oref's various forecast curves are displayed as a \"Cone of Uncertainty\" that depicts a possible, forecasted range of future glucose fluctuation based on the current data and the algothim's result.\n\nTo modify the forecast display type, go to Trio Settings > Features > User Interface > Forecast Display Type.",
  743. color: Color.blue.opacity(0.5)
  744. )
  745. }
  746. .padding(.trailing, 10)
  747. .navigationBarTitle("Legend", displayMode: .inline)
  748. }
  749. Button { state.isLegendPresented.toggle() }
  750. label: { Text("Got it!").frame(maxWidth: .infinity, alignment: .center) }
  751. .buttonStyle(.bordered)
  752. .padding(.top)
  753. }
  754. .padding()
  755. .presentationDetents(
  756. [.fraction(0.9), .large],
  757. selection: $state.legendSheetDetent
  758. )
  759. }
  760. }
  761. @State var settingsPath = NavigationPath()
  762. @ViewBuilder func tabBar() -> some View {
  763. ZStack(alignment: .bottom) {
  764. TabView(selection: $selectedTab) {
  765. let carbsRequiredBadge: String? = {
  766. guard let carbsRequired = state.enactedAndNonEnactedDeterminations.first?.carbsRequired,
  767. state.showCarbsRequiredBadge
  768. else {
  769. return nil
  770. }
  771. let carbsRequiredDecimal = Decimal(carbsRequired)
  772. if carbsRequiredDecimal > state.settingsManager.settings.carbsRequiredThreshold {
  773. let numberAsNSNumber = NSDecimalNumber(decimal: carbsRequiredDecimal)
  774. return (numberFormatter.string(from: numberAsNSNumber) ?? "") + " g"
  775. }
  776. return nil
  777. }()
  778. NavigationStack { mainView() }
  779. .tabItem { Label("Main", systemImage: "chart.xyaxis.line") }
  780. .badge(carbsRequiredBadge).tag(0)
  781. NavigationStack { DataTable.RootView(resolver: resolver) }
  782. .tabItem { Label("History", systemImage: historySFSymbol) }.tag(1)
  783. Spacer()
  784. NavigationStack { OverrideConfig.RootView(resolver: resolver) }
  785. .tabItem {
  786. Label(
  787. "Adjustments",
  788. systemImage: "slider.horizontal.2.gobackward"
  789. ) }.tag(2)
  790. NavigationStack(path: self.$settingsPath) {
  791. Settings.RootView(resolver: resolver) }
  792. .tabItem { Label(
  793. "Settings",
  794. systemImage: "gear"
  795. ) }.tag(3)
  796. }
  797. .tint(Color.tabBar)
  798. Button(
  799. action: {
  800. state.showModal(for: .bolus) },
  801. label: {
  802. Image(systemName: "plus.circle.fill")
  803. .font(.system(size: 40))
  804. .foregroundStyle(Color.tabBar)
  805. .padding(.bottom, 1)
  806. .padding(.horizontal, 20)
  807. }
  808. )
  809. }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0)
  810. .onChange(of: selectedTab) { _ in
  811. print("current path is empty: \(settingsPath.isEmpty)")
  812. settingsPath = NavigationPath()
  813. }
  814. }
  815. var body: some View {
  816. ZStack(alignment: .center) {
  817. tabBar()
  818. if state.waitForSuggestion {
  819. CustomProgressView(text: "Updating IOB...")
  820. }
  821. }
  822. }
  823. private func parseReasonConclusion(_ reasonConclusion: String, isMmolL: Bool) -> String {
  824. var updatedConclusion = reasonConclusion
  825. // Handle "minGuardBG x<y" pattern
  826. if let range = updatedConclusion.range(of: "minGuardBG\\s*-?\\d+<\\d+", options: .regularExpression) {
  827. let matchedString = updatedConclusion[range]
  828. let parts = matchedString.components(separatedBy: "<")
  829. if let firstValue = Double(parts[0].components(separatedBy: CharacterSet.decimalDigits.inverted).joined()),
  830. let secondValue = Double(parts[1])
  831. {
  832. let formattedFirstValue = isMmolL ? Double(firstValue.asMmolL) : firstValue
  833. let formattedSecondValue = isMmolL ? Double(secondValue.asMmolL) : secondValue
  834. let formattedString = "minGuardBG \(formattedFirstValue)<\(formattedSecondValue)"
  835. updatedConclusion = updatedConclusion.replacingOccurrences(of: matchedString, with: formattedString)
  836. }
  837. }
  838. // Handle "Eventual BG x >= target" pattern
  839. if let range = updatedConclusion.range(of: "Eventual BG\\s*\\d+\\s*>?=\\s*\\d+", options: .regularExpression) {
  840. let matchedString = updatedConclusion[range]
  841. let parts = matchedString.components(separatedBy: " >= ")
  842. if let firstValue = Double(parts[0].components(separatedBy: CharacterSet.decimalDigits.inverted).joined()),
  843. let secondValue = Double(parts[1])
  844. {
  845. let formattedFirstValue = isMmolL ? Double(firstValue.asMmolL) : firstValue
  846. let formattedSecondValue = isMmolL ? Double(secondValue.asMmolL) : secondValue
  847. let formattedString = "Eventual BG \(formattedFirstValue) >= \(formattedSecondValue)"
  848. updatedConclusion = updatedConclusion.replacingOccurrences(of: matchedString, with: formattedString)
  849. }
  850. }
  851. return updatedConclusion.capitalizingFirstLetter()
  852. }
  853. private var popup: some View {
  854. VStack(alignment: .leading, spacing: 4) {
  855. Text(statusTitle).font(.headline).foregroundColor(.white)
  856. .padding(.bottom, 4)
  857. if let determination = state.determinationsFromPersistence.first {
  858. if determination.glucose == 400 {
  859. Text("Invalid CGM reading (HIGH).").font(.callout).bold().foregroundColor(.loopRed).padding(.top, 8)
  860. Text("SMBs and High Temps Disabled.").font(.caption).foregroundColor(.white).padding(.bottom, 4)
  861. } else {
  862. let tags = !state.isSmoothingEnabled ? determination.reasonParts : determination
  863. .reasonParts + ["Smoothing: On"]
  864. TagCloudView(
  865. tags: tags,
  866. shouldParseToMmolL: state.units == .mmolL
  867. )
  868. .animation(.none, value: false)
  869. Text(
  870. self
  871. .parseReasonConclusion(
  872. determination.reasonConclusion,
  873. isMmolL: state.units == .mmolL
  874. )
  875. ).font(.caption).foregroundColor(.white)
  876. }
  877. } else {
  878. Text("No determination found").font(.body).foregroundColor(.white)
  879. }
  880. if let errorMessage = state.errorMessage, let date = state.errorDate {
  881. Text(NSLocalizedString("Error at", comment: "") + " " + dateFormatter.string(from: date))
  882. .foregroundColor(.white)
  883. .font(.headline)
  884. .padding(.bottom, 4)
  885. .padding(.top, 8)
  886. Text(errorMessage).font(.caption).foregroundColor(.loopRed)
  887. }
  888. }
  889. }
  890. private func setStatusTitle() {
  891. if let determination = state.determinationsFromPersistence.first {
  892. let dateFormatter = DateFormatter()
  893. dateFormatter.timeStyle = .short
  894. statusTitle = NSLocalizedString("Oref Determination enacted at", comment: "Headline in enacted pop up") +
  895. " " +
  896. dateFormatter
  897. .string(from: determination.deliverAt ?? Date())
  898. } else {
  899. statusTitle = "No Oref determination"
  900. return
  901. }
  902. }
  903. }
  904. }
  905. extension UIDevice {
  906. public enum DeviceSize: CGFloat {
  907. case smallDevice = 667 // Height for 4" iPhone SE
  908. case largeDevice = 852 // Height for 6.1" iPhone 15 Pro
  909. }
  910. @usableFromInline static func adjustPadding(min: CGFloat? = nil, max: CGFloat? = nil) -> CGFloat? {
  911. if UIScreen.screenHeight > UIDevice.DeviceSize.smallDevice.rawValue {
  912. if UIScreen.screenHeight >= UIDevice.DeviceSize.largeDevice.rawValue {
  913. return max
  914. } else {
  915. return min != nil ?
  916. (max != nil ? max! * (UIScreen.screenHeight / UIDevice.DeviceSize.largeDevice.rawValue) : nil) : nil
  917. }
  918. } else {
  919. return min
  920. }
  921. }
  922. }
  923. extension UIScreen {
  924. static var screenHeight: CGFloat {
  925. UIScreen.main.bounds.height
  926. }
  927. static var screenWidth: CGFloat {
  928. UIScreen.main.bounds.width
  929. }
  930. }