| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241 |
- // LoopFollow
- // MainViewController.swift
- import AVFAudio
- import Combine
- import CoreBluetooth
- import EventKit
- import ShareClient
- import SwiftUI
- import UIKit
- import UserNotifications
- func IsNightscoutEnabled() -> Bool {
- return !Storage.shared.url.value.isEmpty
- }
- private struct APNSCredentialSnapshot: Equatable {
- let remoteApnsKey: String
- let teamId: String?
- let remoteKeyId: String
- let lfApnsKey: String
- let lfKeyId: String
- }
- class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
- /// The single, long-lived MainViewController that owns the app's data
- /// pipeline (scheduleAllTasks). Held strongly so it stays alive — and the
- /// engine keeps running — regardless of which tabs are visible or whether
- /// Home has been opened. Created once via bootstrap() on first foreground.
- private(set) static var shared: MainViewController?
- /// Creates and force-loads the shared instance if it does not yet exist.
- /// loadViewIfNeeded() triggers viewDidLoad, which starts scheduleAllTasks.
- /// Idempotent and main-thread only. Called from MainTabView on appear so
- /// the engine runs even when Home lives in the Menu rather than a tab.
- static func bootstrap() {
- guard shared == nil else { return }
- let vc = MainViewController()
- shared = vc
- vc.loadViewIfNeeded()
- }
- var statsDisplayModel = StatsDisplayModel()
- /// The hosting controller's view — hidden during loading / first-time setup.
- private var mainContentView: UIView!
- // 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 yesterdayBGData: [ShareGlucoseData] = [] // readings already shifted +24h for the comparison overlay
- 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 ztPredictionData: [ShareGlucoseData] = []
- var iobPredictionData: [ShareGlucoseData] = []
- var cobPredictionData: [ShareGlucoseData] = []
- var uamPredictionData: [ShareGlucoseData] = []
- var openAPSPredBGs: [String: [Double]]?
- var openAPSPredUpdatedTime: TimeInterval?
- var bgCheckData: [ShareGlucoseData] = []
- var suspendGraphData: [DataStructs.timestampOnlyStruct] = []
- var resumeGraphData: [DataStructs.timestampOnlyStruct] = []
- var sensorStartGraphData: [DataStructs.timestampOnlyStruct] = []
- var noteGraphData: [DataStructs.noteStruct] = []
- 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 IsNotLooping = false
- let contactImageUpdater = ContactImageUpdater()
- let chartModel = BGChartModel()
- private var cancellables = Set<AnyCancellable>()
- // 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?
- // MARK: - Programmatic UI Setup
- private func setupUI() {
- view.backgroundColor = .systemBackground
- infoManager = InfoManager()
- let mainView = MainHomeView(
- chartModel: chartModel,
- infoManager: infoManager,
- statsModel: statsDisplayModel,
- onRefresh: { [weak self] in self?.refresh() },
- onStatsTap: { [weak self] in self?.statsViewTapped() }
- )
- let hosting = UIHostingController(rootView: mainView)
- // Exclude the keyboard from the hosting controller's safe area. Home has
- // no text input, but a stale keyboard frame replayed on foregrounding can
- // otherwise compress the layout until a rotation recomputes the safe area.
- hosting.safeAreaRegions = .container
- hosting.view.translatesAutoresizingMaskIntoConstraints = false
- hosting.view.backgroundColor = .clear
- addChild(hosting)
- view.addSubview(hosting.view)
- let safeArea = view.safeAreaLayoutGuide
- NSLayoutConstraint.activate([
- hosting.view.topAnchor.constraint(equalTo: safeArea.topAnchor),
- hosting.view.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor),
- hosting.view.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
- hosting.view.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor),
- ])
- hosting.didMove(toParent: self)
- mainContentView = hosting.view
- }
- override func viewDidLoad() {
- super.viewDidLoad()
- // Adopt the singleton only if it is not already set (normally bootstrap()
- // has set it to this same instance). Guarding prevents a stray instance
- // from displacing the long-lived engine and spawning a second pipeline.
- if MainViewController.shared == nil {
- MainViewController.shared = self
- }
- setupUI()
- loadDebugData()
- // Migrations run in foreground only — see runMigrationsIfNeeded() for details.
- runMigrationsIfNeeded()
- // Synchronize info types to ensure arrays are the correct size
- synchronizeInfoTypes()
- 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 graphs (first-time setup check)
- updateGraphVisibility()
- // 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)
- // didBecomeActive is used (not willEnterForeground) to ensure applicationState == .active
- // when runMigrationsIfNeeded() is called. This catches migrations deferred by a
- // background BGAppRefreshTask launch in Before-First-Unlock state.
- notificationCenter.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
- // Posted by AppDelegate after Storage.reloadAll has refreshed every StorageValue
- // following a BFU launch. If we're alive when this fires, our scheduled tasks
- // were set up with BFU defaults (url='') and need to be redone.
- notificationCenter.addObserver(self, selector: #selector(handleBFUReloadCompleted), name: .bfuReloadCompleted, object: nil)
- #if !targetEnvironment(macCatalyst)
- notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil)
- #endif
- // Setup the Graph
- if firstGraphLoad {
- createGraph()
- createSmallBGGraph()
- }
- // setup display for NS vs Dex
- showHideNSDetails()
- scheduleAllTasks()
- setupNightscoutSocket()
- NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil)
- /// When an alarm is triggered, go to the snoozer tab
- Observable.shared.currentAlarm.$value
- .receive(on: DispatchQueue.main)
- .compactMap { $0 }
- .sink { _ in
- let orderedItems = Storage.shared.orderedTabBarItems()
- if let index = orderedItems.firstIndex(of: .snoozer) {
- Observable.shared.selectedTabIndex.value = index
- }
- }
- .store(in: &cancellables)
- Storage.shared.colorBGText.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.updateBGTextAppearance()
- }
- .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)
- Publishers.MergeMany(
- Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(),
- Storage.shared.useIFCC.$value.map { _ in () }.eraseToAnyPublisher(),
- Storage.shared.showGMI.$value.map { _ in () }.eraseToAnyPublisher(),
- Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher()
- )
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.updateStats()
- }
- .store(in: &cancellables)
- Storage.shared.timeInRangeModeRaw.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.updateBGGraphSettings()
- self?.updateBGGraph()
- }
- .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.speakBG.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.updateQuickActions()
- }
- .store(in: &cancellables)
- Storage.shared.url.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- 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)
- Publishers.CombineLatest4(
- Storage.shared.remoteApnsKey.$value,
- Storage.shared.teamId.$value,
- Storage.shared.remoteKeyId.$value,
- Storage.shared.lfApnsKey.$value
- )
- .combineLatest(Storage.shared.lfKeyId.$value)
- .map { values, lfKeyId in
- APNSCredentialSnapshot(
- remoteApnsKey: values.0,
- teamId: values.1,
- remoteKeyId: values.2,
- lfApnsKey: values.3,
- lfKeyId: lfKeyId
- )
- }
- .removeDuplicates()
- .dropFirst()
- .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
- .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)
- if shouldReset {
- Storage.shared.remoteType.value = .none
- }
- }
- .store(in: &cancellables)
- Storage.shared.device.$value
- .receive(on: DispatchQueue.main)
- .map { device -> Bool? in
- device.isEmpty ? nil : (device == "Loop")
- }
- .removeDuplicates()
- .dropFirst()
- .sink { [weak self] isLoop in
- guard let isLoop = isLoop else { return }
- Storage.shared.predictionDisplayType.value = isLoop ? .lines : .cone
- self?.updateOpenAPSPredictionDisplay()
- }
- .store(in: &cancellables)
- updateQuickActions()
- 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()
- }
- }
- }
- @objc private func navigateOnLAForeground() {
- let orderedItems = Storage.shared.orderedTabBarItems()
- if Observable.shared.currentAlarm.value != nil,
- let snoozerIndex = orderedItems.firstIndex(of: .snoozer)
- {
- Observable.shared.selectedTabIndex.value = snoozerIndex
- } else {
- Observable.shared.selectedTabIndex.value = 0
- }
- }
- @objc private func statsViewTapped() {
- #if !targetEnvironment(macCatalyst)
- let orderedItems = Storage.shared.orderedTabBarItems()
- if let statsIndex = orderedItems.firstIndex(of: .stats) {
- Observable.shared.selectedTabIndex.value = statsIndex
- return
- }
- #endif
- let statsModalView = AggregatedStatsModalView(mainViewController: self)
- let hostingController = UIHostingController(rootView: statsModalView)
- hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
- hostingController.modalPresentationStyle = .fullScreen
- present(hostingController, animated: true)
- }
- // 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)
- }
- // Clean all timers and start new ones when refreshing
- @objc func refresh() {
- LogManager.shared.log(category: .general, message: "Refreshing")
- Observable.shared.minAgoText.value = "Refreshing"
- scheduleAllTasks()
- NightscoutSocketManager.shared.connectIfNeeded()
- currentCage = nil
- currentSage = nil
- currentIage = nil
- }
- override func viewWillAppear(_ animated: Bool) {
- super.viewWillAppear(animated)
- UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
- if Observable.shared.chartSettingsChanged.value {
- updateBGGraphSettings()
- Observable.shared.chartSettingsChanged.value = false
- }
- }
- @objc func appMovedToBackground() {
- // Allow screen to turn off
- UIApplication.shared.isIdleTimerDisabled = false
- // We want to always come back to the home screen
- Observable.shared.selectedTabIndex.value = 0
- if Storage.shared.backgroundRefreshType.value == .silentTune {
- backgroundTask.startBackgroundTask()
- BackgroundRefreshManager.shared.scheduleRefresh()
- }
- if Storage.shared.backgroundRefreshType.value != .none {
- BackgroundAlertManager.shared.startBackgroundAlert()
- }
- NightscoutSocketManager.shared.disconnect()
- }
- // Migrations must only run when UserDefaults is accessible (i.e. after first unlock).
- // When the app is launched in the background by BGAppRefreshTask immediately after a
- // reboot, the device may be in Before-First-Unlock (BFU) state: UserDefaults files are
- // still encrypted, so every read returns the default value (0 / ""). Running migrations
- // in that state would overwrite real settings with empty strings.
- //
- // Strategy: skip migrations if applicationState == .background; call this method again
- // from appCameToForeground() so they run on the first foreground after a BFU launch.
- func runMigrationsIfNeeded() {
- guard UIApplication.shared.applicationState != .background else { return }
- // Capture before migrations run: true for existing users, false for fresh installs.
- let isExistingUser = Storage.shared.migrationStep.exists
- // When adding a new migration step below:
- // 1. Bump the `migrationStep` defaultValue in Storage.swift to the new latest step
- // number so fresh installs skip every migration.
- // 2. Update any other StorageValue defaults in Storage.swift that this new step
- // mutates, so a fresh install ends up in the same state as a migrated user.
- // Step 2: Released in v3.1.0 (2025-07-21). Can be removed after 2026-07-21.
- if Storage.shared.migrationStep.value < 2 {
- Storage.shared.migrateStep2()
- Storage.shared.migrationStep.value = 2
- }
- // Step 3: Released in v4.5.0 (2026-02-01). Can be removed after 2027-02-01.
- if Storage.shared.migrationStep.value < 3 {
- Storage.shared.migrateStep3()
- Storage.shared.migrationStep.value = 3
- }
- // Step 4: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20.
- if Storage.shared.migrationStep.value < 4 {
- // Existing users need to see the fat/protein order change banner.
- // New users never saw the old order, so mark it as already seen.
- Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser
- Storage.shared.migrationStep.value = 4
- }
- // Step 5: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20.
- if Storage.shared.migrationStep.value < 5 {
- Storage.shared.migrateStep5()
- Storage.shared.migrationStep.value = 5
- }
- if Storage.shared.migrationStep.value < 6 {
- Storage.shared.migrateStep6()
- Storage.shared.migrationStep.value = 6
- }
- if Storage.shared.migrationStep.value < 7 {
- Storage.shared.migrateStep7()
- Storage.shared.migrationStep.value = 7
- }
- if Storage.shared.migrationStep.value < 8 {
- Storage.shared.migrateStep8()
- Storage.shared.migrationStep.value = 8
- }
- if Storage.shared.migrationStep.value < 9 {
- Storage.shared.migrateStep9()
- Storage.shared.migrationStep.value = 9
- }
- }
- @objc func appDidBecomeActive() {
- // applicationState == .active is guaranteed here, so the BFU guard in
- // runMigrationsIfNeeded() will always pass. Catches the case where viewDidLoad
- // ran during a BGAppRefreshTask background launch and deferred migrations.
- runMigrationsIfNeeded()
- }
- @objc func handleBFUReloadCompleted() {
- // Show the loading overlay so the user sees feedback during the 2-5s
- // while tasks re-run with the now-correct credentials. Tasks scheduled
- // before reload used url='' and rescheduled themselves 60s out — reset
- // them so they run within their normal 2-5s initial delay.
- loadingStates = ["bg": false, "profile": false, "deviceStatus": false]
- isInitialLoad = true
- setupLoadingState()
- showLoadingOverlay()
- scheduleAllTasks()
- }
- @objc func appCameToForeground() {
- // BFU recovery (Storage.reloadAll) is driven by AppDelegate; this controller
- // reacts via .bfuReloadCompleted in handleBFUReloadCompleted() above.
- // 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()
- NightscoutSocketManager.shared.connectIfNeeded()
- 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)
- }
- }
- override func viewDidAppear(_ animated: Bool) {
- super.viewDidAppear(animated)
- showHideNSDetails()
- // Re-render the graph every time Home appears. The single MainViewController
- // is reused across tab/Menu hosts, so its chart view gets re-parented when
- // the user switches to Home or moves it between the tab bar and the Menu —
- // and Charts does not redraw itself after a re-parent. Rebuilding here keeps
- // the curve visible regardless of how Home was reached. It also recovers the
- // one-shot firstGraphLoad zoom that is skipped while the view is off-screen
- // (force-loaded headless when Home lives in the Menu). Deferred one runloop
- // so the nested SwiftUI chart has its final frame; updateBGGraph's own
- // width>0 guard skips the initial zoom until it does.
- if !bgData.isEmpty {
- DispatchQueue.main.async { [weak self] in
- self?.updateBGGraph()
- }
- }
- #if !targetEnvironment(macCatalyst)
- LiveActivityManager.shared.startFromCurrentState()
- #endif
- }
- 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)
- }
- func showHideNSDetails() {
- // Info table visibility is handled reactively by MainHomeView.
- }
- 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 updateBGTextAppearance() {
- if bgData.count > 0 {
- let latestBG = bgData[bgData.count - 1].sgv
- if Storage.shared.colorBGText.value {
- let thresholds = UnitSettingsStore.shared.effectiveThresholds()
- if Double(latestBG) >= thresholds.high {
- Observable.shared.bgTextColor.value = .yellow
- } else if Double(latestBG) <= thresholds.low {
- Observable.shared.bgTextColor.value = .red
- } else {
- Observable.shared.bgTextColor.value = .green
- }
- } else {
- Observable.shared.bgTextColor.value = .primary
- }
- }
- }
- 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 window (affects the entire app including modals)
- window.overrideUserInterfaceStyle = style
- }
- 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) {}
- 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 navController = UINavigationController()
- let nightscoutSettingsView = NightscoutSettingsView(viewModel: .init(), usesModalCloseButton: true, onContinueToUnits: { [weak navController] in
- let unitsView = UnitsOnboardingView {
- navController?.dismiss(animated: true)
- }
- let unitsController = UIHostingController(rootView: unitsView)
- let style = Storage.shared.appearanceMode.value.userInterfaceStyle
- unitsController.overrideUserInterfaceStyle = style
- navController?.pushViewController(unitsController, animated: true)
- }, onImportSettings: { [weak navController] in
- let importSettingsView = ImportExportSettingsView()
- let importSettingsController = UIHostingController(rootView: importSettingsView)
- let style = Storage.shared.appearanceMode.value.userInterfaceStyle
- importSettingsController.overrideUserInterfaceStyle = style
- navController?.pushViewController(importSettingsController, animated: true)
- })
- let hostingController = UIHostingController(rootView: nightscoutSettingsView)
- // Apply appearance mode
- let style = Storage.shared.appearanceMode.value.userInterfaceStyle
- hostingController.overrideUserInterfaceStyle = style
- navController.overrideUserInterfaceStyle = style
- navController.setViewControllers([hostingController], animated: false)
- hostingController.navigationItem.rightBarButtonItem = makeCloseBarButtonItem()
- navController.modalPresentationStyle = .pageSheet
- present(navController, animated: true)
- }
- @objc private func setupDexcomTapped() {
- let navController = UINavigationController()
- let dexcomSettingsView = DexcomSettingsView(viewModel: .init(), usesModalCloseButton: true, onContinueToUnits: { [weak navController] in
- let unitsView = UnitsOnboardingView {
- navController?.dismiss(animated: true)
- }
- let unitsController = UIHostingController(rootView: unitsView)
- let style = Storage.shared.appearanceMode.value.userInterfaceStyle
- unitsController.overrideUserInterfaceStyle = style
- navController?.pushViewController(unitsController, animated: true)
- })
- let hostingController = UIHostingController(rootView: dexcomSettingsView)
- // Apply appearance mode
- let style = Storage.shared.appearanceMode.value.userInterfaceStyle
- hostingController.overrideUserInterfaceStyle = style
- navController.overrideUserInterfaceStyle = style
- navController.setViewControllers([hostingController], animated: false)
- hostingController.navigationItem.rightBarButtonItem = makeCloseBarButtonItem()
- navController.modalPresentationStyle = .pageSheet
- present(navController, animated: true)
- }
- private func makeCloseBarButtonItem() -> UIBarButtonItem {
- let button = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(dismissModal))
- button.tintColor = .systemBlue
- return button
- }
- private func hideAllDataUI() {
- mainContentView?.isHidden = true
- }
- private func showAllDataUI() {
- mainContentView?.isHidden = false
- }
- private func updateGraphVisibility() {
- // Graph and component visibility is handled reactively by MainHomeView.
- // This method now only manages the overall content visibility for first-time setup.
- if !isDataSourceConfigured() {
- mainContentView?.isHidden = true
- }
- }
- @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)")
- }
- }
- }
- }
|