MainViewController.swift 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345
  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 enum SecondTab {
  16. case remote
  17. case alarms
  18. }
  19. class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate {
  20. @IBOutlet var BGText: UILabel!
  21. @IBOutlet var DeltaText: UILabel!
  22. @IBOutlet var DirectionText: UILabel!
  23. @IBOutlet var BGChart: LineChartView!
  24. @IBOutlet var BGChartFull: LineChartView!
  25. @IBOutlet var MinAgoText: UILabel!
  26. @IBOutlet var infoTable: UITableView!
  27. @IBOutlet var Console: UITableViewCell!
  28. @IBOutlet var DragBar: UIImageView!
  29. @IBOutlet var PredictionLabel: UILabel!
  30. @IBOutlet var LoopStatusLabel: UILabel!
  31. @IBOutlet var statsPieChart: PieChartView!
  32. @IBOutlet var statsLowPercent: UILabel!
  33. @IBOutlet var statsInRangePercent: UILabel!
  34. @IBOutlet var statsHighPercent: UILabel!
  35. @IBOutlet var statsAvgBG: UILabel!
  36. @IBOutlet var statsEstA1C: UILabel!
  37. @IBOutlet var statsStdDev: UILabel!
  38. @IBOutlet var serverText: UILabel!
  39. @IBOutlet var statsView: UIView!
  40. @IBOutlet var smallGraphHeightConstraint: NSLayoutConstraint!
  41. var refreshScrollView: UIScrollView!
  42. var refreshControl: UIRefreshControl!
  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. var overrideGraphData: [DataStructs.overrideStruct] = []
  67. var tempTargetGraphData: [DataStructs.tempTargetStruct] = []
  68. var predictionData: [ShareGlucoseData] = []
  69. var bgCheckData: [ShareGlucoseData] = []
  70. var suspendGraphData: [DataStructs.timestampOnlyStruct] = []
  71. var resumeGraphData: [DataStructs.timestampOnlyStruct] = []
  72. var sensorStartGraphData: [DataStructs.timestampOnlyStruct] = []
  73. var noteGraphData: [DataStructs.noteStruct] = []
  74. var chartData = LineChartData()
  75. var deviceBatteryData: [DataStructs.batteryStruct] = []
  76. var lastCalDate: Double = 0
  77. var latestLoopStatusString = ""
  78. var latestCOB: CarbMetric?
  79. var latestBasal = ""
  80. var latestPumpVolume: Double = 50.0
  81. var latestIOB: InsulinMetric?
  82. var lastOverrideStartTime: TimeInterval = 0
  83. var lastOverrideEndTime: TimeInterval = 0
  84. var topBG: Double = Storage.shared.minBGScale.value
  85. var topPredictionBG: Double = Storage.shared.minBGScale.value
  86. var lastOverrideAlarm: TimeInterval = 0
  87. var lastTempTargetAlarm: TimeInterval = 0
  88. var lastTempTargetStartTime: TimeInterval = 0
  89. var lastTempTargetEndTime: TimeInterval = 0
  90. // share
  91. var bgDataShare: [ShareGlucoseData] = []
  92. var dexShare: ShareClient?
  93. // calendar setup
  94. let store = EKEventStore()
  95. // Stores the timestamp of the last BG value that was spoken.
  96. var lastSpokenBGDate: TimeInterval = 0
  97. var autoScrollPauseUntil: Date?
  98. var IsNotLooping = false
  99. let contactImageUpdater = ContactImageUpdater()
  100. private var cancellables = Set<AnyCancellable>()
  101. // Loading state management
  102. private var loadingOverlay: UIView?
  103. private var isInitialLoad = true
  104. private var loadingStates: [String: Bool] = [
  105. "bg": false,
  106. "profile": false,
  107. "deviceStatus": false,
  108. ]
  109. private var loadingTimeoutTimer: Timer?
  110. override func viewDidLoad() {
  111. super.viewDidLoad()
  112. loadDebugData()
  113. if Storage.shared.migrationStep.value < 1 {
  114. Storage.shared.migrateStep1()
  115. Storage.shared.migrationStep.value = 1
  116. }
  117. if Storage.shared.migrationStep.value < 2 {
  118. Storage.shared.migrateStep2()
  119. Storage.shared.migrationStep.value = 2
  120. }
  121. // Synchronize info types to ensure arrays are the correct size
  122. synchronizeInfoTypes()
  123. infoTable.rowHeight = 21
  124. infoTable.dataSource = self
  125. infoTable.tableFooterView = UIView(frame: .zero)
  126. infoTable.bounces = false
  127. infoTable.addBorder(toSide: .Left, withColor: UIColor.darkGray.cgColor, andThickness: 2)
  128. infoManager = InfoManager(tableView: infoTable)
  129. smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value)
  130. view.layoutIfNeeded()
  131. let shareUserName = Storage.shared.shareUserName.value
  132. let sharePassword = Storage.shared.sharePassword.value
  133. let shareServer = Storage.shared.shareServer.value == "US" ?KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue
  134. dexShare = ShareClient(username: shareUserName, password: sharePassword, shareServer: shareServer)
  135. // setup show/hide small graph and stats
  136. updateGraphVisibility()
  137. statsView.isHidden = !Storage.shared.showStats.value
  138. BGChart.delegate = self
  139. BGChartFull.delegate = self
  140. if Storage.shared.forceDarkMode.value {
  141. overrideUserInterfaceStyle = .dark
  142. tabBarController?.overrideUserInterfaceStyle = .dark
  143. }
  144. // Trigger foreground and background functions
  145. let notificationCenter = NotificationCenter.default
  146. notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
  147. notificationCenter.addObserver(self, selector: #selector(appCameToForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
  148. // Setup the Graph
  149. if firstGraphLoad {
  150. createGraph()
  151. createSmallBGGraph()
  152. }
  153. // setup display for NS vs Dex
  154. showHideNSDetails()
  155. scheduleAllTasks()
  156. // Set up refreshScrollView for BGText
  157. refreshScrollView = UIScrollView()
  158. refreshScrollView.translatesAutoresizingMaskIntoConstraints = false
  159. refreshScrollView.alwaysBounceVertical = true
  160. view.addSubview(refreshScrollView)
  161. NSLayoutConstraint.activate([
  162. refreshScrollView.leadingAnchor.constraint(equalTo: BGText.leadingAnchor),
  163. refreshScrollView.trailingAnchor.constraint(equalTo: BGText.trailingAnchor),
  164. refreshScrollView.topAnchor.constraint(equalTo: BGText.topAnchor),
  165. refreshScrollView.bottomAnchor.constraint(equalTo: BGText.bottomAnchor),
  166. ])
  167. refreshControl = UIRefreshControl()
  168. refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged)
  169. refreshScrollView.addSubview(refreshControl)
  170. // Add this line to prevent scrolling in other directions
  171. refreshScrollView.alwaysBounceVertical = true
  172. refreshScrollView.delegate = self
  173. NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil)
  174. Observable.shared.bgText.$value
  175. .receive(on: DispatchQueue.main)
  176. .sink { [weak self] newValue in
  177. self?.BGText.text = newValue
  178. }
  179. .store(in: &cancellables)
  180. Observable.shared.directionText.$value
  181. .receive(on: DispatchQueue.main)
  182. .sink { [weak self] newValue in
  183. self?.DirectionText.text = newValue
  184. }
  185. .store(in: &cancellables)
  186. Observable.shared.deltaText.$value
  187. .receive(on: DispatchQueue.main)
  188. .sink { [weak self] newValue in
  189. self?.DeltaText.text = newValue
  190. }
  191. .store(in: &cancellables)
  192. /// When an alarm is triggered, go to the snoozer tab
  193. Observable.shared.currentAlarm.$value
  194. .receive(on: DispatchQueue.main)
  195. .compactMap { $0 }
  196. .sink { [weak self] _ in
  197. if let snoozerIndex = self?.getSnoozerTabIndex() {
  198. self?.tabBarController?.selectedIndex = snoozerIndex
  199. }
  200. }
  201. .store(in: &cancellables)
  202. Storage.shared.colorBGText.$value
  203. .receive(on: DispatchQueue.main)
  204. .sink { [weak self] _ in
  205. self?.setBGTextColor()
  206. }
  207. .store(in: &cancellables)
  208. Storage.shared.showStats.$value
  209. .receive(on: DispatchQueue.main)
  210. .sink { [weak self] _ in
  211. self?.statsView.isHidden = !Storage.shared.showStats.value
  212. }
  213. .store(in: &cancellables)
  214. Storage.shared.useIFCC.$value
  215. .receive(on: DispatchQueue.main)
  216. .sink { [weak self] _ in
  217. self?.updateStats()
  218. }
  219. .store(in: &cancellables)
  220. Storage.shared.showSmallGraph.$value
  221. .receive(on: DispatchQueue.main)
  222. .sink { [weak self] _ in
  223. self?.updateGraphVisibility()
  224. }
  225. .store(in: &cancellables)
  226. Storage.shared.screenlockSwitchState.$value
  227. .receive(on: DispatchQueue.main)
  228. .sink { newValue in
  229. UIApplication.shared.isIdleTimerDisabled = newValue
  230. }
  231. .store(in: &cancellables)
  232. Storage.shared.showDisplayName.$value
  233. .receive(on: DispatchQueue.main)
  234. .sink { [weak self] _ in
  235. self?.updateServerText()
  236. }
  237. .store(in: &cancellables)
  238. Storage.shared.speakBG.$value
  239. .receive(on: DispatchQueue.main)
  240. .sink { [weak self] _ in
  241. self?.updateQuickActions()
  242. }
  243. .store(in: &cancellables)
  244. Storage.shared.alarmsPosition.$value
  245. .receive(on: DispatchQueue.main)
  246. .sink { [weak self] _ in
  247. self?.setupTabBar()
  248. }
  249. .store(in: &cancellables)
  250. Storage.shared.remotePosition.$value
  251. .receive(on: DispatchQueue.main)
  252. .sink { [weak self] _ in
  253. self?.setupTabBar()
  254. }
  255. .store(in: &cancellables)
  256. Storage.shared.nightscoutPosition.$value
  257. .receive(on: DispatchQueue.main)
  258. .sink { [weak self] _ in
  259. self?.setupTabBar()
  260. }
  261. .store(in: &cancellables)
  262. Storage.shared.url.$value
  263. .receive(on: DispatchQueue.main)
  264. .sink { [weak self] _ in
  265. self?.updateNightscoutTabState()
  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. Storage.shared.apnsKey.$value
  288. .receive(on: DispatchQueue.main)
  289. .removeDuplicates()
  290. .sink { _ in
  291. JWTManager.shared.invalidateCache()
  292. }
  293. .store(in: &cancellables)
  294. Storage.shared.teamId.$value
  295. .receive(on: DispatchQueue.main)
  296. .removeDuplicates()
  297. .sink { _ in
  298. JWTManager.shared.invalidateCache()
  299. }
  300. .store(in: &cancellables)
  301. Storage.shared.keyId.$value
  302. .receive(on: DispatchQueue.main)
  303. .removeDuplicates()
  304. .sink { _ in
  305. JWTManager.shared.invalidateCache()
  306. }
  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. (currentRemoteType == .nightscout && !isTrioDevice)
  320. if shouldReset {
  321. Storage.shared.remoteType.value = .none
  322. }
  323. }
  324. .store(in: &cancellables)
  325. updateQuickActions()
  326. setupTabBar()
  327. speechSynthesizer.delegate = self
  328. // Check configuration and show appropriate UI
  329. if isDataSourceConfigured() {
  330. // Data source configured - show loading overlay
  331. setupLoadingState()
  332. showLoadingOverlay()
  333. } else {
  334. // No data source - hide all data UI and show setup buttons
  335. hideAllDataUI()
  336. isInitialLoad = false
  337. }
  338. checkAndShowImportButtonIfNeeded()
  339. }
  340. // MARK: - Loading Overlay
  341. private func isDataSourceConfigured() -> Bool {
  342. let isNightscoutConfigured = !Storage.shared.url.value.isEmpty
  343. let isDexcomConfigured = !Storage.shared.shareUserName.value.isEmpty && !Storage.shared.sharePassword.value.isEmpty
  344. return isNightscoutConfigured || isDexcomConfigured
  345. }
  346. private func setupLoadingState() {
  347. // If Nightscout is not enabled, mark profile and deviceStatus as loaded
  348. // since we only need BG data from Dexcom Share
  349. if !IsNightscoutEnabled() {
  350. loadingStates["profile"] = true
  351. loadingStates["deviceStatus"] = true
  352. }
  353. }
  354. private func showLoadingOverlay() {
  355. guard loadingOverlay == nil else { return }
  356. // Hide all data UI while loading
  357. hideAllDataUI()
  358. let overlay = UIView(frame: view.bounds)
  359. overlay.backgroundColor = UIColor.systemBackground
  360. overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
  361. let activityIndicator = UIActivityIndicatorView(style: .large)
  362. activityIndicator.translatesAutoresizingMaskIntoConstraints = false
  363. activityIndicator.startAnimating()
  364. let loadingLabel = UILabel()
  365. loadingLabel.translatesAutoresizingMaskIntoConstraints = false
  366. loadingLabel.text = "Loading..."
  367. loadingLabel.textAlignment = .center
  368. loadingLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
  369. loadingLabel.textColor = UIColor.secondaryLabel
  370. overlay.addSubview(activityIndicator)
  371. overlay.addSubview(loadingLabel)
  372. NSLayoutConstraint.activate([
  373. activityIndicator.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
  374. activityIndicator.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: -20),
  375. loadingLabel.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
  376. loadingLabel.topAnchor.constraint(equalTo: activityIndicator.bottomAnchor, constant: 16),
  377. ])
  378. view.addSubview(overlay)
  379. loadingOverlay = overlay
  380. // Set a timeout to hide the loading overlay if data takes too long
  381. loadingTimeoutTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
  382. guard let self = self else { return }
  383. if self.isInitialLoad {
  384. LogManager.shared.log(category: .general, message: "Loading timeout reached, hiding overlay")
  385. self.isInitialLoad = false
  386. self.hideLoadingOverlay()
  387. }
  388. }
  389. }
  390. private func hideLoadingOverlay() {
  391. guard let overlay = loadingOverlay else { return }
  392. // Cancel the timeout timer
  393. loadingTimeoutTimer?.invalidate()
  394. loadingTimeoutTimer = nil
  395. // Show all data UI now that loading is complete
  396. showAllDataUI()
  397. UIView.animate(withDuration: 0.3, animations: {
  398. overlay.alpha = 0
  399. }, completion: { _ in
  400. overlay.removeFromSuperview()
  401. self.loadingOverlay = nil
  402. })
  403. }
  404. func markDataLoaded(_ key: String) {
  405. guard isInitialLoad else { return }
  406. loadingStates[key] = true
  407. // Check if all critical data is loaded
  408. let allLoaded = loadingStates.values.allSatisfy { $0 }
  409. if allLoaded {
  410. isInitialLoad = false
  411. DispatchQueue.main.async {
  412. self.hideLoadingOverlay()
  413. }
  414. }
  415. }
  416. private func setupTabBar() {
  417. guard let tabBarController = tabBarController else { return }
  418. // Store current selection before making changes
  419. let currentSelectedIndex = tabBarController.selectedIndex
  420. // Check if we need to handle More tab disappearing
  421. let wasInMoreTab = currentSelectedIndex == 4 &&
  422. tabBarController.viewControllers?.last is MoreMenuViewController
  423. let willHaveMoreTab = hasItemsInMore()
  424. // If currently in More tab and it's going away, we need to handle this carefully
  425. if wasInMoreTab, !willHaveMoreTab {
  426. // First, dismiss any modals that might be open
  427. if let presented = tabBarController.presentedViewController {
  428. presented.dismiss(animated: false) { [weak self] in
  429. // After dismissal, rebuild tabs with home selected
  430. self?.rebuildTabs(tabBarController: tabBarController,
  431. willHaveMoreTab: willHaveMoreTab,
  432. selectedIndex: 0)
  433. }
  434. return
  435. }
  436. }
  437. // For all other cases, rebuild tabs normally
  438. rebuildTabs(tabBarController: tabBarController,
  439. willHaveMoreTab: willHaveMoreTab,
  440. selectedIndex: wasInMoreTab && !willHaveMoreTab ? 0 : currentSelectedIndex)
  441. }
  442. private func rebuildTabs(tabBarController: UITabBarController,
  443. willHaveMoreTab: Bool,
  444. selectedIndex: Int)
  445. {
  446. let storyboard = UIStoryboard(name: "Main", bundle: nil)
  447. var viewControllers: [UIViewController] = []
  448. // Tab 0 - Home (always)
  449. viewControllers.append(self)
  450. // Tab 1 - Dynamic based on what's assigned to position2
  451. if let vc = createViewController(for: .position2, storyboard: storyboard) {
  452. viewControllers.append(vc)
  453. }
  454. // Tab 2 - Snoozer (always)
  455. let snoozerVC = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController")
  456. snoozerVC.tabBarItem = UITabBarItem(title: "Snoozer", image: UIImage(systemName: "zzz"), tag: 2)
  457. viewControllers.append(snoozerVC)
  458. // Tab 3 - Dynamic based on what's assigned to position4
  459. if let vc = createViewController(for: .position4, storyboard: storyboard) {
  460. viewControllers.append(vc)
  461. }
  462. // Tab 4 - Settings or More
  463. if willHaveMoreTab {
  464. let moreVC = MoreMenuViewController()
  465. moreVC.tabBarItem = UITabBarItem(title: "More", image: UIImage(systemName: "ellipsis"), tag: 4)
  466. viewControllers.append(moreVC)
  467. } else {
  468. let settingsVC = SettingsViewController()
  469. settingsVC.tabBarItem = UITabBarItem(title: "Settings", image: UIImage(systemName: "gear"), tag: 4)
  470. viewControllers.append(settingsVC)
  471. }
  472. // Update view controllers without animation to prevent glitches
  473. tabBarController.setViewControllers(viewControllers, animated: false)
  474. // Restore selection if valid, otherwise default to home
  475. let safeIndex = min(selectedIndex, viewControllers.count - 1)
  476. tabBarController.selectedIndex = max(0, safeIndex)
  477. updateNightscoutTabState()
  478. }
  479. private func getSnoozerTabIndex() -> Int? {
  480. guard let tabBarController = tabBarController,
  481. let viewControllers = tabBarController.viewControllers else { return nil }
  482. for (index, vc) in viewControllers.enumerated() {
  483. if let _ = vc as? SnoozerViewController {
  484. return index
  485. }
  486. }
  487. return nil
  488. }
  489. private func createViewController(for position: TabPosition, storyboard: UIStoryboard) -> UIViewController? {
  490. if Storage.shared.alarmsPosition.value == position {
  491. let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController")
  492. vc.tabBarItem = UITabBarItem(title: "Alarms", image: UIImage(systemName: "alarm"), tag: position == .position2 ? 1 : 3)
  493. return vc
  494. }
  495. if Storage.shared.remotePosition.value == position {
  496. let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController")
  497. vc.tabBarItem = UITabBarItem(title: "Remote", image: UIImage(systemName: "antenna.radiowaves.left.and.right"), tag: position == .position2 ? 1 : 3)
  498. return vc
  499. }
  500. if Storage.shared.nightscoutPosition.value == position {
  501. let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController")
  502. vc.tabBarItem = UITabBarItem(title: "Nightscout", image: UIImage(systemName: "safari"), tag: position == .position2 ? 1 : 3)
  503. return vc
  504. }
  505. return nil
  506. }
  507. private func hasItemsInMore() -> Bool {
  508. return Storage.shared.alarmsPosition.value == .more ||
  509. Storage.shared.remotePosition.value == .more ||
  510. Storage.shared.nightscoutPosition.value == .more
  511. }
  512. // Update the Home Screen Quick Action for toggling the "Speak BG" feature based on the current speakBG setting.
  513. func updateQuickActions() {
  514. let iconName = Storage.shared.speakBG.value ? "pause.circle.fill" : "play.circle.fill"
  515. let iconTemplate = UIApplicationShortcutIcon(systemImageName: iconName)
  516. let shortcut = UIApplicationShortcutItem(type: Bundle.main.bundleIdentifier! + ".toggleSpeakBG",
  517. localizedTitle: "Speak BG",
  518. localizedSubtitle: nil,
  519. icon: iconTemplate,
  520. userInfo: nil)
  521. UIApplication.shared.shortcutItems = [shortcut]
  522. }
  523. deinit {
  524. NotificationCenter.default.removeObserver(self, name: NSNotification.Name("refresh"), object: nil)
  525. }
  526. // Clean all timers and start new ones when refreshing
  527. @objc func refresh() {
  528. LogManager.shared.log(category: .general, message: "Refreshing")
  529. // Clear prediction for both Loop or OpenAPS
  530. // Check if Loop prediction data exists and clear it if necessary
  531. if !predictionData.isEmpty {
  532. predictionData.removeAll()
  533. updatePredictionGraph()
  534. }
  535. // Check if OpenAPS prediction data exists and clear it if necessary
  536. let openAPSDataIndices = [12, 13, 14, 15]
  537. for dataIndex in openAPSDataIndices {
  538. let mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet
  539. let smallChart = BGChartFull.lineData!.dataSets[dataIndex] as! LineChartDataSet
  540. if !mainChart.entries.isEmpty || !smallChart.entries.isEmpty {
  541. updatePredictionGraphGeneric(
  542. dataIndex: dataIndex,
  543. predictionData: [],
  544. chartLabel: "",
  545. color: UIColor.systemGray
  546. )
  547. }
  548. }
  549. MinAgoText.text = "Refreshing"
  550. Observable.shared.minAgoText.value = "Refreshing"
  551. scheduleAllTasks()
  552. currentCage = nil
  553. currentSage = nil
  554. currentIage = nil
  555. refreshControl.endRefreshing()
  556. }
  557. // Scroll down BGText when refreshing
  558. func scrollViewDidScroll(_ scrollView: UIScrollView) {
  559. if scrollView == refreshScrollView {
  560. let yOffset = scrollView.contentOffset.y
  561. if yOffset < 0 {
  562. BGText.transform = CGAffineTransform(translationX: 0, y: -yOffset)
  563. } else {
  564. BGText.transform = CGAffineTransform.identity
  565. }
  566. }
  567. }
  568. override func viewWillAppear(_: Bool) {
  569. UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
  570. if Observable.shared.chartSettingsChanged.value {
  571. updateBGGraphSettings()
  572. smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value)
  573. view.layoutIfNeeded()
  574. Observable.shared.chartSettingsChanged.value = false
  575. }
  576. }
  577. // Info Table Functions
  578. func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
  579. guard let infoManager = infoManager else {
  580. return 0
  581. }
  582. return infoManager.numberOfRows()
  583. }
  584. func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  585. let cell = tableView.dequeueReusableCell(withIdentifier: "LabelCell", for: indexPath)
  586. if let values = infoManager.dataForIndexPath(indexPath) {
  587. cell.textLabel?.text = values.name
  588. cell.detailTextLabel?.text = values.value
  589. } else {
  590. cell.textLabel?.text = ""
  591. cell.detailTextLabel?.text = ""
  592. }
  593. return cell
  594. }
  595. @objc func appMovedToBackground() {
  596. // Allow screen to turn off
  597. UIApplication.shared.isIdleTimerDisabled = false
  598. // We want to always come back to the home screen
  599. tabBarController?.selectedIndex = 0
  600. if Storage.shared.backgroundRefreshType.value == .silentTune {
  601. backgroundTask.startBackgroundTask()
  602. }
  603. if Storage.shared.backgroundRefreshType.value != .none {
  604. BackgroundAlertManager.shared.startBackgroundAlert()
  605. }
  606. }
  607. @objc func appCameToForeground() {
  608. // reset screenlock state if needed
  609. UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
  610. if Storage.shared.backgroundRefreshType.value == .silentTune {
  611. backgroundTask.stopBackgroundTask()
  612. }
  613. if Storage.shared.backgroundRefreshType.value != .none {
  614. BackgroundAlertManager.shared.stopBackgroundAlert()
  615. }
  616. TaskScheduler.shared.checkTasksNow()
  617. checkAndNotifyVersionStatus()
  618. checkAppExpirationStatus()
  619. }
  620. func checkAndNotifyVersionStatus() {
  621. let versionManager = AppVersionManager()
  622. versionManager.checkForNewVersion { latestVersion, isNewer, isBlacklisted in
  623. let now = Date()
  624. // Check if the current version is blacklisted, or if there is a newer version available
  625. if isBlacklisted {
  626. let lastBlacklistShown = Storage.shared.lastBlacklistNotificationShown.value ?? Date.distantPast
  627. if now.timeIntervalSince(lastBlacklistShown) > 86400 { // 24 hours
  628. self.versionAlert(message: "The current version has a critical issue and should be updated as soon as possible.")
  629. Storage.shared.lastBlacklistNotificationShown.value = now
  630. Storage.shared.lastVersionUpdateNotificationShown.value = now
  631. }
  632. } else if isNewer {
  633. let lastVersionUpdateShown = Storage.shared.lastVersionUpdateNotificationShown.value ?? Date.distantPast
  634. if now.timeIntervalSince(lastVersionUpdateShown) > 1_209_600 { // 2 weeks
  635. self.versionAlert(message: "A new version is available: \(latestVersion ?? "Unknown"). It is recommended to update.")
  636. Storage.shared.lastVersionUpdateNotificationShown.value = now
  637. }
  638. }
  639. }
  640. }
  641. func versionAlert(title: String = "Update Available", message: String) {
  642. DispatchQueue.main.async {
  643. let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
  644. alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
  645. self.present(alert, animated: true)
  646. }
  647. }
  648. func checkAppExpirationStatus() {
  649. let now = Date()
  650. let expirationDate = BuildDetails.default.calculateExpirationDate()
  651. let weekBeforeExpiration = Calendar.current.date(byAdding: .day, value: -7, to: expirationDate)!
  652. if now >= weekBeforeExpiration {
  653. let lastExpirationShown = Storage.shared.lastExpirationNotificationShown.value ?? Date.distantPast
  654. if now.timeIntervalSince(lastExpirationShown) > 86400 { // 24 hours
  655. expirationAlert()
  656. Storage.shared.lastExpirationNotificationShown.value = now
  657. }
  658. }
  659. }
  660. func expirationAlert() {
  661. DispatchQueue.main.async {
  662. 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)
  663. alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
  664. self.present(alert, animated: true)
  665. }
  666. }
  667. @objc override func viewDidAppear(_: Bool) {
  668. showHideNSDetails()
  669. }
  670. func stringFromTimeInterval(interval: TimeInterval) -> String {
  671. let interval = Int(interval)
  672. let minutes = (interval / 60) % 60
  673. let hours = (interval / 3600)
  674. return String(format: "%02d:%02d", hours, minutes)
  675. }
  676. private func updateNightscoutTabState() {
  677. guard let tabBarController = tabBarController,
  678. let viewControllers = tabBarController.viewControllers else { return }
  679. let isNightscoutEnabled = !Storage.shared.url.value.isEmpty
  680. for (index, vc) in viewControllers.enumerated() {
  681. if vc is NightscoutViewController {
  682. tabBarController.tabBar.items?[index].isEnabled = isNightscoutEnabled
  683. }
  684. }
  685. }
  686. func showHideNSDetails() {
  687. if isInitialLoad || !isDataSourceConfigured() {
  688. return
  689. }
  690. var isHidden = false
  691. if !IsNightscoutEnabled() {
  692. isHidden = true
  693. }
  694. LoopStatusLabel.isHidden = isHidden
  695. if IsNotLooping {
  696. PredictionLabel.isHidden = true
  697. } else {
  698. PredictionLabel.isHidden = isHidden
  699. }
  700. infoTable.isHidden = isHidden
  701. if Storage.shared.hideInfoTable.value {
  702. infoTable.isHidden = true
  703. }
  704. updateNightscoutTabState()
  705. }
  706. func updateBadge(val: Int) {
  707. if Storage.shared.appBadge.value {
  708. let latestBG = String(val)
  709. UIApplication.shared.applicationIconBadgeNumber = Int(Localizer.removePeriodAndCommaForBadge(Localizer.toDisplayUnits(latestBG))) ?? val
  710. } else {
  711. UIApplication.shared.applicationIconBadgeNumber = 0
  712. }
  713. }
  714. func setBGTextColor() {
  715. if bgData.count > 0 {
  716. let latestBG = bgData[bgData.count - 1].sgv
  717. var color = NSUIColor.label
  718. if Storage.shared.colorBGText.value {
  719. if Double(latestBG) >= Storage.shared.highLine.value {
  720. color = NSUIColor.systemYellow
  721. Observable.shared.bgTextColor.value = .yellow
  722. } else if Double(latestBG) <= Storage.shared.lowLine.value {
  723. color = NSUIColor.systemRed
  724. Observable.shared.bgTextColor.value = .red
  725. } else {
  726. color = NSUIColor.systemGreen
  727. Observable.shared.bgTextColor.value = .green
  728. }
  729. } else {
  730. Observable.shared.bgTextColor.value = .primary
  731. }
  732. BGText.textColor = color
  733. }
  734. }
  735. func bgDirectionGraphic(_ value: String) -> String {
  736. 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":"-"]
  737. graphics: [String: String] = ["Flat": "→", "DoubleUp": "↑↑", "SingleUp": "↑", "FortyFiveUp": "↗", "FortyFiveDown": "↘︎", "SingleDown": "↓", "DoubleDown": "↓↓", "None": "-", "NONE": "-", "NOT COMPUTABLE": "-", "RATE OUT OF RANGE": "-", "": "-"]
  738. return graphics[value]!
  739. }
  740. func writeCalendar() {
  741. store.requestCalendarAccess { granted, error in
  742. if !granted {
  743. LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))")
  744. return
  745. }
  746. self.processCalendarUpdates()
  747. }
  748. }
  749. func processCalendarUpdates() {
  750. if Storage.shared.calendarIdentifier.value == "" { return }
  751. if bgData.count < 1 { return }
  752. // This lets us fire the method to write Min Ago entries only once a minute starting after 6 minutes but allows new readings through
  753. let now = dateTimeUtils.getNowTimeIntervalUTC()
  754. let newestBGDate = bgData[bgData.count - 1].date
  755. if lastCalDate == newestBGDate {
  756. if (now - lastCalendarWriteAttemptTime) < 60 || (now - newestBGDate) < 360 {
  757. return
  758. }
  759. }
  760. // Create Event info
  761. var deltaBG = 0 // protect index out of bounds
  762. if bgData.count > 1 {
  763. deltaBG = bgData[bgData.count - 1].sgv - bgData[bgData.count - 2].sgv as Int
  764. }
  765. let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - bgData[bgData.count - 1].date) / 60
  766. var deltaString = ""
  767. if deltaBG < 0 {
  768. deltaString = Localizer.toDisplayUnits(String(deltaBG))
  769. } else {
  770. deltaString = "+" + Localizer.toDisplayUnits(String(deltaBG))
  771. }
  772. let direction = bgDirectionGraphic(bgData[bgData.count - 1].direction ?? "")
  773. let eventStartDate = Date(timeIntervalSince1970: bgData[bgData.count - 1].date)
  774. var eventEndDate = eventStartDate.addingTimeInterval(60 * 10)
  775. var eventTitle = Storage.shared.watchLine1.value
  776. if Storage.shared.watchLine2.value.count > 1 {
  777. eventTitle += "\n" + Storage.shared.watchLine2.value
  778. }
  779. eventTitle = eventTitle.replacingOccurrences(of: "%BG%", with: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)))
  780. eventTitle = eventTitle.replacingOccurrences(of: "%DIRECTION%", with: direction)
  781. eventTitle = eventTitle.replacingOccurrences(of: "%DELTA%", with: deltaString)
  782. if currentOverride != 1.0 {
  783. let val = Int(currentOverride * 100)
  784. // let overrideText = String(format:"%f1", self.currentOverride*100)
  785. let text = String(val) + "%"
  786. eventTitle = eventTitle.replacingOccurrences(of: "%OVERRIDE%", with: text)
  787. } else {
  788. eventTitle = eventTitle.replacingOccurrences(of: "%OVERRIDE%", with: "")
  789. }
  790. eventTitle = eventTitle.replacingOccurrences(of: "%LOOP%", with: latestLoopStatusString)
  791. var minAgo = ""
  792. if deltaTime > 9 {
  793. // write old BG reading and continue pushing out end date to show last entry
  794. minAgo = String(Int(deltaTime)) + " min"
  795. eventEndDate = eventStartDate.addingTimeInterval((60 * 10) + (deltaTime * 60))
  796. }
  797. var basal = "~"
  798. if latestBasal != "" {
  799. basal = latestBasal
  800. }
  801. eventTitle = eventTitle.replacingOccurrences(of: "%MINAGO%", with: minAgo)
  802. eventTitle = eventTitle.replacingOccurrences(of: "%IOB%", with: latestIOB?.formattedValue() ?? "0")
  803. eventTitle = eventTitle.replacingOccurrences(of: "%COB%", with: latestCOB?.formattedValue() ?? "0")
  804. eventTitle = eventTitle.replacingOccurrences(of: "%BASAL%", with: basal)
  805. // Delete Events from last 2 hours and 2 hours in future
  806. let deleteStartDate = Date().addingTimeInterval(-60 * 60 * 2)
  807. let deleteEndDate = Date().addingTimeInterval(60 * 60 * 2)
  808. // guard solves for some ios upgrades removing the calendar
  809. guard let deleteCalendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value) as? EKCalendar else { return }
  810. let predicate2 = store.predicateForEvents(withStart: deleteStartDate, end: deleteEndDate, calendars: [deleteCalendar])
  811. let eVDelete = store.events(matching: predicate2) as [EKEvent]?
  812. if eVDelete != nil {
  813. for i in eVDelete! {
  814. do {
  815. try store.remove(i, span: EKSpan.thisEvent, commit: true)
  816. } catch {
  817. print(error)
  818. }
  819. }
  820. }
  821. // Write New Event
  822. let event = EKEvent(eventStore: store)
  823. event.title = eventTitle
  824. event.startDate = eventStartDate
  825. event.endDate = eventEndDate
  826. event.calendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value)
  827. do {
  828. try store.save(event, span: .thisEvent, commit: true)
  829. lastCalendarWriteAttemptTime = now
  830. lastCalDate = bgData[bgData.count - 1].date
  831. } catch {
  832. let msg = "Error storing to calendar: \(error.localizedDescription) (\(error))"
  833. LogManager.shared.log(category: .calendar, message: msg)
  834. }
  835. }
  836. func userNotificationCenter(_: UNUserNotificationCenter, didReceive _: UNNotificationResponse, withCompletionHandler _: @escaping () -> Void) {}
  837. // User has scrolled the chart
  838. func chartTranslated(_: ChartViewBase, dX _: CGFloat, dY _: CGFloat) {
  839. let isViewingLatestData = abs(BGChart.highestVisibleX - BGChart.chartXMax) < 0.001
  840. if isViewingLatestData {
  841. autoScrollPauseUntil = nil // User is back at the latest data, allow auto-scrolling
  842. } else {
  843. autoScrollPauseUntil = Date().addingTimeInterval(5 * 60) // User is viewing historical data, pause auto-scrolling
  844. }
  845. }
  846. func calculateMaxBgGraphValue() -> Float {
  847. return max(Float(topBG), Float(topPredictionBG))
  848. }
  849. func loadDebugData() {
  850. struct DebugData: Codable {
  851. let debug: Bool?
  852. let url: String?
  853. let token: String?
  854. }
  855. let fileManager = FileManager.default
  856. let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("debugData.json")
  857. if fileManager.fileExists(atPath: url.path) {
  858. do {
  859. let data = try Data(contentsOf: url)
  860. let decoder = JSONDecoder()
  861. decoder.dateDecodingStrategy = .iso8601
  862. let debugData = try decoder.decode(DebugData.self, from: data)
  863. LogManager.shared.log(category: .alarm, message: "Loaded DebugData from \(url.path)", isDebug: true)
  864. if let debug = debugData.debug {
  865. Observable.shared.debug.value = debug
  866. }
  867. if let url = debugData.url {
  868. Storage.shared.url.value = url
  869. }
  870. if let token = debugData.token {
  871. Storage.shared.token.value = token
  872. }
  873. } catch {
  874. LogManager.shared.log(category: .alarm, message: "Failed to load DebugData: \(error)", isDebug: true)
  875. }
  876. }
  877. }
  878. private func synchronizeInfoTypes() {
  879. var sortArray = Storage.shared.infoSort.value
  880. var visibleArray = Storage.shared.infoVisible.value
  881. // Current valid indices based on InfoType
  882. let currentValidIndices = InfoType.allCases.map { $0.rawValue }
  883. // Add missing indices to sortArray
  884. for index in currentValidIndices {
  885. if !sortArray.contains(index) {
  886. sortArray.append(index)
  887. // print("Added missing index \(index) to sortArray")
  888. }
  889. }
  890. // Remove deprecated indices
  891. sortArray = sortArray.filter { currentValidIndices.contains($0) }
  892. // Ensure visibleArray is updated with new entries
  893. if visibleArray.count < currentValidIndices.count {
  894. for i in visibleArray.count ..< currentValidIndices.count {
  895. visibleArray.append(InfoType(rawValue: i)?.defaultVisible ?? false)
  896. // print("Added default visibility for new index \(i)")
  897. }
  898. }
  899. // Trim excess elements if there are more than needed
  900. if visibleArray.count > currentValidIndices.count {
  901. visibleArray = Array(visibleArray.prefix(currentValidIndices.count))
  902. // print("Trimmed visibleArray to match current valid indices")
  903. }
  904. Storage.shared.infoSort.value = sortArray
  905. Storage.shared.infoVisible.value = visibleArray
  906. }
  907. // MARK: - First Time Setup
  908. private func checkAndShowImportButtonIfNeeded() {
  909. // Check if this is first-time setup (no data source configured)
  910. let isFirstTimeSetup = !isDataSourceConfigured()
  911. if isFirstTimeSetup {
  912. setupFirstTimeButtons()
  913. hideAllDataUI()
  914. // Hide loading overlay if it's showing and mark as not loading
  915. if loadingOverlay != nil {
  916. isInitialLoad = false
  917. hideLoadingOverlay()
  918. }
  919. } else {
  920. hideFirstTimeButtons()
  921. // Only show data UI if we're not in initial loading state
  922. if !isInitialLoad || loadingOverlay == nil {
  923. showAllDataUI()
  924. }
  925. }
  926. }
  927. private func setupFirstTimeButtons() {
  928. // Create Setup Nightscout button
  929. if setupNightscoutButton == nil {
  930. setupNightscoutButton = UIButton(type: .system)
  931. setupNightscoutButton.setTitle("Setup Nightscout", for: .normal)
  932. setupNightscoutButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
  933. setupNightscoutButton.backgroundColor = UIColor.systemBlue
  934. setupNightscoutButton.setTitleColor(.white, for: .normal)
  935. setupNightscoutButton.layer.cornerRadius = 12
  936. setupNightscoutButton.layer.shadowColor = UIColor.black.cgColor
  937. setupNightscoutButton.layer.shadowOffset = CGSize(width: 0, height: 2)
  938. setupNightscoutButton.layer.shadowOpacity = 0.3
  939. setupNightscoutButton.layer.shadowRadius = 4
  940. setupNightscoutButton.addTarget(self, action: #selector(setupNightscoutTapped), for: .touchUpInside)
  941. view.addSubview(setupNightscoutButton)
  942. setupNightscoutButton.translatesAutoresizingMaskIntoConstraints = false
  943. NSLayoutConstraint.activate([
  944. setupNightscoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  945. setupNightscoutButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -30),
  946. setupNightscoutButton.widthAnchor.constraint(equalToConstant: 200),
  947. setupNightscoutButton.heightAnchor.constraint(equalToConstant: 50),
  948. ])
  949. }
  950. // Create Setup Dexcom Share button
  951. if setupDexcomButton == nil {
  952. setupDexcomButton = UIButton(type: .system)
  953. setupDexcomButton.setTitle("Setup Dexcom Share", for: .normal)
  954. setupDexcomButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
  955. setupDexcomButton.backgroundColor = UIColor.systemGreen
  956. setupDexcomButton.setTitleColor(.white, for: .normal)
  957. setupDexcomButton.layer.cornerRadius = 12
  958. setupDexcomButton.layer.shadowColor = UIColor.black.cgColor
  959. setupDexcomButton.layer.shadowOffset = CGSize(width: 0, height: 2)
  960. setupDexcomButton.layer.shadowOpacity = 0.3
  961. setupDexcomButton.layer.shadowRadius = 4
  962. setupDexcomButton.addTarget(self, action: #selector(setupDexcomTapped), for: .touchUpInside)
  963. view.addSubview(setupDexcomButton)
  964. setupDexcomButton.translatesAutoresizingMaskIntoConstraints = false
  965. NSLayoutConstraint.activate([
  966. setupDexcomButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  967. setupDexcomButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 30),
  968. setupDexcomButton.widthAnchor.constraint(equalToConstant: 200),
  969. setupDexcomButton.heightAnchor.constraint(equalToConstant: 50),
  970. ])
  971. }
  972. setupNightscoutButton.isHidden = false
  973. setupDexcomButton.isHidden = false
  974. }
  975. private func hideFirstTimeButtons() {
  976. setupNightscoutButton?.isHidden = true
  977. setupDexcomButton?.isHidden = true
  978. }
  979. @objc private func setupNightscoutTapped() {
  980. let nightscoutSettingsView = NightscoutSettingsView(viewModel: .init())
  981. let hostingController = UIHostingController(rootView: nightscoutSettingsView)
  982. let navController = UINavigationController(rootViewController: hostingController)
  983. // Apply dark mode if needed
  984. if Storage.shared.forceDarkMode.value {
  985. hostingController.overrideUserInterfaceStyle = .dark
  986. navController.overrideUserInterfaceStyle = .dark
  987. }
  988. // Add a Done button
  989. hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem(
  990. barButtonSystemItem: .done,
  991. target: self,
  992. action: #selector(dismissModal)
  993. )
  994. navController.modalPresentationStyle = .pageSheet
  995. present(navController, animated: true)
  996. }
  997. @objc private func setupDexcomTapped() {
  998. let dexcomSettingsView = DexcomSettingsView(viewModel: .init())
  999. let hostingController = UIHostingController(rootView: dexcomSettingsView)
  1000. let navController = UINavigationController(rootViewController: hostingController)
  1001. // Apply dark mode if needed
  1002. if Storage.shared.forceDarkMode.value {
  1003. hostingController.overrideUserInterfaceStyle = .dark
  1004. navController.overrideUserInterfaceStyle = .dark
  1005. }
  1006. // Add a Done button
  1007. hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem(
  1008. barButtonSystemItem: .done,
  1009. target: self,
  1010. action: #selector(dismissModal)
  1011. )
  1012. navController.modalPresentationStyle = .pageSheet
  1013. present(navController, animated: true)
  1014. }
  1015. private func hideGraphs() {
  1016. BGChart.isHidden = true
  1017. BGChartFull.isHidden = true
  1018. }
  1019. private func showGraphs() {
  1020. updateGraphVisibility()
  1021. }
  1022. private func hideAllDataUI() {
  1023. // Hide graphs
  1024. BGChart.isHidden = true
  1025. BGChartFull.isHidden = true
  1026. // Hide BG display elements
  1027. BGText.isHidden = true
  1028. DeltaText.isHidden = true
  1029. DirectionText.isHidden = true
  1030. MinAgoText.isHidden = true
  1031. serverText.isHidden = true
  1032. // Hide info table and stats
  1033. infoTable.isHidden = true
  1034. statsView.isHidden = true
  1035. // Hide loop status and prediction
  1036. LoopStatusLabel.isHidden = true
  1037. PredictionLabel.isHidden = true
  1038. }
  1039. private func showAllDataUI() {
  1040. // Show BG display elements
  1041. BGText.isHidden = false
  1042. DeltaText.isHidden = false
  1043. DirectionText.isHidden = false
  1044. MinAgoText.isHidden = false
  1045. serverText.isHidden = false
  1046. // Show graphs based on settings
  1047. updateGraphVisibility()
  1048. // Show/hide info table and stats based on user settings
  1049. let isNightscoutEnabled = IsNightscoutEnabled()
  1050. if isNightscoutEnabled {
  1051. infoTable.isHidden = Storage.shared.hideInfoTable.value
  1052. LoopStatusLabel.isHidden = false
  1053. PredictionLabel.isHidden = IsNotLooping
  1054. } else {
  1055. infoTable.isHidden = true
  1056. LoopStatusLabel.isHidden = true
  1057. PredictionLabel.isHidden = true
  1058. }
  1059. statsView.isHidden = !Storage.shared.showStats.value
  1060. }
  1061. private func updateGraphVisibility() {
  1062. let isFirstTimeSetup = !isDataSourceConfigured()
  1063. if isFirstTimeSetup {
  1064. BGChart.isHidden = true
  1065. BGChartFull.isHidden = true
  1066. } else {
  1067. BGChart.isHidden = false
  1068. BGChartFull.isHidden = !Storage.shared.showSmallGraph.value
  1069. }
  1070. }
  1071. @objc private func importSettingsButtonTapped() {
  1072. presentImportSettingsView()
  1073. }
  1074. private func presentImportSettingsView() {
  1075. let importExportView = ImportExportSettingsView()
  1076. let hostingController = UIHostingController(rootView: importExportView)
  1077. hostingController.modalPresentationStyle = .pageSheet
  1078. present(hostingController, animated: true)
  1079. }
  1080. @objc private func dismissModal() {
  1081. dismiss(animated: true) { [weak self] in
  1082. guard let self = self else { return }
  1083. // Check if user just configured a data source
  1084. if self.isDataSourceConfigured(), self.loadingOverlay == nil {
  1085. // Reset loading states for fresh load
  1086. self.loadingStates = [
  1087. "bg": false,
  1088. "profile": false,
  1089. "deviceStatus": false,
  1090. ]
  1091. self.isInitialLoad = true
  1092. // Show loading overlay and trigger refresh
  1093. self.setupLoadingState()
  1094. self.showLoadingOverlay()
  1095. self.refresh()
  1096. }
  1097. }
  1098. }
  1099. }
  1100. extension MainViewController: AVSpeechSynthesizerDelegate {
  1101. func speechSynthesizer(_: AVSpeechSynthesizer, didFinish _: AVSpeechUtterance) {
  1102. let appState = UIApplication.shared.applicationState
  1103. let isSilentTuneMode = Storage.shared.backgroundRefreshType.value == .silentTune
  1104. if isSilentTuneMode, appState == .background {
  1105. LogManager.shared.log(category: .general, message: "Silent tune active in background; not deactivating session.", isDebug: true)
  1106. } else {
  1107. do {
  1108. try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
  1109. LogManager.shared.log(category: .general, message: "Audio session deactivated after speech.", isDebug: true)
  1110. } catch {
  1111. LogManager.shared.log(category: .alarm, message: "Failed to deactivate audio session: \(error)")
  1112. }
  1113. }
  1114. }
  1115. }