HomeRootView.swift 56 KB

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