Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 136 additions & 83 deletions LoopFollow.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions LoopFollow/Application/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions LoopFollow/BackgroundRefresh/BT/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ class BLEManager: NSObject, ObservableObject {
private var centralManager: CBCentralManager!
private var activeDevice: BluetoothDevice?

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

Expand Down Expand Up @@ -49,6 +64,8 @@ class BLEManager: NSObject, ObservableObject {
activeDevice = nil
device.lastHeartbeatTime = nil
}
heartbeatDropoutTimes.removeAll()
BannerManager.shared.clear(.heartbeat)
Storage.shared.selectedBLEDevice.value = nil
}

Expand Down Expand Up @@ -208,6 +225,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)")
}
Expand All @@ -216,6 +234,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 {
Expand Down
75 changes: 75 additions & 0 deletions LoopFollow/Banner/AppBannerView.swift
Original file line number Diff line number Diff line change
@@ -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 changes: 172 additions & 0 deletions LoopFollow/Banner/BannerManager.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading