HomeRootView.swift 50 KB

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