| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554 |
- // LoopFollow
- // MainViewController.swift
- import AVFAudio
- import Charts
- import Combine
- import CoreBluetooth
- import EventKit
- import ShareClient
- import SwiftUI
- import UIKit
- import UserNotifications
- func IsNightscoutEnabled() -> Bool {
- return !Storage.shared.url.value.isEmpty
- }
- class MainViewController: UIViewController, UITableViewDataSource, ChartViewDelegate, UNUserNotificationCenterDelegate, UIScrollViewDelegate {
- var isPresentedAsModal: Bool = false
- @IBOutlet var BGText: UILabel!
- @IBOutlet var DeltaText: UILabel!
- @IBOutlet var DirectionText: UILabel!
- @IBOutlet var BGChart: LineChartView!
- @IBOutlet var BGChartFull: LineChartView!
- @IBOutlet var MinAgoText: UILabel!
- @IBOutlet var infoTable: UITableView!
- @IBOutlet var Console: UITableViewCell!
- @IBOutlet var DragBar: UIImageView!
- @IBOutlet var PredictionLabel: UILabel!
- @IBOutlet var LoopStatusLabel: UILabel!
- @IBOutlet var statsPieChart: PieChartView!
- @IBOutlet var statsLowPercent: UILabel!
- @IBOutlet var statsInRangePercent: UILabel!
- @IBOutlet var statsHighPercent: UILabel!
- @IBOutlet var statsAvgBG: UILabel!
- @IBOutlet var statsEstA1C: UILabel!
- @IBOutlet var statsStdDev: UILabel!
- @IBOutlet var serverText: UILabel!
- @IBOutlet var statsView: UIView!
- @IBOutlet var smallGraphHeightConstraint: NSLayoutConstraint!
- var refreshScrollView: UIScrollView!
- var refreshControl: UIRefreshControl!
- // Setup buttons for first-time configuration
- private var setupNightscoutButton: UIButton!
- private var setupDexcomButton: UIButton!
- let speechSynthesizer = AVSpeechSynthesizer()
- // Variables for BG Charts
- var firstGraphLoad: Bool = true
- var currentOverride = 1.0
- var currentSage: sageData?
- var currentCage: cageData?
- var currentIage: iageData?
- var backgroundTask = BackgroundTask()
- var graphNowTimer = Timer()
- var lastCalendarWriteAttemptTime: TimeInterval = 0
- // Info Table Setup
- var infoManager: InfoManager!
- var profileManager = ProfileManager.shared
- var bgData: [ShareGlucoseData] = []
- var basalProfile: [basalProfileStruct] = []
- var basalData: [basalGraphStruct] = []
- var basalScheduleData: [basalGraphStruct] = []
- var bolusData: [bolusGraphStruct] = []
- var smbData: [bolusGraphStruct] = []
- var carbData: [carbGraphStruct] = []
- // Stats-specific data storage (can hold up to 30 days)
- var statsBGData: [ShareGlucoseData] = []
- var statsBolusData: [bolusGraphStruct] = []
- var statsSMBData: [bolusGraphStruct] = []
- var statsCarbData: [carbGraphStruct] = []
- var statsBasalData: [basalGraphStruct] = []
- var overrideGraphData: [DataStructs.overrideStruct] = []
- var tempTargetGraphData: [DataStructs.tempTargetStruct] = []
- var predictionData: [ShareGlucoseData] = []
- var bgCheckData: [ShareGlucoseData] = []
- var suspendGraphData: [DataStructs.timestampOnlyStruct] = []
- var resumeGraphData: [DataStructs.timestampOnlyStruct] = []
- var sensorStartGraphData: [DataStructs.timestampOnlyStruct] = []
- var noteGraphData: [DataStructs.noteStruct] = []
- var chartData = LineChartData()
- var deviceBatteryData: [DataStructs.batteryStruct] = []
- var lastCalDate: Double = 0
- var latestLoopStatusString = ""
- var latestCOB: CarbMetric?
- var latestBasal = ""
- var latestPumpVolume: Double = 50.0
- var latestIOB: InsulinMetric?
- var lastOverrideStartTime: TimeInterval = 0
- var lastOverrideEndTime: TimeInterval = 0
- var topBG: Double = Storage.shared.minBGScale.value
- var topPredictionBG: Double = Storage.shared.minBGScale.value
- var lastOverrideAlarm: TimeInterval = 0
- var lastTempTargetAlarm: TimeInterval = 0
- var lastTempTargetStartTime: TimeInterval = 0
- var lastTempTargetEndTime: TimeInterval = 0
- // share
- var bgDataShare: [ShareGlucoseData] = []
- var dexShare: ShareClient?
- // calendar setup
- let store = EKEventStore()
- // Stores the timestamp of the last BG value that was spoken.
- var lastSpokenBGDate: TimeInterval = 0
- var autoScrollPauseUntil: Date?
- var IsNotLooping = false
- let contactImageUpdater = ContactImageUpdater()
- private var cancellables = Set<AnyCancellable>()
- private var isViewHierarchyReady = false
- // Loading state management
- private var loadingOverlay: UIView?
- private var isInitialLoad = true
- private var loadingStates: [String: Bool] = [
- "bg": false,
- "profile": false,
- "deviceStatus": false,
- ]
- private var loadingTimeoutTimer: Timer?
- override func viewDidLoad() {
- super.viewDidLoad()
- loadDebugData()
- if Storage.shared.migrationStep.value < 1 {
- Storage.shared.migrateStep1()
- Storage.shared.migrationStep.value = 1
- }
- if Storage.shared.migrationStep.value < 2 {
- Storage.shared.migrateStep2()
- Storage.shared.migrationStep.value = 2
- }
- if Storage.shared.migrationStep.value < 3 {
- Storage.shared.migrateStep3()
- Storage.shared.migrationStep.value = 3
- }
- // Synchronize info types to ensure arrays are the correct size
- synchronizeInfoTypes()
- infoTable.rowHeight = 21
- infoTable.dataSource = self
- infoTable.tableFooterView = UIView(frame: .zero)
- infoTable.bounces = false
- infoTable.addBorder(toSide: .Left, withColor: UIColor.darkGray.cgColor, andThickness: 2)
- infoManager = InfoManager(tableView: infoTable)
- smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value)
- view.layoutIfNeeded()
- let shareUserName = Storage.shared.shareUserName.value
- let sharePassword = Storage.shared.sharePassword.value
- let shareServer = Storage.shared.shareServer.value == "US" ?KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue
- dexShare = ShareClient(username: shareUserName, password: sharePassword, shareServer: shareServer)
- // setup show/hide small graph and stats
- updateGraphVisibility()
- statsView.isHidden = !Storage.shared.showStats.value
- BGChart.delegate = self
- BGChartFull.delegate = self
- // Apply initial appearance mode
- updateAppearance(Storage.shared.appearanceMode.value)
- // Trigger foreground and background functions
- let notificationCenter = NotificationCenter.default
- notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
- notificationCenter.addObserver(self, selector: #selector(appCameToForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
- // Setup the Graph
- if firstGraphLoad {
- createGraph()
- createSmallBGGraph()
- }
- // setup display for NS vs Dex
- showHideNSDetails()
- scheduleAllTasks()
- // Set up refreshScrollView for BGText
- refreshScrollView = UIScrollView()
- refreshScrollView.translatesAutoresizingMaskIntoConstraints = false
- refreshScrollView.alwaysBounceVertical = true
- view.addSubview(refreshScrollView)
- NSLayoutConstraint.activate([
- refreshScrollView.leadingAnchor.constraint(equalTo: BGText.leadingAnchor),
- refreshScrollView.trailingAnchor.constraint(equalTo: BGText.trailingAnchor),
- refreshScrollView.topAnchor.constraint(equalTo: BGText.topAnchor),
- refreshScrollView.bottomAnchor.constraint(equalTo: BGText.bottomAnchor),
- ])
- refreshControl = UIRefreshControl()
- refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged)
- refreshScrollView.addSubview(refreshControl)
- refreshScrollView.alwaysBounceVertical = true
- refreshScrollView.delegate = self
- NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: NSNotification.Name("refresh"), object: nil)
- Observable.shared.bgText.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] newValue in
- self?.BGText.text = newValue
- }
- .store(in: &cancellables)
- Observable.shared.directionText.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] newValue in
- self?.DirectionText.text = newValue
- }
- .store(in: &cancellables)
- Observable.shared.deltaText.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] newValue in
- self?.DeltaText.text = newValue
- }
- .store(in: &cancellables)
- /// When an alarm is triggered, go to the snoozer tab
- Observable.shared.currentAlarm.$value
- .receive(on: DispatchQueue.main)
- .compactMap { $0 }
- .sink { [weak self] _ in
- guard let self = self,
- let tabBarController = self.tabBarController,
- let vcs = tabBarController.viewControllers, !vcs.isEmpty,
- let snoozerIndex = self.getSnoozerTabIndex(),
- snoozerIndex < vcs.count else { return }
- tabBarController.selectedIndex = snoozerIndex
- }
- .store(in: &cancellables)
- Storage.shared.colorBGText.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.setBGTextColor()
- }
- .store(in: &cancellables)
- // Update appearance when setting changes
- Storage.shared.appearanceMode.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] mode in
- self?.updateAppearance(mode)
- }
- .store(in: &cancellables)
- Storage.shared.showStats.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.statsView.isHidden = !Storage.shared.showStats.value
- }
- .store(in: &cancellables)
- Storage.shared.useIFCC.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.updateStats()
- }
- .store(in: &cancellables)
- Storage.shared.showSmallGraph.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.updateGraphVisibility()
- }
- .store(in: &cancellables)
- Storage.shared.screenlockSwitchState.$value
- .receive(on: DispatchQueue.main)
- .sink { newValue in
- UIApplication.shared.isIdleTimerDisabled = newValue
- }
- .store(in: &cancellables)
- Storage.shared.showDisplayName.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.updateServerText()
- }
- .store(in: &cancellables)
- Storage.shared.graphTimeZoneEnabled.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.infoTable.reloadData()
- }
- .store(in: &cancellables)
- Storage.shared.graphTimeZoneIdentifier.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.infoTable.reloadData()
- }
- .store(in: &cancellables)
- Storage.shared.speakBG.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.updateQuickActions()
- }
- .store(in: &cancellables)
- // Observe all tab position changes with debouncing to handle batch updates
- Publishers.MergeMany(
- Storage.shared.homePosition.$value.map { _ in () }.eraseToAnyPublisher(),
- Storage.shared.alarmsPosition.$value.map { _ in () }.eraseToAnyPublisher(),
- Storage.shared.remotePosition.$value.map { _ in () }.eraseToAnyPublisher(),
- Storage.shared.nightscoutPosition.$value.map { _ in () }.eraseToAnyPublisher(),
- Storage.shared.snoozerPosition.$value.map { _ in () }.eraseToAnyPublisher(),
- Storage.shared.statisticsPosition.$value.map { _ in () }.eraseToAnyPublisher(),
- Storage.shared.treatmentsPosition.$value.map { _ in () }.eraseToAnyPublisher()
- )
- .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.setupTabBar()
- }
- .store(in: &cancellables)
- Storage.shared.url.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.updateNightscoutTabState()
- self?.checkAndShowImportButtonIfNeeded()
- }
- .store(in: &cancellables)
- Storage.shared.token.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.checkAndShowImportButtonIfNeeded()
- }
- .store(in: &cancellables)
- Storage.shared.shareUserName.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.checkAndShowImportButtonIfNeeded()
- }
- .store(in: &cancellables)
- Storage.shared.sharePassword.$value
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.checkAndShowImportButtonIfNeeded()
- }
- .store(in: &cancellables)
- Storage.shared.apnsKey.$value
- .receive(on: DispatchQueue.main)
- .removeDuplicates()
- .sink { _ in
- JWTManager.shared.invalidateCache()
- }
- .store(in: &cancellables)
- Storage.shared.teamId.$value
- .receive(on: DispatchQueue.main)
- .removeDuplicates()
- .sink { _ in
- JWTManager.shared.invalidateCache()
- }
- .store(in: &cancellables)
- Storage.shared.keyId.$value
- .receive(on: DispatchQueue.main)
- .removeDuplicates()
- .sink { _ in
- JWTManager.shared.invalidateCache()
- }
- .store(in: &cancellables)
- Storage.shared.device.$value
- .receive(on: DispatchQueue.main)
- .removeDuplicates()
- .sink { [weak self] _ in
- guard let self = self else { return }
- let isTrioDevice = (Storage.shared.device.value == "Trio")
- let isLoopDevice = (Storage.shared.device.value == "Loop")
- let currentRemoteType = Storage.shared.remoteType.value
- // Check if current remote type is invalid for the device
- let shouldReset = (currentRemoteType == .loopAPNS && !isLoopDevice) ||
- (currentRemoteType == .trc && !isTrioDevice) ||
- (currentRemoteType == .nightscout && !isTrioDevice)
- if shouldReset {
- Storage.shared.remoteType.value = .none
- }
- }
- .store(in: &cancellables)
- updateQuickActions()
- // Delay initial tab setup to ensure view hierarchy is ready
- // This prevents crashes when trying to modify tabs during viewWillAppear
- DispatchQueue.main.async { [weak self] in
- self?.isViewHierarchyReady = true
- self?.setupTabBar()
- }
- speechSynthesizer.delegate = self
- // Check configuration and show appropriate UI
- if isDataSourceConfigured() {
- // Data source configured - show loading overlay
- setupLoadingState()
- showLoadingOverlay()
- } else {
- // No data source - hide all data UI and show setup buttons
- hideAllDataUI()
- isInitialLoad = false
- }
- checkAndShowImportButtonIfNeeded()
- }
- // MARK: - Loading Overlay
- private func isDataSourceConfigured() -> Bool {
- let isNightscoutConfigured = !Storage.shared.url.value.isEmpty
- let isDexcomConfigured = !Storage.shared.shareUserName.value.isEmpty && !Storage.shared.sharePassword.value.isEmpty
- return isNightscoutConfigured || isDexcomConfigured
- }
- private func setupLoadingState() {
- // If Nightscout is not enabled, mark profile and deviceStatus as loaded
- // since we only need BG data from Dexcom Share
- if !IsNightscoutEnabled() {
- loadingStates["profile"] = true
- loadingStates["deviceStatus"] = true
- }
- }
- private func showLoadingOverlay() {
- guard loadingOverlay == nil else { return }
- // Hide all data UI while loading
- hideAllDataUI()
- let overlay = UIView(frame: view.bounds)
- overlay.backgroundColor = UIColor.systemBackground
- overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
- let activityIndicator = UIActivityIndicatorView(style: .large)
- activityIndicator.translatesAutoresizingMaskIntoConstraints = false
- activityIndicator.startAnimating()
- let loadingLabel = UILabel()
- loadingLabel.translatesAutoresizingMaskIntoConstraints = false
- loadingLabel.text = "Loading..."
- loadingLabel.textAlignment = .center
- loadingLabel.font = UIFont.systemFont(ofSize: 17, weight: .medium)
- loadingLabel.textColor = UIColor.secondaryLabel
- overlay.addSubview(activityIndicator)
- overlay.addSubview(loadingLabel)
- NSLayoutConstraint.activate([
- activityIndicator.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
- activityIndicator.centerYAnchor.constraint(equalTo: overlay.centerYAnchor, constant: -20),
- loadingLabel.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
- loadingLabel.topAnchor.constraint(equalTo: activityIndicator.bottomAnchor, constant: 16),
- ])
- view.addSubview(overlay)
- loadingOverlay = overlay
- // Set a timeout to hide the loading overlay if data takes too long
- loadingTimeoutTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in
- guard let self = self else { return }
- if self.isInitialLoad {
- LogManager.shared.log(category: .general, message: "Loading timeout reached, hiding overlay")
- self.isInitialLoad = false
- self.hideLoadingOverlay()
- }
- }
- }
- private func hideLoadingOverlay() {
- guard let overlay = loadingOverlay else { return }
- // Cancel the timeout timer
- loadingTimeoutTimer?.invalidate()
- loadingTimeoutTimer = nil
- // Show all data UI now that loading is complete
- showAllDataUI()
- UIView.animate(withDuration: 0.3, animations: {
- overlay.alpha = 0
- }, completion: { _ in
- overlay.removeFromSuperview()
- self.loadingOverlay = nil
- })
- }
- func markDataLoaded(_ key: String) {
- guard isInitialLoad else { return }
- loadingStates[key] = true
- // Check if all critical data is loaded
- let allLoaded = loadingStates.values.allSatisfy { $0 }
- if allLoaded {
- isInitialLoad = false
- DispatchQueue.main.async {
- self.hideLoadingOverlay()
- }
- }
- }
- private func setupTabBar() {
- guard isViewHierarchyReady else { return }
- guard !isPresentedAsModal else { return }
- var tbc = tabBarController
- if tbc == nil {
- if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
- let window = windowScene.windows.first,
- let rootVC = window.rootViewController as? UITabBarController
- {
- tbc = rootVC
- }
- }
- guard let tabBarController = tbc else { return }
- // If settings modal is presented, skip rebuild - it will happen when settings is dismissed
- if tabBarController.presentedViewController != nil {
- return
- }
- rebuildTabs(tabBarController: tabBarController)
- }
- /// Static method to rebuild tabs from anywhere in the app
- /// This is useful when the MainViewController instance may not be in the tab bar
- static func rebuildTabsIfNeeded() {
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
- let window = windowScene.windows.first,
- let tabBarController = window.rootViewController as? UITabBarController
- else { return }
- let previousSelectedIndex = tabBarController.selectedIndex
- let storyboard = UIStoryboard(name: "Main", bundle: nil)
- var viewControllers: [UIViewController] = []
- let orderedItems = Storage.shared.orderedTabBarItems()
- for (index, item) in orderedItems.prefix(4).enumerated() {
- let position = TabPosition.customizablePositions[index]
- if let vc = createViewControllerStatic(for: item, position: position, storyboard: storyboard) {
- viewControllers.append(vc)
- }
- }
- let menuVC = MoreMenuViewController()
- menuVC.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: 4)
- viewControllers.append(menuVC)
- if let presented = tabBarController.presentedViewController {
- presented.dismiss(animated: false) {
- tabBarController.setViewControllers(viewControllers, animated: false)
- guard !viewControllers.isEmpty else { return }
- let targetIndex = min(previousSelectedIndex, viewControllers.count - 1)
- tabBarController.selectedIndex = targetIndex
- }
- } else {
- tabBarController.setViewControllers(viewControllers, animated: false)
- guard !viewControllers.isEmpty else { return }
- let targetIndex = min(previousSelectedIndex, viewControllers.count - 1)
- tabBarController.selectedIndex = targetIndex
- }
- }
- /// Static helper to create view controllers
- private static func createViewControllerStatic(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? {
- let tag = position.tabIndex ?? 0
- switch item {
- case .home:
- guard let mainVC = storyboard.instantiateViewController(withIdentifier: "MainViewController") as? MainViewController else {
- return nil
- }
- mainVC.tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: item.icon), tag: tag)
- return mainVC
- case .alarms:
- let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController")
- vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return vc
- case .remote:
- let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController")
- vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return vc
- case .nightscout:
- let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController")
- vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return vc
- case .snoozer:
- let vc = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController")
- vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return vc
- case .treatments:
- let treatmentsVC = UIHostingController(rootView: TreatmentsView())
- treatmentsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return treatmentsVC
- case .stats:
- // You may need to provide a MainViewController or view model as needed
- let statsVC = UIHostingController(rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: nil)))
- statsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return statsVC
- }
- }
- private func rebuildTabs(tabBarController: UITabBarController) {
- let previousSelectedIndex = tabBarController.selectedIndex
- let storyboard = UIStoryboard(name: "Main", bundle: nil)
- var viewControllers: [UIViewController] = []
- let orderedItems = Storage.shared.orderedTabBarItems()
- for (index, item) in orderedItems.prefix(4).enumerated() {
- let position = TabPosition.customizablePositions[index]
- if let vc = createViewController(for: item, position: position, storyboard: storyboard) {
- viewControllers.append(vc)
- }
- }
- let menuVC = MoreMenuViewController()
- menuVC.tabBarItem = UITabBarItem(title: "Menu", image: UIImage(systemName: "line.3.horizontal"), tag: 4)
- viewControllers.append(menuVC)
- tabBarController.setViewControllers(viewControllers, animated: false)
- guard !viewControllers.isEmpty else { return }
- let targetIndex = min(previousSelectedIndex, viewControllers.count - 1)
- tabBarController.selectedIndex = targetIndex
- updateNightscoutTabState()
- }
- private func getSnoozerTabIndex() -> Int? {
- guard let tabBarController = tabBarController,
- let viewControllers = tabBarController.viewControllers else { return nil }
- for (index, vc) in viewControllers.enumerated() {
- if let _ = vc as? SnoozerViewController {
- return index
- }
- }
- return nil
- }
- private func createViewController(for item: TabItem, position: TabPosition, storyboard: UIStoryboard) -> UIViewController? {
- let tag = position.tabIndex ?? 0
- switch item {
- case .home:
- tabBarItem = UITabBarItem(title: "Home", image: UIImage(systemName: item.icon), tag: tag)
- return self
- case .alarms:
- let vc = storyboard.instantiateViewController(withIdentifier: "AlarmViewController")
- vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return vc
- case .remote:
- let vc = storyboard.instantiateViewController(withIdentifier: "RemoteViewController")
- vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return vc
- case .nightscout:
- let vc = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController")
- vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return vc
- case .snoozer:
- let vc = storyboard.instantiateViewController(withIdentifier: "SnoozerViewController")
- vc.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return vc
- case .treatments:
- let treatmentsVC = UIHostingController(rootView: TreatmentsView())
- treatmentsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return treatmentsVC
- case .stats:
- let statsVC = UIHostingController(rootView: AggregatedStatsView(viewModel: AggregatedStatsViewModel(mainViewController: self)))
- statsVC.tabBarItem = UITabBarItem(title: item.displayName, image: UIImage(systemName: item.icon), tag: tag)
- return statsVC
- }
- }
- private func createComingSoonViewController(title: String, icon: String) -> UIViewController {
- let vc = UIViewController()
- vc.view.backgroundColor = .systemBackground
- let stackView = UIStackView()
- stackView.axis = .vertical
- stackView.alignment = .center
- stackView.spacing = 16
- stackView.translatesAutoresizingMaskIntoConstraints = false
- let imageView = UIImageView(image: UIImage(systemName: icon))
- imageView.tintColor = .secondaryLabel
- imageView.contentMode = .scaleAspectFit
- imageView.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- imageView.widthAnchor.constraint(equalToConstant: 60),
- imageView.heightAnchor.constraint(equalToConstant: 60),
- ])
- let titleLabel = UILabel()
- titleLabel.text = title
- titleLabel.font = .preferredFont(forTextStyle: .title1)
- titleLabel.textColor = .label
- stackView.addArrangedSubview(imageView)
- stackView.addArrangedSubview(titleLabel)
- vc.view.addSubview(stackView)
- NSLayoutConstraint.activate([
- stackView.centerXAnchor.constraint(equalTo: vc.view.centerXAnchor),
- stackView.centerYAnchor.constraint(equalTo: vc.view.centerYAnchor),
- ])
- vc.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
- return vc
- }
- // Update the Home Screen Quick Action for toggling the "Speak BG" feature based on the current speakBG setting.
- func updateQuickActions() {
- let iconName = Storage.shared.speakBG.value ? "pause.circle.fill" : "play.circle.fill"
- let iconTemplate = UIApplicationShortcutIcon(systemImageName: iconName)
- let shortcut = UIApplicationShortcutItem(type: Bundle.main.bundleIdentifier! + ".toggleSpeakBG",
- localizedTitle: "Speak BG",
- localizedSubtitle: nil,
- icon: iconTemplate,
- userInfo: nil)
- UIApplication.shared.shortcutItems = [shortcut]
- }
- deinit {
- NotificationCenter.default.removeObserver(self, name: NSNotification.Name("refresh"), object: nil)
- }
- // Clean all timers and start new ones when refreshing
- @objc func refresh() {
- LogManager.shared.log(category: .general, message: "Refreshing")
- // Clear prediction for both Loop or OpenAPS
- // Check if Loop prediction data exists and clear it if necessary
- if !predictionData.isEmpty {
- predictionData.removeAll()
- updatePredictionGraph()
- }
- // Check if OpenAPS prediction data exists and clear it if necessary
- let openAPSDataIndices = [12, 13, 14, 15]
- for dataIndex in openAPSDataIndices {
- let mainChart = BGChart.lineData!.dataSets[dataIndex] as! LineChartDataSet
- let smallChart = BGChartFull.lineData!.dataSets[dataIndex] as! LineChartDataSet
- if !mainChart.entries.isEmpty || !smallChart.entries.isEmpty {
- updatePredictionGraphGeneric(
- dataIndex: dataIndex,
- predictionData: [],
- chartLabel: "",
- color: UIColor.systemGray
- )
- }
- }
- MinAgoText.text = "Refreshing"
- Observable.shared.minAgoText.value = "Refreshing"
- scheduleAllTasks()
- currentCage = nil
- currentSage = nil
- currentIage = nil
- refreshControl.endRefreshing()
- }
- // Scroll down BGText when refreshing
- func scrollViewDidScroll(_ scrollView: UIScrollView) {
- if scrollView == refreshScrollView {
- let yOffset = scrollView.contentOffset.y
- if yOffset < 0 {
- BGText.transform = CGAffineTransform(translationX: 0, y: -yOffset)
- } else {
- BGText.transform = CGAffineTransform.identity
- }
- }
- }
- override func viewWillAppear(_: Bool) {
- UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
- infoTable.reloadData()
- if Observable.shared.chartSettingsChanged.value {
- updateBGGraphSettings()
- smallGraphHeightConstraint.constant = CGFloat(Storage.shared.smallGraphHeight.value)
- view.layoutIfNeeded()
- Observable.shared.chartSettingsChanged.value = false
- }
- }
- private var timeZoneOverrideInfoValue: String? {
- guard Storage.shared.graphTimeZoneEnabled.value,
- let overrideTimeZone = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
- else {
- return nil
- }
- return overrideTimeZone.identifier
- }
- // Info Table Functions
- func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
- guard let infoManager = infoManager else {
- return 0
- }
- let overrideRowCount = timeZoneOverrideInfoValue == nil ? 0 : 1
- return infoManager.numberOfRows() + overrideRowCount
- }
- func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- let cell = tableView.dequeueReusableCell(withIdentifier: "LabelCell", for: indexPath)
- if indexPath.row == 0, let timeZoneOverrideInfoValue {
- cell.textLabel?.text = "Time Zone"
- cell.detailTextLabel?.text = timeZoneOverrideInfoValue
- return cell
- }
- let adjustedIndexPath: IndexPath
- if timeZoneOverrideInfoValue != nil {
- adjustedIndexPath = IndexPath(row: indexPath.row - 1, section: indexPath.section)
- } else {
- adjustedIndexPath = indexPath
- }
- if let values = infoManager.dataForIndexPath(adjustedIndexPath) {
- cell.textLabel?.text = values.name
- cell.detailTextLabel?.text = values.value
- } else {
- cell.textLabel?.text = ""
- cell.detailTextLabel?.text = ""
- }
- return cell
- }
- @objc func appMovedToBackground() {
- // Allow screen to turn off
- UIApplication.shared.isIdleTimerDisabled = false
- // We want to always come back to the home screen
- if let tabBarController = tabBarController,
- let vcs = tabBarController.viewControllers, !vcs.isEmpty
- {
- tabBarController.selectedIndex = 0
- }
- if Storage.shared.backgroundRefreshType.value == .silentTune {
- backgroundTask.startBackgroundTask()
- }
- if Storage.shared.backgroundRefreshType.value != .none {
- BackgroundAlertManager.shared.startBackgroundAlert()
- }
- }
- @objc func appCameToForeground() {
- // reset screenlock state if needed
- UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value
- if Storage.shared.backgroundRefreshType.value == .silentTune {
- backgroundTask.stopBackgroundTask()
- }
- if Storage.shared.backgroundRefreshType.value != .none {
- BackgroundAlertManager.shared.stopBackgroundAlert()
- }
- TaskScheduler.shared.checkTasksNow()
- checkAndNotifyVersionStatus()
- checkAppExpirationStatus()
- }
- func checkAndNotifyVersionStatus() {
- let versionManager = AppVersionManager()
- versionManager.checkForNewVersion { latestVersion, isNewer, isBlacklisted in
- let now = Date()
- // Check if the current version is blacklisted, or if there is a newer version available
- if isBlacklisted {
- let lastBlacklistShown = Storage.shared.lastBlacklistNotificationShown.value ?? Date.distantPast
- if now.timeIntervalSince(lastBlacklistShown) > 86400 { // 24 hours
- self.versionAlert(message: "The current version has a critical issue and should be updated as soon as possible.")
- Storage.shared.lastBlacklistNotificationShown.value = now
- Storage.shared.lastVersionUpdateNotificationShown.value = now
- }
- } else if isNewer {
- let lastVersionUpdateShown = Storage.shared.lastVersionUpdateNotificationShown.value ?? Date.distantPast
- if now.timeIntervalSince(lastVersionUpdateShown) > 1_209_600 { // 2 weeks
- self.versionAlert(message: "A new version is available: \(latestVersion ?? "Unknown"). It is recommended to update.")
- Storage.shared.lastVersionUpdateNotificationShown.value = now
- }
- }
- }
- }
- func versionAlert(title: String = "Update Available", message: String) {
- DispatchQueue.main.async {
- let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
- alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
- self.present(alert, animated: true)
- }
- }
- func checkAppExpirationStatus() {
- let now = Date()
- let expirationDate = BuildDetails.default.calculateExpirationDate()
- let weekBeforeExpiration = Calendar.current.date(byAdding: .day, value: -7, to: expirationDate)!
- if now >= weekBeforeExpiration {
- let lastExpirationShown = Storage.shared.lastExpirationNotificationShown.value ?? Date.distantPast
- if now.timeIntervalSince(lastExpirationShown) > 86400 { // 24 hours
- expirationAlert()
- Storage.shared.lastExpirationNotificationShown.value = now
- }
- }
- }
- func expirationAlert() {
- DispatchQueue.main.async {
- 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)
- alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
- self.present(alert, animated: true)
- }
- }
- @objc override func viewDidAppear(_: Bool) {
- showHideNSDetails()
- }
- func stringFromTimeInterval(interval: TimeInterval) -> String {
- let interval = Int(interval)
- let minutes = (interval / 60) % 60
- let hours = (interval / 3600)
- return String(format: "%02d:%02d", hours, minutes)
- }
- private func updateNightscoutTabState() {
- guard let tabBarController = tabBarController,
- let viewControllers = tabBarController.viewControllers else { return }
- let isNightscoutEnabled = !Storage.shared.url.value.isEmpty
- for (index, vc) in viewControllers.enumerated() {
- if vc is NightscoutViewController {
- tabBarController.tabBar.items?[index].isEnabled = isNightscoutEnabled
- }
- }
- }
- func showHideNSDetails() {
- if isInitialLoad || !isDataSourceConfigured() {
- return
- }
- var isHidden = false
- if !IsNightscoutEnabled() {
- isHidden = true
- }
- LoopStatusLabel.isHidden = isHidden
- if IsNotLooping {
- PredictionLabel.isHidden = true
- } else {
- PredictionLabel.isHidden = isHidden
- }
- infoTable.isHidden = isHidden
- if Storage.shared.hideInfoTable.value {
- infoTable.isHidden = true
- }
- updateNightscoutTabState()
- }
- func updateBadge(val: Int) {
- if Storage.shared.appBadge.value {
- let latestBG = String(val)
- UIApplication.shared.applicationIconBadgeNumber = Int(Localizer.removePeriodAndCommaForBadge(Localizer.toDisplayUnits(latestBG))) ?? val
- } else {
- UIApplication.shared.applicationIconBadgeNumber = 0
- }
- }
- func setBGTextColor() {
- if bgData.count > 0 {
- let latestBG = bgData[bgData.count - 1].sgv
- var color = NSUIColor.label
- if Storage.shared.colorBGText.value {
- if Double(latestBG) >= Storage.shared.highLine.value {
- color = NSUIColor.systemYellow
- Observable.shared.bgTextColor.value = .yellow
- } else if Double(latestBG) <= Storage.shared.lowLine.value {
- color = NSUIColor.systemRed
- Observable.shared.bgTextColor.value = .red
- } else {
- color = NSUIColor.systemGreen
- Observable.shared.bgTextColor.value = .green
- }
- } else {
- Observable.shared.bgTextColor.value = .primary
- }
- BGText.textColor = color
- }
- }
- func updateAppearance(_ mode: AppearanceMode) {
- guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
- let window = windowScene.windows.first else { return }
- let style: UIUserInterfaceStyle
- switch mode {
- case .light:
- style = .light
- case .dark:
- style = .dark
- case .system:
- // Use .unspecified to follow system
- style = .unspecified
- }
- // Update this view controller
- overrideUserInterfaceStyle = style
- // Update the tab bar controller (affects all tabs)
- tabBarController?.overrideUserInterfaceStyle = style
- // Update the window (affects the entire app including modals)
- window.overrideUserInterfaceStyle = style
- }
- override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
- super.traitCollectionDidChange(previousTraitCollection)
- // When system appearance changes and we're in "System" mode, notify all observers
- if Storage.shared.appearanceMode.value == .system,
- previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
- {
- // Post notification so other view controllers can update if needed
- NotificationCenter.default.post(name: .appearanceDidChange, object: nil)
- }
- }
- func bgDirectionGraphic(_ value: String) -> String {
- 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":"-"]
- graphics: [String: String] = ["Flat": "→", "DoubleUp": "↑↑", "SingleUp": "↑", "FortyFiveUp": "↗", "FortyFiveDown": "↘︎", "SingleDown": "↓", "DoubleDown": "↓↓", "None": "-", "NONE": "-", "NOT COMPUTABLE": "-", "RATE OUT OF RANGE": "-", "": "-"]
- return graphics[value]!
- }
- func writeCalendar() {
- store.requestCalendarAccess { granted, error in
- if !granted {
- LogManager.shared.log(category: .calendar, message: "Failed to get calendar access: \(String(describing: error))")
- return
- }
- self.processCalendarUpdates()
- }
- }
- func processCalendarUpdates() {
- if Storage.shared.calendarIdentifier.value == "" { return }
- if bgData.count < 1 { return }
- // This lets us fire the method to write Min Ago entries only once a minute starting after 6 minutes but allows new readings through
- let now = dateTimeUtils.getNowTimeIntervalUTC()
- let newestBGDate = bgData[bgData.count - 1].date
- if lastCalDate == newestBGDate {
- if (now - lastCalendarWriteAttemptTime) < 60 || (now - newestBGDate) < 360 {
- return
- }
- }
- // Create Event info
- var deltaBG = 0 // protect index out of bounds
- if bgData.count > 1 {
- deltaBG = bgData[bgData.count - 1].sgv - bgData[bgData.count - 2].sgv as Int
- }
- let deltaTime = (TimeInterval(Date().timeIntervalSince1970) - bgData[bgData.count - 1].date) / 60
- var deltaString = ""
- if deltaBG < 0 {
- deltaString = Localizer.toDisplayUnits(String(deltaBG))
- } else {
- deltaString = "+" + Localizer.toDisplayUnits(String(deltaBG))
- }
- let direction = bgDirectionGraphic(bgData[bgData.count - 1].direction ?? "")
- let eventStartDate = Date(timeIntervalSince1970: bgData[bgData.count - 1].date)
- var eventEndDate = eventStartDate.addingTimeInterval(60 * 10)
- var eventTitle = Storage.shared.watchLine1.value
- if Storage.shared.watchLine2.value.count > 1 {
- eventTitle += "\n" + Storage.shared.watchLine2.value
- }
- eventTitle = eventTitle.replacingOccurrences(of: "%BG%", with: Localizer.toDisplayUnits(String(bgData[bgData.count - 1].sgv)))
- eventTitle = eventTitle.replacingOccurrences(of: "%DIRECTION%", with: direction)
- eventTitle = eventTitle.replacingOccurrences(of: "%DELTA%", with: deltaString)
- if currentOverride != 1.0 {
- let val = Int(currentOverride * 100)
- // let overrideText = String(format:"%f1", self.currentOverride*100)
- let text = String(val) + "%"
- eventTitle = eventTitle.replacingOccurrences(of: "%OVERRIDE%", with: text)
- } else {
- eventTitle = eventTitle.replacingOccurrences(of: "%OVERRIDE%", with: "")
- }
- eventTitle = eventTitle.replacingOccurrences(of: "%LOOP%", with: latestLoopStatusString)
- var minAgo = ""
- if deltaTime > 9 {
- // write old BG reading and continue pushing out end date to show last entry
- minAgo = String(Int(deltaTime)) + " min"
- eventEndDate = eventStartDate.addingTimeInterval((60 * 10) + (deltaTime * 60))
- }
- var basal = "~"
- if latestBasal != "" {
- basal = latestBasal
- }
- eventTitle = eventTitle.replacingOccurrences(of: "%MINAGO%", with: minAgo)
- eventTitle = eventTitle.replacingOccurrences(of: "%IOB%", with: latestIOB?.formattedValue() ?? "0")
- eventTitle = eventTitle.replacingOccurrences(of: "%COB%", with: latestCOB?.formattedValue() ?? "0")
- eventTitle = eventTitle.replacingOccurrences(of: "%BASAL%", with: basal)
- // Delete Events from last 2 hours and 2 hours in future
- let deleteStartDate = Date().addingTimeInterval(-60 * 60 * 2)
- let deleteEndDate = Date().addingTimeInterval(60 * 60 * 2)
- // guard solves for some ios upgrades removing the calendar
- guard let deleteCalendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value) as? EKCalendar else { return }
- let predicate2 = store.predicateForEvents(withStart: deleteStartDate, end: deleteEndDate, calendars: [deleteCalendar])
- let eVDelete = store.events(matching: predicate2) as [EKEvent]?
- if eVDelete != nil {
- for i in eVDelete! {
- do {
- try store.remove(i, span: EKSpan.thisEvent, commit: true)
- } catch {
- LogManager.shared.log(category: .calendar, message: "Failed to remove calendar event: \(error.localizedDescription)")
- }
- }
- }
- // Write New Event
- let event = EKEvent(eventStore: store)
- event.title = eventTitle
- event.startDate = eventStartDate
- event.endDate = eventEndDate
- event.calendar = store.calendar(withIdentifier: Storage.shared.calendarIdentifier.value)
- do {
- try store.save(event, span: .thisEvent, commit: true)
- lastCalendarWriteAttemptTime = now
- lastCalDate = bgData[bgData.count - 1].date
- } catch {
- let msg = "Error storing to calendar: \(error.localizedDescription) (\(error))"
- LogManager.shared.log(category: .calendar, message: msg)
- }
- }
- func userNotificationCenter(_: UNUserNotificationCenter, didReceive _: UNNotificationResponse, withCompletionHandler _: @escaping () -> Void) {}
- // User has scrolled the chart
- func chartTranslated(_: ChartViewBase, dX _: CGFloat, dY _: CGFloat) {
- let isViewingLatestData = abs(BGChart.highestVisibleX - BGChart.chartXMax) < 0.001
- if isViewingLatestData {
- autoScrollPauseUntil = nil // User is back at the latest data, allow auto-scrolling
- } else {
- autoScrollPauseUntil = Date().addingTimeInterval(5 * 60) // User is viewing historical data, pause auto-scrolling
- }
- }
- func calculateMaxBgGraphValue() -> Float {
- return max(Float(topBG), Float(topPredictionBG))
- }
- func loadDebugData() {
- struct DebugData: Codable {
- let debug: Bool?
- let url: String?
- let token: String?
- }
- let fileManager = FileManager.default
- let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("debugData.json")
- if fileManager.fileExists(atPath: url.path) {
- do {
- let data = try Data(contentsOf: url)
- let decoder = JSONDecoder()
- decoder.dateDecodingStrategy = .iso8601
- let debugData = try decoder.decode(DebugData.self, from: data)
- LogManager.shared.log(category: .alarm, message: "Loaded DebugData from \(url.path)", isDebug: true)
- if let debug = debugData.debug {
- Observable.shared.debug.value = debug
- }
- if let url = debugData.url {
- Storage.shared.url.value = url
- }
- if let token = debugData.token {
- Storage.shared.token.value = token
- }
- } catch {
- LogManager.shared.log(category: .alarm, message: "Failed to load DebugData: \(error)", isDebug: true)
- }
- }
- }
- private func synchronizeInfoTypes() {
- var sortArray = Storage.shared.infoSort.value
- var visibleArray = Storage.shared.infoVisible.value
- // Current valid indices based on InfoType
- let currentValidIndices = InfoType.allCases.map { $0.rawValue }
- // Add missing indices to sortArray
- for index in currentValidIndices {
- if !sortArray.contains(index) {
- sortArray.append(index)
- }
- }
- // Remove deprecated indices
- sortArray = sortArray.filter { currentValidIndices.contains($0) }
- // Ensure visibleArray is updated with new entries
- if visibleArray.count < currentValidIndices.count {
- for i in visibleArray.count ..< currentValidIndices.count {
- visibleArray.append(InfoType(rawValue: i)?.defaultVisible ?? false)
- }
- }
- // Trim excess elements if there are more than needed
- if visibleArray.count > currentValidIndices.count {
- visibleArray = Array(visibleArray.prefix(currentValidIndices.count))
- }
- Storage.shared.infoSort.value = sortArray
- Storage.shared.infoVisible.value = visibleArray
- }
- // MARK: - First Time Setup
- private func checkAndShowImportButtonIfNeeded() {
- // Check if this is first-time setup (no data source configured)
- let isFirstTimeSetup = !isDataSourceConfigured()
- if isFirstTimeSetup {
- setupFirstTimeButtons()
- hideAllDataUI()
- // Hide loading overlay if it's showing and mark as not loading
- if loadingOverlay != nil {
- isInitialLoad = false
- hideLoadingOverlay()
- }
- } else {
- hideFirstTimeButtons()
- // Only show data UI if we're not in initial loading state
- if !isInitialLoad || loadingOverlay == nil {
- showAllDataUI()
- }
- }
- }
- private func setupFirstTimeButtons() {
- // Create Setup Nightscout button
- if setupNightscoutButton == nil {
- setupNightscoutButton = UIButton(type: .system)
- setupNightscoutButton.setTitle("Setup Nightscout", for: .normal)
- setupNightscoutButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
- setupNightscoutButton.backgroundColor = UIColor.systemBlue
- setupNightscoutButton.setTitleColor(.white, for: .normal)
- setupNightscoutButton.layer.cornerRadius = 12
- setupNightscoutButton.layer.shadowColor = UIColor.black.cgColor
- setupNightscoutButton.layer.shadowOffset = CGSize(width: 0, height: 2)
- setupNightscoutButton.layer.shadowOpacity = 0.3
- setupNightscoutButton.layer.shadowRadius = 4
- setupNightscoutButton.addTarget(self, action: #selector(setupNightscoutTapped), for: .touchUpInside)
- view.addSubview(setupNightscoutButton)
- setupNightscoutButton.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- setupNightscoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
- setupNightscoutButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -30),
- setupNightscoutButton.widthAnchor.constraint(equalToConstant: 200),
- setupNightscoutButton.heightAnchor.constraint(equalToConstant: 50),
- ])
- }
- // Create Setup Dexcom Share button
- if setupDexcomButton == nil {
- setupDexcomButton = UIButton(type: .system)
- setupDexcomButton.setTitle("Setup Dexcom Share", for: .normal)
- setupDexcomButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .medium)
- setupDexcomButton.backgroundColor = UIColor.systemGreen
- setupDexcomButton.setTitleColor(.white, for: .normal)
- setupDexcomButton.layer.cornerRadius = 12
- setupDexcomButton.layer.shadowColor = UIColor.black.cgColor
- setupDexcomButton.layer.shadowOffset = CGSize(width: 0, height: 2)
- setupDexcomButton.layer.shadowOpacity = 0.3
- setupDexcomButton.layer.shadowRadius = 4
- setupDexcomButton.addTarget(self, action: #selector(setupDexcomTapped), for: .touchUpInside)
- view.addSubview(setupDexcomButton)
- setupDexcomButton.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- setupDexcomButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
- setupDexcomButton.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: 30),
- setupDexcomButton.widthAnchor.constraint(equalToConstant: 200),
- setupDexcomButton.heightAnchor.constraint(equalToConstant: 50),
- ])
- }
- setupNightscoutButton.isHidden = false
- setupDexcomButton.isHidden = false
- }
- private func hideFirstTimeButtons() {
- setupNightscoutButton?.isHidden = true
- setupDexcomButton?.isHidden = true
- }
- @objc private func setupNightscoutTapped() {
- let nightscoutSettingsView = NightscoutSettingsView(viewModel: .init())
- let hostingController = UIHostingController(rootView: nightscoutSettingsView)
- let navController = UINavigationController(rootViewController: hostingController)
- // Apply appearance mode
- let style = Storage.shared.appearanceMode.value.userInterfaceStyle
- hostingController.overrideUserInterfaceStyle = style
- navController.overrideUserInterfaceStyle = style
- // Add a Done button
- hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem(
- image: UIImage(systemName: "checkmark"),
- style: .plain,
- target: self,
- action: #selector(dismissModal)
- )
- hostingController.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
- navController.modalPresentationStyle = .pageSheet
- present(navController, animated: true)
- }
- @objc private func setupDexcomTapped() {
- let dexcomSettingsView = DexcomSettingsView(viewModel: .init())
- let hostingController = UIHostingController(rootView: dexcomSettingsView)
- let navController = UINavigationController(rootViewController: hostingController)
- // Apply appearance mode
- let style = Storage.shared.appearanceMode.value.userInterfaceStyle
- hostingController.overrideUserInterfaceStyle = style
- navController.overrideUserInterfaceStyle = style
- // Add a Done button
- hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem(
- image: UIImage(systemName: "checkmark"),
- style: .plain,
- target: self,
- action: #selector(dismissModal)
- )
- hostingController.navigationItem.rightBarButtonItem?.tintColor = .systemBlue
- navController.modalPresentationStyle = .pageSheet
- present(navController, animated: true)
- }
- private func hideGraphs() {
- BGChart.isHidden = true
- BGChartFull.isHidden = true
- }
- private func showGraphs() {
- updateGraphVisibility()
- }
- private func hideAllDataUI() {
- // Hide graphs
- BGChart.isHidden = true
- BGChartFull.isHidden = true
- // Hide BG display elements
- BGText.isHidden = true
- DeltaText.isHidden = true
- DirectionText.isHidden = true
- MinAgoText.isHidden = true
- serverText.isHidden = true
- // Hide info table and stats
- infoTable.isHidden = true
- statsView.isHidden = true
- // Hide loop status and prediction
- LoopStatusLabel.isHidden = true
- PredictionLabel.isHidden = true
- }
- private func showAllDataUI() {
- // Show BG display elements
- BGText.isHidden = false
- DeltaText.isHidden = false
- DirectionText.isHidden = false
- MinAgoText.isHidden = false
- serverText.isHidden = false
- // Show graphs based on settings
- updateGraphVisibility()
- // Show/hide info table and stats based on user settings
- let isNightscoutEnabled = IsNightscoutEnabled()
- if isNightscoutEnabled {
- infoTable.isHidden = Storage.shared.hideInfoTable.value
- LoopStatusLabel.isHidden = false
- PredictionLabel.isHidden = IsNotLooping
- } else {
- infoTable.isHidden = true
- LoopStatusLabel.isHidden = true
- PredictionLabel.isHidden = true
- }
- statsView.isHidden = !Storage.shared.showStats.value
- }
- private func updateGraphVisibility() {
- let isFirstTimeSetup = !isDataSourceConfigured()
- if isFirstTimeSetup {
- BGChart.isHidden = true
- BGChartFull.isHidden = true
- } else {
- BGChart.isHidden = false
- BGChartFull.isHidden = !Storage.shared.showSmallGraph.value
- }
- }
- @objc private func importSettingsButtonTapped() {
- presentImportSettingsView()
- }
- private func presentImportSettingsView() {
- let importExportView = ImportExportSettingsView()
- let hostingController = UIHostingController(rootView: importExportView)
- hostingController.modalPresentationStyle = .pageSheet
- present(hostingController, animated: true)
- }
- @objc private func dismissModal() {
- dismiss(animated: true) { [weak self] in
- guard let self = self else { return }
- // Check if user just configured a data source
- if self.isDataSourceConfigured(), self.loadingOverlay == nil {
- // Reset loading states for fresh load
- self.loadingStates = [
- "bg": false,
- "profile": false,
- "deviceStatus": false,
- ]
- self.isInitialLoad = true
- // Show loading overlay and trigger refresh
- self.setupLoadingState()
- self.showLoadingOverlay()
- self.refresh()
- }
- }
- }
- }
- extension MainViewController: AVSpeechSynthesizerDelegate {
- func speechSynthesizer(_: AVSpeechSynthesizer, didFinish _: AVSpeechUtterance) {
- let appState = UIApplication.shared.applicationState
- let isSilentTuneMode = Storage.shared.backgroundRefreshType.value == .silentTune
- if isSilentTuneMode, appState == .background {
- LogManager.shared.log(category: .general, message: "Silent tune active in background; not deactivating session.", isDebug: true)
- } else {
- do {
- try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
- LogManager.shared.log(category: .general, message: "Audio session deactivated after speech.", isDebug: true)
- } catch {
- LogManager.shared.log(category: .alarm, message: "Failed to deactivate audio session: \(error)")
- }
- }
- }
- }
|