// 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) } } }