From 0f353ff788491b2e20ecefa0c0391b44de6209b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 26 Sep 2026 15:31:12 +0200 Subject: [PATCH 1/3] Share carb Treatment construction and root-meal lookup Treatment.carb builds a carb entry for both the treatments list and the chart's detail sheet, with one id fallback for entries without a Nightscout _id. Treatment.rootMealsByFPUID and rootMeal(in:) back the FPU-child root lookup in both places. --- LoopFollow/Treatments/CarbTreatment.swift | 28 +------ LoopFollow/Treatments/LoopCarbTreatment.swift | 4 +- LoopFollow/Treatments/TreatmentsView.swift | 78 ++++++++++++------- LoopFollow/Treatments/TrioMealTreatment.swift | 4 +- 4 files changed, 58 insertions(+), 56 deletions(-) diff --git a/LoopFollow/Treatments/CarbTreatment.swift b/LoopFollow/Treatments/CarbTreatment.swift index 869f377f2..8a373cf61 100644 --- a/LoopFollow/Treatments/CarbTreatment.swift +++ b/LoopFollow/Treatments/CarbTreatment.swift @@ -22,33 +22,9 @@ enum CarbTreatment: Codable, Equatable { var detailTreatment: Treatment { switch self { case let .loop(carb): - return Treatment( - id: "\(carb.nightscoutID)-carb", - type: .carb, - date: carb.date, - title: "\(Int(carb.carbs))g", - subtitle: "Carbs", - icon: "circle.fill", - color: .orange, - bgValue: 0, - loopCarb: carb - ) + return .carb(nightscoutID: carb.nightscoutID, date: carb.date, carbs: carb.carbs, bgValue: 0, loopCarb: carb) case let .trio(meal): - let macros = [ - meal.fat > 0 ? "\(meal.fat) g fat" : nil, - meal.protein > 0 ? "\(meal.protein) g protein" : nil, - ].compactMap { $0 }.joined(separator: " • ") - return Treatment( - id: "\(meal.nightscoutID)-carb", - type: .carb, - date: meal.date, - title: meal.carbs > 0 ? "\(Int(meal.carbs))g" : "Meal", - subtitle: meal.isFPUChild ? "Carbs • FPU" : (meal.carbs > 0 ? "Carbs" : macros), - icon: "circle.fill", - color: .orange, - bgValue: 0, - trioMeal: meal - ) + return .carb(nightscoutID: meal.nightscoutID, date: meal.date, carbs: meal.carbs, bgValue: 0, trioMeal: meal) } } } diff --git a/LoopFollow/Treatments/LoopCarbTreatment.swift b/LoopFollow/Treatments/LoopCarbTreatment.swift index 3a8abbd22..b7a28f480 100644 --- a/LoopFollow/Treatments/LoopCarbTreatment.swift +++ b/LoopFollow/Treatments/LoopCarbTreatment.swift @@ -11,7 +11,7 @@ struct LoopCarbTreatment: Codable, Equatable { static let editWindow: TimeInterval = 23 * 3600 static let requiredRemoteCommands: Set = ["carbs-delete", "carbs-edit"] - let nightscoutID: String + let nightscoutID: String? let syncIdentifier: String let enteredBy: String let date: TimeInterval @@ -31,7 +31,7 @@ struct LoopCarbTreatment: Codable, Equatable { return nil } - nightscoutID = entry["_id"] as? String ?? "" + nightscoutID = entry["_id"] as? String self.syncIdentifier = syncIdentifier self.enteredBy = enteredBy self.date = date diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index ff7bc4cad..7aea0415a 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -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) -> [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 @@ -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() { @@ -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 @@ -1550,20 +1591,8 @@ 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 @@ -1571,11 +1600,8 @@ class TreatmentsViewModel: ObservableObject { 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 { diff --git a/LoopFollow/Treatments/TrioMealTreatment.swift b/LoopFollow/Treatments/TrioMealTreatment.swift index a9604d050..b87559a07 100644 --- a/LoopFollow/Treatments/TrioMealTreatment.swift +++ b/LoopFollow/Treatments/TrioMealTreatment.swift @@ -13,7 +13,7 @@ struct TrioMealTreatment: Codable, Equatable { static let futureEditWindow: TimeInterval = 12 * 3600 static let requiredRemoteCommands: Set = [TRCCommandType.editMeal.rawValue, TRCCommandType.deleteMeal.rawValue] - let nightscoutID: String + let nightscoutID: String? let mealID: UUID let fpuID: UUID? let date: TimeInterval @@ -42,7 +42,7 @@ struct TrioMealTreatment: Codable, 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 From 0b284c310c73cabc900c9dce895d03e3af7a470e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 26 Sep 2026 15:31:12 +0200 Subject: [PATCH 2/3] Refresh treatments when a remote meal command completes The chart's carb marks follow a remote meal edit or delete from any screen by running the treatments task as soon as the command resolves. --- LoopFollow/ViewControllers/MainViewController.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 06c5e1f76..8f0f1187b 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -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) @@ -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 From a54bcccfcf90550485c04d339ca6446bfede8696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 26 Sep 2026 15:31:12 +0200 Subject: [PATCH 3/3] Keep the carb detail sheet in the chart shell BGChartView owns the selected carb and its sheet, outside the subtree that is remounted on foregrounding, so an open sheet survives a trip to Notification Center or the background. Double-tap hit-tests carb marks only, so a nearby bolus cannot shadow the carb it sits next to. --- LoopFollow/Charts/BGChartView.swift | 93 ++++++++++++++--------------- 1 file changed, 45 insertions(+), 48 deletions(-) diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 53dba520b..8c1c407f8 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -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 })) } } @@ -98,13 +116,14 @@ 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 -/// opens carb treatment details or cycles zoom presets elsewhere. +/// 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 - @State private var selectedTreatment: Treatment? + /// 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 @@ -112,9 +131,10 @@ private struct MainBGChart: View { @State private var renderWindowStart: Date @State private var renderWindowEnd: Date - init(model: BGChartModel, interaction: BGChartInteraction) { + init(model: BGChartModel, interaction: BGChartInteraction, selectedTreatment: Binding) { _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 @@ -188,18 +208,6 @@ private struct MainBGChart: View { chart(viewport: geo.size) } .background(Color(.systemBackground)) - .sheet(item: $selectedTreatment, onDismiss: { - MainViewController.shared?.WebLoadNSTreatments() - }) { treatment in - NavigationStack { - TreatmentDetailView(treatment: treatment, rootMeal: rootMeal(for: treatment)) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { selectedTreatment = nil } - } - } - } - } } private func chart(viewport: CGSize) -> some View { @@ -826,30 +834,29 @@ private struct MainBGChart: View { tapped = tappedAnchor(at: location, viewportWidth: viewportWidth) } - private func handleDoubleTap(at location: CGPoint, viewportWidth: CGFloat) { - // Hit-test the drawn positions, including decluttering offsets. Keep - // the original entry metadata so nearby entries cannot be confused. - guard plotFrame.height > 0 else { - cycleZoomPreset() - return - } + /// 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 - var nearest: BGChartModel.TreatmentPoint? - var bestDistance = BGChartConfig.tapHitRadius * BGChartConfig.tapHitRadius - forEachTreatmentAnchor { point in - let x = xPosition(for: point.drawnDate, viewportWidth: viewportWidth) - let y = yPosition(forValue: point.sgv) - guard x >= 0, x <= viewportWidth, y >= plotFrame.minY, y <= plotFrame.maxY else { return } - let dx = x - location.x - let dy = y - location.y - let distance = dx * dx + dy * dy - if distance <= bestDistance { - nearest = point - bestDistance = distance + 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 + } - guard let treatment = nearest?.treatment else { + 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 } @@ -857,17 +864,7 @@ private struct MainBGChart: View { momentumTask = nil resetGestureState() tapped = nil - selectedTreatment = treatment.detailTreatment - } - - private func rootMeal(for treatment: Treatment) -> Treatment? { - guard let child = treatment.trioMeal, child.isFPUChild, let fpuID = child.fpuID else { return nil } - return model.carbs.compactMap { point -> Treatment? in - guard case let .trio(meal) = point.treatment, - !meal.isFPUChild, meal.fpuID == fpuID - else { return nil } - return point.treatment?.detailTreatment - }.first + selectedTreatment = carb.detailTreatment } /// The anchor the overlay should show: a live scrub wins over a sticky tap.