HomeRootView.swift 54 KB

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