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