MainViewController.swift 60 KB

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