HomeRootView.swift 51 KB

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