Преглед изворни кода

Add dismissable top banner for Nightscout, Dexcom, and heartbeat issues (#696)

* Add dismissable top banner for Nightscout and Dexcom errors

Failed BG downloads were only visible in the log viewer, and a failed
Dexcom Share login silently fell back to Nightscout. Users with a bad
token, mistyped URL, or wrong Dexcom credentials saw no data with no
explanation.

Add a banner above the tab view (visible on every tab, pushes content
down) that surfaces these failures with a clear cause:

- Nightscout fetch failures trigger a rate-limited status.json probe
  to classify the error (invalid token, token required, site not
  found, no network) using the existing NightscoutError messages.
- Dexcom Share errors are mapped to friendly text (account not found,
  incorrect username or password, too many attempts), including the
  current AccountPasswordInvalid code alongside the legacy SSO_ ones.
  The previously silent Nightscout fallback is now shown as a warning.
- Banners clear automatically on a successful fetch or when the
  source is deconfigured. Dismissing a banner suppresses that exact
  error for 30 minutes; a different error shows immediately.
- Repeated failing retries reuse the same message so the banner does
  not re-animate every cycle.

Also fix verifyURLAndToken transport-error classification, which
reported DNS/connection failures as siteNotFound and everything else
as networkError; it now distinguishes them by URLError code, which
also improves the settings status label.

* Add banner warning for frequent Bluetooth heartbeat dropouts
Jonas Björkert пре 1 недеља
родитељ
комит
26b8cdf52b

+ 18 - 1
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 */; };
@@ -477,6 +479,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 = "<group>"; };
+		098BA6FC4279B166734A0E13 /* BannerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BannerManager.swift; sourceTree = "<group>"; };
 		21EBF6ACF259782445DA1A7E /* DBSizeAlarmEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBSizeAlarmEditor.swift; sourceTree = "<group>"; };
 		2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = "<group>"; };
 		2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = "<group>"; };
@@ -531,6 +534,7 @@
 		65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsConfigurationView.swift; sourceTree = "<group>"; };
 		65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
 		65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
+		8000BC181BCE5AA8553FB3B6 /* AppBannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppBannerView.swift; sourceTree = "<group>"; };
 		6C508E0E86BEC0B13280F34B /* BGChartStubs.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BGChartStubs.swift; sourceTree = "<group>"; };
 		7C8CB992D98689FC7D019CEC /* BGChartModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BGChartModel.swift; sourceTree = "<group>"; };
 		8B81E18A2489A364AFC0A9E4 /* DBSize.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBSize.swift; sourceTree = "<group>"; };
@@ -1048,6 +1052,16 @@
 			path = Settings;
 			sourceTree = "<group>";
 		};
+		6A3463E271ADFF0E844E3E61 /* Banner */ = {
+			isa = PBXGroup;
+			children = (
+				098BA6FC4279B166734A0E13 /* BannerManager.swift */,
+				8000BC181BCE5AA8553FB3B6 /* AppBannerView.swift */,
+			);
+			name = Banner;
+			path = Banner;
+			sourceTree = "<group>";
+		};
 		6A5880E0B811AF443B05AB02 /* Frameworks */ = {
 			isa = PBXGroup;
 			children = (
@@ -1725,6 +1739,7 @@
 				DDC7E5CD2DC6637800EB1127 /* Storage */,
 				DDEF503D2D32753A00999A5D /* Task */,
 				FCC68871248A736700A0279D /* ViewControllers */,
+				6A3463E271ADFF0E844E3E61 /* Banner */,
 				CC1E389FE3B6E6EA3184474B /* Charts */,
 			);
 			path = LoopFollow;
@@ -2492,6 +2507,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 */,

+ 16 - 0
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)

+ 67 - 0
LoopFollow/BackgroundRefresh/BT/BLEManager.swift

@@ -14,6 +14,21 @@ class BLEManager: NSObject, ObservableObject {
     private var activeDevice: BluetoothDevice?
     private var readinessCancellable: AnyCancellable?
 
+    /// 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()
 
@@ -65,6 +80,8 @@ class BLEManager: NSObject, ObservableObject {
             activeDevice = nil
             device.lastHeartbeatTime = nil
         }
+        heartbeatDropoutTimes.removeAll()
+        BannerManager.shared.clear(.heartbeat)
         Storage.shared.selectedBLEDevice.value = nil
     }
 
@@ -224,6 +241,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)")
         }
@@ -232,6 +250,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 {

+ 75 - 0
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)
+        }
+    }
+}

+ 172 - 0
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)
+        }
+    }
+}

+ 6 - 0
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,

+ 8 - 2
LoopFollow/Helpers/NightscoutUtils.swift

@@ -257,8 +257,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)
                 }

+ 3 - 0
LoopFollow/Storage/Observable.swift

@@ -55,5 +55,8 @@ class Observable {
     /// Selected tab index used by SwiftUI TabView — set from MainViewController to switch tabs
     var selectedTabIndex = ObservableValue<Int>(default: 0)
 
+    /// Currently visible app-wide banner (nil = hidden). Managed by BannerManager.
+    var activeBanner = ObservableValue<BannerMessage?>(default: nil)
+
     private init() {}
 }

+ 8 - 0
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 == "",