HomeRootView.swift 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034
  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. @ObservedObject var appState = AppState()
  10. @StateObject var state = StateModel()
  11. @State var isStatusPopupPresented = false
  12. @State var showCancelAlert = false
  13. @State var isMenuPresented = false
  14. @State var selectedTab: Int = 0
  15. @State var currentTab: Tab
  16. struct Buttons: Identifiable {
  17. let label: String
  18. let number: String
  19. var active: Bool
  20. let hours: Int16
  21. var id: String { label }
  22. }
  23. @State var timeButtons: [Buttons] = [
  24. Buttons(label: "2 hours", number: "2", active: false, hours: 2),
  25. Buttons(label: "4 hours", number: "4", active: false, hours: 4),
  26. Buttons(label: "6 hours", number: "6", active: false, hours: 6),
  27. Buttons(label: "12 hours", number: "12", active: false, hours: 12),
  28. Buttons(label: "24 hours", number: "24", active: false, hours: 24)
  29. ]
  30. let buttonFont = Font.custom("TimeButtonFont", size: 14)
  31. @Environment(\.managedObjectContext) var moc
  32. @Environment(\.colorScheme) var colorScheme
  33. @FetchRequest(
  34. entity: Override.entity(),
  35. sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)]
  36. ) var fetchedPercent: FetchedResults<Override>
  37. @FetchRequest(
  38. entity: OverridePresets.entity(),
  39. sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)], predicate: NSPredicate(
  40. format: "name != %@", "" as String
  41. )
  42. ) var fetchedProfiles: FetchedResults<OverridePresets>
  43. @FetchRequest(
  44. entity: TempTargets.entity(),
  45. sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)]
  46. ) var sliderTTpresets: FetchedResults<TempTargets>
  47. @FetchRequest(
  48. entity: TempTargetsSlider.entity(),
  49. sortDescriptors: [NSSortDescriptor(key: "date", ascending: false)]
  50. ) var enactedSliderTT: FetchedResults<TempTargetsSlider>
  51. var bolusProgressFormatter: NumberFormatter {
  52. let formatter = NumberFormatter()
  53. formatter.numberStyle = .decimal
  54. formatter.minimum = 0
  55. formatter.maximumFractionDigits = state.settingsManager.preferences.bolusIncrement > 0.05 ? 1 : 2
  56. formatter.minimumFractionDigits = state.settingsManager.preferences.bolusIncrement > 0.05 ? 1 : 2
  57. formatter.allowsFloats = true
  58. formatter.roundingIncrement = Double(state.settingsManager.preferences.bolusIncrement) as NSNumber
  59. return formatter
  60. }
  61. private var numberFormatter: NumberFormatter {
  62. let formatter = NumberFormatter()
  63. formatter.numberStyle = .decimal
  64. formatter.maximumFractionDigits = 2
  65. return formatter
  66. }
  67. private var fetchedTargetFormatter: NumberFormatter {
  68. let formatter = NumberFormatter()
  69. formatter.numberStyle = .decimal
  70. if state.units == .mmolL {
  71. formatter.maximumFractionDigits = 1
  72. } else { formatter.maximumFractionDigits = 0 }
  73. return formatter
  74. }
  75. private var targetFormatter: NumberFormatter {
  76. let formatter = NumberFormatter()
  77. formatter.numberStyle = .decimal
  78. formatter.maximumFractionDigits = 1
  79. return formatter
  80. }
  81. private var tirFormatter: NumberFormatter {
  82. let formatter = NumberFormatter()
  83. formatter.numberStyle = .decimal
  84. formatter.maximumFractionDigits = 0
  85. return formatter
  86. }
  87. private var dateFormatter: DateFormatter {
  88. let dateFormatter = DateFormatter()
  89. dateFormatter.timeStyle = .short
  90. return dateFormatter
  91. }
  92. private var spriteScene: SKScene {
  93. let scene = SnowScene()
  94. scene.scaleMode = .resizeFill
  95. scene.backgroundColor = .clear
  96. return scene
  97. }
  98. private var color: LinearGradient {
  99. colorScheme == .dark ? LinearGradient(
  100. gradient: Gradient(colors: [
  101. Color.bgDarkBlue,
  102. Color.bgDarkBlue,
  103. Color.bgDarkerDarkBlue,
  104. Color.bgDarkBlue
  105. ]),
  106. startPoint: .top,
  107. endPoint: .bottom
  108. )
  109. :
  110. LinearGradient(
  111. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  112. startPoint: .top,
  113. endPoint: .bottom
  114. )
  115. }
  116. private var historySFSymbol: String {
  117. if #available(iOS 17.0, *) {
  118. return "book.pages"
  119. } else {
  120. return "book"
  121. }
  122. }
  123. var glucoseView: some View {
  124. CurrentGlucoseView(
  125. recentGlucose: $state.recentGlucose,
  126. timerDate: $state.timerDate,
  127. delta: $state.glucoseDelta,
  128. units: $state.units,
  129. alarm: $state.alarm,
  130. lowGlucose: $state.lowGlucose,
  131. highGlucose: $state.highGlucose
  132. ).scaleEffect(0.9)
  133. /*
  134. .onTapGesture {
  135. if state.alarm == nil {
  136. state.openCGM()
  137. } else {
  138. state.showModal(for: .snooze)
  139. }
  140. }
  141. .onLongPressGesture {
  142. let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
  143. impactHeavy.impactOccurred()
  144. if state.alarm == nil {
  145. state.showModal(for: .snooze)
  146. } else {
  147. state.openCGM()
  148. }
  149. }
  150. */
  151. }
  152. var pumpView: some View {
  153. PumpView(
  154. reservoir: $state.reservoir,
  155. battery: $state.battery,
  156. name: $state.pumpName,
  157. expiresAtDate: $state.pumpExpiresAtDate,
  158. timerDate: $state.timerDate,
  159. timeZone: $state.timeZone,
  160. state: state
  161. )
  162. }
  163. var tempBasalString: String? {
  164. guard let tempRate = state.tempRate else {
  165. return nil
  166. }
  167. let rateString = numberFormatter.string(from: tempRate as NSNumber) ?? "0"
  168. var manualBasalString = ""
  169. if state.apsManager.isManualTempBasal {
  170. manualBasalString = NSLocalizedString(
  171. " - Manual Basal ⚠️",
  172. comment: "Manual Temp basal"
  173. )
  174. }
  175. return rateString + " " + NSLocalizedString(" U/hr", comment: "Unit per hour with space") + manualBasalString
  176. }
  177. var tempTargetString: String? {
  178. guard let tempTarget = state.tempTarget else {
  179. return nil
  180. }
  181. let target = tempTarget.targetBottom ?? 0
  182. let unitString = targetFormatter.string(from: (tempTarget.targetBottom?.asMmolL ?? 0) as NSNumber) ?? ""
  183. let rawString = (tirFormatter.string(from: (tempTarget.targetBottom ?? 0) as NSNumber) ?? "") + " " + state.units
  184. .rawValue
  185. var string = ""
  186. if sliderTTpresets.first?.active ?? false {
  187. let hbt = sliderTTpresets.first?.hbt ?? 0
  188. string = ", " + (tirFormatter.string(from: state.infoPanelTTPercentage(hbt, target) as NSNumber) ?? "") + " %"
  189. }
  190. let percentString = state
  191. .units == .mmolL ? (unitString + " mmol/L" + string) : (rawString + (string == "0" ? "" : string))
  192. return tempTarget.displayName + " " + percentString
  193. }
  194. var overrideString: String? {
  195. guard fetchedPercent.first?.enabled ?? false else {
  196. return nil
  197. }
  198. var percentString = "\((fetchedPercent.first?.percentage ?? 100).formatted(.number)) %"
  199. var target = (fetchedPercent.first?.target ?? 100) as Decimal
  200. let indefinite = (fetchedPercent.first?.indefinite ?? false)
  201. let unit = state.units.rawValue
  202. if state.units == .mmolL {
  203. target = target.asMmolL
  204. }
  205. var targetString = (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + unit
  206. if tempTargetString != nil || target == 0 { targetString = "" }
  207. percentString = percentString == "100 %" ? "" : percentString
  208. let duration = (fetchedPercent.first?.duration ?? 0) as Decimal
  209. let addedMinutes = Int(duration)
  210. let date = fetchedPercent.first?.date ?? Date()
  211. var newDuration: Decimal = 0
  212. if date.addingTimeInterval(addedMinutes.minutes.timeInterval) > Date() {
  213. newDuration = Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes)
  214. }
  215. var durationString = indefinite ?
  216. "" : newDuration >= 1 ?
  217. (newDuration.formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) + " min") :
  218. (
  219. newDuration > 0 ? (
  220. (newDuration * 60).formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) + " s"
  221. ) :
  222. ""
  223. )
  224. let smbToggleString = (fetchedPercent.first?.smbIsOff ?? false) ? " \u{20e0}" : ""
  225. var comma1 = ", "
  226. var comma2 = comma1
  227. var comma3 = comma1
  228. if targetString == "" || percentString == "" { comma1 = "" }
  229. if durationString == "" { comma2 = "" }
  230. if smbToggleString == "" { comma3 = "" }
  231. if percentString == "", targetString == "" {
  232. comma1 = ""
  233. comma2 = ""
  234. }
  235. if percentString == "", targetString == "", smbToggleString == "" {
  236. durationString = ""
  237. comma1 = ""
  238. comma2 = ""
  239. comma3 = ""
  240. }
  241. if durationString == "" {
  242. comma2 = ""
  243. }
  244. if smbToggleString == "" {
  245. comma3 = ""
  246. }
  247. if durationString == "", !indefinite {
  248. return nil
  249. }
  250. return percentString + comma1 + targetString + comma2 + durationString + comma3 + smbToggleString
  251. }
  252. var infoPanel: some View {
  253. HStack(alignment: .center) {
  254. if state.pumpSuspended {
  255. Text("Pump suspended")
  256. .font(.system(size: 15, weight: .bold)).foregroundColor(.loopGray)
  257. .padding(.leading, 8)
  258. } else if let tempBasalString = tempBasalString {
  259. Text(tempBasalString)
  260. .font(.system(size: 15, weight: .bold))
  261. .foregroundColor(.insulin)
  262. .padding(.leading, 8)
  263. }
  264. if state.tins {
  265. Text(
  266. "TINS: \(state.calculateTINS())" +
  267. NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)")
  268. )
  269. .font(.system(size: 15, weight: .bold))
  270. .foregroundColor(.insulin)
  271. }
  272. if let tempTargetString = tempTargetString {
  273. Text(tempTargetString)
  274. .font(.caption)
  275. .foregroundColor(.secondary)
  276. }
  277. Spacer()
  278. if state.closedLoop, state.settingsManager.preferences.maxIOB == 0 {
  279. Text("Max IOB: 0").font(.callout).foregroundColor(.orange).padding(.trailing, 20)
  280. }
  281. }
  282. .frame(maxWidth: .infinity, maxHeight: 30)
  283. }
  284. var timeInterval: some View {
  285. HStack(alignment: .center) {
  286. ForEach(timeButtons) { button in
  287. Text(button.active ? NSLocalizedString(button.label, comment: "") : button.number).onTapGesture {
  288. state.hours = button.hours
  289. }
  290. .foregroundStyle(button.active ? (colorScheme == .dark ? Color.white : Color.black).opacity(0.9) : .secondary)
  291. .frame(maxHeight: 30).padding(.horizontal, 8)
  292. .background(
  293. button.active ?
  294. // RGB(30, 60, 95)
  295. (
  296. colorScheme == .dark ? Color(red: 0.1176470588, green: 0.2352941176, blue: 0.3725490196) :
  297. Color.white
  298. ) :
  299. Color
  300. .clear
  301. )
  302. .cornerRadius(20)
  303. }
  304. }
  305. .shadow(
  306. color: Color.black.opacity(colorScheme == .dark ? 0.75 : 0.33),
  307. radius: colorScheme == .dark ? 5 : 3
  308. )
  309. .font(buttonFont)
  310. }
  311. var mainChart: some View {
  312. ZStack {
  313. if state.animatedBackground {
  314. SpriteView(scene: spriteScene, options: [.allowsTransparency])
  315. .ignoresSafeArea()
  316. .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
  317. }
  318. MainChartView(
  319. glucose: $state.glucose,
  320. units: $state.units,
  321. eventualBG: $state.eventualBG,
  322. suggestion: $state.suggestion,
  323. tempBasals: $state.tempBasals,
  324. boluses: $state.boluses,
  325. suspensions: $state.suspensions,
  326. announcement: $state.announcement,
  327. hours: .constant(state.filteredHours),
  328. maxBasal: $state.maxBasal,
  329. autotunedBasalProfile: $state.autotunedBasalProfile,
  330. basalProfile: $state.basalProfile,
  331. tempTargets: $state.tempTargets,
  332. carbs: $state.carbs,
  333. smooth: $state.smooth,
  334. highGlucose: $state.highGlucose,
  335. lowGlucose: $state.lowGlucose,
  336. screenHours: $state.hours,
  337. displayXgridLines: $state.displayXgridLines,
  338. displayYgridLines: $state.displayYgridLines,
  339. thresholdLines: $state.thresholdLines,
  340. isTempTargetActive: $state.isTempTargetActive
  341. )
  342. }
  343. .padding(.bottom)
  344. }
  345. func highlightButtons() {
  346. for i in 0 ..< timeButtons.count {
  347. timeButtons[i].active = timeButtons[i].hours == state.hours
  348. }
  349. }
  350. @ViewBuilder private func bottomPanel(_: GeometryProxy) -> some View {
  351. let colorIcon: Color = (colorScheme == .dark ? Color.white : Color.black).opacity(0.9)
  352. ZStack {
  353. Rectangle()
  354. .fill(Color("Chart"))
  355. .frame(height: UIScreen.main.bounds.height / 13)
  356. .cornerRadius(15)
  357. .shadow(
  358. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) : Color
  359. .black.opacity(0.33),
  360. radius: 3
  361. )
  362. .padding([.leading, .trailing], 10)
  363. HStack {
  364. Button {
  365. state.showModal(for: .dataTable)
  366. }
  367. label: {
  368. if #available(iOS 17.0, *) {
  369. Image(systemName: "book.pages")
  370. .font(.system(size: 24))
  371. .foregroundColor(colorIcon)
  372. .padding(8)
  373. } else {
  374. Image(systemName: "book")
  375. .font(.system(size: 24))
  376. .foregroundColor(colorIcon)
  377. .padding(8)
  378. }
  379. }
  380. .foregroundColor(colorIcon)
  381. .buttonStyle(.borderless)
  382. Spacer()
  383. Button { state.showModal(for: .addCarbs(editMode: false, override: false)) }
  384. label: {
  385. ZStack(alignment: Alignment(horizontal: .trailing, vertical: .bottom)) {
  386. Image(systemName: "fork.knife")
  387. .font(.system(size: 24))
  388. .foregroundColor(colorIcon)
  389. .padding(8)
  390. if let carbsReq = state.carbsRequired {
  391. Text(numberFormatter.string(from: carbsReq as NSNumber)!)
  392. .font(.caption)
  393. .foregroundColor(.white)
  394. .padding(4)
  395. .background(Capsule().fill(Color.red))
  396. }
  397. }
  398. }.buttonStyle(.borderless)
  399. Spacer()
  400. Button {
  401. state.showModal(for: .bolus(
  402. waitForSuggestion: true,
  403. fetch: false
  404. ))
  405. }
  406. label: {
  407. Image(systemName: "syringe.fill")
  408. .font(.system(size: 24))
  409. .foregroundColor(colorIcon)
  410. .padding(8)
  411. }
  412. .foregroundColor(colorIcon)
  413. .buttonStyle(.borderless)
  414. Spacer()
  415. if state.allowManualTemp {
  416. Button { state.showModal(for: .manualTempBasal) }
  417. label: {
  418. Image("bolus1")
  419. .renderingMode(.template)
  420. .resizable()
  421. .frame(width: 24, height: 24)
  422. .padding(8)
  423. }
  424. .foregroundColor(colorIcon)
  425. .buttonStyle(.borderless)
  426. Spacer()
  427. }
  428. let isOverrideActive = fetchedPercent.first?.enabled ?? false
  429. Button {
  430. state.showModal(for: .overrideProfilesConfig)
  431. } label: {
  432. Image(systemName: (state.isTempTargetActive || isOverrideActive) ? "person.fill" : "person")
  433. .font(.system(size: 26))
  434. .padding(8)
  435. }
  436. .foregroundColor((state.isTempTargetActive || isOverrideActive) ? Color.purple : colorIcon)
  437. .buttonStyle(.borderless)
  438. Spacer()
  439. Button {
  440. state.showModal(for: .settings)
  441. } label: {
  442. Image(systemName: "gear")
  443. .font(.system(size: 26))
  444. .padding(8)
  445. }
  446. .foregroundColor(colorIcon)
  447. .buttonStyle(.borderless)
  448. }
  449. .padding(.horizontal, 24)
  450. .padding(.bottom, 16)
  451. }
  452. }
  453. @ViewBuilder func bolusProgressBar(_ progress: Decimal) -> some View {
  454. GeometryReader { geo in
  455. Rectangle()
  456. .frame(height: 6)
  457. .foregroundColor(.clear)
  458. .background(
  459. LinearGradient(colors: [
  460. Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
  461. Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569),
  462. Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765),
  463. Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961),
  464. Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
  465. ], startPoint: .leading, endPoint: .trailing)
  466. .mask(alignment: .leading) {
  467. Rectangle()
  468. .frame(width: geo.size.width * CGFloat(progress))
  469. }
  470. )
  471. }
  472. }
  473. @ViewBuilder func bolusProgressView(_: GeometryProxy, _ progress: Decimal) -> some View {
  474. let colorRectangle: Color = colorScheme == .dark ? Color(
  475. "Chart"
  476. ) : Color.white
  477. let colorIcon = (colorScheme == .dark ? Color.white : Color.black).opacity(0.9)
  478. let bolusTotal = state.boluses.last?.amount ?? 0
  479. let bolusFraction = progress * bolusTotal
  480. let bolusString =
  481. (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0")
  482. + " of " +
  483. (numberFormatter.string(from: bolusTotal as NSNumber) ?? "0")
  484. + NSLocalizedString(" U", comment: "Insulin unit")
  485. ZStack(alignment: .bottom) {
  486. HStack {
  487. Button {
  488. state.cancelBolus()
  489. } label: {
  490. HStack(alignment: .center) {
  491. Text("Bolusing")
  492. .font(.subheadline)
  493. .fontWeight(.bold)
  494. Text(bolusString)
  495. .font(.subheadline)
  496. Spacer()
  497. Image(systemName: "xmark.app")
  498. .font(.system(size: 30))
  499. .padding(1)
  500. }
  501. }.foregroundColor(colorIcon)
  502. }.padding()
  503. bolusProgressBar(progress).offset(y: 59)
  504. }
  505. .background(colorRectangle)
  506. .clipShape(RoundedRectangle(cornerRadius: 8))
  507. .shadow(
  508. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  509. Color.black.opacity(0.33),
  510. radius: 3
  511. )
  512. .frame(height: 62, alignment: .center)
  513. .padding(.horizontal, 10)
  514. .offset(y: -90)
  515. }
  516. @ViewBuilder func rightHeaderPanel(_: GeometryProxy) -> some View {
  517. VStack(alignment: .leading, spacing: 20) {
  518. /// Loop view at bottomLeading
  519. LoopView(
  520. suggestion: $state.suggestion,
  521. enactedSuggestion: $state.enactedSuggestion,
  522. closedLoop: $state.closedLoop,
  523. timerDate: $state.timerDate,
  524. isLooping: $state.isLooping,
  525. lastLoopDate: $state.lastLoopDate,
  526. manualTempBasal: $state.manualTempBasal
  527. ).onTapGesture {
  528. state.isStatusPopupPresented = true
  529. }.onLongPressGesture {
  530. let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
  531. impactHeavy.impactOccurred()
  532. state.runLoop()
  533. }
  534. /// eventualBG string at bottomTrailing
  535. if let eventualBG = state.eventualBG {
  536. HStack {
  537. Image(systemName: "arrow.right.circle")
  538. .font(.system(size: 16, weight: .bold))
  539. Text(
  540. numberFormatter.string(
  541. from: (
  542. state.units == .mmolL ? eventualBG
  543. .asMmolL : Decimal(eventualBG)
  544. ) as NSNumber
  545. )!
  546. )
  547. .font(.system(size: 16))
  548. }
  549. }
  550. }
  551. }
  552. @ViewBuilder func mealPanel(_: GeometryProxy) -> some View {
  553. HStack {
  554. HStack {
  555. Image(systemName: "syringe.fill")
  556. .font(.system(size: 16))
  557. .foregroundColor(Color.insulin)
  558. Text(
  559. (numberFormatter.string(from: (state.suggestion?.iob ?? 0) as NSNumber) ?? "0") +
  560. NSLocalizedString(" U", comment: "Insulin unit")
  561. )
  562. .font(.system(size: 16, weight: .bold))
  563. }
  564. Spacer()
  565. HStack {
  566. Image(systemName: "fork.knife")
  567. .font(.system(size: 16))
  568. .foregroundColor(.loopYellow)
  569. Text(
  570. (numberFormatter.string(from: (state.suggestion?.cob ?? 0) as NSNumber) ?? "0") +
  571. NSLocalizedString(" g", comment: "gram of carbs")
  572. )
  573. .font(.system(size: 16, weight: .bold))
  574. }
  575. Spacer()
  576. HStack {
  577. if state.pumpSuspended {
  578. Text("Pump suspended")
  579. .font(.system(size: 12, weight: .bold)).foregroundColor(.loopGray)
  580. } else if let tempBasalString = tempBasalString {
  581. Image(systemName: "drop.circle")
  582. .font(.system(size: 16))
  583. .foregroundColor(.insulinTintColor)
  584. Text(tempBasalString)
  585. .font(.system(size: 16, weight: .bold))
  586. }
  587. }
  588. if !state.tins {
  589. Spacer()
  590. Text(
  591. "TDD: " + (numberFormatter.string(from: (state.suggestion?.tdd ?? 0) as NSNumber) ?? "0") +
  592. NSLocalizedString(" U", comment: "Insulin unit")
  593. )
  594. .font(.system(size: 16, weight: .bold))
  595. } else {
  596. Spacer()
  597. HStack {
  598. Text(
  599. "TINS: \(state.roundedTotalBolus)" +
  600. NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)")
  601. )
  602. .font(.system(size: 16, weight: .bold))
  603. .onChange(of: state.hours) { _ in
  604. state.roundedTotalBolus = state.calculateTINS()
  605. }
  606. .onAppear {
  607. DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
  608. state.roundedTotalBolus = state.calculateTINS()
  609. }
  610. }
  611. }
  612. }
  613. }.padding(.horizontal, 10)
  614. }
  615. @ViewBuilder func profileView(_: GeometryProxy) -> some View {
  616. let colourChart: Color = colorScheme == .dark ? Color(
  617. "Chart"
  618. ) : .white
  619. if let overrideString = overrideString {
  620. ZStack {
  621. /// rectangle as background
  622. RoundedRectangle(cornerRadius: 15)
  623. .fill(colourChart)
  624. .clipShape(RoundedRectangle(cornerRadius: 15))
  625. .frame(height: UIScreen.main.bounds.height / 20)
  626. .shadow(
  627. color:
  628. Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
  629. radius: 1
  630. )
  631. HStack {
  632. /// actual profile view
  633. Image(systemName: "person.fill")
  634. .font(.system(size: 20))
  635. .foregroundStyle(Color.purple)
  636. Spacer()
  637. Text(overrideString)
  638. .font(.system(size: 18))
  639. Spacer()
  640. Image(systemName: "xmark.app")
  641. .font(.system(size: 20))
  642. }.padding(.horizontal, 10)
  643. .alert(
  644. "Return to Normal?", isPresented: $showCancelAlert,
  645. actions: {
  646. Button("No", role: .cancel) {}
  647. Button("Yes", role: .destructive) {
  648. state.cancelProfile()
  649. }
  650. }, message: { Text("This will change settings back to your normal profile.") }
  651. )
  652. .padding(.trailing, 8)
  653. .onTapGesture {
  654. showCancelAlert = true
  655. }
  656. }.padding(.horizontal, 10)
  657. }
  658. /// just show temp target if no profile is already active
  659. if overrideString == nil, let tempTargetString = tempTargetString {
  660. ZStack {
  661. /// rectangle as background
  662. RoundedRectangle(cornerRadius: 15)
  663. .fill(colourChart)
  664. .clipShape(RoundedRectangle(cornerRadius: 15))
  665. .frame(height: UIScreen.main.bounds.height / 20)
  666. .shadow(
  667. color:
  668. Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
  669. radius: 1
  670. )
  671. HStack {
  672. Image(systemName: "person.fill")
  673. .font(.system(size: 20))
  674. .foregroundStyle(Color.purple)
  675. Spacer()
  676. Text(tempTargetString)
  677. .font(.system(size: 15))
  678. Spacer()
  679. }.padding(.horizontal, 10)
  680. }.padding(.horizontal, 10)
  681. }
  682. }
  683. @ViewBuilder func menuSymbols(action: @escaping () -> Void, systemName: String) -> some View {
  684. Button(
  685. action: action,
  686. label: {
  687. HStack {
  688. Image(systemName: systemName)
  689. .font(.system(size: 21))
  690. .foregroundStyle(colorScheme == .dark ? .white : .black)
  691. }.padding(.top, 1)
  692. }
  693. )
  694. }
  695. @ViewBuilder func menuElements(action: @escaping () -> Void, title: String) -> some View {
  696. Button(
  697. action: action,
  698. label: {
  699. HStack {
  700. Text(title)
  701. .font(.system(size: 19))
  702. .foregroundStyle(colorScheme == .dark ? .white : .black)
  703. Spacer()
  704. Image(systemName: "arrow.right")
  705. .font(.system(size: 21))
  706. .foregroundStyle(colorScheme == .dark ? .white : .black)
  707. }.padding(.top, 1)
  708. }
  709. )
  710. }
  711. @ViewBuilder func sideMenuView() -> some View {
  712. ZStack {
  713. RoundedRectangle(cornerRadius: 8)
  714. .fill(color)
  715. .shadow(
  716. color: Color.black.opacity(0.33),
  717. radius: 3
  718. )
  719. .ignoresSafeArea(edges: .all)
  720. VStack(alignment: .leading) {
  721. Button {
  722. isMenuPresented.toggle()
  723. } label: {
  724. HStack {
  725. Image(systemName: "arrow.left")
  726. .font(.system(size: 30))
  727. .foregroundStyle(colorScheme == .dark ? .white : .black)
  728. Text("Menu")
  729. .font(.system(size: 30)).fontWeight(.bold)
  730. .foregroundStyle(colorScheme == .dark ? .white : .black)
  731. }
  732. }
  733. .padding(.top, 60)
  734. HStack(spacing: 15) {
  735. VStack(alignment: .leading, spacing: 25, content: {
  736. menuSymbols(action: { state.showModal(for: .statistics) }, systemName: "chart.bar.xaxis")
  737. .padding(.top, 20)
  738. menuSymbols(action: {
  739. if state.pumpDisplayState != nil {
  740. state.setupPump = true
  741. }
  742. }, systemName: "cross.vial.fill")
  743. menuSymbols(action: {
  744. if state.alarm == nil {
  745. state.openCGM()
  746. } else {
  747. state.showModal(for: .snooze)
  748. }
  749. }, systemName: "sensor.tag.radiowaves.forward.fill")
  750. menuSymbols(action: { state.showModal(for: .addTempTarget) }, systemName: "target")
  751. Spacer()
  752. })
  753. VStack(alignment: .leading, spacing: 25, content: {
  754. menuElements(action: { state.showModal(for: .statistics) }, title: "Statistics")
  755. .padding(.top, 20)
  756. menuElements(action: {
  757. if state.pumpDisplayState != nil {
  758. state.setupPump = true
  759. }
  760. }, title: "Pump Settings")
  761. menuElements(action: {
  762. if state.alarm == nil {
  763. state.openCGM()
  764. } else {
  765. state.showModal(for: .snooze)
  766. }
  767. }, title: "CGM")
  768. menuElements(action: { state.showModal(for: .addTempTarget) }, title: "Temp targets")
  769. Spacer()
  770. })
  771. }
  772. }.padding(.horizontal, 25)
  773. }
  774. .frame(width: UIScreen.main.bounds.width / 1.2, height: UIScreen.main.bounds.height - 20)
  775. }
  776. @ViewBuilder func mainView() -> some View {
  777. GeometryReader { geo in
  778. ZStack(alignment: .trailing) {
  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. HStack {
  794. Spacer()
  795. Button {
  796. isMenuPresented.toggle()
  797. }
  798. label: {
  799. Image(systemName: "text.justify")
  800. .font(.body).foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
  801. }.padding(.trailing, 20).padding(.bottom, 110)
  802. }
  803. }.padding(.top, 70)
  804. mealPanel(geo).padding(.vertical, 25)
  805. profileView(geo).padding(.vertical)
  806. RoundedRectangle(cornerRadius: 15)
  807. .fill(Color("Chart"))
  808. .overlay(mainChart)
  809. .clipShape(RoundedRectangle(cornerRadius: 15))
  810. .shadow(
  811. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  812. Color.black.opacity(0.33),
  813. radius: 3
  814. )
  815. .padding(.horizontal, 10)
  816. .frame(maxHeight: UIScreen.main.bounds.height / 2.1)
  817. timeInterval.padding(.top, 25)
  818. Spacer()
  819. if let progress = state.bolusProgress {
  820. bolusProgressView(geo, progress)
  821. }
  822. }
  823. }
  824. .background(color)
  825. .blur(radius: isMenuPresented ? 5 : 0)
  826. .edgesIgnoringSafeArea(.all)
  827. }
  828. .onChange(of: state.hours) { _ in
  829. highlightButtons()
  830. }
  831. .onAppear {
  832. configureView {
  833. highlightButtons()
  834. }
  835. }
  836. .navigationTitle("Home")
  837. .navigationBarHidden(true)
  838. .ignoresSafeArea(.keyboard)
  839. .popup(isPresented: state.isStatusPopupPresented, alignment: .top, direction: .top) {
  840. popup
  841. .padding()
  842. .background(
  843. RoundedRectangle(cornerRadius: 8, style: .continuous)
  844. .fill(colorScheme == .dark ? Color(
  845. "Chart"
  846. ) : Color(UIColor.darkGray))
  847. )
  848. .onTapGesture {
  849. state.isStatusPopupPresented = false
  850. }
  851. .gesture(
  852. DragGesture(minimumDistance: 10, coordinateSpace: .local)
  853. .onEnded { value in
  854. if value.translation.height < 0 {
  855. state.isStatusPopupPresented = false
  856. }
  857. }
  858. )
  859. }
  860. }
  861. @ViewBuilder func tabBar() -> some View {
  862. TabView(selection: $appState.currentTab) {
  863. mainView()
  864. .tabItem { Label("Home", systemImage: "house") }
  865. .tag(Tab.home)
  866. NavigationStack { DataTable.RootView(resolver: resolver) }
  867. .tabItem { Label("History", systemImage: historySFSymbol) }
  868. .tag(Tab.history)
  869. NavigationStack { AddCarbs.RootView(resolver: resolver, editMode: false, override: false) }
  870. .tabItem { Label("Treatments", systemImage: "plus") }
  871. .tag(Tab.treatments)
  872. NavigationStack { OverrideProfilesConfig.RootView(resolver: resolver) }
  873. .tabItem {
  874. Label(
  875. "Profile",
  876. systemImage: state.isTempTargetActive || overrideString != nil ? "person.fill" : "person"
  877. ) }
  878. .tag(Tab.profile)
  879. NavigationStack { Settings.RootView(resolver: resolver) }
  880. .tabItem {
  881. Label(
  882. "Settings",
  883. systemImage: "gear"
  884. ) }
  885. .tag(Tab.settings)
  886. }.tint(Color.tabBar)
  887. }
  888. var body: some View {
  889. ZStack(alignment: .trailing) {
  890. // mainView()
  891. tabBar()
  892. // burger menu
  893. if isMenuPresented {
  894. HStack {
  895. sideMenuView().background(Color.chart).ignoresSafeArea(.all)
  896. }
  897. }
  898. }
  899. }
  900. private var popup: some View {
  901. VStack(alignment: .leading, spacing: 4) {
  902. Text(state.statusTitle).font(.headline).foregroundColor(.white)
  903. .padding(.bottom, 4)
  904. if let suggestion = state.suggestion {
  905. TagCloudView(tags: suggestion.reasonParts).animation(.none, value: false)
  906. Text(suggestion.reasonConclusion.capitalizingFirstLetter()).font(.caption).foregroundColor(.white)
  907. } else {
  908. Text("No sugestion found").font(.body).foregroundColor(.white)
  909. }
  910. if let errorMessage = state.errorMessage, let date = state.errorDate {
  911. Text(NSLocalizedString("Error at", comment: "") + " " + dateFormatter.string(from: date))
  912. .foregroundColor(.white)
  913. .font(.headline)
  914. .padding(.bottom, 4)
  915. .padding(.top, 8)
  916. Text(errorMessage).font(.caption).foregroundColor(.loopRed)
  917. } else if let suggestion = state.suggestion, (suggestion.bg ?? 100) == 400 {
  918. Text("Invalid CGM reading (HIGH).").font(.callout).bold().foregroundColor(.loopRed).padding(.top, 8)
  919. Text("SMBs and High Temps Disabled.").font(.caption).foregroundColor(.white).padding(.bottom, 4)
  920. }
  921. }
  922. }
  923. }
  924. }
  925. class AppState: ObservableObject {
  926. @Published var currentTab: Tab = .home
  927. }
  928. enum Tab {
  929. case home
  930. case history
  931. case treatments
  932. case profile
  933. case settings
  934. }