MainViewController.swift 52 KB

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