MainViewController.swift 52 KB

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