MainViewController.swift 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290
  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. // Clear prediction for both Loop or OpenAPS
  469. // Check if Loop prediction data exists and clear it if necessary
  470. if !predictionData.isEmpty {
  471. predictionData.removeAll()
  472. updatePredictionGraph()
  473. }
  474. // Check if OpenAPS prediction data exists and clear it if necessary
  475. let openAPSDataIndices = [12, 13, 14, 15]
  476. for dataIndex in openAPSDataIndices {
  477. let mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet
  478. let smallChart = BGChartFull.lineData!.dataSets[dataIndex] as! LineChartDataSet
  479. if !mainChart.entries.isEmpty || !smallChart.entries.isEmpty {
  480. updatePredictionGraphGeneric(
  481. dataIndex: dataIndex,
  482. predictionData: [],
  483. chartLabel: "",
  484. color: UIColor.systemGray
  485. )
  486. }
  487. }
  488. Observable.shared.minAgoText.value = "Refreshing"
  489. scheduleAllTasks()
  490. NightscoutSocketManager.shared.connectIfNeeded()
  491. currentCage = nil
  492. currentSage = nil
  493. currentIage = nil
  494. }
  495. override func viewWillAppear(_ animated: Bool) {
  496. super.viewWillAppear(animated)
  497. UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
  498. if Observable.shared.chartSettingsChanged.value {
  499. updateBGGraphSettings()
  500. Observable.shared.chartSettingsChanged.value = false
  501. }
  502. }
  503. @objc func appMovedToBackground() {
  504. // Allow screen to turn off
  505. UIApplication.shared.isIdleTimerDisabled = false
  506. // We want to always come back to the home screen
  507. Observable.shared.selectedTabIndex.value = 0
  508. if Storage.shared.backgroundRefreshType.value == .silentTune {
  509. backgroundTask.startBackgroundTask()
  510. BackgroundRefreshManager.shared.scheduleRefresh()
  511. }
  512. if Storage.shared.backgroundRefreshType.value != .none {
  513. BackgroundAlertManager.shared.startBackgroundAlert()
  514. }
  515. NightscoutSocketManager.shared.disconnect()
  516. }
  517. // Migrations must only run when UserDefaults is accessible (i.e. after first unlock).
  518. // When the app is launched in the background by BGAppRefreshTask immediately after a
  519. // reboot, the device may be in Before-First-Unlock (BFU) state: UserDefaults files are
  520. // still encrypted, so every read returns the default value (0 / ""). Running migrations
  521. // in that state would overwrite real settings with empty strings.
  522. //
  523. // Strategy: skip migrations if applicationState == .background; call this method again
  524. // from appCameToForeground() so they run on the first foreground after a BFU launch.
  525. func runMigrationsIfNeeded() {
  526. guard UIApplication.shared.applicationState != .background else { return }
  527. // Capture before migrations run: true for existing users, false for fresh installs.
  528. let isExistingUser = Storage.shared.migrationStep.exists
  529. // When adding a new migration step below:
  530. // 1. Bump the `migrationStep` defaultValue in Storage.swift to the new latest step
  531. // number so fresh installs skip every migration.
  532. // 2. Update any other StorageValue defaults in Storage.swift that this new step
  533. // mutates, so a fresh install ends up in the same state as a migrated user.
  534. // Step 1: Released in v3.0.0 (2025-07-07). Can be removed after 2026-07-07.
  535. if Storage.shared.migrationStep.value < 1 {
  536. Storage.shared.migrateStep1()
  537. Storage.shared.migrationStep.value = 1
  538. }
  539. // Step 2: Released in v3.1.0 (2025-07-21). Can be removed after 2026-07-21.
  540. if Storage.shared.migrationStep.value < 2 {
  541. Storage.shared.migrateStep2()
  542. Storage.shared.migrationStep.value = 2
  543. }
  544. // Step 3: Released in v4.5.0 (2026-02-01). Can be removed after 2027-02-01.
  545. if Storage.shared.migrationStep.value < 3 {
  546. Storage.shared.migrateStep3()
  547. Storage.shared.migrationStep.value = 3
  548. }
  549. // Step 4: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20.
  550. if Storage.shared.migrationStep.value < 4 {
  551. // Existing users need to see the fat/protein order change banner.
  552. // New users never saw the old order, so mark it as already seen.
  553. Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser
  554. Storage.shared.migrationStep.value = 4
  555. }
  556. // Step 5: Released in v5.0.0 (2026-03-20). Can be removed after 2027-03-20.
  557. if Storage.shared.migrationStep.value < 5 {
  558. Storage.shared.migrateStep5()
  559. Storage.shared.migrationStep.value = 5
  560. }
  561. if Storage.shared.migrationStep.value < 6 {
  562. Storage.shared.migrateStep6()
  563. Storage.shared.migrationStep.value = 6
  564. }
  565. if Storage.shared.migrationStep.value < 7 {
  566. Storage.shared.migrateStep7()
  567. Storage.shared.migrationStep.value = 7
  568. }
  569. if Storage.shared.migrationStep.value < 8 {
  570. Storage.shared.migrateStep8()
  571. Storage.shared.migrationStep.value = 8
  572. }
  573. if Storage.shared.migrationStep.value < 9 {
  574. Storage.shared.migrateStep9()
  575. Storage.shared.migrationStep.value = 9
  576. }
  577. }
  578. @objc func appDidBecomeActive() {
  579. // applicationState == .active is guaranteed here, so the BFU guard in
  580. // runMigrationsIfNeeded() will always pass. Catches the case where viewDidLoad
  581. // ran during a BGAppRefreshTask background launch and deferred migrations.
  582. runMigrationsIfNeeded()
  583. }
  584. @objc func handleBFUReloadCompleted() {
  585. // Show the loading overlay so the user sees feedback during the 2-5s
  586. // while tasks re-run with the now-correct credentials. Tasks scheduled
  587. // before reload used url='' and rescheduled themselves 60s out — reset
  588. // them so they run within their normal 2-5s initial delay.
  589. loadingStates = ["bg": false, "profile": false, "deviceStatus": false]
  590. isInitialLoad = true
  591. setupLoadingState()
  592. showLoadingOverlay()
  593. scheduleAllTasks()
  594. }
  595. @objc func appCameToForeground() {
  596. // BFU recovery (Storage.reloadAll) is driven by AppDelegate; this controller
  597. // reacts via .bfuReloadCompleted in handleBFUReloadCompleted() above.
  598. // reset screenlock state if needed
  599. UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
  600. if Storage.shared.backgroundRefreshType.value == .silentTune {
  601. backgroundTask.stopBackgroundTask()
  602. }
  603. if Storage.shared.backgroundRefreshType.value != .none {
  604. BackgroundAlertManager.shared.stopBackgroundAlert()
  605. }
  606. TaskScheduler.shared.checkTasksNow()
  607. NightscoutSocketManager.shared.connectIfNeeded()
  608. checkAndNotifyVersionStatus()
  609. checkAppExpirationStatus()
  610. }
  611. func checkAndNotifyVersionStatus() {
  612. let versionManager = AppVersionManager()
  613. versionManager.checkForNewVersion { latestVersion, isNewer, isBlacklisted in
  614. let now = Date()
  615. // Check if the current version is blacklisted, or if there is a newer version available
  616. if isBlacklisted {
  617. let lastBlacklistShown = Storage.shared.lastBlacklistNotificationShown.value ?? Date.distantPast
  618. if now.timeIntervalSince(lastBlacklistShown) > 86400 { // 24 hours
  619. self.versionAlert(message: "The current version has a critical issue and should be updated as soon as possible.")
  620. Storage.shared.lastBlacklistNotificationShown.value = now
  621. Storage.shared.lastVersionUpdateNotificationShown.value = now
  622. }
  623. } else if isNewer {
  624. let lastVersionUpdateShown = Storage.shared.lastVersionUpdateNotificationShown.value ?? Date.distantPast
  625. if now.timeIntervalSince(lastVersionUpdateShown) > 1_209_600 { // 2 weeks
  626. self.versionAlert(message: "A new version is available: \(latestVersion ?? "Unknown"). It is recommended to update.")
  627. Storage.shared.lastVersionUpdateNotificationShown.value = now
  628. }
  629. }
  630. }
  631. }
  632. func versionAlert(title: String = "Update Available", message: String) {
  633. DispatchQueue.main.async {
  634. let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
  635. alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
  636. self.present(alert, animated: true)
  637. }
  638. }
  639. func checkAppExpirationStatus() {
  640. let now = Date()
  641. let expirationDate = BuildDetails.default.calculateExpirationDate()
  642. let weekBeforeExpiration = Calendar.current.date(byAdding: .day, value: -7, to: expirationDate)!
  643. if now >= weekBeforeExpiration {
  644. let lastExpirationShown = Storage.shared.lastExpirationNotificationShown.value ?? Date.distantPast
  645. if now.timeIntervalSince(lastExpirationShown) > 86400 { // 24 hours
  646. expirationAlert()
  647. Storage.shared.lastExpirationNotificationShown.value = now
  648. }
  649. }
  650. }
  651. func expirationAlert() {
  652. DispatchQueue.main.async {
  653. 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)
  654. alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
  655. self.present(alert, animated: true)
  656. }
  657. }
  658. override func viewDidAppear(_ animated: Bool) {
  659. super.viewDidAppear(animated)
  660. showHideNSDetails()
  661. // Re-render the graph every time Home appears. The single MainViewController
  662. // is reused across tab/Menu hosts, so its chart view gets re-parented when
  663. // the user switches to Home or moves it between the tab bar and the Menu —
  664. // and Charts does not redraw itself after a re-parent. Rebuilding here keeps
  665. // the curve visible regardless of how Home was reached. It also recovers the
  666. // one-shot firstGraphLoad zoom that is skipped while the view is off-screen
  667. // (force-loaded headless when Home lives in the Menu). Deferred one runloop
  668. // so the nested SwiftUI chart has its final frame; updateBGGraph's own
  669. // width>0 guard skips the initial zoom until it does.
  670. if !bgData.isEmpty {
  671. DispatchQueue.main.async { [weak self] in
  672. self?.updateBGGraph()
  673. }
  674. }
  675. #if !targetEnvironment(macCatalyst)
  676. LiveActivityManager.shared.startFromCurrentState()
  677. #endif
  678. }
  679. func stringFromTimeInterval(interval: TimeInterval) -> String {
  680. let interval = Int(interval)
  681. let minutes = (interval / 60) % 60
  682. let hours = (interval / 3600)
  683. return String(format: "%02d:%02d", hours, minutes)
  684. }
  685. func showHideNSDetails() {
  686. // Info table visibility is handled reactively by MainHomeView.
  687. }
  688. func updateBadge(val: Int) {
  689. if Storage.shared.appBadge.value {
  690. let latestBG = String(val)
  691. UIApplication.shared.applicationIconBadgeNumber = Int(Localizer.removePeriodAndCommaForBadge(Localizer.toDisplayUnits(latestBG))) ?? val
  692. } else {
  693. UIApplication.shared.applicationIconBadgeNumber = 0
  694. }
  695. }
  696. func updateBGTextAppearance() {
  697. if bgData.count > 0 {
  698. let latestBG = bgData[bgData.count - 1].sgv
  699. if Storage.shared.colorBGText.value {
  700. let thresholds = UnitSettingsStore.shared.effectiveThresholds()
  701. if Double(latestBG) >= thresholds.high {
  702. Observable.shared.bgTextColor.value = .yellow
  703. } else if Double(latestBG) <= thresholds.low {
  704. Observable.shared.bgTextColor.value = .red
  705. } else {
  706. Observable.shared.bgTextColor.value = .green
  707. }
  708. } else {
  709. Observable.shared.bgTextColor.value = .primary
  710. }
  711. }
  712. }
  713. func updateAppearance(_ mode: AppearanceMode) {
  714. guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
  715. let window = windowScene.windows.first else { return }
  716. let style: UIUserInterfaceStyle
  717. switch mode {
  718. case .light:
  719. style = .light
  720. case .dark:
  721. style = .dark
  722. case .system:
  723. // Use .unspecified to follow system
  724. style = .unspecified
  725. }
  726. // Update this view controller
  727. overrideUserInterfaceStyle = style
  728. // Update the window (affects the entire app including modals)
  729. window.overrideUserInterfaceStyle = style
  730. }
  731. func bgDirectionGraphic(_ value: String) -> String {
  732. 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":"-"]
  733. graphics: [String: String] = ["Flat": "→", "DoubleUp": "↑↑", "SingleUp": "↑", "FortyFiveUp": "↗", "FortyFiveDown": "↘︎", "SingleDown": "↓", "DoubleDown": "↓↓", "None": "-", "NONE": "-", "NOT COMPUTABLE": "-", "RATE OUT OF RANGE": "-", "": "-"]
  734. return graphics[value]!
  735. }
  736. func writeCalendar() {
  737. store.requestCalendarAccess { granted, error in
  738. if !granted {
  739. LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))")
  740. return
  741. }
  742. self.processCalendarUpdates()
  743. }
  744. }
  745. func processCalendarUpdates() {
  746. if Storage.shared.calendarIdentifier.value == "" { return }
  747. if bgData.count < 1 { return }
  748. // This lets us fire the method to write Min Ago entries only once a minute starting after 6 minutes but allows new readings through
  749. let now = dateTimeUtils.getNowTimeIntervalUTC()
  750. let newestBGDate = bgData[bgData.count - 1].date
  751. if lastCalDate == newestBGDate {
  752. if (now - lastCalendarWriteAttemptTime) < 60 || (now - newestBGDate) < 360 {
  753. return
  754. }
  755. }
  756. // Create Event info
  757. var deltaBG = 0 // protect index out of bounds
  758. if bgData.count > 1 {
  759. deltaBG = bgData[bgData.count - 1].sgv - bgData[bgData.count - 2].sgv as Int
  760. }
  761. let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - bgData[bgData.count - 1].date) / 60
  762. var deltaString = ""
  763. if deltaBG < 0 {
  764. deltaString = Localizer.toDisplayUnits(String(deltaBG))
  765. } else {
  766. deltaString = "+" + Localizer.toDisplayUnits(String(deltaBG))
  767. }
  768. let direction = bgDirectionGraphic(bgData[bgData.count - 1].direction ?? "")
  769. let eventStartDate = Date(timeIntervalSince1970: bgData[bgData.count - 1].date)
  770. var eventEndDate = eventStartDate.addingTimeInterval(60 * 10)
  771. var eventTitle = Storage.shared.watchLine1.value
  772. if Storage.shared.watchLine2.value.count > 1 {
  773. eventTitle += "\n" + Storage.shared.watchLine2.value
  774. }
  775. eventTitle = eventTitle.replacingOccurrences(of: "%BG%", with: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)))
  776. eventTitle = eventTitle.replacingOccurrences(of: "%DIRECTION%", with: direction)
  777. eventTitle = eventTitle.replacingOccurrences(of: "%DELTA%", with: deltaString)
  778. if currentOverride != 1.0 {
  779. let val = Int(currentOverride * 100)
  780. // let overrideText = String(format:"%f1", self.currentOverride*100)
  781. let text = String(val) + "%"
  782. eventTitle = eventTitle.replacingOccurrences(of: "%OVERRIDE%", with: text)
  783. } else {
  784. eventTitle = eventTitle.replacingOccurrences(of: "%OVERRIDE%", with: "")
  785. }
  786. eventTitle = eventTitle.replacingOccurrences(of: "%LOOP%", with: latestLoopStatusString)
  787. var minAgo = ""
  788. if deltaTime > 9 {
  789. // write old BG reading and continue pushing out end date to show last entry
  790. minAgo = String(Int(deltaTime)) + " min"
  791. eventEndDate = eventStartDate.addingTimeInterval((60 * 10) + (deltaTime * 60))
  792. }
  793. var basal = "~"
  794. if latestBasal != "" {
  795. basal = latestBasal
  796. }
  797. eventTitle = eventTitle.replacingOccurrences(of: "%MINAGO%", with: minAgo)
  798. eventTitle = eventTitle.replacingOccurrences(of: "%IOB%", with: latestIOB?.formattedValue() ?? "0")
  799. eventTitle = eventTitle.replacingOccurrences(of: "%COB%", with: latestCOB?.formattedValue() ?? "0")
  800. eventTitle = eventTitle.replacingOccurrences(of: "%BASAL%", with: basal)
  801. // Delete Events from last 2 hours and 2 hours in future
  802. let deleteStartDate = Date().addingTimeInterval(-60 * 60 * 2)
  803. let deleteEndDate = Date().addingTimeInterval(60 * 60 * 2)
  804. // guard solves for some ios upgrades removing the calendar
  805. guard let deleteCalendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value) as? EKCalendar else { return }
  806. let predicate2 = store.predicateForEvents(withStart: deleteStartDate, end: deleteEndDate, calendars: [deleteCalendar])
  807. let eVDelete = store.events(matching: predicate2) as [EKEvent]?
  808. if eVDelete != nil {
  809. for i in eVDelete! {
  810. do {
  811. try store.remove(i, span: EKSpan.thisEvent, commit: true)
  812. } catch {
  813. LogManager.shared.log(category: .calendar, message: "Failed to remove calendar event: \(error.localizedDescription)")
  814. }
  815. }
  816. }
  817. // Write New Event
  818. let event = EKEvent(eventStore: store)
  819. event.title = eventTitle
  820. event.startDate = eventStartDate
  821. event.endDate = eventEndDate
  822. event.calendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value)
  823. do {
  824. try store.save(event, span: .thisEvent, commit: true)
  825. lastCalendarWriteAttemptTime = now
  826. lastCalDate = bgData[bgData.count - 1].date
  827. } catch {
  828. let msg = "Error storing to calendar: \(error.localizedDescription) (\(error))"
  829. LogManager.shared.log(category: .calendar, message: msg)
  830. }
  831. }
  832. func userNotificationCenter(_: UNUserNotificationCenter, didReceive _: UNNotificationResponse, withCompletionHandler _: @escaping () -> Void) {}
  833. // User has scrolled the chart
  834. func chartTranslated(_: ChartViewBase, dX _: CGFloat, dY _: CGFloat) {
  835. let isViewingLatestData = abs(BGChart.highestVisibleX - BGChart.chartXMax) < 0.001
  836. if isViewingLatestData {
  837. autoScrollPauseUntil = nil // User is back at the latest data, allow auto-scrolling
  838. } else {
  839. autoScrollPauseUntil = Date().addingTimeInterval(5 * 60) // User is viewing historical data, pause auto-scrolling
  840. }
  841. }
  842. func calculateMaxBgGraphValue() -> Float {
  843. return max(Float(topBG), Float(topPredictionBG))
  844. }
  845. func loadDebugData() {
  846. struct DebugData: Codable {
  847. let debug: Bool?
  848. let url: String?
  849. let token: String?
  850. }
  851. let fileManager = FileManager.default
  852. let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("debugData.json")
  853. if fileManager.fileExists(atPath: url.path) {
  854. do {
  855. let data = try Data(contentsOf: url)
  856. let decoder = JSONDecoder()
  857. decoder.dateDecodingStrategy = .iso8601
  858. let debugData = try decoder.decode(DebugData.self, from: data)
  859. LogManager.shared.log(category: .alarm, message: "Loaded DebugData from \(url.path)", isDebug: true)
  860. if let debug = debugData.debug {
  861. Observable.shared.debug.value = debug
  862. }
  863. if let url = debugData.url {
  864. Storage.shared.url.value = url
  865. }
  866. if let token = debugData.token {
  867. Storage.shared.token.value = token
  868. }
  869. } catch {
  870. LogManager.shared.log(category: .alarm, message: "Failed to load DebugData: \(error)", isDebug: true)
  871. }
  872. }
  873. }
  874. private func synchronizeInfoTypes() {
  875. var sortArray = Storage.shared.infoSort.value
  876. var visibleArray = Storage.shared.infoVisible.value
  877. // Current valid indices based on InfoType
  878. let currentValidIndices = InfoType.allCases.map { $0.rawValue }
  879. // Add missing indices to sortArray
  880. for index in currentValidIndices {
  881. if !sortArray.contains(index) {
  882. sortArray.append(index)
  883. }
  884. }
  885. // Remove deprecated indices
  886. sortArray = sortArray.filter { currentValidIndices.contains($0) }
  887. // Ensure visibleArray is updated with new entries
  888. if visibleArray.count < currentValidIndices.count {
  889. for i in visibleArray.count ..< currentValidIndices.count {
  890. visibleArray.append(InfoType(rawValue: i)?.defaultVisible ?? false)
  891. }
  892. }
  893. // Trim excess elements if there are more than needed
  894. if visibleArray.count > currentValidIndices.count {
  895. visibleArray = Array(visibleArray.prefix(currentValidIndices.count))
  896. }
  897. Storage.shared.infoSort.value = sortArray
  898. Storage.shared.infoVisible.value = visibleArray
  899. }
  900. // MARK: - First Time Setup
  901. private func checkAndShowImportButtonIfNeeded() {
  902. // Check if this is first-time setup (no data source configured)
  903. let isFirstTimeSetup = !isDataSourceConfigured()
  904. if isFirstTimeSetup {
  905. setupFirstTimeButtons()
  906. hideAllDataUI()
  907. // Hide loading overlay if it's showing and mark as not loading
  908. if loadingOverlay != nil {
  909. isInitialLoad = false
  910. hideLoadingOverlay()
  911. }
  912. } else {
  913. hideFirstTimeButtons()
  914. // Only show data UI if we're not in initial loading state
  915. if !isInitialLoad || loadingOverlay == nil {
  916. showAllDataUI()
  917. }
  918. }
  919. }
  920. private func setupFirstTimeButtons() {
  921. // Create Setup Nightscout button
  922. if setupNightscoutButton == nil {
  923. setupNightscoutButton = UIButton(type: .system)
  924. setupNightscoutButton.setTitle("Setup Nightscout", for: .normal)
  925. setupNightscoutButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
  926. setupNightscoutButton.backgroundColor = UIColor.systemBlue
  927. setupNightscoutButton.setTitleColor(.white, for: .normal)
  928. setupNightscoutButton.layer.cornerRadius = 12
  929. setupNightscoutButton.layer.shadowColor = UIColor.black.cgColor
  930. setupNightscoutButton.layer.shadowOffset = CGSize(width: 0, height: 2)
  931. setupNightscoutButton.layer.shadowOpacity = 0.3
  932. setupNightscoutButton.layer.shadowRadius = 4
  933. setupNightscoutButton.addTarget(self, action: #selector(setupNightscoutTapped), for: .touchUpInside)
  934. view.addSubview(setupNightscoutButton)
  935. setupNightscoutButton.translatesAutoresizingMaskIntoConstraints = false
  936. NSLayoutConstraint.activate([
  937. setupNightscoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  938. setupNightscoutButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -30),
  939. setupNightscoutButton.widthAnchor.constraint(equalToConstant: 200),
  940. setupNightscoutButton.heightAnchor.constraint(equalToConstant: 50),
  941. ])
  942. }
  943. // Create Setup Dexcom Share button
  944. if setupDexcomButton == nil {
  945. setupDexcomButton = UIButton(type: .system)
  946. setupDexcomButton.setTitle("Setup Dexcom Share", for: .normal)
  947. setupDexcomButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
  948. setupDexcomButton.backgroundColor = UIColor.systemGreen
  949. setupDexcomButton.setTitleColor(.white, for: .normal)
  950. setupDexcomButton.layer.cornerRadius = 12
  951. setupDexcomButton.layer.shadowColor = UIColor.black.cgColor
  952. setupDexcomButton.layer.shadowOffset = CGSize(width: 0, height: 2)
  953. setupDexcomButton.layer.shadowOpacity = 0.3
  954. setupDexcomButton.layer.shadowRadius = 4
  955. setupDexcomButton.addTarget(self, action: #selector(setupDexcomTapped), for: .touchUpInside)
  956. view.addSubview(setupDexcomButton)
  957. setupDexcomButton.translatesAutoresizingMaskIntoConstraints = false
  958. NSLayoutConstraint.activate([
  959. setupDexcomButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  960. setupDexcomButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 30),
  961. setupDexcomButton.widthAnchor.constraint(equalToConstant: 200),
  962. setupDexcomButton.heightAnchor.constraint(equalToConstant: 50),
  963. ])
  964. }
  965. setupNightscoutButton.isHidden = false
  966. setupDexcomButton.isHidden = false
  967. }
  968. private func hideFirstTimeButtons() {
  969. setupNightscoutButton?.isHidden = true
  970. setupDexcomButton?.isHidden = true
  971. }
  972. @objc private func setupNightscoutTapped() {
  973. let navController = UINavigationController()
  974. let nightscoutSettingsView = NightscoutSettingsView(viewModel: .init(), usesModalCloseButton: true, onContinueToUnits: { [weak navController] in
  975. let unitsView = UnitsOnboardingView {
  976. navController?.dismiss(animated: true)
  977. }
  978. let unitsController = UIHostingController(rootView: unitsView)
  979. let style = Storage.shared.appearanceMode.value.userInterfaceStyle
  980. unitsController.overrideUserInterfaceStyle = style
  981. navController?.pushViewController(unitsController, animated: true)
  982. }, onImportSettings: { [weak navController] in
  983. let importSettingsView = ImportExportSettingsView()
  984. let importSettingsController = UIHostingController(rootView: importSettingsView)
  985. let style = Storage.shared.appearanceMode.value.userInterfaceStyle
  986. importSettingsController.overrideUserInterfaceStyle = style
  987. navController?.pushViewController(importSettingsController, animated: true)
  988. })
  989. let hostingController = UIHostingController(rootView: nightscoutSettingsView)
  990. // Apply appearance mode
  991. let style = Storage.shared.appearanceMode.value.userInterfaceStyle
  992. hostingController.overrideUserInterfaceStyle = style
  993. navController.overrideUserInterfaceStyle = style
  994. navController.setViewControllers([hostingController], animated: false)
  995. hostingController.navigationItem.rightBarButtonItem = makeCloseBarButtonItem()
  996. navController.modalPresentationStyle = .pageSheet
  997. present(navController, animated: true)
  998. }
  999. @objc private func setupDexcomTapped() {
  1000. let navController = UINavigationController()
  1001. let dexcomSettingsView = DexcomSettingsView(viewModel: .init(), usesModalCloseButton: true, onContinueToUnits: { [weak navController] in
  1002. let unitsView = UnitsOnboardingView {
  1003. navController?.dismiss(animated: true)
  1004. }
  1005. let unitsController = UIHostingController(rootView: unitsView)
  1006. let style = Storage.shared.appearanceMode.value.userInterfaceStyle
  1007. unitsController.overrideUserInterfaceStyle = style
  1008. navController?.pushViewController(unitsController, animated: true)
  1009. })
  1010. let hostingController = UIHostingController(rootView: dexcomSettingsView)
  1011. // Apply appearance mode
  1012. let style = Storage.shared.appearanceMode.value.userInterfaceStyle
  1013. hostingController.overrideUserInterfaceStyle = style
  1014. navController.overrideUserInterfaceStyle = style
  1015. navController.setViewControllers([hostingController], animated: false)
  1016. hostingController.navigationItem.rightBarButtonItem = makeCloseBarButtonItem()
  1017. navController.modalPresentationStyle = .pageSheet
  1018. present(navController, animated: true)
  1019. }
  1020. private func makeCloseBarButtonItem() -> UIBarButtonItem {
  1021. let button = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(dismissModal))
  1022. button.tintColor = .systemBlue
  1023. return button
  1024. }
  1025. private func hideAllDataUI() {
  1026. mainContentView?.isHidden = true
  1027. }
  1028. private func showAllDataUI() {
  1029. mainContentView?.isHidden = false
  1030. }
  1031. private func updateGraphVisibility() {
  1032. // Graph and component visibility is handled reactively by MainHomeView.
  1033. // This method now only manages the overall content visibility for first-time setup.
  1034. if !isDataSourceConfigured() {
  1035. mainContentView?.isHidden = true
  1036. }
  1037. }
  1038. @objc private func importSettingsButtonTapped() {
  1039. presentImportSettingsView()
  1040. }
  1041. private func presentImportSettingsView() {
  1042. let importExportView = ImportExportSettingsView()
  1043. let hostingController = UIHostingController(rootView: importExportView)
  1044. hostingController.modalPresentationStyle = .pageSheet
  1045. present(hostingController, animated: true)
  1046. }
  1047. @objc private func dismissModal() {
  1048. dismiss(animated: true) { [weak self] in
  1049. guard let self = self else { return }
  1050. // Check if user just configured a data source
  1051. if self.isDataSourceConfigured(), self.loadingOverlay == nil {
  1052. // Reset loading states for fresh load
  1053. self.loadingStates = [
  1054. "bg": false,
  1055. "profile": false,
  1056. "deviceStatus": false,
  1057. ]
  1058. self.isInitialLoad = true
  1059. // Show loading overlay and trigger refresh
  1060. self.setupLoadingState()
  1061. self.showLoadingOverlay()
  1062. self.refresh()
  1063. }
  1064. }
  1065. }
  1066. }
  1067. extension MainViewController: AVSpeechSynthesizerDelegate {
  1068. func speechSynthesizer(_: AVSpeechSynthesizer, didFinish _: AVSpeechUtterance) {
  1069. let appState = UIApplication.shared.applicationState
  1070. let isSilentTuneMode = Storage.shared.backgroundRefreshType.value == .silentTune
  1071. if isSilentTuneMode, appState == .background {
  1072. LogManager.shared.log(category: .general, message: "Silent tune active in background; not deactivating session.", isDebug: true)
  1073. } else {
  1074. do {
  1075. try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
  1076. LogManager.shared.log(category: .general, message: "Audio session deactivated after speech.", isDebug: true)
  1077. } catch {
  1078. LogManager.shared.log(category: .alarm, message: "Failed to deactivate audio session: \(error)")
  1079. }
  1080. }
  1081. }
  1082. }