diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index f3cea3e5e..dd55a9d3b 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -67,9 +67,11 @@ 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; + 87F526CE28FD073330D2F516 /* BannerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 098BA6FC4279B166734A0E13 /* BannerManager.swift */; }; + 8897B18D53EA2F1963B8B4E0 /* AppBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8000BC181BCE5AA8553FB3B6 /* AppBannerView.swift */; }; + 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; 92664FA270DA9905E1F08676 /* BGChartStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C508E0E86BEC0B13280F34B /* BGChartStubs.swift */; }; 9727B75402723679BD39CDE5 /* DBSizeTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBE9ECA96BEA20DBD21B570D /* DBSizeTask.swift */; }; - 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; 9F24B79D2722BB040AE3311F /* BGChartModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8CB992D98689FC7D019CEC /* BGChartModel.swift */; }; A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; }; A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; @@ -474,6 +476,7 @@ /* Begin PBXFileReference section */ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; + 098BA6FC4279B166734A0E13 /* BannerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BannerManager.swift; sourceTree = ""; }; 21EBF6ACF259782445DA1A7E /* DBSizeAlarmEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBSizeAlarmEditor.swift; sourceTree = ""; }; 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; @@ -528,6 +531,7 @@ 65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsConfigurationView.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; + 8000BC181BCE5AA8553FB3B6 /* AppBannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppBannerView.swift; sourceTree = ""; }; 6C508E0E86BEC0B13280F34B /* BGChartStubs.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BGChartStubs.swift; sourceTree = ""; }; 7C8CB992D98689FC7D019CEC /* BGChartModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BGChartModel.swift; sourceTree = ""; }; 8B81E18A2489A364AFC0A9E4 /* DBSize.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBSize.swift; sourceTree = ""; }; @@ -1042,6 +1046,16 @@ path = Settings; sourceTree = ""; }; + 6A3463E271ADFF0E844E3E61 /* Banner */ = { + isa = PBXGroup; + children = ( + 098BA6FC4279B166734A0E13 /* BannerManager.swift */, + 8000BC181BCE5AA8553FB3B6 /* AppBannerView.swift */, + ); + name = Banner; + path = Banner; + sourceTree = ""; + }; 6A5880E0B811AF443B05AB02 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -1717,6 +1731,7 @@ DDC7E5CD2DC6637800EB1127 /* Storage */, DDEF503D2D32753A00999A5D /* Task */, FCC68871248A736700A0279D /* ViewControllers */, + 6A3463E271ADFF0E844E3E61 /* Banner */, CC1E389FE3B6E6EA3184474B /* Charts */, ); path = LoopFollow; @@ -2480,6 +2495,8 @@ DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */, DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */, DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */, + 87F526CE28FD073330D2F516 /* BannerManager.swift in Sources */, + 8897B18D53EA2F1963B8B4E0 /* AppBannerView.swift in Sources */, 9F24B79D2722BB040AE3311F /* BGChartModel.swift in Sources */, 2F99F2B5C902F3018383C086 /* BGChartView.swift in Sources */, 92664FA270DA9905E1F08676 /* BGChartStubs.swift in Sources */, diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift index 140204358..1755f2fe8 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -13,6 +13,7 @@ struct MainTabView: View { @ObservedObject private var snoozerPosition = Storage.shared.snoozerPosition @ObservedObject private var statisticsPosition = Storage.shared.statisticsPosition @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition + @ObservedObject private var activeBanner = Observable.shared.activeBanner @State private var showTelemetryConsent = false @@ -21,6 +22,21 @@ struct MainTabView: View { } var body: some View { + // The banner sits in a VStack above the TabView (rather than a + // .safeAreaInset) so the UIKit-hosted Home content is physically + // pushed down too — safe-area changes don't propagate into + // UIViewControllerRepresentable. + VStack(spacing: 0) { + if let message = activeBanner.value { + AppBannerView(message: message) + .transition(.move(edge: .top).combined(with: .opacity)) + } + tabView + } + .animation(.easeInOut(duration: 0.25), value: activeBanner.value) + } + + private var tabView: some View { TabView(selection: $selectedTab.value) { ForEach(Array(orderedItems.prefix(4).enumerated()), id: \.element) { index, item in tabContent(for: item) diff --git a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift index c4a39874c..3513a68da 100644 --- a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift +++ b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift @@ -13,6 +13,21 @@ class BLEManager: NSObject, ObservableObject { private var centralManager: CBCentralManager! private var activeDevice: BluetoothDevice? + /// Arrival times of recent heartbeat dropouts. Main-queue-confined + /// (centralManager delivers on .main) and deliberately not persisted: + /// stale history must not resurrect the banner after a relaunch. + private var heartbeatDropoutTimes: [Date] = [] + + /// A beat this much later than expected counts as one dropout event. + /// Stricter than the 15% logging margin - normal jitter must not count. + private let dropoutFactor = 1.5 + + /// Sliding window over which dropout events are counted. + private let dropoutWindow: TimeInterval = 60 * 60 + + /// Dropout events within the window needed to raise the banner. + private let dropoutTriggerCount = 5 + override private init() { super.init() @@ -49,6 +64,8 @@ class BLEManager: NSObject, ObservableObject { activeDevice = nil device.lastHeartbeatTime = nil } + heartbeatDropoutTimes.removeAll() + BannerManager.shared.clear(.heartbeat) Storage.shared.selectedBLEDevice.value = nil } @@ -208,6 +225,7 @@ extension BLEManager: BluetoothDeviceDelegate { let delay = elapsedTime - expectedInterval LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (Delayed by \(String(format: "%.1f", delay)) seconds)") } + recordHeartbeatOutcome(elapsed: elapsedTime, expectedInterval: expectedInterval, now: now) } else { LogManager.shared.log(category: .bluetooth, message: "Heartbeat triggered (First heartbeat)") } @@ -216,6 +234,55 @@ extension BLEManager: BluetoothDeviceDelegate { TaskScheduler.shared.checkTasksNow() } + + /// Counts late heartbeats over a sliding window and raises a banner when + /// dropouts become frequent - the typical symptom of a dying transmitter + /// battery. Detection is arrival-based on purpose: a struggling battery + /// still delivers (late) beats, whereas total silence usually means + /// out-of-range or a dead device and is already surfaced by the + /// connection status in Background Refresh settings and by BG alarms. + private func recordHeartbeatOutcome(elapsed: TimeInterval, expectedInterval: TimeInterval, now: Date) { + heartbeatDropoutTimes.removeAll { now.timeIntervalSince($0) > dropoutWindow } + + // A gap this large means out-of-range, Bluetooth off or a suspended + // app - not a struggling battery. Discard the history collected + // before the blind spot instead of counting it. + let resetGap = max(6 * expectedInterval, 30 * 60) + if elapsed > resetGap { + if !heartbeatDropoutTimes.isEmpty { + heartbeatDropoutTimes.removeAll() + LogManager.shared.log(category: .bluetooth, message: "Heartbeat gap of \(Int(elapsed)) seconds, resetting dropout history") + } + } else if elapsed > expectedInterval * dropoutFactor { + heartbeatDropoutTimes.append(now) + LogManager.shared.log(category: .bluetooth, message: "Heartbeat dropout recorded (\(heartbeatDropoutTimes.count) in the last hour)") + } + + // Hysteresis: raise at the trigger count, clear only once the window + // is completely clean, so the banner doesn't flap at the boundary. + if heartbeatDropoutTimes.count >= dropoutTriggerCount { + BannerManager.shared.report(source: .heartbeat, severity: .warning, text: heartbeatDropoutBannerText()) + } else if heartbeatDropoutTimes.isEmpty { + BannerManager.shared.clear(.heartbeat) + } + } + + /// The text must stay stable while the condition persists (no counts or + /// durations) so BannerManager's dedupe keeps the banner from + /// re-animating on every beat and user dismissal keeps working. + private func heartbeatDropoutBannerText() -> String { + let name = activeDevice?.deviceName ?? "heartbeat device" + let cause: String + switch Storage.shared.backgroundRefreshType.value { + case .rileyLink: + cause = "a low RileyLink battery" + case .omnipodDash: + cause = "a low pod battery" + default: + cause = "a low transmitter battery" + } + return "Heartbeat: repeated Bluetooth dropouts from \(name). This can be a sign of \(cause) or a weak Bluetooth connection." + } } extension BLEManager { diff --git a/LoopFollow/Banner/AppBannerView.swift b/LoopFollow/Banner/AppBannerView.swift new file mode 100644 index 000000000..56d196909 --- /dev/null +++ b/LoopFollow/Banner/AppBannerView.swift @@ -0,0 +1,75 @@ +// LoopFollow +// AppBannerView.swift + +import SwiftUI + +/// Dismissable banner shown at the top of the app, above the tab content. +struct AppBannerView: View { + let message: BannerMessage + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: iconName) + .font(.title3) + .foregroundColor(iconColor) + + Text(message.text) + .font(.callout) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + BannerManager.shared.dismissCurrent() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundColor(.secondary) + } + .accessibilityLabel("Dismiss") + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(tint) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(border, lineWidth: 1) + ) + .padding(.horizontal, 10) + .padding(.vertical, 4) + } + + private var iconName: String { + switch message.severity { + case .info: return "info.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.octagon.fill" + } + } + + private var iconColor: Color { + switch message.severity { + case .info: return .blue + case .warning: return .orange + case .error: return .red + } + } + + private var tint: Color { + switch message.severity { + case .info: return Color.blue.opacity(0.20) + case .warning: return Color.orange.opacity(0.20) + case .error: return Color.red.opacity(0.20) + } + } + + private var border: Color { + switch message.severity { + case .info: return Color.blue.opacity(0.40) + case .warning: return Color.orange.opacity(0.40) + case .error: return Color.red.opacity(0.40) + } + } +} diff --git a/LoopFollow/Banner/BannerManager.swift b/LoopFollow/Banner/BannerManager.swift new file mode 100644 index 000000000..bfefb0e2c --- /dev/null +++ b/LoopFollow/Banner/BannerManager.swift @@ -0,0 +1,172 @@ +// LoopFollow +// BannerManager.swift + +import Foundation +import ShareClient + +/// Identifies who produced a banner message. Each source owns at most one +/// message at a time; reporting a new message for a source replaces its old one. +enum BannerSource: Hashable { + case nightscout + case dexcom + case heartbeat + case custom(String) +} + +enum BannerSeverity: Int, Comparable { + case info + case warning + case error + + static func < (lhs: BannerSeverity, rhs: BannerSeverity) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +struct BannerMessage: Equatable, Identifiable { + let id: UUID + let source: BannerSource + let severity: BannerSeverity + let text: String + let timestamp: Date +} + +/// App-wide banner state. Producers call `report`/`clear`; the view layer +/// observes `Observable.shared.activeBanner` and calls `dismissCurrent()`. +final class BannerManager { + static let shared = BannerManager() + + /// How long a user dismissal suppresses a still-occurring error before it re-appears. + static let dismissCooldown: TimeInterval = 30 * 60 + + /// Minimum time between Nightscout diagnostic probes (status.json calls). + private static let diagnosticInterval: TimeInterval = 5 * 60 + + // All state is read and mutated on the main queue only. + private var messages: [BannerSource: BannerMessage] = [:] + private var dismissals: [BannerSource: (until: Date, text: String)] = [:] + private var lastNightscoutDiagnostic: Date? + + private init() {} + + func report(source: BannerSource, severity: BannerSeverity = .error, text: String) { + DispatchQueue.main.async { + if let existing = self.messages[source], existing.text == text, existing.severity == severity { + // Same problem re-reported (fetches retry every 10-60 s): keep the + // message untouched so the banner doesn't re-animate, but publish in + // case a dismissal cooldown has expired since the last report. + } else { + // A different problem: show it even if the previous one was dismissed. + self.dismissals[source] = nil + self.messages[source] = BannerMessage( + id: UUID(), + source: source, + severity: severity, + text: text, + timestamp: Date() + ) + } + self.publish() + } + } + + func clear(_ source: BannerSource) { + DispatchQueue.main.async { + guard self.messages[source] != nil || self.dismissals[source] != nil else { return } + self.messages[source] = nil + self.dismissals[source] = nil + self.publish() + } + } + + func dismissCurrent() { + DispatchQueue.main.async { + guard let current = Observable.shared.activeBanner.value else { return } + self.dismissals[current.source] = (Date().addingTimeInterval(Self.dismissCooldown), current.text) + self.publish() + } + } + + /// Classifies a failed Nightscout fetch by probing status.json, so the banner + /// can say *why* it failed (invalid token, site not found, no network, ...) + /// instead of showing a generic decode/transport error. + func reportNightscoutFetchFailure(_ underlying: Error) { + DispatchQueue.main.async { + if let last = self.lastNightscoutDiagnostic, Date().timeIntervalSince(last) < Self.diagnosticInterval { + return + } + self.lastNightscoutDiagnostic = Date() + + NightscoutUtils.verifyURLAndToken { error, _, _, _ in + if let error = error { + if case .emptyAddress = error { + // URL was removed while a fetch was in flight — not an error state. + self.clear(.nightscout) + return + } + self.report( + source: .nightscout, + severity: .error, + text: "Nightscout: \(error.localizedDescription)" + ) + } else { + // Server reachable and token accepted, yet the data fetch failed. + LogManager.shared.log( + category: .nightscout, + message: "Nightscout diagnostic OK but data fetch failed: \(underlying)", + limitIdentifier: "Nightscout diagnostic OK but data fetch failed" + ) + self.report( + source: .nightscout, + severity: .warning, + text: "Nightscout: data download failed, but the server is reachable. Retrying automatically." + ) + } + } + } + } + + func reportDexcomFailure(_ error: ShareError, nightscoutFallback: Bool) { + var text: String + switch error { + case let .loginError(errorCode): + // Dexcom has returned both legacy "SSO_Authenticate…" codes and newer + // ones like "AccountPasswordInvalid" — match on the common substrings. + if errorCode.contains("AccountNotFound") { + text = "Dexcom Share: account not found — check your username." + } else if errorCode.contains("PasswordInvalid") { + text = "Dexcom Share: incorrect username or password." + } else if errorCode.contains("MaxAttempts") { + text = "Dexcom Share: too many failed login attempts — temporarily locked out." + } else { + text = "Dexcom Share: login failed (\(errorCode))." + } + case .httpError: + text = "Dexcom Share: network error while downloading." + case .fetchError, .dataError, .dateError: + text = "Dexcom Share: could not download readings." + } + + if nightscoutFallback { + text += " Using Nightscout as backup." + } + report(source: .dexcom, severity: nightscoutFallback ? .warning : .error, text: text) + } + + /// Pushes the highest-priority non-dismissed message to the UI. + private func publish() { + let now = Date() + let candidate = messages.values + .filter { message in + guard let dismissal = dismissals[message.source] else { return true } + return now >= dismissal.until || dismissal.text != message.text + } + .max { lhs, rhs in + (lhs.severity, lhs.timestamp) < (rhs.severity, rhs.timestamp) + } + + if Observable.shared.activeBanner.value != candidate { + Observable.shared.activeBanner.set(candidate) + } + } +} diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index 2d6c73a77..d2622f25a 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -22,6 +22,7 @@ extension MainViewController { dexShare?.fetchData(count) { err, result in if let error = err { LogManager.shared.log(category: .dexcom, message: "Error fetching Dexcom data: \(error.localizedDescription)", limitIdentifier: "Error fetching Dexcom data") + BannerManager.shared.reportDexcomFailure(error, nightscoutFallback: IsNightscoutEnabled()) self.webLoadNSBGData() return } @@ -32,6 +33,8 @@ extension MainViewController { return } + BannerManager.shared.clear(.dexcom) + // If Dex data is old, load from NS instead let latestDate = data[0].date let now = dateTimeUtils.getNowTimeIntervalUTC() @@ -60,6 +63,7 @@ extension MainViewController { func webLoadNSBGData(dexData: [ShareGlucoseData] = []) { // This kicks it out in the instance where dexcom fails but they aren't using NS && if !IsNightscoutEnabled() { + BannerManager.shared.clear(.nightscout) Storage.shared.lastBGChecked.value = Date() return } @@ -75,6 +79,7 @@ extension MainViewController { NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters) { (result: Result<[ShareGlucoseData], Error>) in switch result { case let .success(entriesResponse): + BannerManager.shared.clear(.nightscout) var nsData = entriesResponse DispatchQueue.main.async { // transform NS data to look like Dex data @@ -103,6 +108,7 @@ extension MainViewController { } case let .failure(error): LogManager.shared.log(category: .nightscout, message: "Failed to fetch bg data: \(error)", limitIdentifier: "Failed to fetch bg data") + BannerManager.shared.reportNightscoutFetchFailure(error) DispatchQueue.main.async { TaskScheduler.shared.rescheduleTask( id: .fetchBG, diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 0ea3c136c..44859da54 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -254,8 +254,14 @@ class NightscoutUtils { completion(.unknown, nil, false, false) } } else { - if let _ = error { - completion(.siteNotFound, nil, false, false) + if let urlError = error as? URLError { + switch urlError.code { + case .notConnectedToInternet, .networkConnectionLost, .timedOut, .dataNotAllowed: + completion(.networkError, nil, false, false) + default: + // cannotFindHost, cannotConnectToHost, etc. — the address is wrong or the site is down. + completion(.siteNotFound, nil, false, false) + } } else { completion(.networkError, nil, false, false) } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 7acd14652..c76e643ef 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -54,5 +54,8 @@ class Observable { /// Selected tab index used by SwiftUI TabView — set from MainViewController to switch tabs var selectedTabIndex = ObservableValue(default: 0) + /// Currently visible app-wide banner (nil = hidden). Managed by BannerManager. + var activeBanner = ObservableValue(default: nil) + private init() {} } diff --git a/LoopFollow/Task/BGTask.swift b/LoopFollow/Task/BGTask.swift index 08b5703a3..b5728970c 100644 --- a/LoopFollow/Task/BGTask.swift +++ b/LoopFollow/Task/BGTask.swift @@ -19,6 +19,14 @@ extension MainViewController { to: Date().addingTimeInterval(60) ) + // A deconfigured source can no longer be in an error state. + if Storage.shared.shareUserName.value == "" || Storage.shared.sharePassword.value == "" { + BannerManager.shared.clear(.dexcom) + } + if !IsNightscoutEnabled() { + BannerManager.shared.clear(.nightscout) + } + // If no Dexcom credentials and no Nightscout, schedule a retry in 60 seconds. if Storage.shared.shareUserName.value == "", Storage.shared.sharePassword.value == "",