// LoopFollow // MainViewController.swift import AVFAudio import Charts import Combine import CoreBluetooth import EventKit import ShareClient import SwiftUI import UIKit import UserNotifications func IsNightscoutEnabled() -> Bool { return !Storage.shared.url.value.isEmpty } class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate { var isPresentedAsModal: Bool = false @IBOutlet var BGText: UILabel! @IBOutlet var DeltaText: UILabel! @IBOutlet var DirectionText: UILabel! @IBOutlet var BGChart: LineChartView! @IBOutlet var BGChartFull: LineChartView! @IBOutlet var MinAgoText: UILabel! @IBOutlet var infoTable: UITableView! @IBOutlet var Console: UITableViewCell! @IBOutlet var DragBar: UIImageView! @IBOutlet var PredictionLabel: UILabel! @IBOutlet var LoopStatusLabel: UILabel! @IBOutlet var statsPieChart: PieChartView! @IBOutlet var statsLowPercent: UILabel! @IBOutlet var statsInRangePercent: UILabel! @IBOutlet var statsHighPercent: UILabel! @IBOutlet var statsAvgBG: UILabel! @IBOutlet var statsEstA1C: UILabel! @IBOutlet var statsStdDev: UILabel! @IBOutlet var serverText: UILabel! @IBOutlet var statsView: UIView! @IBOutlet var smallGraphHeightConstraint: NSLayoutConstraint! var refreshScrollView: UIScrollView! var refreshControl: UIRefreshControl! // Setup buttons for first-time configuration private var setupNightscoutButton: UIButton! private var setupDexcomButton: UIButton! let speechSynthesizer = AVSpeechSynthesizer() // Variables for BG Charts var firstGraphLoad: Bool = true var currentOverride = 1.0 var currentSage: sageData? var currentCage: cageData? var currentIage: iageData? var backgroundTask = BackgroundTask() var graphNowTimer = Timer() var lastCalendarWriteAttemptTime: TimeInterval = 0 // Info Table Setup var infoManager: InfoManager! var profileManager = ProfileManager.shared var bgData: [ShareGlucoseData] = [] var basalProfile: [basalProfileStruct] = [] var basalData: [basalGraphStruct] = [] var basalScheduleData: [basalGraphStruct] = [] var bolusData: [bolusGraphStruct] = [] var smbData: [bolusGraphStruct] = [] var carbData: [carbGraphStruct] = [] // Stats-specific data storage (can hold up to 30 days) var statsBGData: [ShareGlucoseData] = [] var statsBolusData: [bolusGraphStruct] = [] var statsSMBData: [bolusGraphStruct] = [] var statsCarbData: [carbGraphStruct] = [] var statsBasalData: [basalGraphStruct] = [] var overrideGraphData: [DataStructs.overrideStruct] = [] var tempTargetGraphData: [DataStructs.tempTargetStruct] = [] var predictionData: [ShareGlucoseData] = [] var bgCheckData: [ShareGlucoseData] = [] var suspendGraphData: [DataStructs.timestampOnlyStruct] = [] var resumeGraphData: [DataStructs.timestampOnlyStruct] = [] var sensorStartGraphData: [DataStructs.timestampOnlyStruct] = [] var noteGraphData: [DataStructs.noteStruct] = [] var chartData = LineChartData() var deviceBatteryData: [DataStructs.batteryStruct] = [] var lastCalDate: Double = 0 var latestLoopStatusString = "" var latestCOB: CarbMetric? var latestBasal = "" var latestPumpVolume: Double = 50.0 var latestIOB: InsulinMetric? var lastOverrideStartTime: TimeInterval = 0 var lastOverrideEndTime: TimeInterval = 0 var topBG: Double = Storage.shared.minBGScale.value var topPredictionBG: Double = Storage.shared.minBGScale.value var lastOverrideAlarm: TimeInterval = 0 var lastTempTargetAlarm: TimeInterval = 0 var lastTempTargetStartTime: TimeInterval = 0 var lastTempTargetEndTime: TimeInterval = 0 // share var bgDataShare: [ShareGlucoseData] = [] var dexShare: ShareClient? // calendar setup let store = EKEventStore() // Stores the timestamp of the last BG value that was spoken. var lastSpokenBGDate: TimeInterval = 0 var autoScrollPauseUntil: Date? var IsNotLooping = false let contactImageUpdater = ContactImageUpdater() private var cancellables = Set() private var isViewHierarchyReady = false // Loading state management private var loadingOverlay: UIView? private var isInitialLoad = true private var loadingStates: [String: Bool] = [ "bg": false, "profile": false, "deviceStatus": false, ] private var loadingTimeoutTimer: Timer? override func viewDidLoad() { super.viewDidLoad() loadDebugData() if Storage.shared.migrationStep.value < 1 { Storage.shared.migrateStep1() Storage.shared.migrationStep.value = 1 } if Storage.shared.migrationStep.value < 2 { Storage.shared.migrateStep2() Storage.shared.migrationStep.value = 2 } if Storage.shared.migrationStep.value < 3 { Storage.shared.migrateStep3() Storage.shared.migrationStep.value = 3 } // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() infoTable.rowHeight = 21 infoTable.dataSource = self infoTable.tableFooterView = UIView(frame: .zero) infoTable.bounces = false infoTable.addBorder(toSide: .Left, withColor: UIColor.darkGray.cgColor, andThickness: 2) infoManager = InfoManager(tableView: infoTable) smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value) view.layoutIfNeeded() let shareUserName = Storage.shared.shareUserName.value let sharePassword = Storage.shared.sharePassword.value let shareServer = Storage.shared.shareServer.value == "US" ?KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue dexShare = ShareClient(username: shareUserName, password: sharePassword, shareServer: shareServer) // setup show/hide small graph and stats updateGraphVisibility() statsView.isHidden = !Storage.shared.showStats.value BGChart.delegate = self BGChartFull.delegate = self // Apply initial appearance mode updateAppearance(Storage.shared.appearanceMode.value) // Trigger foreground and background functions let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(appCameToForeground), name: UIApplication.willEnterForegroundNotification, object: nil) // Setup the Graph if firstGraphLoad { createGraph() createSmallBGGraph() } // setup display for NS vs Dex showHideNSDetails() scheduleAllTasks() // Set up refreshScrollView for BGText refreshScrollView = UIScrollView() refreshScrollView.translatesAutoresizingMaskIntoConstraints = false refreshScrollView.alwaysBounceVertical = true view.addSubview(refreshScrollView) NSLayoutConstraint.activate([ refreshScrollView.leadingAnchor.constraint(equalTo: BGText.leadingAnchor), refreshScrollView.trailingAnchor.constraint(equalTo: BGText.trailingAnchor), refreshScrollView.topAnchor.constraint(equalTo: BGText.topAnchor), refreshScrollView.bottomAnchor.constraint(equalTo: BGText.bottomAnchor), ]) refreshControl = UIRefreshControl() refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged) refreshScrollView.addSubview(refreshControl) refreshScrollView.alwaysBounceVertical = true refreshScrollView.delegate = self NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil) Observable.shared.bgText.$value .receive(on: DispatchQueue.main) .sink { [weak self] newValue in self?.BGText.text = newValue } .store(in: &cancellables) Observable.shared.directionText.$value .receive(on: DispatchQueue.main) .sink { [weak self] newValue in self?.DirectionText.text = newValue } .store(in: &cancellables) Observable.shared.deltaText.$value .receive(on: DispatchQueue.main) .sink { [weak self] newValue in self?.DeltaText.text = newValue } .store(in: &cancellables) /// When an alarm is triggered, go to the snoozer tab Observable.shared.currentAlarm.$value .receive(on: DispatchQueue.main) .compactMap { $0 } .sink { [weak self] _ in guard let self = self, let tabBarController = self.tabBarController, let vcs = tabBarController.viewControllers, !vcs.isEmpty, let snoozerIndex = self.getSnoozerTabIndex(), snoozerIndex < vcs.count else { return } tabBarController.selectedIndex = snoozerIndex } .store(in: &cancellables) Storage.shared.colorBGText.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.setBGTextColor() } .store(in: &cancellables) // Update appearance when setting changes Storage.shared.appearanceMode.$value .receive(on: DispatchQueue.main) .sink { [weak self] mode in self?.updateAppearance(mode) } .store(in: &cancellables) Storage.shared.showStats.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.statsView.isHidden = !Storage.shared.showStats.value } .store(in: &cancellables) Storage.shared.useIFCC.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateStats() } .store(in: &cancellables) Storage.shared.showSmallGraph.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateGraphVisibility() } .store(in: &cancellables) Storage.shared.screenlockSwitchState.$value .receive(on: DispatchQueue.main) .sink { newValue in UIApplication.shared.isIdleTimerDisabled = newValue } .store(in: &cancellables) Storage.shared.showDisplayName.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateServerText() } .store(in: &cancellables) Storage.shared.graphTimeZoneEnabled.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.infoTable.reloadData() } .store(in: &cancellables) Storage.shared.graphTimeZoneIdentifier.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.infoTable.reloadData() } .store(in: &cancellables) Storage.shared.speakBG.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateQuickActions() } .store(in: &cancellables) // Observe all tab position changes with debouncing to handle batch updates Publishers.MergeMany( Storage.shared.homePosition.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.alarmsPosition.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.remotePosition.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.nightscoutPosition.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.snoozerPosition.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.statisticsPosition.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.treatmentsPosition.$value.map { _ in () }.eraseToAnyPublisher() ) .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) .sink { [weak self] _ in self?.setupTabBar() } .store(in: &cancellables) Storage.shared.url.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateNightscoutTabState() self?.checkAndShowImportButtonIfNeeded() } .store(in: &cancellables) Storage.shared.token.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.checkAndShowImportButtonIfNeeded() } .store(in: &cancellables) Storage.shared.shareUserName.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.checkAndShowImportButtonIfNeeded() } .store(in: &cancellables) Storage.shared.sharePassword.$value .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.checkAndShowImportButtonIfNeeded() } .store(in: &cancellables) Storage.shared.apnsKey.$value .receive(on: DispatchQueue.main) .removeDuplicates() .sink { _ in JWTManager.shared.invalidateCache() } .store(in: &cancellables) Storage.shared.teamId.$value .receive(on: DispatchQueue.main) .removeDuplicates() .sink { _ in JWTManager.shared.invalidateCache() } .store(in: &cancellables) Storage.shared.keyId.$value .receive(on: DispatchQueue.main) .removeDuplicates() .sink { _ in JWTManager.shared.invalidateCache() } .store(in: &cancellables) Storage.shared.device.$value .receive(on: DispatchQueue.main) .removeDuplicates() .sink { [weak self] _ in guard let self = self else { return } let isTrioDevice = (Storage.shared.device.value == "Trio") let isLoopDevice = (Storage.shared.device.value == "Loop") let currentRemoteType = Storage.shared.remoteType.value // Check if current remote type is invalid for the device let shouldReset = (currentRemoteType == .loopAPNS && !isLoopDevice) || (currentRemoteType == .trc && !isTrioDevice) || (currentRemoteType == .nightscout && !isTrioDevice) if shouldReset { Storage.shared.remoteType.value = .none } } .store(in: &cancellables) updateQuickActions() // Delay initial tab setup to ensure view hierarchy is ready // This prevents crashes when trying to modify tabs during viewWillAppear DispatchQueue.main.async { [weak self] in self?.isViewHierarchyReady = true self?.setupTabBar() } speechSynthesizer.delegate = self // Check configuration and show appropriate UI if isDataSourceConfigured() { // Data source configured - show loading overlay setupLoadingState() showLoadingOverlay() } else { // No data source - hide all data UI and show setup buttons hideAllDataUI() isInitialLoad = false } checkAndShowImportButtonIfNeeded() } // MARK: - Loading Overlay private func isDataSourceConfigured() -> Bool { let isNightscoutConfigured = !Storage.shared.url.value.isEmpty let isDexcomConfigured = !Storage.shared.shareUserName.value.isEmpty && !Storage.shared.sharePassword.value.isEmpty return isNightscoutConfigured || isDexcomConfigured } private func setupLoadingState() { // If Nightscout is not enabled, mark profile and deviceStatus as loaded // since we only need BG data from Dexcom Share if !IsNightscoutEnabled() { loadingStates["profile"] = true loadingStates["deviceStatus"] = true } } private func showLoadingOverlay() { guard loadingOverlay == nil else { return } // Hide all data UI while loading hideAllDataUI() let overlay = UIView(frame: view.bounds) overlay.backgroundColor = UIColor.systemBackground overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] let activityIndicator = UIActivityIndicatorView(style: .large) activityIndicator.translatesAutoresizingMaskIntoConstraints = false activityIndicator.startAnimating() let loadingLabel = UILabel() loadingLabel.translatesAutoresizingMaskIntoConstraints = false loadingLabel.text = "Loading..." loadingLabel.textAlignment = .center loadingLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium) loadingLabel.textColor = UIColor.secondaryLabel overlay.addSubview(activityIndicator) overlay.addSubview(loadingLabel) NSLayoutConstraint.activate([ activityIndicator.centerXAnchor.constraint(equalTo: overlay.centerXAnchor), activityIndicator.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: -20), loadingLabel.centerXAnchor.constraint(equalTo: overlay.centerXAnchor), loadingLabel.topAnchor.constraint(equalTo: activityIndicator.bottomAnchor, constant: 16), ]) view.addSubview(overlay) loadingOverlay = overlay // Set a timeout to hide the loading overlay if data takes too long loadingTimeoutTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in guard let self = self else { return } if self.isInitialLoad { LogManager.shared.log(category: .general, message: "Loading timeout reached, hiding overlay") self.isInitialLoad = false self.hideLoadingOverlay() } } } private func hideLoadingOverlay() { guard let overlay = loadingOverlay else { return } // Cancel the timeout timer loadingTimeoutTimer?.invalidate() loadingTimeoutTimer = nil // Show all data UI now that loading is complete showAllDataUI() UIView.animate(withDuration: 0.3, animations: { overlay.alpha = 0 }, completion: { _ in overlay.removeFromSuperview() self.loadingOverlay = nil }) } func markDataLoaded(_ key: String) { guard isInitialLoad else { return } loadingStates[key] = true // Check if all critical data is loaded let allLoaded = loadingStates.values.allSatisfy { $0 } if allLoaded { isInitialLoad = false DispatchQueue.main.async { self.hideLoadingOverlay() } } } private func setupTabBar() { guard isViewHierarchyReady else { return } guard !isPresentedAsModal else { return } var tbc = tabBarController if tbc == nil { if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first, let rootVC = window.rootViewController as? UITabBarController { tbc = rootVC } } guard let tabBarController = tbc else { return } // If settings modal is presented, skip rebuild - it will happen when settings is dismissed if tabBarController.presentedViewController != nil { return } rebuildTabs(tabBarController: tabBarController) } /// Static method to rebuild tabs from anywhere in the app /// This is useful when the MainViewController instance may not be in the tab bar static func rebuildTabsIfNeeded() { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first, let tabBarController = window.rootViewController as? UITabBarController else { return } let previousSelectedIndex = tabBarController.selectedIndex let storyboard = UIStoryboard(name: "Main", bundle: nil) var viewControllers: [UIViewController] = [] let orderedItems = Storage.shared.orderedTabBarItems() for (index, item) in orderedItems.prefix(4).enumerated() { let position = TabPosition.customizablePositions[index] if let vc = createViewControllerStatic(for: item, position: position, storyboard: storyboard) { viewControllers.append(vc) } } let menuVC = MoreMenuViewController() menuVC.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: 4) viewControllers.append(menuVC) if let presented = tabBarController.presentedViewController { presented.dismiss(animated: false) { tabBarController.setViewControllers(viewControllers, animated: false) guard !viewControllers.isEmpty else { return } let targetIndex = min(previousSelectedIndex, viewControllers.count - 1) tabBarController.selectedIndex = targetIndex } } else { tabBarController.setViewControllers(viewControllers, animated: false) guard !viewControllers.isEmpty else { return } let targetIndex = min(previousSelectedIndex, viewControllers.count - 1) tabBarController.selectedIndex = targetIndex } } /// Static helper to create view controllers private static func createViewControllerStatic(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? { let tag = position.tabIndex ?? 0 switch item { case .home: guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else { return nil } mainVC.tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: item.icon), tag: tag) return mainVC case .alarms: let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController") vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return vc case .remote: let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController") vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return vc case .nightscout: let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController") vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return vc case .snoozer: let vc = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController") vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return vc case .treatments: let treatmentsVC = UIHostingController(rootView: TreatmentsView()) treatmentsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return treatmentsVC case .stats: // You may need to provide a MainViewController or view model as needed let statsVC = UIHostingController(rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: nil))) statsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return statsVC } } private func rebuildTabs(tabBarController: UITabBarController) { let previousSelectedIndex = tabBarController.selectedIndex let storyboard = UIStoryboard(name: "Main", bundle: nil) var viewControllers: [UIViewController] = [] let orderedItems = Storage.shared.orderedTabBarItems() for (index, item) in orderedItems.prefix(4).enumerated() { let position = TabPosition.customizablePositions[index] if let vc = createViewController(for: item, position: position, storyboard: storyboard) { viewControllers.append(vc) } } let menuVC = MoreMenuViewController() menuVC.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: 4) viewControllers.append(menuVC) tabBarController.setViewControllers(viewControllers, animated: false) guard !viewControllers.isEmpty else { return } let targetIndex = min(previousSelectedIndex, viewControllers.count - 1) tabBarController.selectedIndex = targetIndex updateNightscoutTabState() } private func getSnoozerTabIndex() -> Int? { guard let tabBarController = tabBarController, let viewControllers = tabBarController.viewControllers else { return nil } for (index, vc) in viewControllers.enumerated() { if let _ = vc as? SnoozerViewController { return index } } return nil } private func createViewController(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? { let tag = position.tabIndex ?? 0 switch item { case .home: tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: item.icon), tag: tag) return self case .alarms: let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController") vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return vc case .remote: let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController") vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return vc case .nightscout: let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController") vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return vc case .snoozer: let vc = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController") vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return vc case .treatments: let treatmentsVC = UIHostingController(rootView: TreatmentsView()) treatmentsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return treatmentsVC case .stats: let statsVC = UIHostingController(rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: self))) statsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag) return statsVC } } private func createComingSoonViewController(title: String, icon: String) -> UIViewController { let vc = UIViewController() vc.view.backgroundColor = .systemBackground let stackView = UIStackView() stackView.axis = .vertical stackView.alignment = .center stackView.spacing = 16 stackView.translatesAutoresizingMaskIntoConstraints = false let imageView = UIImageView(image: UIImage(systemName: icon)) imageView.tintColor = .secondaryLabel imageView.contentMode = .scaleAspectFit imageView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ imageView.widthAnchor.constraint(equalToConstant: 60), imageView.heightAnchor.constraint(equalToConstant: 60), ]) let titleLabel = UILabel() titleLabel.text = title titleLabel.font = .preferredFont(forTextStyle: .title1) titleLabel.textColor = .label stackView.addArrangedSubview(imageView) stackView.addArrangedSubview(titleLabel) vc.view.addSubview(stackView) NSLayoutConstraint.activate([ stackView.centerXAnchor.constraint(equalTo: vc.view.centerXAnchor), stackView.centerYAnchor.constraint(equalTo: vc.view.centerYAnchor), ]) vc.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle return vc } // Update the Home Screen Quick Action for toggling the "Speak BG" feature based on the current speakBG setting. func updateQuickActions() { let iconName = Storage.shared.speakBG.value ? "pause.circle.fill" : "play.circle.fill" let iconTemplate = UIApplicationShortcutIcon(systemImageName: iconName) let shortcut = UIApplicationShortcutItem(type: Bundle.main.bundleIdentifier! + ".toggleSpeakBG", localizedTitle: "Speak BG", localizedSubtitle: nil, icon: iconTemplate, userInfo: nil) UIApplication.shared.shortcutItems = [shortcut] } deinit { NotificationCenter.default.removeObserver(self, name: NSNotification.Name("refresh"), object: nil) } // Clean all timers and start new ones when refreshing @objc func refresh() { LogManager.shared.log(category: .general, message: "Refreshing") // Clear prediction for both Loop or OpenAPS // Check if Loop prediction data exists and clear it if necessary if !predictionData.isEmpty { predictionData.removeAll() updatePredictionGraph() } // Check if OpenAPS prediction data exists and clear it if necessary let openAPSDataIndices = [12, 13, 14, 15] for dataIndex in openAPSDataIndices { let mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet let smallChart = BGChartFull.lineData!.dataSets[dataIndex] as! LineChartDataSet if !mainChart.entries.isEmpty || !smallChart.entries.isEmpty { updatePredictionGraphGeneric( dataIndex: dataIndex, predictionData: [], chartLabel: "", color: UIColor.systemGray ) } } MinAgoText.text = "Refreshing" Observable.shared.minAgoText.value = "Refreshing" scheduleAllTasks() currentCage = nil currentSage = nil currentIage = nil refreshControl.endRefreshing() } // Scroll down BGText when refreshing func scrollViewDidScroll(_ scrollView: UIScrollView) { if scrollView == refreshScrollView { let yOffset = scrollView.contentOffset.y if yOffset < 0 { BGText.transform = CGAffineTransform(translationX: 0, y: -yOffset) } else { BGText.transform = CGAffineTransform.identity } } } override func viewWillAppear(_: Bool) { UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value infoTable.reloadData() if Observable.shared.chartSettingsChanged.value { updateBGGraphSettings() smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value) view.layoutIfNeeded() Observable.shared.chartSettingsChanged.value = false } } private var timeZoneOverrideInfoValue: String? { guard Storage.shared.graphTimeZoneEnabled.value, let overrideTimeZone = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value) else { return nil } return overrideTimeZone.identifier } // Info Table Functions func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { guard let infoManager = infoManager else { return 0 } let overrideRowCount = timeZoneOverrideInfoValue == nil ? 0 : 1 return infoManager.numberOfRows() + overrideRowCount } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "LabelCell", for: indexPath) if indexPath.row == 0, let timeZoneOverrideInfoValue { cell.textLabel?.text = "Time Zone" cell.detailTextLabel?.text = timeZoneOverrideInfoValue return cell } let adjustedIndexPath: IndexPath if timeZoneOverrideInfoValue != nil { adjustedIndexPath = IndexPath(row: indexPath.row - 1, section: indexPath.section) } else { adjustedIndexPath = indexPath } if let values = infoManager.dataForIndexPath(adjustedIndexPath) { cell.textLabel?.text = values.name cell.detailTextLabel?.text = values.value } else { cell.textLabel?.text = "" cell.detailTextLabel?.text = "" } return cell } @objc func appMovedToBackground() { // Allow screen to turn off UIApplication.shared.isIdleTimerDisabled = false // We want to always come back to the home screen if let tabBarController = tabBarController, let vcs = tabBarController.viewControllers, !vcs.isEmpty { tabBarController.selectedIndex = 0 } if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() } if Storage.shared.backgroundRefreshType.value != .none { BackgroundAlertManager.shared.startBackgroundAlert() } } @objc func appCameToForeground() { // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.stopBackgroundTask() } if Storage.shared.backgroundRefreshType.value != .none { BackgroundAlertManager.shared.stopBackgroundAlert() } TaskScheduler.shared.checkTasksNow() checkAndNotifyVersionStatus() checkAppExpirationStatus() } func checkAndNotifyVersionStatus() { let versionManager = AppVersionManager() versionManager.checkForNewVersion { latestVersion, isNewer, isBlacklisted in let now = Date() // Check if the current version is blacklisted, or if there is a newer version available if isBlacklisted { let lastBlacklistShown = Storage.shared.lastBlacklistNotificationShown.value ?? Date.distantPast if now.timeIntervalSince(lastBlacklistShown) > 86400 { // 24 hours self.versionAlert(message: "The current version has a critical issue and should be updated as soon as possible.") Storage.shared.lastBlacklistNotificationShown.value = now Storage.shared.lastVersionUpdateNotificationShown.value = now } } else if isNewer { let lastVersionUpdateShown = Storage.shared.lastVersionUpdateNotificationShown.value ?? Date.distantPast if now.timeIntervalSince(lastVersionUpdateShown) > 1_209_600 { // 2 weeks self.versionAlert(message: "A new version is available: \(latestVersion ?? "Unknown"). It is recommended to update.") Storage.shared.lastVersionUpdateNotificationShown.value = now } } } } func versionAlert(title: String = "Update Available", message: String) { DispatchQueue.main.async { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self.present(alert, animated: true) } } func checkAppExpirationStatus() { let now = Date() let expirationDate = BuildDetails.default.calculateExpirationDate() let weekBeforeExpiration = Calendar.current.date(byAdding: .day, value: -7, to: expirationDate)! if now >= weekBeforeExpiration { let lastExpirationShown = Storage.shared.lastExpirationNotificationShown.value ?? Date.distantPast if now.timeIntervalSince(lastExpirationShown) > 86400 { // 24 hours expirationAlert() Storage.shared.lastExpirationNotificationShown.value = now } } } func expirationAlert() { DispatchQueue.main.async { let alert = UIAlertController(title: "App Expiration Warning", message: "This app will expire in less than a week. Please rebuild to continue using it.", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self.present(alert, animated: true) } } @objc override func viewDidAppear(_: Bool) { showHideNSDetails() } func stringFromTimeInterval(interval: TimeInterval) -> String { let interval = Int(interval) let minutes = (interval / 60) % 60 let hours = (interval / 3600) return String(format: "%02d:%02d", hours, minutes) } private func updateNightscoutTabState() { guard let tabBarController = tabBarController, let viewControllers = tabBarController.viewControllers else { return } let isNightscoutEnabled = !Storage.shared.url.value.isEmpty for (index, vc) in viewControllers.enumerated() { if vc is NightscoutViewController { tabBarController.tabBar.items?[index].isEnabled = isNightscoutEnabled } } } func showHideNSDetails() { if isInitialLoad || !isDataSourceConfigured() { return } var isHidden = false if !IsNightscoutEnabled() { isHidden = true } LoopStatusLabel.isHidden = isHidden if IsNotLooping { PredictionLabel.isHidden = true } else { PredictionLabel.isHidden = isHidden } infoTable.isHidden = isHidden if Storage.shared.hideInfoTable.value { infoTable.isHidden = true } updateNightscoutTabState() } func updateBadge(val: Int) { if Storage.shared.appBadge.value { let latestBG = String(val) UIApplication.shared.applicationIconBadgeNumber = Int(Localizer.removePeriodAndCommaForBadge(Localizer.toDisplayUnits(latestBG))) ?? val } else { UIApplication.shared.applicationIconBadgeNumber = 0 } } func setBGTextColor() { if bgData.count > 0 { let latestBG = bgData[bgData.count - 1].sgv var color = NSUIColor.label if Storage.shared.colorBGText.value { if Double(latestBG) >= Storage.shared.highLine.value { color = NSUIColor.systemYellow Observable.shared.bgTextColor.value = .yellow } else if Double(latestBG) <= Storage.shared.lowLine.value { color = NSUIColor.systemRed Observable.shared.bgTextColor.value = .red } else { color = NSUIColor.systemGreen Observable.shared.bgTextColor.value = .green } } else { Observable.shared.bgTextColor.value = .primary } BGText.textColor = color } } func updateAppearance(_ mode: AppearanceMode) { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first else { return } let style: UIUserInterfaceStyle switch mode { case .light: style = .light case .dark: style = .dark case .system: // Use .unspecified to follow system style = .unspecified } // Update this view controller overrideUserInterfaceStyle = style // Update the tab bar controller (affects all tabs) tabBarController?.overrideUserInterfaceStyle = style // Update the window (affects the entire app including modals) window.overrideUserInterfaceStyle = style } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) // When system appearance changes and we're in "System" mode, notify all observers if Storage.shared.appearanceMode.value == .system, previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle { // Post notification so other view controllers can update if needed NotificationCenter.default.post(name: .appearanceDidChange, object: nil) } } func bgDirectionGraphic(_ value: String) -> String { let // graphics:[String:String]=["Flat":"\u{2192}","DoubleUp":"\u{21C8}","SingleUp":"\u{2191}","FortyFiveUp":"\u{2197}\u{FE0E}","FortyFiveDown":"\u{2198}\u{FE0E}","SingleDown":"\u{2193}","DoubleDown":"\u{21CA}","None":"-","NOT COMPUTABLE":"-","RATE OUT OF RANGE":"-"] graphics: [String: String] = ["Flat": "→", "DoubleUp": "↑↑", "SingleUp": "↑", "FortyFiveUp": "↗", "FortyFiveDown": "↘︎", "SingleDown": "↓", "DoubleDown": "↓↓", "None": "-", "NONE": "-", "NOT COMPUTABLE": "-", "RATE OUT OF RANGE": "-", "": "-"] return graphics[value]! } func writeCalendar() { store.requestCalendarAccess { granted, error in if !granted { LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))") return } self.processCalendarUpdates() } } func processCalendarUpdates() { if Storage.shared.calendarIdentifier.value == "" { return } if bgData.count < 1 { return } // This lets us fire the method to write Min Ago entries only once a minute starting after 6 minutes but allows new readings through let now = dateTimeUtils.getNowTimeIntervalUTC() let newestBGDate = bgData[bgData.count - 1].date if lastCalDate == newestBGDate { if (now - lastCalendarWriteAttemptTime) < 60 || (now - newestBGDate) < 360 { return } } // Create Event info var deltaBG = 0 // protect index out of bounds if bgData.count > 1 { deltaBG = bgData[bgData.count - 1].sgv - bgData[bgData.count - 2].sgv as Int } let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - bgData[bgData.count - 1].date) / 60 var deltaString = "" if deltaBG < 0 { deltaString = Localizer.toDisplayUnits(String(deltaBG)) } else { deltaString = "+" + Localizer.toDisplayUnits(String(deltaBG)) } let direction = bgDirectionGraphic(bgData[bgData.count - 1].direction ?? "") let eventStartDate = Date(timeIntervalSince1970: bgData[bgData.count - 1].date) var eventEndDate = eventStartDate.addingTimeInterval(60 * 10) var eventTitle = Storage.shared.watchLine1.value if Storage.shared.watchLine2.value.count > 1 { eventTitle += "\n" + Storage.shared.watchLine2.value } eventTitle = eventTitle.replacingOccurrences(of: "%BG%", with: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv))) eventTitle = eventTitle.replacingOccurrences(of: "%DIRECTION%", with: direction) eventTitle = eventTitle.replacingOccurrences(of: "%DELTA%", with: deltaString) if currentOverride != 1.0 { let val = Int(currentOverride * 100) // let overrideText = String(format:"%f1", self.currentOverride*100) let text = String(val) + "%" eventTitle = eventTitle.replacingOccurrences(of: "%OVERRIDE%", with: text) } else { eventTitle = eventTitle.replacingOccurrences(of: "%OVERRIDE%", with: "") } eventTitle = eventTitle.replacingOccurrences(of: "%LOOP%", with: latestLoopStatusString) var minAgo = "" if deltaTime > 9 { // write old BG reading and continue pushing out end date to show last entry minAgo = String(Int(deltaTime)) + " min" eventEndDate = eventStartDate.addingTimeInterval((60 * 10) + (deltaTime * 60)) } var basal = "~" if latestBasal != "" { basal = latestBasal } eventTitle = eventTitle.replacingOccurrences(of: "%MINAGO%", with: minAgo) eventTitle = eventTitle.replacingOccurrences(of: "%IOB%", with: latestIOB?.formattedValue() ?? "0") eventTitle = eventTitle.replacingOccurrences(of: "%COB%", with: latestCOB?.formattedValue() ?? "0") eventTitle = eventTitle.replacingOccurrences(of: "%BASAL%", with: basal) // Delete Events from last 2 hours and 2 hours in future let deleteStartDate = Date().addingTimeInterval(-60 * 60 * 2) let deleteEndDate = Date().addingTimeInterval(60 * 60 * 2) // guard solves for some ios upgrades removing the calendar guard let deleteCalendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value) as? EKCalendar else { return } let predicate2 = store.predicateForEvents(withStart: deleteStartDate, end: deleteEndDate, calendars: [deleteCalendar]) let eVDelete = store.events(matching: predicate2) as [EKEvent]? if eVDelete != nil { for i in eVDelete! { do { try store.remove(i, span: EKSpan.thisEvent, commit: true) } catch { LogManager.shared.log(category: .calendar, message: "Failed to remove calendar event: \(error.localizedDescription)") } } } // Write New Event let event = EKEvent(eventStore: store) event.title = eventTitle event.startDate = eventStartDate event.endDate = eventEndDate event.calendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value) do { try store.save(event, span: .thisEvent, commit: true) lastCalendarWriteAttemptTime = now lastCalDate = bgData[bgData.count - 1].date } catch { let msg = "Error storing to calendar: \(error.localizedDescription) (\(error))" LogManager.shared.log(category: .calendar, message: msg) } } func userNotificationCenter(_: UNUserNotificationCenter, didReceive _: UNNotificationResponse, withCompletionHandler _: @escaping () -> Void) {} // User has scrolled the chart func chartTranslated(_: ChartViewBase, dX _: CGFloat, dY _: CGFloat) { let isViewingLatestData = abs(BGChart.highestVisibleX - BGChart.chartXMax) < 0.001 if isViewingLatestData { autoScrollPauseUntil = nil // User is back at the latest data, allow auto-scrolling } else { autoScrollPauseUntil = Date().addingTimeInterval(5 * 60) // User is viewing historical data, pause auto-scrolling } } func calculateMaxBgGraphValue() -> Float { return max(Float(topBG), Float(topPredictionBG)) } func loadDebugData() { struct DebugData: Codable { let debug: Bool? let url: String? let token: String? } let fileManager = FileManager.default let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("debugData.json") if fileManager.fileExists(atPath: url.path) { do { let data = try Data(contentsOf: url) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let debugData = try decoder.decode(DebugData.self, from: data) LogManager.shared.log(category: .alarm, message: "Loaded DebugData from \(url.path)", isDebug: true) if let debug = debugData.debug { Observable.shared.debug.value = debug } if let url = debugData.url { Storage.shared.url.value = url } if let token = debugData.token { Storage.shared.token.value = token } } catch { LogManager.shared.log(category: .alarm, message: "Failed to load DebugData: \(error)", isDebug: true) } } } private func synchronizeInfoTypes() { var sortArray = Storage.shared.infoSort.value var visibleArray = Storage.shared.infoVisible.value // Current valid indices based on InfoType let currentValidIndices = InfoType.allCases.map { $0.rawValue } // Add missing indices to sortArray for index in currentValidIndices { if !sortArray.contains(index) { sortArray.append(index) } } // Remove deprecated indices sortArray = sortArray.filter { currentValidIndices.contains($0) } // Ensure visibleArray is updated with new entries if visibleArray.count < currentValidIndices.count { for i in visibleArray.count ..< currentValidIndices.count { visibleArray.append(InfoType(rawValue: i)?.defaultVisible ?? false) } } // Trim excess elements if there are more than needed if visibleArray.count > currentValidIndices.count { visibleArray = Array(visibleArray.prefix(currentValidIndices.count)) } Storage.shared.infoSort.value = sortArray Storage.shared.infoVisible.value = visibleArray } // MARK: - First Time Setup private func checkAndShowImportButtonIfNeeded() { // Check if this is first-time setup (no data source configured) let isFirstTimeSetup = !isDataSourceConfigured() if isFirstTimeSetup { setupFirstTimeButtons() hideAllDataUI() // Hide loading overlay if it's showing and mark as not loading if loadingOverlay != nil { isInitialLoad = false hideLoadingOverlay() } } else { hideFirstTimeButtons() // Only show data UI if we're not in initial loading state if !isInitialLoad || loadingOverlay == nil { showAllDataUI() } } } private func setupFirstTimeButtons() { // Create Setup Nightscout button if setupNightscoutButton == nil { setupNightscoutButton = UIButton(type: .system) setupNightscoutButton.setTitle("Setup Nightscout", for: .normal) setupNightscoutButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium) setupNightscoutButton.backgroundColor = UIColor.systemBlue setupNightscoutButton.setTitleColor(.white, for: .normal) setupNightscoutButton.layer.cornerRadius = 12 setupNightscoutButton.layer.shadowColor = UIColor.black.cgColor setupNightscoutButton.layer.shadowOffset = CGSize(width: 0, height: 2) setupNightscoutButton.layer.shadowOpacity = 0.3 setupNightscoutButton.layer.shadowRadius = 4 setupNightscoutButton.addTarget(self, action: #selector(setupNightscoutTapped), for: .touchUpInside) view.addSubview(setupNightscoutButton) setupNightscoutButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ setupNightscoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), setupNightscoutButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -30), setupNightscoutButton.widthAnchor.constraint(equalToConstant: 200), setupNightscoutButton.heightAnchor.constraint(equalToConstant: 50), ]) } // Create Setup Dexcom Share button if setupDexcomButton == nil { setupDexcomButton = UIButton(type: .system) setupDexcomButton.setTitle("Setup Dexcom Share", for: .normal) setupDexcomButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium) setupDexcomButton.backgroundColor = UIColor.systemGreen setupDexcomButton.setTitleColor(.white, for: .normal) setupDexcomButton.layer.cornerRadius = 12 setupDexcomButton.layer.shadowColor = UIColor.black.cgColor setupDexcomButton.layer.shadowOffset = CGSize(width: 0, height: 2) setupDexcomButton.layer.shadowOpacity = 0.3 setupDexcomButton.layer.shadowRadius = 4 setupDexcomButton.addTarget(self, action: #selector(setupDexcomTapped), for: .touchUpInside) view.addSubview(setupDexcomButton) setupDexcomButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ setupDexcomButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), setupDexcomButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 30), setupDexcomButton.widthAnchor.constraint(equalToConstant: 200), setupDexcomButton.heightAnchor.constraint(equalToConstant: 50), ]) } setupNightscoutButton.isHidden = false setupDexcomButton.isHidden = false } private func hideFirstTimeButtons() { setupNightscoutButton?.isHidden = true setupDexcomButton?.isHidden = true } @objc private func setupNightscoutTapped() { let nightscoutSettingsView = NightscoutSettingsView(viewModel: .init()) let hostingController = UIHostingController(rootView: nightscoutSettingsView) let navController = UINavigationController(rootViewController: hostingController) // Apply appearance mode let style = Storage.shared.appearanceMode.value.userInterfaceStyle hostingController.overrideUserInterfaceStyle = style navController.overrideUserInterfaceStyle = style // Add a Done button hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem( image: UIImage(systemName: "checkmark"), style: .plain, target: self, action: #selector(dismissModal) ) hostingController.navigationItem.rightBarButtonItem?.tintColor = .systemBlue navController.modalPresentationStyle = .pageSheet present(navController, animated: true) } @objc private func setupDexcomTapped() { let dexcomSettingsView = DexcomSettingsView(viewModel: .init()) let hostingController = UIHostingController(rootView: dexcomSettingsView) let navController = UINavigationController(rootViewController: hostingController) // Apply appearance mode let style = Storage.shared.appearanceMode.value.userInterfaceStyle hostingController.overrideUserInterfaceStyle = style navController.overrideUserInterfaceStyle = style // Add a Done button hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem( image: UIImage(systemName: "checkmark"), style: .plain, target: self, action: #selector(dismissModal) ) hostingController.navigationItem.rightBarButtonItem?.tintColor = .systemBlue navController.modalPresentationStyle = .pageSheet present(navController, animated: true) } private func hideGraphs() { BGChart.isHidden = true BGChartFull.isHidden = true } private func showGraphs() { updateGraphVisibility() } private func hideAllDataUI() { // Hide graphs BGChart.isHidden = true BGChartFull.isHidden = true // Hide BG display elements BGText.isHidden = true DeltaText.isHidden = true DirectionText.isHidden = true MinAgoText.isHidden = true serverText.isHidden = true // Hide info table and stats infoTable.isHidden = true statsView.isHidden = true // Hide loop status and prediction LoopStatusLabel.isHidden = true PredictionLabel.isHidden = true } private func showAllDataUI() { // Show BG display elements BGText.isHidden = false DeltaText.isHidden = false DirectionText.isHidden = false MinAgoText.isHidden = false serverText.isHidden = false // Show graphs based on settings updateGraphVisibility() // Show/hide info table and stats based on user settings let isNightscoutEnabled = IsNightscoutEnabled() if isNightscoutEnabled { infoTable.isHidden = Storage.shared.hideInfoTable.value LoopStatusLabel.isHidden = false PredictionLabel.isHidden = IsNotLooping } else { infoTable.isHidden = true LoopStatusLabel.isHidden = true PredictionLabel.isHidden = true } statsView.isHidden = !Storage.shared.showStats.value } private func updateGraphVisibility() { let isFirstTimeSetup = !isDataSourceConfigured() if isFirstTimeSetup { BGChart.isHidden = true BGChartFull.isHidden = true } else { BGChart.isHidden = false BGChartFull.isHidden = !Storage.shared.showSmallGraph.value } } @objc private func importSettingsButtonTapped() { presentImportSettingsView() } private func presentImportSettingsView() { let importExportView = ImportExportSettingsView() let hostingController = UIHostingController(rootView: importExportView) hostingController.modalPresentationStyle = .pageSheet present(hostingController, animated: true) } @objc private func dismissModal() { dismiss(animated: true) { [weak self] in guard let self = self else { return } // Check if user just configured a data source if self.isDataSourceConfigured(), self.loadingOverlay == nil { // Reset loading states for fresh load self.loadingStates = [ "bg": false, "profile": false, "deviceStatus": false, ] self.isInitialLoad = true // Show loading overlay and trigger refresh self.setupLoadingState() self.showLoadingOverlay() self.refresh() } } } } extension MainViewController: AVSpeechSynthesizerDelegate { func speechSynthesizer(_: AVSpeechSynthesizer, didFinish _: AVSpeechUtterance) { let appState = UIApplication.shared.applicationState let isSilentTuneMode = Storage.shared.backgroundRefreshType.value == .silentTune if isSilentTuneMode, appState == .background { LogManager.shared.log(category: .general, message: "Silent tune active in background; not deactivating session.", isDebug: true) } else { do { try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) LogManager.shared.log(category: .general, message: "Audio session deactivated after speech.", isDebug: true) } catch { LogManager.shared.log(category: .alarm, message: "Failed to deactivate audio session: \(error)") } } } }