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