Skip to content
Merged
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
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
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
30 changes: 30 additions & 0 deletions LoopFollow/Treatments/CarbTreatment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// LoopFollow
// CarbTreatment.swift

import Foundation

/// Source metadata for a carb mark, preserving the identifier used by its AID app.
enum CarbTreatment: Codable, Equatable {
case loop(LoopCarbTreatment)
case trio(TrioMealTreatment)

init?(nightscoutEntry entry: [String: AnyObject], date: TimeInterval) {
if let meal = TrioMealTreatment(nightscoutEntry: entry, date: date) {
self = .trio(meal)
} else if let carb = LoopCarbTreatment(nightscoutEntry: entry, date: date) {
self = .loop(carb)
} else {
return nil
}
}

/// The existing detail screen owns all remote action availability and command handling.
var detailTreatment: Treatment {
switch self {
case let .loop(carb):
return .carb(nightscoutID: carb.nightscoutID, date: carb.date, carbs: carb.carbs, bgValue: 0, loopCarb: carb)
case let .trio(meal):
return .carb(nightscoutID: meal.nightscoutID, date: meal.date, carbs: meal.carbs, bgValue: 0, trioMeal: meal)
}
}
}
6 changes: 3 additions & 3 deletions LoopFollow/Treatments/LoopCarbTreatment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import Foundation
///
/// `syncIdentifier` is Loop's stable handle for the entry; it survives edits on the phone,
/// so it addresses the entry in remote delete/edit commands.
struct LoopCarbTreatment: Equatable {
struct LoopCarbTreatment: Codable, Equatable {
static let editWindow: TimeInterval = 23 * 3600
static let requiredRemoteCommands: Set<String> = ["carbs-delete", "carbs-edit"]

let nightscoutID: String
let nightscoutID: String?
let syncIdentifier: String
let enteredBy: String
let date: TimeInterval
Expand All @@ -31,7 +31,7 @@ struct LoopCarbTreatment: Equatable {
return nil
}

nightscoutID = entry["_id"] as? String ?? ""
nightscoutID = entry["_id"] as? String
self.syncIdentifier = syncIdentifier
self.enteredBy = enteredBy
self.date = date
Expand Down
78 changes: 52 additions & 26 deletions LoopFollow/Treatments/TreatmentsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,52 @@ struct Treatment: Identifiable {
}
}

extension Treatment {
/// A carb entry; `trioMeal` / `loopCarb` carry the AID app's handle for remote edit and delete.
static func carb(nightscoutID: String?, date: TimeInterval, carbs: Double, bgValue: Int, trioMeal: TrioMealTreatment? = nil, loopCarb: LoopCarbTreatment? = nil) -> Treatment {
Treatment(
id: "\(nightscoutID ?? "unknown-\(date)")-carb",
type: .carb,
date: date,
title: carbs > 0 ? "\(Int(carbs))g" : "Meal",
subtitle: carbSubtitle(carbs: carbs, trioMeal: trioMeal),
icon: "circle.fill",
color: .orange,
bgValue: bgValue,
trioMeal: trioMeal,
loopCarb: loopCarb
)
}

/// "Carbs" for a carb entry, "Carbs • FPU" for a Trio FPU child, and the fat/protein grams for a Trio meal without carbs.
private static func carbSubtitle(carbs: Double, trioMeal: TrioMealTreatment?) -> String {
if trioMeal?.isFPUChild == true { return "Carbs • FPU" }
guard carbs == 0, let trioMeal else { return "Carbs" }
let parts = [
trioMeal.fat > 0 ? "\(trioMeal.fat) g fat" : nil,
trioMeal.protein > 0 ? "\(trioMeal.protein) g protein" : nil,
]
return parts.compactMap { $0 }.joined(separator: " • ")
}

/// Trio root meals keyed by the `fpuID` their FPU children carry.
static func rootMealsByFPUID(_ treatments: some Sequence<Treatment>) -> [UUID: Treatment] {
var roots: [UUID: Treatment] = [:]
for treatment in treatments {
if let meal = treatment.trioMeal, !meal.isFPUChild, let fpuID = meal.fpuID {
roots[fpuID] = treatment
}
}
return roots
}

/// The root meal for an FPU child, when the Trio build publishes `fpuID` and `roots` holds it.
func rootMeal(in roots: [UUID: Treatment]) -> Treatment? {
guard let child = trioMeal, child.isFPUChild, let fpuID = child.fpuID else { return nil }
return roots[fpuID]
}
}

class TreatmentsViewModel: ObservableObject {
@Published var groupedTreatments: [String: [Treatment]] = [:]
@Published var isInitialLoading = false
Expand Down Expand Up @@ -1204,10 +1250,9 @@ class TreatmentsViewModel: ObservableObject {
}
}

/// The root meal for an FPU child, when the Trio build publishes `fpuID` and the root is loaded.
/// The root meal for an FPU child, when it is loaded.
func rootMeal(forFPUChild treatment: Treatment) -> Treatment? {
guard let child = treatment.trioMeal, child.isFPUChild, let fpuID = child.fpuID else { return nil }
return rootMealsByFPUID[fpuID]
treatment.rootMeal(in: rootMealsByFPUID)
}

func refreshTreatments() {
Expand Down Expand Up @@ -1371,14 +1416,10 @@ class TreatmentsViewModel: ObservableObject {
let carbs = entry["carbs"] as? Double ?? 0
if carbs > 0 || trioMeal != nil {
let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData)
let treatment = Treatment(
id: "\(nsId)-carb",
type: .carb,
let treatment = Treatment.carb(
nightscoutID: nsId,
date: timestamp,
title: carbs > 0 ? "\(Int(carbs))g" : "Meal",
subtitle: carbSubtitle(carbs: carbs, trioMeal: trioMeal),
icon: "circle.fill",
color: .orange,
carbs: carbs,
bgValue: actualBG,
trioMeal: trioMeal,
loopCarb: loopCarb
Expand Down Expand Up @@ -1550,32 +1591,17 @@ class TreatmentsViewModel: ObservableObject {
return (treatments.sorted { $0.date > $1.date }, detectedSMB, detectedAutomatic)
}

/// "Carbs" for a carb entry, "Carbs • FPU" for a Trio FPU child, and the fat/protein grams for a Trio meal without carbs.
private func carbSubtitle(carbs: Double, trioMeal: TrioMealTreatment?) -> String {
if trioMeal?.isFPUChild == true { return "Carbs • FPU" }
guard carbs == 0, let trioMeal else { return "Carbs" }
let parts = [
trioMeal.fat > 0 ? "\(trioMeal.fat) g fat" : nil,
trioMeal.protein > 0 ? "\(trioMeal.protein) g protein" : nil,
]
return parts.compactMap { $0 }.joined(separator: " • ")
}

private func regroupTreatments() {
var grouped: [String: [Treatment]] = [:]
var roots: [UUID: Treatment] = [:]

for treatment in allTreatments {
let key = treatment.hourKey
if grouped[key] == nil {
grouped[key] = []
}
grouped[key]?.append(treatment)
if let meal = treatment.trioMeal, !meal.isFPUChild, let fpuID = meal.fpuID {
roots[fpuID] = treatment
}
}
rootMealsByFPUID = roots
rootMealsByFPUID = Treatment.rootMealsByFPUID(allTreatments)

// Sort treatments within each hour
for key in grouped.keys {
Expand Down
6 changes: 3 additions & 3 deletions LoopFollow/Treatments/TrioMealTreatment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import Foundation
/// Nightscout's `_id` identifies the document; `id` is Trio's meal UUID. FPU children are
/// separate documents whose `id` equals the root's `fpuID`, so either value works as a handle
/// for Trio's remote edit/delete commands. A document without `fpuID` is a root.
struct TrioMealTreatment: Equatable {
struct TrioMealTreatment: Codable, Equatable {
static let pastEditWindow: TimeInterval = 24 * 3600
static let futureEditWindow: TimeInterval = 12 * 3600
static let requiredRemoteCommands: Set<String> = [TRCCommandType.editMeal.rawValue, TRCCommandType.deleteMeal.rawValue]

let nightscoutID: String
let nightscoutID: String?
let mealID: UUID
let fpuID: UUID?
let date: TimeInterval
Expand Down Expand Up @@ -42,7 +42,7 @@ struct TrioMealTreatment: Equatable {
let rawNote = (entry["notes"] as? String) ?? (entry["foodType"] as? String)
let trimmedNote = rawNote?.trimmingCharacters(in: .whitespacesAndNewlines)

nightscoutID = entry["_id"] as? String ?? ""
nightscoutID = entry["_id"] as? String
self.mealID = mealID
self.fpuID = fpuID
self.date = date
Expand Down
6 changes: 6 additions & 0 deletions LoopFollow/ViewControllers/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
// (this controller is built only after storage is ready, so it never fires
// while we're alive); retained one release as a safety net.
notificationCenter.addObserver(self, selector: #selector(handleBFUReloadCompleted), name: .bfuReloadCompleted, object: nil)
// A remote meal edit/delete changes the carb marks on the chart, whichever screen sent it.
notificationCenter.addObserver(self, selector: #selector(handleRemoteMealCommandDidComplete), name: .remoteMealCommandDidComplete, object: nil)

#if !targetEnvironment(macCatalyst)
notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil)
Expand Down Expand Up @@ -681,6 +683,10 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
runMigrationsIfNeeded()
}

@objc func handleRemoteMealCommandDidComplete() {
TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date())
}

@objc func handleBFUReloadCompleted() {
// Show the loading overlay so the user sees feedback during the 2-5s
// while tasks re-run with the now-correct credentials. Tasks scheduled
Expand Down
Loading