MainViewController.swift 51 KB

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