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
4 changes: 4 additions & 0 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)")
RemoteCommandTracker.shared.handleNotification(userInfo: userInfo)

// Check if this is a response notification from Loop or Trio
if let aps = userInfo["aps"] as? [String: Any] {
Expand Down Expand Up @@ -183,6 +184,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}

func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
RemoteCommandTracker.shared.handleNotification(userInfo: response.notification.request.content.userInfo)

if response.actionIdentifier == "OPEN_APP_ACTION" {
// Dismiss any presented modal/sheet so the user actually sees Home
UIApplication.shared.topMost?.dismiss(animated: true)
Expand Down Expand Up @@ -267,6 +270,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
let content = notification.request.content
RemoteCommandTracker.shared.handleNotification(userInfo: content.userInfo)
let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted()
LogManager.shared.log(
category: .general,
Expand Down
7 changes: 5 additions & 2 deletions LoopFollow/Charts/BGChartModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,19 @@ final class BGChartModel: ObservableObject {
let sgv: Double
let label: String
let pillText: String
let treatment: CarbTreatment?
/// Where the symbol is drawn. Equals `date` unless `spread` nudged it
/// left to keep a crowded run of treatments from stacking up.
var drawnDate: Date
var id: Double { date.timeIntervalSince1970 }

init(date: Date, value: Double, sgv: Double, label: String, pillText: String) {
init(date: Date, value: Double, sgv: Double, label: String, pillText: String, treatment: CarbTreatment? = nil) {
self.date = date
self.value = value
self.sgv = sgv
self.label = label
self.pillText = pillText
self.treatment = treatment
drawnDate = date
}
}
Expand Down Expand Up @@ -475,7 +477,8 @@ final class BGChartModel: ObservableObject {
value: $0.value,
sgv: Double($0.sgv),
label: label,
pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))"
pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))",
treatment: $0.treatment
)
}, minGap: Spread.carbGap, maxShift: Spread.carbShift)
let smbPoints = (showBolus ? vc.smbData : []).map {
Expand Down
73 changes: 64 additions & 9 deletions LoopFollow/Charts/BGChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,36 @@ struct BGChartView: View {
/// attachments while BGChartInteraction preserves the viewport.
@State private var gestureMountEpoch = 0

/// Carb entry opened by a double-tap on the main chart. Owned here, outside
/// the remounted subtree, so the sheet survives a foregrounding remount.
@State private var selectedTreatment: Treatment?

var body: some View {
Group {
if config == .small {
SmallBGChart(model: model, interaction: model.interaction)
} else {
MainBGChart(model: model, interaction: model.interaction)
MainBGChart(model: model, interaction: model.interaction, selectedTreatment: $selectedTreatment)
}
}
.id(gestureMountEpoch)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
gestureMountEpoch &+= 1
}
.sheet(item: $selectedTreatment) { treatment in
NavigationStack {
TreatmentDetailView(treatment: treatment, rootMeal: rootMeal(for: treatment))
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { selectedTreatment = nil }
}
}
}
}
}

private func rootMeal(for treatment: Treatment) -> Treatment? {
treatment.rootMeal(in: Treatment.rootMealsByFPUID(model.carbs.compactMap { $0.treatment?.detailTreatment }))
}
}

Expand All @@ -98,21 +116,25 @@ struct BGChartView: View {
/// whose live preview is a `.scaleEffect(x:)` stretch anchored under the
/// pinch centroid, committed on a geometric zoom grid. A one-finger press
/// held stationary latches into inspect mode and scrubs a selection that is
/// rendered by a shell overlay (never re-laying the canvas). Double-tap
/// cycles zoom presets. No `.chartScrollableAxes`, no UIKit gesture hacks.
/// rendered by a shell overlay (never re-laying the canvas). Double-tap on a
/// carb mark opens its details; elsewhere it cycles zoom presets.
/// No `.chartScrollableAxes`, no UIKit gesture hacks.
private struct MainBGChart: View {
@ObservedObject var model: BGChartModel
@ObservedObject var interaction: BGChartInteraction
/// Carb entry to show in the detail sheet; the shell owns the sheet.
@Binding var selectedTreatment: Treatment?

/// Rendered slice of the domain. The canvas covers only this window
/// (visible ± `renderWindowPadFactor` viewports), bounding canvas width
/// and per-layout cost no matter how long the data domain grows.
@State private var renderWindowStart: Date
@State private var renderWindowEnd: Date

init(model: BGChartModel, interaction: BGChartInteraction) {
init(model: BGChartModel, interaction: BGChartInteraction, selectedTreatment: Binding<Treatment?>) {
_model = ObservedObject(wrappedValue: model)
_interaction = ObservedObject(wrappedValue: interaction)
_selectedTreatment = selectedTreatment
// Seed the render window around the current viewport so a remount's
// first frame draws in place.
let pad = BGChartConfig.renderWindowPadFactor * interaction.visibleSeconds
Expand Down Expand Up @@ -241,15 +263,15 @@ private struct MainBGChart: View {
.contentShape(Rectangle())
.simultaneousGesture(panAndInspectGesture(viewportWidth: viewportWidth))
.simultaneousGesture(magnifyGesture(viewportWidth: viewportWidth))
// Double-tap zooms; a single tap (only recognized once the double-tap
// window lapses) selects the mark under the finger, or clears the pill.
// Double-tap opens carb treatment details, otherwise zooms. Single-tap
// waits for the double-tap window before selecting a mark.
.simultaneousGesture(
TapGesture(count: 2)
SpatialTapGesture(count: 2)
.exclusively(before: SpatialTapGesture())
.onEnded { value in
switch value {
case .first:
cycleZoomPreset()
case let .first(tap):
handleDoubleTap(at: tap.location, viewportWidth: viewportWidth)
case let .second(tap):
handleTap(at: tap.location, viewportWidth: viewportWidth)
}
Expand Down Expand Up @@ -812,6 +834,39 @@ private struct MainBGChart: View {
tapped = tappedAnchor(at: location, viewportWidth: viewportWidth)
}

/// Double-tap hit test (screen-space, 2D) over carb marks only, at their
/// drawn (decluttered) positions. Returns nil when no carb is under the
/// finger — which cycles the zoom preset.
private func tappedCarb(at location: CGPoint, viewportWidth: CGFloat) -> BGChartModel.TreatmentPoint? {
let radius = BGChartConfig.tapHitRadius
var best: BGChartModel.TreatmentPoint?
var bestDistance2 = radius * radius

for point in model.carbs {
let dx = xPosition(for: point.drawnDate, viewportWidth: viewportWidth) - location.x
let dy = yPosition(forValue: point.sgv) - location.y
let d2 = dx * dx + dy * dy
if d2 <= bestDistance2 {
bestDistance2 = d2
best = point
}
}
return best
}

private func handleDoubleTap(at location: CGPoint, viewportWidth: CGFloat) {
// A carb mark without AID metadata (no remote handle) has no details to show.
guard plotFrame.height > 0, let carb = tappedCarb(at: location, viewportWidth: viewportWidth)?.treatment else {
cycleZoomPreset()
return
}
momentumTask?.cancel()
momentumTask = nil
resetGestureState()
tapped = nil
selectedTreatment = carb.detailTreatment
}

/// The anchor the overlay should show: a live scrub wins over a sticky tap.
private func activeAnchor() -> SelectionAnchor? {
if isInspectLatched, let selected = selection {
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/Controllers/NightScout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ extension MainViewController {
var date: TimeInterval
var sgv: Int
var absorptionTime: Int
var treatment: CarbTreatment? = nil
}

func clearOldTempBasal() {
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SwiftUI
extension MainViewController {
func DeviceStatusLoop(formatter: ISO8601DateFormatter, lastLoopRecord: [String: AnyObject]) {
Storage.shared.device.value = "Loop"
Storage.shared.loopRemoteCommands.value = (lastLoopRecord["testingDetails"] as? [String: AnyObject])?["remoteCommands"] as? [String] ?? []

if Storage.shared.remoteType.value == .trc {
Storage.shared.remoteType.value = .none
Expand Down
2 changes: 2 additions & 0 deletions LoopFollow/Controllers/Nightscout/NSProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ struct NSProfile: Decodable {
let expirationDate: String?
let startDate: String?
let createdAt: String?
let remoteCommands: [String]?

struct TrioOverrideEntry: Decodable {
let name: String
Expand Down Expand Up @@ -101,5 +102,6 @@ struct NSProfile: Decodable {
case expirationDate
case startDate
case createdAt = "created_at"
case remoteCommands
}
}
1 change: 1 addition & 0 deletions LoopFollow/Controllers/Nightscout/ProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ final class ProfileManager {
}

Storage.shared.teamId.value = profileData.teamID ?? Storage.shared.teamId.value ?? ""
Storage.shared.remoteCommands.value = profileData.remoteCommands ?? []
}

func currentISF() -> HKQuantity? {
Expand Down
8 changes: 7 additions & 1 deletion LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ extension MainViewController {

if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (3600 * Storage.shared.predictionToLoad.value)) {
// Make the dot
let dot = carbGraphStruct(value: Double(carbs), date: Double(dateTimeStamp), sgv: Int(sgv.sgv + Double(offset)), absorptionTime: absorptionTime)
let dot = carbGraphStruct(
value: Double(carbs),
date: Double(dateTimeStamp),
sgv: Int(sgv.sgv + Double(offset)),
absorptionTime: absorptionTime,
treatment: CarbTreatment(nightscoutEntry: currentEntry, date: dateTimeStamp)
)
carbData.append(dot)
}
}
Expand Down
16 changes: 16 additions & 0 deletions LoopFollow/Remote/APNSEnvironment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// LoopFollow
// APNSEnvironment.swift

import Foundation

enum APNSEnvironment {
/// Base URL of the APNs HTTP/2 endpoint. Debug builds honor `LOOPFOLLOW_APNS_BASE_URL` as a local stand-in.
static func baseURL(production: Bool) -> String {
#if DEBUG
if let override = ProcessInfo.processInfo.environment["LOOPFOLLOW_APNS_BASE_URL"], !override.isEmpty {
return override
}
#endif
return production ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"
}
}
Loading
Loading