BannerManager.swift 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. // LoopFollow
  2. // BannerManager.swift
  3. import Foundation
  4. import ShareClient
  5. /// Identifies who produced a banner message. Each source owns at most one
  6. /// message at a time; reporting a new message for a source replaces its old one.
  7. enum BannerSource: Hashable {
  8. case nightscout
  9. case dexcom
  10. case heartbeat
  11. case custom(String)
  12. }
  13. enum BannerSeverity: Int, Comparable {
  14. case info
  15. case warning
  16. case error
  17. static func < (lhs: BannerSeverity, rhs: BannerSeverity) -> Bool {
  18. lhs.rawValue < rhs.rawValue
  19. }
  20. }
  21. struct BannerMessage: Equatable, Identifiable {
  22. let id: UUID
  23. let source: BannerSource
  24. let severity: BannerSeverity
  25. let text: String
  26. let timestamp: Date
  27. }
  28. /// App-wide banner state. Producers call `report`/`clear`; the view layer
  29. /// observes `Observable.shared.activeBanner` and calls `dismissCurrent()`.
  30. final class BannerManager {
  31. static let shared = BannerManager()
  32. /// How long a user dismissal suppresses a still-occurring error before it re-appears.
  33. static let dismissCooldown: TimeInterval = 30 * 60
  34. /// Minimum time between Nightscout diagnostic probes (status.json calls).
  35. private static let diagnosticInterval: TimeInterval = 5 * 60
  36. // All state is read and mutated on the main queue only.
  37. private var messages: [BannerSource: BannerMessage] = [:]
  38. private var dismissals: [BannerSource: (until: Date, text: String)] = [:]
  39. private var lastNightscoutDiagnostic: Date?
  40. private init() {}
  41. func report(source: BannerSource, severity: BannerSeverity = .error, text: String) {
  42. DispatchQueue.main.async {
  43. if let existing = self.messages[source], existing.text == text, existing.severity == severity {
  44. // Same problem re-reported (fetches retry every 10-60 s): keep the
  45. // message untouched so the banner doesn't re-animate, but publish in
  46. // case a dismissal cooldown has expired since the last report.
  47. } else {
  48. // A different problem: show it even if the previous one was dismissed.
  49. self.dismissals[source] = nil
  50. self.messages[source] = BannerMessage(
  51. id: UUID(),
  52. source: source,
  53. severity: severity,
  54. text: text,
  55. timestamp: Date()
  56. )
  57. }
  58. self.publish()
  59. }
  60. }
  61. func clear(_ source: BannerSource) {
  62. DispatchQueue.main.async {
  63. guard self.messages[source] != nil || self.dismissals[source] != nil else { return }
  64. self.messages[source] = nil
  65. self.dismissals[source] = nil
  66. self.publish()
  67. }
  68. }
  69. func dismissCurrent() {
  70. DispatchQueue.main.async {
  71. guard let current = Observable.shared.activeBanner.value else { return }
  72. self.dismissals[current.source] = (Date().addingTimeInterval(Self.dismissCooldown), current.text)
  73. self.publish()
  74. }
  75. }
  76. /// Classifies a failed Nightscout fetch by probing status.json, so the banner
  77. /// can say *why* it failed (invalid token, site not found, no network, ...)
  78. /// instead of showing a generic decode/transport error.
  79. func reportNightscoutFetchFailure(_ underlying: Error) {
  80. DispatchQueue.main.async {
  81. if let last = self.lastNightscoutDiagnostic, Date().timeIntervalSince(last) < Self.diagnosticInterval {
  82. return
  83. }
  84. self.lastNightscoutDiagnostic = Date()
  85. NightscoutUtils.verifyURLAndToken { error, _, _, _ in
  86. if let error = error {
  87. if case .emptyAddress = error {
  88. // URL was removed while a fetch was in flight — not an error state.
  89. self.clear(.nightscout)
  90. return
  91. }
  92. self.report(
  93. source: .nightscout,
  94. severity: .error,
  95. text: "Nightscout: \(error.localizedDescription)"
  96. )
  97. } else {
  98. // Server reachable and token accepted, yet the data fetch failed.
  99. LogManager.shared.log(
  100. category: .nightscout,
  101. message: "Nightscout diagnostic OK but data fetch failed: \(underlying)",
  102. limitIdentifier: "Nightscout diagnostic OK but data fetch failed"
  103. )
  104. self.report(
  105. source: .nightscout,
  106. severity: .warning,
  107. text: "Nightscout: data download failed, but the server is reachable. Retrying automatically."
  108. )
  109. }
  110. }
  111. }
  112. }
  113. func reportDexcomFailure(_ error: ShareError, nightscoutFallback: Bool) {
  114. var text: String
  115. switch error {
  116. case let .loginError(errorCode):
  117. // Dexcom has returned both legacy "SSO_Authenticate…" codes and newer
  118. // ones like "AccountPasswordInvalid" — match on the common substrings.
  119. if errorCode.contains("AccountNotFound") {
  120. text = "Dexcom Share: account not found — check your username."
  121. } else if errorCode.contains("PasswordInvalid") {
  122. text = "Dexcom Share: incorrect username or password."
  123. } else if errorCode.contains("MaxAttempts") {
  124. text = "Dexcom Share: too many failed login attempts — temporarily locked out."
  125. } else {
  126. text = "Dexcom Share: login failed (\(errorCode))."
  127. }
  128. case .httpError:
  129. text = "Dexcom Share: network error while downloading."
  130. case .fetchError, .dataError, .dateError:
  131. text = "Dexcom Share: could not download readings."
  132. }
  133. if nightscoutFallback {
  134. text += " Using Nightscout as backup."
  135. }
  136. report(source: .dexcom, severity: nightscoutFallback ? .warning : .error, text: text)
  137. }
  138. /// Pushes the highest-priority non-dismissed message to the UI.
  139. private func publish() {
  140. let now = Date()
  141. let candidate = messages.values
  142. .filter { message in
  143. guard let dismissal = dismissals[message.source] else { return true }
  144. return now >= dismissal.until || dismissal.text != message.text
  145. }
  146. .max { lhs, rhs in
  147. (lhs.severity, lhs.timestamp) < (rhs.severity, rhs.timestamp)
  148. }
  149. if Observable.shared.activeBanner.value != candidate {
  150. Observable.shared.activeBanner.set(candidate)
  151. }
  152. }
  153. }