MainViewController.swift 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241
  1. // LoopFollow
  2. // MainViewController.swift
  3. import AVFAudio
  4. import Combine
  5. import CoreBluetooth
  6. import EventKit
  7. import ShareClient
  8. import SwiftUI
  9. import UIKit
  10. import UserNotifications
  11. func IsNightscoutEnabled() -> Bool {
  12. return !Storage.shared.url.value.isEmpty
  13. }
  14. private struct APNSCredentialSnapshot: Equatable {
  15. let remoteApnsKey: String
  16. let teamId: String?
  17. let remoteKeyId: String
  18. let lfApnsKey: String
  19. let lfKeyId: String
  20. }
  21. class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
  22. /// The single, long-lived MainViewController that owns the app's data
  23. /// pipeline (scheduleAllTasks). Held strongly so it stays alive — and the
  24. /// engine keeps running — regardless of which tabs are visible or whether
  25. /// Home has been opened. Created once via bootstrap() on first foreground.
  26. private(set) static var shared: MainViewController?
  27. /// Creates and force-loads the shared instance if it does not yet exist.
  28. /// loadViewIfNeeded() triggers viewDidLoad, which starts scheduleAllTasks.
  29. /// Idempotent and main-thread only. Called from MainTabView on appear so
  30. /// the engine runs even when Home lives in the Menu rather than a tab.
  31. static func bootstrap() {
  32. guard shared == nil else { return }
  33. let vc = MainViewController()
  34. shared = vc
  35. vc.loadViewIfNeeded()
  36. }
  37. var statsDisplayModel = StatsDisplayModel()
  38. /// The hosting controller's view — hidden during loading / first-time setup.
  39. private var mainContentView: UIView!
  40. // Setup buttons for first-time configuration
  41. private var setupNightscoutButton: UIButton!
  42. private var setupDexcomButton: UIButton!
  43. let speechSynthesizer = AVSpeechSynthesizer()
  44. // Variables for BG Charts
  45. var firstGraphLoad: Bool = true
  46. var currentOverride = 1.0
  47. var currentSage: sageData?
  48. var currentCage: cageData?
  49. var currentIage: iageData?
  50. var backgroundTask = BackgroundTask()
  51. var graphNowTimer = Timer()
  52. var lastCalendarWriteAttemptTime: TimeInterval = 0
  53. // Info Table Setup
  54. var infoManager: InfoManager!
  55. var profileManager = ProfileManager.shared
  56. var bgData: [ShareGlucoseData] = []
  57. var yesterdayBGData: [ShareGlucoseData] = [] // readings already shifted +24h for the comparison overlay
  58. var basalProfile: [basalProfileStruct] = []
  59. var basalData: [basalGraphStruct] = []
  60. var basalScheduleData: [basalGraphStruct] = []
  61. var bolusData: [bolusGraphStruct] = []
  62. var smbData: [bolusGraphStruct] = []
  63. var carbData: [carbGraphStruct] = []
  64. // Stats-specific data storage (can hold up to 30 days)
  65. var statsBGData: [ShareGlucoseData] = []
  66. var statsBolusData: [bolusGraphStruct] = []
  67. var statsSMBData: [bolusGraphStruct] = []
  68. var statsCarbData: [carbGraphStruct] = []
  69. var statsBasalData: [basalGraphStruct] = []
  70. var overrideGraphData: [DataStructs.overrideStruct] = []
  71. var tempTargetGraphData: [DataStructs.tempTargetStruct] = []
  72. var predictionData: [ShareGlucoseData] = []
  73. var ztPredictionData: [ShareGlucoseData] = []
  74. var iobPredictionData: [ShareGlucoseData] = []
  75. var cobPredictionData: [ShareGlucoseData] = []
  76. var uamPredictionData: [ShareGlucoseData] = []
  77. var openAPSPredBGs: [String: [Double]]?
  78. var openAPSPredUpdatedTime: TimeInterval?
  79. var bgCheckData: [ShareGlucoseData] = []
  80. var suspendGraphData: [DataStructs.timestampOnlyStruct] = []
  81. var resumeGraphData: [DataStructs.timestampOnlyStruct] = []
  82. var sensorStartGraphData: [DataStructs.timestampOnlyStruct] = []
  83. var noteGraphData: [DataStructs.noteStruct] = []
  84. var deviceBatteryData: [DataStructs.batteryStruct] = []
  85. var lastCalDate: Double = 0
  86. var latestLoopStatusString = ""
  87. var latestCOB: CarbMetric?
  88. var latestBasal = ""
  89. var latestPumpVolume: Double = 50.0
  90. var latestIOB: InsulinMetric?
  91. var lastOverrideStartTime: TimeInterval = 0
  92. var lastOverrideEndTime: TimeInterval = 0
  93. var topBG: Double = Storage.shared.minBGScale.value
  94. var topPredictionBG: Double = Storage.shared.minBGScale.value
  95. var lastOverrideAlarm: TimeInterval = 0
  96. var lastTempTargetAlarm: TimeInterval = 0
  97. var lastTempTargetStartTime: TimeInterval = 0
  98. var lastTempTargetEndTime: TimeInterval = 0
  99. // share
  100. var bgDataShare: [ShareGlucoseData] = []
  101. var dexShare: ShareClient?
  102. // calendar setup
  103. let store = EKEventStore()
  104. // Stores the timestamp of the last BG value that was spoken.
  105. var lastSpokenBGDate: TimeInterval = 0
  106. var IsNotLooping = false
  107. let contactImageUpdater = ContactImageUpdater()
  108. let chartModel = BGChartModel()
  109. private var cancellables = Set<AnyCancellable>()
  110. // Loading state management
  111. private var loadingOverlay: UIView?
  112. private var isInitialLoad = true
  113. private var loadingStates: [String: Bool] = [
  114. "bg": false,
  115. "profile": false,
  116. "deviceStatus": false,
  117. ]
  118. private var loadingTimeoutTimer: Timer?
  119. // MARK: - Programmatic UI Setup
  120. private func setupUI() {
  121. view.backgroundColor = .systemBackground
  122. infoManager = InfoManager()
  123. let mainView = MainHomeView(
  124. chartModel: chartModel,
  125. infoManager: infoManager,
  126. statsModel: statsDisplayModel,
  127. onRefresh: { [weak self] in self?.refresh() },
  128. onStatsTap: { [weak self] in self?.statsViewTapped() }
  129. )
  130. let hosting = UIHostingController(rootView: mainView)
  131. // Exclude the keyboard from the hosting controller's safe area. Home has
  132. // no text input, but a stale keyboard frame replayed on foregrounding can
  133. // otherwise compress the layout until a rotation recomputes the safe area.
  134. hosting.safeAreaRegions = .container
  135. hosting.view.translatesAutoresizingMaskIntoConstraints = false
  136. hosting.view.backgroundColor = .clear
  137. addChild(hosting)
  138. view.addSubview(hosting.view)
  139. let safeArea = view.safeAreaLayoutGuide
  140. NSLayoutConstraint.activate([
  141. hosting.view.topAnchor.constraint(equalTo: safeArea.topAnchor),
  142. hosting.view.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor),
  143. hosting.view.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
  144. hosting.view.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor),
  145. ])
  146. hosting.didMove(toParent: self)
  147. mainContentView = hosting.view
  148. }
  149. override func viewDidLoad() {
  150. super.viewDidLoad()
  151. // Adopt the singleton only if it is not already set (normally bootstrap()
  152. // has set it to this same instance). Guarding prevents a stray instance
  153. // from displacing the long-lived engine and spawning a second pipeline.
  154. if MainViewController.shared == nil {
  155. MainViewController.shared = self
  156. }
  157. setupUI()
  158. loadDebugData()
  159. // Migrations run in foreground only — see runMigrationsIfNeeded() for details.
  160. runMigrationsIfNeeded()
  161. // Synchronize info types to ensure arrays are the correct size
  162. synchronizeInfoTypes()
  163. let shareUserName = Storage.shared.shareUserName.value
  164. let sharePassword = Storage.shared.sharePassword.value
  165. let shareServer = Storage.shared.shareServer.value == "US" ?KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue
  166. dexShare = ShareClient(username: shareUserName, password: sharePassword, shareServer: shareServer)
  167. // setup show/hide graphs (first-time setup check)
  168. updateGraphVisibility()
  169. // Apply initial appearance mode
  170. updateAppearance(Storage.shared.appearanceMode.value)
  171. // Trigger foreground and background functions
  172. let notificationCenter = NotificationCenter.default
  173. notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
  174. notificationCenter.addObserver(self, selector: #selector(appCameToForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
  175. // didBecomeActive is used (not willEnterForeground) to ensure applicationState == .active
  176. // when runMigrationsIfNeeded() is called. This catches migrations deferred by a
  177. // background BGAppRefreshTask launch in Before-First-Unlock state.
  178. notificationCenter.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
  179. // Posted by AppDelegate after Storage.reloadAll has refreshed every StorageValue
  180. // following a BFU launch. If we're alive when this fires, our scheduled tasks
  181. // were set up with BFU defaults (url='') and need to be redone.
  182. notificationCenter.addObserver(self, selector: #selector(handleBFUReloadCompleted), name: .bfuReloadCompleted, object: nil)
  183. #if !targetEnvironment(macCatalyst)
  184. notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil)
  185. #endif
  186. // Setup the Graph
  187. if firstGraphLoad {
  188. createGraph()
  189. createSmallBGGraph()
  190. }
  191. // setup display for NS vs Dex
  192. showHideNSDetails()
  193. scheduleAllTasks()
  194. setupNightscoutSocket()
  195. NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil)
  196. /// When an alarm is triggered, go to the snoozer tab
  197. Observable.shared.currentAlarm.$value
  198. .receive(on: DispatchQueue.main)
  199. .compactMap { $0 }
  200. .sink { _ in
  201. let orderedItems = Storage.shared.orderedTabBarItems()
  202. if let index = orderedItems.firstIndex(of: .snoozer) {
  203. Observable.shared.selectedTabIndex.value = index
  204. }
  205. }
  206. .store(in: &cancellables)
  207. Storage.shared.colorBGText.$value
  208. .receive(on: DispatchQueue.main)
  209. .sink { [weak self] _ in
  210. self?.updateBGTextAppearance()
  211. }
  212. .store(in: &cancellables)
  213. // Update appearance when setting changes
  214. Storage.shared.appearanceMode.$value
  215. .receive(on: DispatchQueue.main)
  216. .sink { [weak self] mode in
  217. self?.updateAppearance(mode)
  218. }
  219. .store(in: &cancellables)
  220. Publishers.MergeMany(
  221. Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(),
  222. Storage.shared.useIFCC.$value.map { _ in () }.eraseToAnyPublisher(),
  223. Storage.shared.showGMI.$value.map { _ in () }.eraseToAnyPublisher(),
  224. Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher()
  225. )
  226. .receive(on: DispatchQueue.main)
  227. .sink { [weak self] _ in
  228. self?.updateStats()
  229. }
  230. .store(in: &cancellables)
  231. Storage.shared.timeInRangeModeRaw.$value
  232. .receive(on: DispatchQueue.main)
  233. .sink { [weak self] _ in
  234. self?.updateBGGraphSettings()
  235. self?.updateBGGraph()
  236. }
  237. .store(in: &cancellables)
  238. Storage.shared.screenlockSwitchState.$value
  239. .receive(on: DispatchQueue.main)
  240. .sink { newValue in
  241. UIApplication.shared.isIdleTimerDisabled = newValue
  242. }
  243. .store(in: &cancellables)
  244. Storage.shared.showDisplayName.$value
  245. .receive(on: DispatchQueue.main)
  246. .sink { [weak self] _ in
  247. self?.updateServerText()
  248. }
  249. .store(in: &cancellables)
  250. Storage.shared.speakBG.$value
  251. .receive(on: DispatchQueue.main)
  252. .sink { [weak self] _ in
  253. self?.updateQuickActions()
  254. }
  255. .store(in: &cancellables)
  256. Storage.shared.url.$value
  257. .receive(on: DispatchQueue.main)
  258. .sink { [weak self] _ in
  259. self?.checkAndShowImportButtonIfNeeded()
  260. }
  261. .store(in: &cancellables)
  262. Storage.shared.token.$value
  263. .receive(on: DispatchQueue.main)
  264. .sink { [weak self] _ in
  265. self?.checkAndShowImportButtonIfNeeded()
  266. }
  267. .store(in: &cancellables)
  268. Storage.shared.shareUserName.$value
  269. .receive(on: DispatchQueue.main)
  270. .sink { [weak self] _ in
  271. self?.checkAndShowImportButtonIfNeeded()
  272. }
  273. .store(in: &cancellables)
  274. Storage.shared.sharePassword.$value
  275. .receive(on: DispatchQueue.main)
  276. .sink { [weak self] _ in
  277. self?.checkAndShowImportButtonIfNeeded()
  278. }
  279. .store(in: &cancellables)
  280. Publishers.CombineLatest4(
  281. Storage.shared.remoteApnsKey.$value,
  282. Storage.shared.teamId.$value,
  283. Storage.shared.remoteKeyId.$value,
  284. Storage.shared.lfApnsKey.$value
  285. )
  286. .combineLatest(Storage.shared.lfKeyId.$value)
  287. .map { values, lfKeyId in
  288. APNSCredentialSnapshot(
  289. remoteApnsKey: values.0,
  290. teamId: values.1,
  291. remoteKeyId: values.2,
  292. lfApnsKey: values.3,
  293. lfKeyId: lfKeyId
  294. )
  295. }
  296. .removeDuplicates()
  297. .dropFirst()
  298. .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
  299. .sink { _ in JWTManager.shared.invalidateCache() }
  300. .store(in: &cancellables)
  301. Storage.shared.device.$value
  302. .receive(on: DispatchQueue.main)
  303. .removeDuplicates()
  304. .sink { [weak self] _ in
  305. guard let self = self else { return }
  306. let isTrioDevice = (Storage.shared.device.value == "Trio")
  307. let isLoopDevice = (Storage.shared.device.value == "Loop")
  308. let currentRemoteType = Storage.shared.remoteType.value
  309. // Check if current remote type is invalid for the device
  310. let shouldReset = (currentRemoteType == .loopAPNS && !isLoopDevice) ||
  311. (currentRemoteType == .trc && !isTrioDevice)
  312. if shouldReset {
  313. Storage.shared.remoteType.value = .none
  314. }
  315. }
  316. .store(in: &cancellables)
  317. Storage.shared.device.$value
  318. .receive(on: DispatchQueue.main)
  319. .map { device -> Bool? in
  320. device.isEmpty ? nil : (device == "Loop")
  321. }
  322. .removeDuplicates()
  323. .dropFirst()
  324. .sink { [weak self] isLoop in
  325. guard let isLoop = isLoop else { return }
  326. Storage.shared.predictionDisplayType.value = isLoop ? .lines : .cone
  327. self?.updateOpenAPSPredictionDisplay()
  328. }
  329. .store(in: &cancellables)
  330. updateQuickActions()
  331. speechSynthesizer.delegate = self
  332. // Check configuration and show appropriate UI
  333. if isDataSourceConfigured() {
  334. // Data source configured - show loading overlay
  335. setupLoadingState()
  336. showLoadingOverlay()
  337. } else {
  338. // No data source - hide all data UI and show setup buttons
  339. hideAllDataUI()
  340. isInitialLoad = false
  341. }
  342. checkAndShowImportButtonIfNeeded()
  343. }
  344. // MARK: - Loading Overlay
  345. private func isDataSourceConfigured() -> Bool {
  346. let isNightscoutConfigured = !Storage.shared.url.value.isEmpty
  347. let isDexcomConfigured = !Storage.shared.shareUserName.value.isEmpty && !Storage.shared.sharePassword.value.isEmpty
  348. return isNightscoutConfigured || isDexcomConfigured
  349. }
  350. private func setupLoadingState() {
  351. // If Nightscout is not enabled, mark profile and deviceStatus as loaded
  352. // since we only need BG data from Dexcom Share
  353. if !IsNightscoutEnabled() {
  354. loadingStates["profile"] = true
  355. loadingStates["deviceStatus"] = true
  356. }
  357. }
  358. private func showLoadingOverlay() {
  359. guard loadingOverlay == nil else { return }
  360. // Hide all data UI while loading
  361. hideAllDataUI()
  362. let overlay = UIView(frame: view.bounds)
  363. overlay.backgroundColor = UIColor.systemBackground
  364. overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
  365. let activityIndicator = UIActivityIndicatorView(style: .large)
  366. activityIndicator.translatesAutoresizingMaskIntoConstraints = false
  367. activityIndicator.startAnimating()
  368. let loadingLabel = UILabel()
  369. loadingLabel.translatesAutoresizingMaskIntoConstraints = false
  370. loadingLabel.text = "Loading..."
  371. loadingLabel.textAlignment = .center
  372. loadingLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
  373. loadingLabel.textColor = UIColor.secondaryLabel
  374. overlay.addSubview(activityIndicator)
  375. overlay.addSubview(loadingLabel)
  376. NSLayoutConstraint.activate([
  377. activityIndicator.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
  378. activityIndicator.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: -20),
  379. loadingLabel.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
  380. loadingLabel.topAnchor.constraint(equalTo: activityIndicator.bottomAnchor, constant: 16),
  381. ])
  382. view.addSubview(overlay)
  383. loadingOverlay = overlay
  384. // Set a timeout to hide the loading overlay if data takes too long
  385. loadingTimeoutTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
  386. guard let self = self else { return }
  387. if self.isInitialLoad {
  388. LogManager.shared.log(category: .general, message: "Loading timeout reached, hiding overlay")
  389. self.isInitialLoad = false
  390. self.hideLoadingOverlay()
  391. }
  392. }
  393. }
  394. private func hideLoadingOverlay() {
  395. guard let overlay = loadingOverlay else { return }
  396. // Cancel the timeout timer
  397. loadingTimeoutTimer?.invalidate()
  398. loadingTimeoutTimer = nil
  399. // Show all data UI now that loading is complete
  400. showAllDataUI()
  401. UIView.animate(withDuration: 0.3, animations: {
  402. overlay.alpha = 0
  403. }, completion: { _ in
  404. overlay.removeFromSuperview()
  405. self.loadingOverlay = nil
  406. })
  407. }
  408. func markDataLoaded(_ key: String) {
  409. guard isInitialLoad else { return }
  410. loadingStates[key] = true
  411. // Check if all critical data is loaded
  412. let allLoaded = loadingStates.values.allSatisfy { $0 }
  413. if allLoaded {
  414. isInitialLoad = false
  415. DispatchQueue.main.async {
  416. self.hideLoadingOverlay()
  417. }
  418. }
  419. }
  420. @objc private func navigateOnLAForeground() {
  421. let orderedItems = Storage.shared.orderedTabBarItems()
  422. if Observable.shared.currentAlarm.value != nil,
  423. let snoozerIndex = orderedItems.firstIndex(of: .snoozer)
  424. {
  425. Observable.shared.selectedTabIndex.value = snoozerIndex
  426. } else {
  427. Observable.shared.selectedTabIndex.value = 0
  428. }
  429. }
  430. @objc private func statsViewTapped() {
  431. #if !targetEnvironment(macCatalyst)
  432. let orderedItems = Storage.shared.orderedTabBarItems()
  433. if let statsIndex = orderedItems.firstIndex(of: .stats) {
  434. Observable.shared.selectedTabIndex.value = statsIndex
  435. return
  436. }
  437. #endif
  438. let statsModalView = AggregatedStatsModalView(mainViewController: self)
  439. let hostingController = UIHostingController(rootView: statsModalView)
  440. hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
  441. hostingController.modalPresentationStyle = .fullScreen
  442. present(hostingController, animated: true)
  443. }
  444. // Update the Home Screen Quick Action for toggling the "Speak BG" feature based on the current speakBG setting.
  445. func updateQuickActions() {
  446. let iconName = Storage.shared.speakBG.value ? "pause.circle.fill" : "play.circle.fill"
  447. let iconTemplate = UIApplicationShortcutIcon(systemImageName: iconName)
  448. let shortcut = UIApplicationShortcutItem(type: Bundle.main.bundleIdentifier! + ".toggleSpeakBG",
  449. localizedTitle: "Speak BG",
  450. localizedSubtitle: nil,
  451. icon: iconTemplate,
  452. userInfo: nil)
  453. UIApplication.shared.shortcutItems = [shortcut]
  454. }
  455. deinit {
  456. NotificationCenter.default.removeObserver(self)
  457. }
  458. // Clean all timers and start new ones when refreshing
  459. @objc func refresh() {
  460. LogManager.shared.log(category: .general, message: "Refreshing")
  461. Observable.shared.minAgoText.value = "Refreshing"
  462. scheduleAllTasks()
  463. NightscoutSocketManager.shared.connectIfNeeded()
  464. currentCage = nil
  465. currentSage = nil
  466. currentIage = nil
  467. }
  468. override func viewWillAppear(_ animated: Bool) {
  469. super.viewWillAppear(animated)
  470. UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
  471. if Observable.shared.chartSettingsChanged.value {
  472. updateBGGraphSettings()
  473. Observable.shared.chartSettingsChanged.value = false
  474. }
  475. }
  476. @objc func appMovedToBackground() {
  477. // Allow screen to turn off
  478. UIApplication.shared.isIdleTimerDisabled = false
  479. // We want to always come back to the home screen
  480. Observable.shared.selectedTabIndex.value = 0
  481. if Storage.shared.backgroundRefreshType.value == .silentTune {
  482. backgroundTask.startBackgroundTask()
  483. BackgroundRefreshManager.shared.scheduleRefresh()
  484. }
  485. if Storage.shared.backgroundRefreshType.value != .none {
  486. BackgroundAlertManager.shared.startBackgroundAlert()
  487. }
  488. NightscoutSocketManager.shared.disconnect()
  489. }
  490. // Migrations must only run when UserDefaults is accessible (i.e. after first unlock).
  491. // When the app is launched in the background by BGAppRefreshTask immediately after a
  492. // reboot, the device may be in Before-First-Unlock (BFU) state: UserDefaults files are
  493. // still encrypted, so every read returns the default value (0 / ""). Running migrations
  494. // in that state would overwrite real settings with empty strings.
  495. //
  496. // Strategy: skip migrations if applicationState == .background; call this method again
  497. // from appCameToForeground() so they run on the first foreground after a BFU launch.
  498. func runMigrationsIfNeeded() {
  499. guard UIApplication.shared.applicationState != .background else { return }
  500. // Capture before migrations run: true for existing users, false for fresh installs.
  501. let isExistingUser = Storage.shared.migrationStep.exists
  502. // When adding a new migration step below:
  503. // 1. Bump the `migrationStep` defaultValue in Storage.swift to the new latest step
  504. // number so fresh installs skip every migration.
  505. // 2. Update any other StorageValue defaults in Storage.swift that this new step
  506. // mutates, so a fresh install ends up in the same state as a migrated user.
  507. // Step 2: Released in v3.1.0 (2025-07-21). Can be removed after 2026-07-21.
  508. if Storage.shared.migrationStep.value < 2 {
  509. Storage.shared.migrateStep2()
  510. Storage.shared.migrationStep.value = 2
  511. }
  512. // Step 3: Released in v4.5.0 (2026-02-01). Can be removed after 2027-02-01.
  513. if Storage.shared.migrationStep.value < 3 {
  514. Storage.shared.migrateStep3()
  515. Storage.shared.migrationStep.value = 3
  516. }
  517. // Step 4: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20.
  518. if Storage.shared.migrationStep.value < 4 {
  519. // Existing users need to see the fat/protein order change banner.
  520. // New users never saw the old order, so mark it as already seen.
  521. Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser
  522. Storage.shared.migrationStep.value = 4
  523. }
  524. // Step 5: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20.
  525. if Storage.shared.migrationStep.value < 5 {
  526. Storage.shared.migrateStep5()
  527. Storage.shared.migrationStep.value = 5
  528. }
  529. if Storage.shared.migrationStep.value < 6 {
  530. Storage.shared.migrateStep6()
  531. Storage.shared.migrationStep.value = 6
  532. }
  533. if Storage.shared.migrationStep.value < 7 {
  534. Storage.shared.migrateStep7()
  535. Storage.shared.migrationStep.value = 7
  536. }
  537. if Storage.shared.migrationStep.value < 8 {
  538. Storage.shared.migrateStep8()
  539. Storage.shared.migrationStep.value = 8
  540. }
  541. if Storage.shared.migrationStep.value < 9 {
  542. Storage.shared.migrateStep9()
  543. Storage.shared.migrationStep.value = 9
  544. }
  545. }
  546. @objc func appDidBecomeActive() {
  547. // applicationState == .active is guaranteed here, so the BFU guard in
  548. // runMigrationsIfNeeded() will always pass. Catches the case where viewDidLoad
  549. // ran during a BGAppRefreshTask background launch and deferred migrations.
  550. runMigrationsIfNeeded()
  551. }
  552. @objc func handleBFUReloadCompleted() {
  553. // Show the loading overlay so the user sees feedback during the 2-5s
  554. // while tasks re-run with the now-correct credentials. Tasks scheduled
  555. // before reload used url='' and rescheduled themselves 60s out — reset
  556. // them so they run within their normal 2-5s initial delay.
  557. loadingStates = ["bg": false, "profile": false, "deviceStatus": false]
  558. isInitialLoad = true
  559. setupLoadingState()
  560. showLoadingOverlay()
  561. scheduleAllTasks()
  562. }
  563. @objc func appCameToForeground() {
  564. // BFU recovery (Storage.reloadAll) is driven by AppDelegate; this controller
  565. // reacts via .bfuReloadCompleted in handleBFUReloadCompleted() above.
  566. // reset screenlock state if needed
  567. UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
  568. if Storage.shared.backgroundRefreshType.value == .silentTune {
  569. backgroundTask.stopBackgroundTask()
  570. }
  571. if Storage.shared.backgroundRefreshType.value != .none {
  572. BackgroundAlertManager.shared.stopBackgroundAlert()
  573. }
  574. TaskScheduler.shared.checkTasksNow()
  575. NightscoutSocketManager.shared.connectIfNeeded()
  576. checkAndNotifyVersionStatus()
  577. checkAppExpirationStatus()
  578. }
  579. func checkAndNotifyVersionStatus() {
  580. let versionManager = AppVersionManager()
  581. versionManager.checkForNewVersion { latestVersion, isNewer, isBlacklisted in
  582. let now = Date()
  583. // Check if the current version is blacklisted, or if there is a newer version available
  584. if isBlacklisted {
  585. let lastBlacklistShown = Storage.shared.lastBlacklistNotificationShown.value ?? Date.distantPast
  586. if now.timeIntervalSince(lastBlacklistShown) > 86400 { // 24 hours
  587. self.versionAlert(message: "The current version has a critical issue and should be updated as soon as possible.")
  588. Storage.shared.lastBlacklistNotificationShown.value = now
  589. Storage.shared.lastVersionUpdateNotificationShown.value = now
  590. }
  591. } else if isNewer {
  592. let lastVersionUpdateShown = Storage.shared.lastVersionUpdateNotificationShown.value ?? Date.distantPast
  593. if now.timeIntervalSince(lastVersionUpdateShown) > 1_209_600 { // 2 weeks
  594. self.versionAlert(message: "A new version is available: \(latestVersion ?? "Unknown"). It is recommended to update.")
  595. Storage.shared.lastVersionUpdateNotificationShown.value = now
  596. }
  597. }
  598. }
  599. }
  600. func versionAlert(title: String = "Update Available", message: String) {
  601. DispatchQueue.main.async {
  602. let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
  603. alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
  604. self.present(alert, animated: true)
  605. }
  606. }
  607. func checkAppExpirationStatus() {
  608. let now = Date()
  609. let expirationDate = BuildDetails.default.calculateExpirationDate()
  610. let weekBeforeExpiration = Calendar.current.date(byAdding: .day, value: -7, to: expirationDate)!
  611. if now >= weekBeforeExpiration {
  612. let lastExpirationShown = Storage.shared.lastExpirationNotificationShown.value ?? Date.distantPast
  613. if now.timeIntervalSince(lastExpirationShown) > 86400 { // 24 hours
  614. expirationAlert()
  615. Storage.shared.lastExpirationNotificationShown.value = now
  616. }
  617. }
  618. }
  619. func expirationAlert() {
  620. DispatchQueue.main.async {
  621. 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)
  622. alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
  623. self.present(alert, animated: true)
  624. }
  625. }
  626. override func viewDidAppear(_ animated: Bool) {
  627. super.viewDidAppear(animated)
  628. showHideNSDetails()
  629. // Re-render the graph every time Home appears. The single MainViewController
  630. // is reused across tab/Menu hosts, so its chart view gets re-parented when
  631. // the user switches to Home or moves it between the tab bar and the Menu —
  632. // and Charts does not redraw itself after a re-parent. Rebuilding here keeps
  633. // the curve visible regardless of how Home was reached. It also recovers the
  634. // one-shot firstGraphLoad zoom that is skipped while the view is off-screen
  635. // (force-loaded headless when Home lives in the Menu). Deferred one runloop
  636. // so the nested SwiftUI chart has its final frame; updateBGGraph's own
  637. // width>0 guard skips the initial zoom until it does.
  638. if !bgData.isEmpty {
  639. DispatchQueue.main.async { [weak self] in
  640. self?.updateBGGraph()
  641. }
  642. }
  643. #if !targetEnvironment(macCatalyst)
  644. LiveActivityManager.shared.startFromCurrentState()
  645. #endif
  646. }
  647. func stringFromTimeInterval(interval: TimeInterval) -> String {
  648. let interval = Int(interval)
  649. let minutes = (interval / 60) % 60
  650. let hours = (interval / 3600)
  651. return String(format: "%02d:%02d", hours, minutes)
  652. }
  653. func showHideNSDetails() {
  654. // Info table visibility is handled reactively by MainHomeView.
  655. }
  656. func updateBadge(val: Int) {
  657. if Storage.shared.appBadge.value {
  658. let latestBG = String(val)
  659. UIApplication.shared.applicationIconBadgeNumber = Int(Localizer.removePeriodAndCommaForBadge(Localizer.toDisplayUnits(latestBG))) ?? val
  660. } else {
  661. UIApplication.shared.applicationIconBadgeNumber = 0
  662. }
  663. }
  664. func updateBGTextAppearance() {
  665. if bgData.count > 0 {
  666. let latestBG = bgData[bgData.count - 1].sgv
  667. if Storage.shared.colorBGText.value {
  668. let thresholds = UnitSettingsStore.shared.effectiveThresholds()
  669. if Double(latestBG) >= thresholds.high {
  670. Observable.shared.bgTextColor.value = .yellow
  671. } else if Double(latestBG) <= thresholds.low {
  672. Observable.shared.bgTextColor.value = .red
  673. } else {
  674. Observable.shared.bgTextColor.value = .green
  675. }
  676. } else {
  677. Observable.shared.bgTextColor.value = .primary
  678. }
  679. }
  680. }
  681. func updateAppearance(_ mode: AppearanceMode) {
  682. guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
  683. let window = windowScene.windows.first else { return }
  684. let style: UIUserInterfaceStyle
  685. switch mode {
  686. case .light:
  687. style = .light
  688. case .dark:
  689. style = .dark
  690. case .system:
  691. // Use .unspecified to follow system
  692. style = .unspecified
  693. }
  694. // Update this view controller
  695. overrideUserInterfaceStyle = style
  696. // Update the window (affects the entire app including modals)
  697. window.overrideUserInterfaceStyle = style
  698. }
  699. func bgDirectionGraphic(_ value: String) -> String {
  700. 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":"-"]
  701. graphics: [String: String] = ["Flat": "→", "DoubleUp": "↑↑", "SingleUp": "↑", "FortyFiveUp": "↗", "FortyFiveDown": "↘︎", "SingleDown": "↓", "DoubleDown": "↓↓", "None": "-", "NONE": "-", "NOT COMPUTABLE": "-", "RATE OUT OF RANGE": "-", "": "-"]
  702. return graphics[value]!
  703. }
  704. func writeCalendar() {
  705. store.requestCalendarAccess { granted, error in
  706. if !granted {
  707. LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))")
  708. return
  709. }
  710. self.processCalendarUpdates()
  711. }
  712. }
  713. func processCalendarUpdates() {
  714. if Storage.shared.calendarIdentifier.value == "" { return }
  715. if bgData.count < 1 { return }
  716. // This lets us fire the method to write Min Ago entries only once a minute starting after 6 minutes but allows new readings through
  717. let now = dateTimeUtils.getNowTimeIntervalUTC()
  718. let newestBGDate = bgData[bgData.count - 1].date
  719. if lastCalDate == newestBGDate {
  720. if (now - lastCalendarWriteAttemptTime) < 60 || (now - newestBGDate) < 360 {
  721. return
  722. }
  723. }
  724. // Create Event info
  725. var deltaBG = 0 // protect index out of bounds
  726. if bgData.count > 1 {
  727. deltaBG = bgData[bgData.count - 1].sgv - bgData[bgData.count - 2].sgv as Int
  728. }
  729. let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - bgData[bgData.count - 1].date) / 60
  730. var deltaString = ""
  731. if deltaBG < 0 {
  732. deltaString = Localizer.toDisplayUnits(String(deltaBG))
  733. } else {
  734. deltaString = "+" + Localizer.toDisplayUnits(String(deltaBG))
  735. }
  736. let direction = bgDirectionGraphic(bgData[bgData.count - 1].direction ?? "")
  737. let eventStartDate = Date(timeIntervalSince1970: bgData[bgData.count - 1].date)
  738. var eventEndDate = eventStartDate.addingTimeInterval(60 * 10)
  739. var eventTitle = Storage.shared.watchLine1.value
  740. if Storage.shared.watchLine2.value.count > 1 {
  741. eventTitle += "\n" + Storage.shared.watchLine2.value
  742. }
  743. eventTitle = eventTitle.replacingOccurrences(of: "%BG%", with: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)))
  744. eventTitle = eventTitle.replacingOccurrences(of: "%DIRECTION%", with: direction)
  745. eventTitle = eventTitle.replacingOccurrences(of: "%DELTA%", with: deltaString)
  746. if currentOverride != 1.0 {
  747. let val = Int(currentOverride * 100)
  748. // let overrideText = String(format:"%f1", self.currentOverride*100)
  749. let text = String(val) + "%"
  750. eventTitle = eventTitle.replacingOccurrences(of: "%OVERRIDE%", with: text)
  751. } else {
  752. eventTitle = eventTitle.replacingOccurrences(of: "%OVERRIDE%", with: "")
  753. }
  754. eventTitle = eventTitle.replacingOccurrences(of: "%LOOP%", with: latestLoopStatusString)
  755. var minAgo = ""
  756. if deltaTime > 9 {
  757. // write old BG reading and continue pushing out end date to show last entry
  758. minAgo = String(Int(deltaTime)) + " min"
  759. eventEndDate = eventStartDate.addingTimeInterval((60 * 10) + (deltaTime * 60))
  760. }
  761. var basal = "~"
  762. if latestBasal != "" {
  763. basal = latestBasal
  764. }
  765. eventTitle = eventTitle.replacingOccurrences(of: "%MINAGO%", with: minAgo)
  766. eventTitle = eventTitle.replacingOccurrences(of: "%IOB%", with: latestIOB?.formattedValue() ?? "0")
  767. eventTitle = eventTitle.replacingOccurrences(of: "%COB%", with: latestCOB?.formattedValue() ?? "0")
  768. eventTitle = eventTitle.replacingOccurrences(of: "%BASAL%", with: basal)
  769. // Delete Events from last 2 hours and 2 hours in future
  770. let deleteStartDate = Date().addingTimeInterval(-60 * 60 * 2)
  771. let deleteEndDate = Date().addingTimeInterval(60 * 60 * 2)
  772. // guard solves for some ios upgrades removing the calendar
  773. guard let deleteCalendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value) as? EKCalendar else { return }
  774. let predicate2 = store.predicateForEvents(withStart: deleteStartDate, end: deleteEndDate, calendars: [deleteCalendar])
  775. let eVDelete = store.events(matching: predicate2) as [EKEvent]?
  776. if eVDelete != nil {
  777. for i in eVDelete! {
  778. do {
  779. try store.remove(i, span: EKSpan.thisEvent, commit: true)
  780. } catch {
  781. LogManager.shared.log(category: .calendar, message: "Failed to remove calendar event: \(error.localizedDescription)")
  782. }
  783. }
  784. }
  785. // Write New Event
  786. let event = EKEvent(eventStore: store)
  787. event.title = eventTitle
  788. event.startDate = eventStartDate
  789. event.endDate = eventEndDate
  790. event.calendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value)
  791. do {
  792. try store.save(event, span: .thisEvent, commit: true)
  793. lastCalendarWriteAttemptTime = now
  794. lastCalDate = bgData[bgData.count - 1].date
  795. } catch {
  796. let msg = "Error storing to calendar: \(error.localizedDescription) (\(error))"
  797. LogManager.shared.log(category: .calendar, message: msg)
  798. }
  799. }
  800. func userNotificationCenter(_: UNUserNotificationCenter, didReceive _: UNNotificationResponse, withCompletionHandler _: @escaping () -> Void) {}
  801. func calculateMaxBgGraphValue() -> Float {
  802. return max(Float(topBG), Float(topPredictionBG))
  803. }
  804. func loadDebugData() {
  805. struct DebugData: Codable {
  806. let debug: Bool?
  807. let url: String?
  808. let token: String?
  809. }
  810. let fileManager = FileManager.default
  811. let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("debugData.json")
  812. if fileManager.fileExists(atPath: url.path) {
  813. do {
  814. let data = try Data(contentsOf: url)
  815. let decoder = JSONDecoder()
  816. decoder.dateDecodingStrategy = .iso8601
  817. let debugData = try decoder.decode(DebugData.self, from: data)
  818. LogManager.shared.log(category: .alarm, message: "Loaded DebugData from \(url.path)", isDebug: true)
  819. if let debug = debugData.debug {
  820. Observable.shared.debug.value = debug
  821. }
  822. if let url = debugData.url {
  823. Storage.shared.url.value = url
  824. }
  825. if let token = debugData.token {
  826. Storage.shared.token.value = token
  827. }
  828. } catch {
  829. LogManager.shared.log(category: .alarm, message: "Failed to load DebugData: \(error)", isDebug: true)
  830. }
  831. }
  832. }
  833. private func synchronizeInfoTypes() {
  834. var sortArray = Storage.shared.infoSort.value
  835. var visibleArray = Storage.shared.infoVisible.value
  836. // Current valid indices based on InfoType
  837. let currentValidIndices = InfoType.allCases.map { $0.rawValue }
  838. // Add missing indices to sortArray
  839. for index in currentValidIndices {
  840. if !sortArray.contains(index) {
  841. sortArray.append(index)
  842. }
  843. }
  844. // Remove deprecated indices
  845. sortArray = sortArray.filter { currentValidIndices.contains($0) }
  846. // Ensure visibleArray is updated with new entries
  847. if visibleArray.count < currentValidIndices.count {
  848. for i in visibleArray.count ..< currentValidIndices.count {
  849. visibleArray.append(InfoType(rawValue: i)?.defaultVisible ?? false)
  850. }
  851. }
  852. // Trim excess elements if there are more than needed
  853. if visibleArray.count > currentValidIndices.count {
  854. visibleArray = Array(visibleArray.prefix(currentValidIndices.count))
  855. }
  856. Storage.shared.infoSort.value = sortArray
  857. Storage.shared.infoVisible.value = visibleArray
  858. }
  859. // MARK: - First Time Setup
  860. private func checkAndShowImportButtonIfNeeded() {
  861. // Check if this is first-time setup (no data source configured)
  862. let isFirstTimeSetup = !isDataSourceConfigured()
  863. if isFirstTimeSetup {
  864. setupFirstTimeButtons()
  865. hideAllDataUI()
  866. // Hide loading overlay if it's showing and mark as not loading
  867. if loadingOverlay != nil {
  868. isInitialLoad = false
  869. hideLoadingOverlay()
  870. }
  871. } else {
  872. hideFirstTimeButtons()
  873. // Only show data UI if we're not in initial loading state
  874. if !isInitialLoad || loadingOverlay == nil {
  875. showAllDataUI()
  876. }
  877. }
  878. }
  879. private func setupFirstTimeButtons() {
  880. // Create Setup Nightscout button
  881. if setupNightscoutButton == nil {
  882. setupNightscoutButton = UIButton(type: .system)
  883. setupNightscoutButton.setTitle("Setup Nightscout", for: .normal)
  884. setupNightscoutButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
  885. setupNightscoutButton.backgroundColor = UIColor.systemBlue
  886. setupNightscoutButton.setTitleColor(.white, for: .normal)
  887. setupNightscoutButton.layer.cornerRadius = 12
  888. setupNightscoutButton.layer.shadowColor = UIColor.black.cgColor
  889. setupNightscoutButton.layer.shadowOffset = CGSize(width: 0, height: 2)
  890. setupNightscoutButton.layer.shadowOpacity = 0.3
  891. setupNightscoutButton.layer.shadowRadius = 4
  892. setupNightscoutButton.addTarget(self, action: #selector(setupNightscoutTapped), for: .touchUpInside)
  893. view.addSubview(setupNightscoutButton)
  894. setupNightscoutButton.translatesAutoresizingMaskIntoConstraints = false
  895. NSLayoutConstraint.activate([
  896. setupNightscoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  897. setupNightscoutButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -30),
  898. setupNightscoutButton.widthAnchor.constraint(equalToConstant: 200),
  899. setupNightscoutButton.heightAnchor.constraint(equalToConstant: 50),
  900. ])
  901. }
  902. // Create Setup Dexcom Share button
  903. if setupDexcomButton == nil {
  904. setupDexcomButton = UIButton(type: .system)
  905. setupDexcomButton.setTitle("Setup Dexcom Share", for: .normal)
  906. setupDexcomButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
  907. setupDexcomButton.backgroundColor = UIColor.systemGreen
  908. setupDexcomButton.setTitleColor(.white, for: .normal)
  909. setupDexcomButton.layer.cornerRadius = 12
  910. setupDexcomButton.layer.shadowColor = UIColor.black.cgColor
  911. setupDexcomButton.layer.shadowOffset = CGSize(width: 0, height: 2)
  912. setupDexcomButton.layer.shadowOpacity = 0.3
  913. setupDexcomButton.layer.shadowRadius = 4
  914. setupDexcomButton.addTarget(self, action: #selector(setupDexcomTapped), for: .touchUpInside)
  915. view.addSubview(setupDexcomButton)
  916. setupDexcomButton.translatesAutoresizingMaskIntoConstraints = false
  917. NSLayoutConstraint.activate([
  918. setupDexcomButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  919. setupDexcomButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 30),
  920. setupDexcomButton.widthAnchor.constraint(equalToConstant: 200),
  921. setupDexcomButton.heightAnchor.constraint(equalToConstant: 50),
  922. ])
  923. }
  924. setupNightscoutButton.isHidden = false
  925. setupDexcomButton.isHidden = false
  926. }
  927. private func hideFirstTimeButtons() {
  928. setupNightscoutButton?.isHidden = true
  929. setupDexcomButton?.isHidden = true
  930. }
  931. @objc private func setupNightscoutTapped() {
  932. let navController = UINavigationController()
  933. let nightscoutSettingsView = NightscoutSettingsView(viewModel: .init(), usesModalCloseButton: true, onContinueToUnits: { [weak navController] in
  934. let unitsView = UnitsOnboardingView {
  935. navController?.dismiss(animated: true)
  936. }
  937. let unitsController = UIHostingController(rootView: unitsView)
  938. let style = Storage.shared.appearanceMode.value.userInterfaceStyle
  939. unitsController.overrideUserInterfaceStyle = style
  940. navController?.pushViewController(unitsController, animated: true)
  941. }, onImportSettings: { [weak navController] in
  942. let importSettingsView = ImportExportSettingsView()
  943. let importSettingsController = UIHostingController(rootView: importSettingsView)
  944. let style = Storage.shared.appearanceMode.value.userInterfaceStyle
  945. importSettingsController.overrideUserInterfaceStyle = style
  946. navController?.pushViewController(importSettingsController, animated: true)
  947. })
  948. let hostingController = UIHostingController(rootView: nightscoutSettingsView)
  949. // Apply appearance mode
  950. let style = Storage.shared.appearanceMode.value.userInterfaceStyle
  951. hostingController.overrideUserInterfaceStyle = style
  952. navController.overrideUserInterfaceStyle = style
  953. navController.setViewControllers([hostingController], animated: false)
  954. hostingController.navigationItem.rightBarButtonItem = makeCloseBarButtonItem()
  955. navController.modalPresentationStyle = .pageSheet
  956. present(navController, animated: true)
  957. }
  958. @objc private func setupDexcomTapped() {
  959. let navController = UINavigationController()
  960. let dexcomSettingsView = DexcomSettingsView(viewModel: .init(), usesModalCloseButton: true, onContinueToUnits: { [weak navController] in
  961. let unitsView = UnitsOnboardingView {
  962. navController?.dismiss(animated: true)
  963. }
  964. let unitsController = UIHostingController(rootView: unitsView)
  965. let style = Storage.shared.appearanceMode.value.userInterfaceStyle
  966. unitsController.overrideUserInterfaceStyle = style
  967. navController?.pushViewController(unitsController, animated: true)
  968. })
  969. let hostingController = UIHostingController(rootView: dexcomSettingsView)
  970. // Apply appearance mode
  971. let style = Storage.shared.appearanceMode.value.userInterfaceStyle
  972. hostingController.overrideUserInterfaceStyle = style
  973. navController.overrideUserInterfaceStyle = style
  974. navController.setViewControllers([hostingController], animated: false)
  975. hostingController.navigationItem.rightBarButtonItem = makeCloseBarButtonItem()
  976. navController.modalPresentationStyle = .pageSheet
  977. present(navController, animated: true)
  978. }
  979. private func makeCloseBarButtonItem() -> UIBarButtonItem {
  980. let button = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(dismissModal))
  981. button.tintColor = .systemBlue
  982. return button
  983. }
  984. private func hideAllDataUI() {
  985. mainContentView?.isHidden = true
  986. }
  987. private func showAllDataUI() {
  988. mainContentView?.isHidden = false
  989. }
  990. private func updateGraphVisibility() {
  991. // Graph and component visibility is handled reactively by MainHomeView.
  992. // This method now only manages the overall content visibility for first-time setup.
  993. if !isDataSourceConfigured() {
  994. mainContentView?.isHidden = true
  995. }
  996. }
  997. @objc private func importSettingsButtonTapped() {
  998. presentImportSettingsView()
  999. }
  1000. private func presentImportSettingsView() {
  1001. let importExportView = ImportExportSettingsView()
  1002. let hostingController = UIHostingController(rootView: importExportView)
  1003. hostingController.modalPresentationStyle = .pageSheet
  1004. present(hostingController, animated: true)
  1005. }
  1006. @objc private func dismissModal() {
  1007. dismiss(animated: true) { [weak self] in
  1008. guard let self = self else { return }
  1009. // Check if user just configured a data source
  1010. if self.isDataSourceConfigured(), self.loadingOverlay == nil {
  1011. // Reset loading states for fresh load
  1012. self.loadingStates = [
  1013. "bg": false,
  1014. "profile": false,
  1015. "deviceStatus": false,
  1016. ]
  1017. self.isInitialLoad = true
  1018. // Show loading overlay and trigger refresh
  1019. self.setupLoadingState()
  1020. self.showLoadingOverlay()
  1021. self.refresh()
  1022. }
  1023. }
  1024. }
  1025. }
  1026. extension MainViewController: AVSpeechSynthesizerDelegate {
  1027. func speechSynthesizer(_: AVSpeechSynthesizer, didFinish _: AVSpeechUtterance) {
  1028. let appState = UIApplication.shared.applicationState
  1029. let isSilentTuneMode = Storage.shared.backgroundRefreshType.value == .silentTune
  1030. if isSilentTuneMode, appState == .background {
  1031. LogManager.shared.log(category: .general, message: "Silent tune active in background; not deactivating session.", isDebug: true)
  1032. } else {
  1033. do {
  1034. try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
  1035. LogManager.shared.log(category: .general, message: "Audio session deactivated after speech.", isDebug: true)
  1036. } catch {
  1037. LogManager.shared.log(category: .alarm, message: "Failed to deactivate audio session: \(error)")
  1038. }
  1039. }
  1040. }
  1041. }