HomeRootView.swift 53 KB

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