From e428e27ea6762d1be5e1f422058298512b98004c Mon Sep 17 00:00:00 2001 From: codebymini Date: Sat, 26 Sep 2026 14:40:41 +0200 Subject: [PATCH 1/2] Add ability to double-click carbs to enter Treatments screen --- LoopFollow/Charts/BGChartModel.swift | 7 +- LoopFollow/Charts/BGChartView.swift | 70 +++++++++++++++++-- LoopFollow/Controllers/NightScout.swift | 1 + .../Nightscout/Treatments/Carbs.swift | 8 ++- LoopFollow/Treatments/CarbTreatment.swift | 54 ++++++++++++++ LoopFollow/Treatments/LoopCarbTreatment.swift | 2 +- LoopFollow/Treatments/TrioMealTreatment.swift | 2 +- 7 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 LoopFollow/Treatments/CarbTreatment.swift diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 8acc2859c..197858d01 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -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 } } @@ -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 { diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 74f9ec03e..53dba520b 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -99,10 +99,12 @@ struct BGChartView: View { /// 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. +/// opens carb treatment details or cycles zoom presets elsewhere. +/// No `.chartScrollableAxes`, no UIKit gesture hacks. private struct MainBGChart: View { @ObservedObject var model: BGChartModel @ObservedObject var interaction: BGChartInteraction + @State private var selectedTreatment: Treatment? /// Rendered slice of the domain. The canvas covers only this window /// (visible ± `renderWindowPadFactor` viewports), bounding canvas width @@ -186,6 +188,18 @@ 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 { @@ -241,15 +255,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) } @@ -812,6 +826,50 @@ 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 + } + + 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 + } + } + + guard let treatment = nearest?.treatment else { + cycleZoomPreset() + return + } + momentumTask?.cancel() + 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 + } + /// The anchor the overlay should show: a live scrub wins over a sticky tap. private func activeAnchor() -> SelectionAnchor? { if isInspectLatched, let selected = selection { diff --git a/LoopFollow/Controllers/NightScout.swift b/LoopFollow/Controllers/NightScout.swift index 90e3c1e9c..34a368ab1 100644 --- a/LoopFollow/Controllers/NightScout.swift +++ b/LoopFollow/Controllers/NightScout.swift @@ -43,6 +43,7 @@ extension MainViewController { var date: TimeInterval var sgv: Int var absorptionTime: Int + var treatment: CarbTreatment? = nil } func clearOldTempBasal() { diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index 23e2e0427..927230dae 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -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) } } diff --git a/LoopFollow/Treatments/CarbTreatment.swift b/LoopFollow/Treatments/CarbTreatment.swift new file mode 100644 index 000000000..869f377f2 --- /dev/null +++ b/LoopFollow/Treatments/CarbTreatment.swift @@ -0,0 +1,54 @@ +// 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 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 + ) + 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 + ) + } + } +} diff --git a/LoopFollow/Treatments/LoopCarbTreatment.swift b/LoopFollow/Treatments/LoopCarbTreatment.swift index 8f6ff5575..3a8abbd22 100644 --- a/LoopFollow/Treatments/LoopCarbTreatment.swift +++ b/LoopFollow/Treatments/LoopCarbTreatment.swift @@ -7,7 +7,7 @@ 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 = ["carbs-delete", "carbs-edit"] diff --git a/LoopFollow/Treatments/TrioMealTreatment.swift b/LoopFollow/Treatments/TrioMealTreatment.swift index ed00e7398..a9604d050 100644 --- a/LoopFollow/Treatments/TrioMealTreatment.swift +++ b/LoopFollow/Treatments/TrioMealTreatment.swift @@ -8,7 +8,7 @@ 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 = [TRCCommandType.editMeal.rawValue, TRCCommandType.deleteMeal.rawValue] From 02dd91003cc3b37f1430c8fd28d73ee0f9876f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 26 Sep 2026 16:54:39 +0200 Subject: [PATCH 2/2] Keep the carb detail sheet across remounts and share carb treatment construction (#758) * 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. * 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. * 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 +++++++++---------- LoopFollow/Treatments/CarbTreatment.swift | 28 +----- LoopFollow/Treatments/LoopCarbTreatment.swift | 4 +- LoopFollow/Treatments/TreatmentsView.swift | 78 ++++++++++------ LoopFollow/Treatments/TrioMealTreatment.swift | 4 +- .../ViewControllers/MainViewController.swift | 6 ++ 6 files changed, 109 insertions(+), 104 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. 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 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