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
211 changes: 211 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion LoopFollow/Alarm/AlarmManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ class AlarmManager {
if let actionTitle = actionTitle {
let action = UNNotificationAction(identifier: "snooze", title: actionTitle, options: [])
let category = UNNotificationCategory(identifier: "category", actions: [action], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([category])
// setNotificationCategories replaces the whole set, so include the base categories too.
UNUserNotificationCenter.current().setNotificationCategories(Set(AppDelegate.baseNotificationCategories() + [category]))
}
}
}
79 changes: 76 additions & 3 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,53 @@ import UserNotifications
class AppDelegate: UIResponder, UIApplicationDelegate {
let notificationCenter = UNUserNotificationCenter.current()

/// Category Trio sets on a "recommended bolus" response; must match Trio's identifier.
static let recommendedBolusCategoryIdentifier = "TRIO_RECOMMENDED_BOLUS"
/// Action on that category that opens the bolus screen pre-filled for review.
static let reviewBolusActionIdentifier = "REVIEW_BOLUS"

/// The app's full set of notification categories. Single source of truth so every caller of
/// setNotificationCategories (which replaces the whole set) registers all of them — otherwise a caller
/// that sets only its own category silently de-registers the others.
static func baseNotificationCategories() -> [UNNotificationCategory] {
let openAction = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
let backgroundCategory = UNNotificationCategory(
identifier: BackgroundAlertIdentifier.categoryIdentifier,
actions: [openAction],
intentIdentifiers: [],
options: []
)

// Category for a Trio "recommended bolus" response. The Review action (and tapping the body) opens
// the bolus screen pre-filled; .authenticationRequired keeps it behind device unlock.
let reviewAction = UNNotificationAction(
identifier: reviewBolusActionIdentifier,
title: "Review",
options: [.foreground, .authenticationRequired]
)
let recommendedBolusCategory = UNNotificationCategory(
identifier: recommendedBolusCategoryIdentifier,
actions: [reviewAction],
intentIdentifiers: [],
options: []
)

return [backgroundCategory, recommendedBolusCategory]
}

/// Removes any delivered "recommended bolus" notifications so a recommendation can't be tapped or
/// confirmed twice from Notification Center after it has been acted on or has expired.
static func removeDeliveredRecommendedBolusNotifications() {
UNUserNotificationCenter.current().getDeliveredNotifications { notifications in
let identifiers = notifications
.filter { $0.request.content.categoryIdentifier == recommendedBolusCategoryIdentifier }
.map { $0.request.identifier }
if !identifiers.isEmpty {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
}
}
}

func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
LogManager.shared.log(category: .general, message: "App started")
LogManager.shared.cleanupOldLogs()
Expand All @@ -29,9 +76,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
}

let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground)
let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: [])
UNUserNotificationCenter.current().setNotificationCategories([category])
UNUserNotificationCenter.current().setNotificationCategories(Set(AppDelegate.baseNotificationCategories()))

UNUserNotificationCenter.current().delegate = self

Expand Down Expand Up @@ -191,9 +236,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
AlarmManager.shared.performSnooze()
}

// A Trio "recommended bolus" review: tapping the body or the Review action opens the bolus screen
// pre-filled; the user still confirms via Face ID and the guardrails, so nothing is dosed here.
if response.actionIdentifier == AppDelegate.reviewBolusActionIdentifier
|| response.actionIdentifier == UNNotificationDefaultActionIdentifier
{
if let request = AppDelegate.reviewBolusRequest(from: response.notification.request.content.userInfo) {
// Presented from the tab root; MainTabView defers behind the first-launch consent sheet.
DispatchQueue.main.async {
Observable.shared.pendingReviewBolus.value = request
}
}
}

completionHandler()
}

/// Builds a review request from Trio's structured `recommended_bolus` field (units) and the response's
/// `timestamp` (send time). APNs delivers JSON numbers as NSNumber; strings are accepted defensively.
/// Returns nil when the amount is absent or not positive.
static func reviewBolusRequest(from userInfo: [AnyHashable: Any]) -> ReviewBolusRequest? {
func double(_ value: Any?) -> Double? {
if let number = value as? NSNumber { return number.doubleValue }
if let string = value as? String { return Double(string) }
return nil
}

guard let amount = double(userInfo["recommended_bolus"]), amount > 0 else { return nil }
let sentAt = double(userInfo["timestamp"]) ?? Date().timeIntervalSince1970
return ReviewBolusRequest(amount: amount, sentAt: sentAt)
}

func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
let forcePortrait = Storage.shared.forcePortraitMode.value

Expand Down
12 changes: 12 additions & 0 deletions LoopFollow/Application/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ struct MainTabView: View {
@ObservedObject private var statisticsPosition = Storage.shared.statisticsPosition
@ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition

@ObservedObject private var pendingReviewBolus = Observable.shared.pendingReviewBolus

@State private var showTelemetryConsent = false

private var orderedItems: [TabItem] {
Expand Down Expand Up @@ -60,6 +62,16 @@ struct MainTabView: View {
TelemetryConsentView()
.interactiveDismissDisabled(true)
}
// Presented from the tab root when the user taps a Trio review notification, gated behind the consent
// sheet so they never contend at first launch.
.sheet(isPresented: Binding(
get: { pendingReviewBolus.value != nil && !showTelemetryConsent },
set: { if !$0 { pendingReviewBolus.value = nil } }
)) {
if let request = pendingReviewBolus.value {
BolusView(reviewRequest: request)
}
}
}

@ViewBuilder
Expand Down
61 changes: 61 additions & 0 deletions LoopFollow/Controllers/Nightscout/BGData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import Foundation
import UIKit
import WidgetKit

extension MainViewController {
/// Number of days of BG history to request from the source. One extra day is
Expand Down Expand Up @@ -322,6 +323,9 @@ extension MainViewController {
LiveActivityManager.shared.refreshFromCurrentState(reason: "bg")
#endif

// Home screen widget update
self.updateWidgetData(entries)

// Update contact
if Storage.shared.contactEnabled.value {
self.contactImageUpdater
Expand All @@ -336,4 +340,61 @@ extension MainViewController {
Storage.shared.lastBGChecked.value = Date()
}
}

/// Must match the kind the widget registers itself under.
private static let widgetKind = "LoopFollowWidget"

/// Publishes what the home screen widget draws: its settings, the chart series
/// and the matching snapshot. The redraw is requested only once both files are
/// on disk, so the widget never renders a new chart against the previous
/// reading. Kept off `LiveActivityManager.refreshFromCurrentState`, whose
/// 20 second debounce is starved whenever BG fetches are rescheduled more
/// often than that.
/// - Parameter entries: readings ordered oldest first.
func updateWidgetData(_ entries: [ShareGlucoseData]) {
LAAppGroupSettings.setNightscout(url: Storage.shared.url.value, token: Storage.shared.token.value)
LAAppGroupSettings.setDisplayName(
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow",
show: Storage.shared.showDisplayName.value
)

// The widget and the Live Activity color their readings by these, so they
// use the same thresholds as the graph and the stats. LiveActivityManager
// publishes the same values on its own paths.
let thresholds = UnitSettingsStore.shared.effectiveThresholds()
LAAppGroupSettings.setThresholds(lowMgdl: thresholds.low, highMgdl: thresholds.high)

// The chart outlives the snapshot, so the unit has to be readable on its
// own rather than only from the reading.
LAAppGroupSettings.setPreferredUnit(PreferredGlucoseUnit.snapshotUnit())

// Clamp plotted BG to the display range, as the app's own graph does
// (see #600), so an out-of-range sgv cannot distort the chart scale.
let minDisplay = globalVariables.minDisplayGlucose
let maxDisplay = globalVariables.maxDisplayGlucose

let cutoff = dateTimeUtils.getNowTimeIntervalUTC() - GlucoseChartSeriesStore.window
let points = entries
.filter { $0.date >= cutoff }
.map { GlucoseChartPoint(value: Double(min(max($0.sgv, minDisplay), maxDisplay)),
date: Date(timeIntervalSince1970: $0.date)) }

// Without a snapshot there is no reading to draw the chart against, so
// skip the whole publish rather than let the two surfaces diverge.
guard let snapshot = GlucoseSnapshotBuilder.build(from: StorageCurrentGlucoseStateProvider()) else { return }

// A fetch that brings nothing new still lands here, and during a sensor
// gap it lands every few seconds, so reload only on new data. Read both
// stores before saving, or the comparisons are always equal.
let publishedSeriesEnd = GlucoseChartSeriesStore.shared.load()?.points.last?.date
let seriesUnchanged = points.last?.date == publishedSeriesEnd
let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot
guard !seriesUnchanged || !snapshotUnchanged else { return }

GlucoseChartSeriesStore.shared.save(GlucoseChartSeries(points: points, updatedAt: Date())) {
GlucoseSnapshotStore.shared.save(snapshot) {
WidgetCenter.shared.reloadTimelines(ofKind: MainViewController.widgetKind)
}
}
}
}
2 changes: 2 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,12 @@ extension MainViewController {
latestPumpVolume = reservoirData
infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U")
Storage.shared.lastPumpReservoirU.value = reservoirData
Storage.shared.lastPumpReservoirAboveMax.value = false
} else {
latestPumpVolume = 50.0
infoManager.updateInfoData(type: .pump, value: "50+U")
Storage.shared.lastPumpReservoirU.value = nil
Storage.shared.lastPumpReservoirAboveMax.value = true
}
}

Expand Down
1 change: 1 addition & 0 deletions LoopFollow/LiveActivity/APNSClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ class APNSClient {
if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery }
if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate }
if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU }
if snapshot.pumpReservoirAboveMax { snapshotDict["pumpReservoirAboveMax"] = true }
if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens }
if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd }
if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl }
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/LiveActivity/AppGroupID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ enum AppGroupID {
".LiveActivity",
".LiveActivityExtension",
".LoopFollowLAExtension",
".LoopFollowWidget",
".Widget",
".WidgetExtension",
".Widgets",
Expand Down
133 changes: 133 additions & 0 deletions LoopFollow/LiveActivity/GlucoseChartSeries.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// LoopFollow
// GlucoseChartSeries.swift

import Foundation

/// A single glucose reading plotted on the home screen widget chart.
struct GlucoseChartPoint: Codable, Equatable, Hashable {
/// Glucose value in mg/dL (canonical internal unit).
let value: Double

/// Timestamp of the reading.
let date: Date

init(value: Double, date: Date) {
self.value = value
self.date = date
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(value, forKey: .value)
try container.encode(date.timeIntervalSince1970, forKey: .date)
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
value = try container.decode(Double.self, forKey: .value)
date = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .date))
}

private enum CodingKeys: String, CodingKey {
case value, date
}
}

/// Recent glucose history for the home screen widget chart.
///
/// Thresholds and the preferred display unit are read separately from
/// `LAAppGroupSettings`, so this stays a plain series of readings.
struct GlucoseChartSeries: Codable, Equatable {
/// Readings ordered oldest first.
let points: [GlucoseChartPoint]

/// When the app last wrote this series.
let updatedAt: Date

/// Age of the series in seconds.
var age: TimeInterval {
Date().timeIntervalSince(updatedAt)
}

init(points: [GlucoseChartPoint], updatedAt: Date) {
self.points = points
self.updatedAt = updatedAt
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(points, forKey: .points)
try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt)
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
points = try container.decode([GlucoseChartPoint].self, forKey: .points)
updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt))
}

private enum CodingKeys: String, CodingKey {
case points, updatedAt
}
}

/// Persists the recent glucose history into the App Group container so the
/// home screen widget can render a chart without the app running.
///
/// Uses an atomic JSON file write to avoid partial/corrupt reads across processes.
final class GlucoseChartSeriesStore {
static let shared = GlucoseChartSeriesStore()
private init() {}

/// Readings older than this are dropped when saving. Covers the longest
/// `WidgetChartDuration`, since the widget can only draw what is stored.
static let window: TimeInterval = 24 * 3600

private let fileName = "glucose_chart_series.json"
private let queue = DispatchQueue(label: "com.loopfollow.glucoseChartSeriesStore", qos: .utility)

// MARK: - Public API

/// The write is asynchronous, so callers that have to act on the stored file
/// (asking the widget to redraw, for one) pass `completion` rather than
/// assuming the series has landed when `save` returns.
func save(_ series: GlucoseChartSeries, completion: (() -> Void)? = nil) {
queue.async {
defer { completion?() }
do {
let url = try self.fileURL()
let data = try JSONEncoder().encode(series)
try data.write(to: url, options: [.atomic])
} catch {
// Intentionally silent (extension-safe, no dependencies).
}
}
}

func load() -> GlucoseChartSeries? {
do {
let url = try fileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }

let data = try Data(contentsOf: url)
return try JSONDecoder().decode(GlucoseChartSeries.self, from: data)
} catch {
// Intentionally silent (extension-safe, no dependencies).
return nil
}
}

// MARK: - Helpers

private func fileURL() throws -> URL {
let groupID = AppGroupID.current()
guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) else {
throw NSError(
domain: "GlucoseChartSeriesStore",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"],
)
}
return containerURL.appendingPathComponent(fileName, isDirectory: false)
}
}
Loading
Loading