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
93 changes: 45 additions & 48 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,23 +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
/// 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
/// 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 @@ -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 {
Expand Down Expand Up @@ -826,48 +834,37 @@ 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
}
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
selectedTreatment = carb.detailTreatment
}

/// The anchor the overlay should show: a live scrub wins over a sticky tap.
Expand Down
28 changes: 2 additions & 26 deletions LoopFollow/Treatments/CarbTreatment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
4 changes: 2 additions & 2 deletions LoopFollow/Treatments/LoopCarbTreatment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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: Codable, 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
4 changes: 2 additions & 2 deletions LoopFollow/Treatments/TrioMealTreatment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct TrioMealTreatment: Codable, Equatable {
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: 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
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