diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 9098bd123..732977a64 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -136,6 +136,9 @@ private struct MainBGChart: View { /// Measured size of the visible selection pill (see PillSizePreferenceKey). @State private var pillSize: CGSize = .zero + @ScaledMetric(relativeTo: .caption) private var scaledPillWidth = SelectionPillLayout.width + @ScaledMetric(relativeTo: .caption) private var scaledPillHeight = SelectionPillLayout.height + // Pinch state: live preview stretch plus the anchor captured at pinch // start so the zoom stays anchored under the pinch centroid. @State private var pinchScale: CGFloat = 1 @@ -155,6 +158,9 @@ private struct MainBGChart: View { /// space, taps another mark, or starts a pan/zoom/inspect. @State private var tapped: SelectionAnchor? + /// Detail route for a supported treatment selected by an explicit tap. + @State private var tappedTreatmentDetail: TreatmentDetailRequest? + /// True once a held press has engaged inspect; from then on finger /// movement scrubs the selection instead of panning, until the finger lifts. @State private var isInspectLatched = false @@ -236,7 +242,8 @@ private struct MainBGChart: View { .allowsHitTesting(false) selectionOverlay(viewportWidth: viewportWidth) - .allowsHitTesting(false) + .environment(\.treatmentPillAction, activeTreatmentPillAction) + .environment(\.selectionPillLayoutSize, selectionPillLayoutSize(viewportWidth: viewportWidth)) overrideBandLabelsOverlay(viewportWidth: viewportWidth) .allowsHitTesting(false) @@ -850,7 +857,76 @@ private struct MainBGChart: View { private func handleTap(at location: CGPoint, viewportWidth: CGFloat) { guard plotFrame.height > 0 else { return } + if let buttonFrame = actionablePillFrame(viewportWidth: viewportWidth), + buttonFrame.contains(location) + { + return + } tapped = tappedAnchor(at: location, viewportWidth: viewportWidth) + tappedTreatmentDetail = tapped.flatMap { + treatmentDetailRequest(for: $0) + } + } + + private func treatmentDetailRequest(for anchor: SelectionAnchor) -> TreatmentDetailRequest? { + func matchesPill(_ pillText: String) -> Bool { + guard let selectedText = anchor.texts.first else { return false } + if selectedText == pillText { return true } + guard let finalLineBreak = pillText.lastIndex(of: "\n") else { return false } + return selectedText == pillText[.. Bool { + anchor.date == point.drawnDate + && anchor.value == point.sgv + && matchesPill(point.pillText) + } + + if let point = model.boluses.first(where: matches) { + return TreatmentDetailRequest( + kind: .bolus, + timestamp: point.date.timeIntervalSince1970, + amount: point.value + ) + } + if let point = model.carbs.first(where: matches) { + return TreatmentDetailRequest( + kind: .carb, + timestamp: point.date.timeIntervalSince1970, + amount: point.value + ) + } + if let point = model.smbs.first(where: matches) { + return TreatmentDetailRequest( + kind: .automaticBolus, + timestamp: point.date.timeIntervalSince1970, + amount: point.value + ) + } + + if let band = model.overrides.first(where: { + anchor.date >= $0.start && anchor.date <= $0.end + && anchor.value == ($0.yTop + $0.yBottom) / 2 + && matchesPill($0.pillText) + }) { + return TreatmentDetailRequest( + kind: .override, + timestamp: band.start.timeIntervalSince1970, + amount: nil + ) + } + if let band = model.tempTargets.first(where: { + anchor.date >= $0.start && anchor.date <= $0.end + && anchor.value == ($0.yTop + $0.yBottom) / 2 + && matchesPill($0.pillText) + }) { + return TreatmentDetailRequest( + kind: .tempTarget, + timestamp: band.start.timeIntervalSince1970, + amount: nil + ) + } + return nil } /// The anchor the overlay should show: a live scrub wins over a sticky tap. @@ -915,11 +991,8 @@ private struct MainBGChart: View { /// shell with the same linear maps the canvas uses — neither scrubbing /// nor a tapped pill ever re-lays the canvas. /// - /// The pill wraps long texts (notes) at its max width; placement uses the - /// measured pill size so the pill always sits fully on screen, below the - /// anchor when there is room and above it otherwise. Wrapping relies on - /// SwiftUI's word wrapping, which respects the actual font metrics, so - /// there is no manual line splitting. + /// The fixed-size pill starts just below the override-banner lane and is + /// clamped inside the plot. @ViewBuilder private func selectionOverlay(viewportWidth: CGFloat) -> some View { if plotFrame.height > 0, let anchor = activeAnchor(viewportWidth: viewportWidth) { @@ -931,13 +1004,14 @@ private struct MainBGChart: View { .fill(Color.primary.opacity(0.5)) .frame(width: 1, height: plotFrame.height) .position(x: x, y: plotFrame.midY) + .allowsHitTesting(false) - // Measured size lags the text by one frame; fall back to a - // small nominal size until the first measurement lands. - let pillW = max(pillSize.width, 60) - let pillH = max(pillSize.height, 28) - let labelX = min(max(x, pillW / 2 + 4), viewportWidth - pillW / 2 - 4) - let below = y + 14 + pillH / 2 + let layoutSize = selectionPillLayoutSize(viewportWidth: viewportWidth) + let pillW = layoutSize.width + let pillH = layoutSize.height + let fixedPosition = pillPosition(viewportWidth: viewportWidth, width: pillW, height: pillH) + let labelX = fixedPosition.x + let below = fixedPosition.y let above = y - 14 - pillH / 2 let fitsBelow = below + pillH / 2 <= plotFrame.maxY - 4 let labelY = fitsBelow ? below : max(above, plotFrame.minY + pillH / 2 + 4) @@ -946,6 +1020,47 @@ private struct MainBGChart: View { } } } + + private func selectionPillLayoutSize(viewportWidth: CGFloat) -> CGSize { + let availableWidth = max(min(300, viewportWidth - 16), 1) + let availableHeight = max(plotFrame.height - 8, 1) + return CGSize( + width: min(scaledPillWidth, availableWidth), + height: min(scaledPillHeight, availableHeight) + ) + } + + private func pillPosition(viewportWidth: CGFloat, width: CGFloat, height: CGFloat) -> CGPoint { + let x = min(max(plotFrame.midX, width / 2 + 4), viewportWidth - width / 2 - 4) + let preferredY = yPosition(forValue: model.maxBG - 25) + 8 + height / 2 + let y = min(max(preferredY, plotFrame.minY + height / 2 + 4), plotFrame.maxY - height / 2 - 4) + return CGPoint(x: x, y: y) + } + + private func actionablePillFrame(viewportWidth: CGFloat) -> CGRect? { + guard !isInspectLatched, tapped != nil, tappedTreatmentDetail != nil else { return nil } + let layoutSize = selectionPillLayoutSize(viewportWidth: viewportWidth) + let width = layoutSize.width + let height = layoutSize.height + let center = pillPosition(viewportWidth: viewportWidth, width: width, height: height) + return CGRect( + x: center.x - width / 2, + y: center.y - height / 2, + width: width, + height: height + ) + } + + private var activeTreatmentPillAction: (() -> Void)? { + guard !isInspectLatched, let request = tappedTreatmentDetail else { return nil } + return { openTreatmentDetail(request) } + } + + private func openTreatmentDetail(_ request: TreatmentDetailRequest) { + Observable.shared.pendingTreatmentDetail.value = request + let tabs = Storage.shared.orderedTabBarItems() + Observable.shared.selectedTabIndex.value = tabs.firstIndex(of: .treatments) ?? 4 + } } // MARK: - Small chart (overview + tap/drag-to-navigate) @@ -1599,6 +1714,58 @@ private struct PillSizePreferenceKey: PreferenceKey { } } +private enum SelectionPillLayout { + static let width: CGFloat = 152 + static let height: CGFloat = 76 +} + +private struct SelectionPillLayoutSizeKey: EnvironmentKey { + static let defaultValue = CGSize(width: SelectionPillLayout.width, height: SelectionPillLayout.height) +} + +private struct TreatmentPillActionKey: EnvironmentKey { + static let defaultValue: (() -> Void)? = nil +} + +private extension EnvironmentValues { + var selectionPillLayoutSize: CGSize { + get { self[SelectionPillLayoutSizeKey.self] } + set { self[SelectionPillLayoutSizeKey.self] = newValue } + } + + var treatmentPillAction: (() -> Void)? { + get { self[TreatmentPillActionKey.self] } + set { self[TreatmentPillActionKey.self] = newValue } + } +} + +private struct TreatmentPillButtonModifier: ViewModifier { + let action: (() -> Void)? + + @ViewBuilder + func body(content: Content) -> some View { + if let action { + Button(action: action) { + content.contentShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(TreatmentPillButtonStyle()) + .accessibilityHint("Opens this treatment in Treatments") + } else { + content + .allowsHitTesting(false) + } + } +} + +private struct TreatmentPillButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.96 : 1) + .brightness(configuration.isPressed ? -0.08 : 0) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + // MARK: - Shared pieces private struct DownwardTriangle: ChartSymbolShape { @@ -1615,6 +1782,9 @@ private struct DownwardTriangle: ChartSymbolShape { } private struct PillLabel: View { + @Environment(\.selectionPillLayoutSize) private var selectionPillLayoutSize + @Environment(\.treatmentPillAction) private var treatmentPillAction + /// One entry per selected item. A lone entry keeps its multi-line layout; /// several stack as compact one-line-per-item rows so the pill stays /// readable over a busy cluster. @@ -1622,27 +1792,48 @@ private struct PillLabel: View { let maxWidth: CGFloat var body: some View { - content - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(.secondarySystemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.primary, lineWidth: 0.5) - ) - ) - .background( - GeometryReader { geo in - Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size) - } - ) - // Transparent flexible container: it caps the width the text can - // wrap to, while the visible pill above still hugs its content. - .frame(maxWidth: maxWidth) + HStack(spacing: 5) { + content + if isActionable { + Image(systemName: "chevron.right") + .font(.caption.bold()) + .foregroundStyle(.white) + .accessibilityHidden(true) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .frame( + width: min(selectionPillLayoutSize.width, max(maxWidth, 1)), + height: selectionPillLayoutSize.height + ) + .clipped() + .background( + RoundedRectangle(cornerRadius: isActionable ? 8 : 6) + .fill(isActionable ? Color.blue : Color(.secondarySystemBackground)) + .overlay( + RoundedRectangle(cornerRadius: isActionable ? 8 : 6) + .strokeBorder( + isActionable ? Color.white.opacity(0.55) : Color.primary, + lineWidth: isActionable ? 1 : 0.5 + ) + ) + ) + .shadow( + color: isActionable ? Color.black.opacity(0.4) : .clear, + radius: isActionable ? 2 : 0, + y: isActionable ? 1 : 0 + ) + .background( + GeometryReader { geo in + Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size) + } + ) + .modifier(TreatmentPillButtonModifier(action: treatmentPillAction)) } + private var isActionable: Bool { treatmentPillAction != nil } + @ViewBuilder private var content: some View { if texts.count == 1 { @@ -1668,8 +1859,8 @@ private struct PillLabel: View { private func entry(_ text: String, lineLimit: Int) -> some View { Text(text) - .font(.caption2) - .foregroundColor(.primary) + .font(.caption) + .foregroundColor(isActionable ? .white : .primary) .multilineTextAlignment(.center) .lineLimit(lineLimit) } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 07877f8a8..26725fca4 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -9,6 +9,20 @@ import SwiftUI Observable in memory storage */ +struct TreatmentDetailRequest: Equatable { + enum Kind: Equatable { + case carb + case bolus + case automaticBolus + case override + case tempTarget + } + + let kind: Kind + let timestamp: TimeInterval + let amount: Double? +} + class Observable { static let shared = Observable() @@ -60,6 +74,9 @@ class Observable { /// Selected tab index used by SwiftUI TabView — set from MainViewController to switch tabs var selectedTabIndex = ObservableValue(default: 0) + /// Treatment selected from the chart and awaiting its existing detail view. + var pendingTreatmentDetail = ObservableValue(default: nil) + /// Currently visible app-wide banner (nil = hidden). Managed by BannerManager. var activeBanner = ObservableValue(default: nil) diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index f1b1c7595..bbc808da1 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -21,9 +21,11 @@ struct TreatmentsView: View { @StateObject private var viewModel = TreatmentsViewModel() @State private var selectedFilter: TreatmentFilter = .all + @State private var routedTreatment: Treatment? @ObservedObject private var device = Storage.shared.device @ObservedObject private var graphTimeZoneEnabled = Storage.shared.graphTimeZoneEnabled @ObservedObject private var graphTimeZoneIdentifier = Storage.shared.graphTimeZoneIdentifier + @ObservedObject private var pendingTreatmentDetail = Observable.shared.pendingTreatmentDetail private var isLoopDevice: Bool { device.value == "Loop" @@ -56,6 +58,15 @@ struct TreatmentsView: View { var body: some View { NavigationView { VStack { + NavigationLink(isActive: treatmentDetailIsPresented) { + if let routedTreatment { + TreatmentDetailView(treatment: routedTreatment) + } + } label: { + EmptyView() + } + .hidden() + Text("Treatments") .font(.largeTitle.weight(.bold)) .frame(maxWidth: .infinity, alignment: .leading) @@ -190,11 +201,20 @@ struct TreatmentsView: View { .refreshable { viewModel.refreshTreatments() } + .onChange(of: pendingTreatmentDetail.value) { _ in + routePendingTreatment(in: viewModel.groupedTreatments) + } + .onReceive(viewModel.$groupedTreatments) { groupedTreatments in + DispatchQueue.main.async { + routePendingTreatment(in: groupedTreatments) + } + } .onAppear { if viewModel.groupedTreatments.isEmpty { viewModel.loadInitialTreatments() } normalizeSelectedFilter(for: device.value) + routePendingTreatment(in: viewModel.groupedTreatments) } .onChange(of: device.value) { newValue in normalizeSelectedFilter(for: newValue) @@ -203,6 +223,40 @@ struct TreatmentsView: View { } } + private var treatmentDetailIsPresented: Binding { + Binding( + get: { routedTreatment != nil }, + set: { isPresented in + guard !isPresented else { return } + routedTreatment = nil + pendingTreatmentDetail.value = nil + } + ) + } + + private func routePendingTreatment(in groupedTreatments: [String: [Treatment]]) { + guard routedTreatment == nil, let request = pendingTreatmentDetail.value else { return } + + let treatments = groupedTreatments.values.flatMap { $0 } + if let treatment = request.bestMatch(in: treatments) { + routedTreatment = treatment + return + } + + guard !viewModel.isInitialLoading, !viewModel.isLoadingMore else { return } + guard viewModel.hasMoreData else { + pendingTreatmentDetail.value = nil + return + } + guard let oldestDate = treatments.map(\.date).min() else { return } + + if oldestDate > request.timestamp - TreatmentDetailRequest.matchTolerance { + viewModel.loadMoreIfNeeded() + } else { + pendingTreatmentDetail.value = nil + } + } + // Filtered grouped treatments based on selectedFilter private var filteredGroupedTreatments: [String: [Treatment]] { switch selectedFilter { @@ -391,6 +445,52 @@ private struct DayRow: Identifiable { let treatment: Treatment? } +extension TreatmentDetailRequest { + static let matchTolerance: TimeInterval = 90 + + func bestMatch(in treatments: [Treatment]) -> Treatment? { + treatments + .filter { supports($0.type) && abs($0.date - timestamp) <= Self.matchTolerance } + .min { lhs, rhs in + let lhsAmountDistance = amountDistance(for: lhs) + let rhsAmountDistance = amountDistance(for: rhs) + if lhsAmountDistance != rhsAmountDistance { + return lhsAmountDistance < rhsAmountDistance + } + + let lhsDateDistance = abs(lhs.date - timestamp) + let rhsDateDistance = abs(rhs.date - timestamp) + if lhsDateDistance != rhsDateDistance { + return lhsDateDistance < rhsDateDistance + } + return lhs.id < rhs.id + } + } + + private func supports(_ type: TreatmentType) -> Bool { + switch kind { + case .carb: + return type == .carb + case .bolus: + return type == .bolusManual || type == .bolusAutomatic || type == .smb + case .automaticBolus: + return type == .bolusAutomatic || type == .smb + case .override: + return type == .override + case .tempTarget: + return type == .tempTarget + } + } + + private func amountDistance(for treatment: Treatment) -> Double { + guard let amount else { return 0 } + guard let treatmentAmount = Double(treatment.title.prefix { + $0.isNumber || $0 == "." || $0 == "-" + }) else { return .infinity } + return abs(treatmentAmount - amount) + } +} + struct TreatmentDetailView: View { let treatment: Treatment @StateObject private var viewModel = TreatmentDetailViewModel() diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index 31f3b3915..a9e83cd35 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -14,6 +14,7 @@ struct MoreMenuView: View { @State private var currentVersion: String = AppVersionManager().version() @State private var searchText = "" @ObservedObject private var nightscoutURL = Storage.shared.url + @ObservedObject private var pendingTreatmentDetail = Observable.shared.pendingTreatmentDetail private var isSearching: Bool { !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -47,6 +48,12 @@ struct MoreMenuView: View { .padding(.horizontal) } } + .onAppear { + openPendingTreatmentIfNeeded() + } + .onChange(of: pendingTreatmentDetail.value) { _ in + openPendingTreatmentIfNeeded() + } .task { await fetchVersionInfo() } @@ -143,6 +150,15 @@ struct MoreMenuView: View { } } + private func openPendingTreatmentIfNeeded() { + guard pendingTreatmentDetail.value != nil, + Storage.shared.position(for: .treatments).normalized == .menu + else { + return + } + pendingRoute = .treatments + } + // MARK: - Search /// External Support & Community links, shared by the menu and search so their diff --git a/Tests/Tests.swift b/Tests/Tests.swift index 35d09193e..ab9f398d4 100644 --- a/Tests/Tests.swift +++ b/Tests/Tests.swift @@ -1,10 +1,85 @@ // LoopFollow // Tests.swift +@testable import LoopFollow +import SwiftUI import Testing -struct Tests { - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. +struct TreatmentDetailRequestTests { + @Test("matches only the requested supported treatment kind") + func matchesSupportedKind() { + let request = TreatmentDetailRequest(kind: .carb, timestamp: 1000, amount: 20) + let treatments = [ + treatment(id: "bolus", type: .bolusManual, date: 1000, title: "1.00 U"), + treatment(id: "carb", type: .carb, date: 1001, title: "20g"), + ] + + #expect(request.bestMatch(in: treatments)?.id == "carb") + } + + @Test("automatic graph boluses match automatic or SMB details") + func matchesAutomaticBolusVariants() { + let request = TreatmentDetailRequest(kind: .automaticBolus, timestamp: 2000, amount: 0.8) + let treatments = [ + treatment(id: "automatic", type: .bolusAutomatic, date: 2000, title: "0.50 U"), + treatment(id: "unparseable", type: .bolusAutomatic, date: 2000, title: "Unknown"), + treatment(id: "smb", type: .smb, date: 2000, title: "0.80 U"), + ] + + #expect(request.bestMatch(in: treatments)?.id == "smb") + + let trioGraphRequest = TreatmentDetailRequest(kind: .bolus, timestamp: 2000, amount: 0.8) + let trioCandidates = [ + treatment(id: "manual", type: .bolusManual, date: 2000, title: "0.50 U"), + treatment(id: "trio-smb", type: .bolusAutomatic, date: 2030, title: "0.80 U"), + ] + #expect(trioGraphRequest.bestMatch(in: trioCandidates)?.id == "trio-smb") + } + + @Test("rejects unsupported types and treatments outside the time tolerance") + func rejectsUnsupportedOrDistantTreatments() { + let request = TreatmentDetailRequest(kind: .bolus, timestamp: 3000, amount: 1) + let treatments = [ + treatment(id: "basal", type: .tempBasal, date: 3000, title: "1.00 U/hr"), + treatment( + id: "distant", + type: .bolusManual, + date: 3000 + TreatmentDetailRequest.matchTolerance + 1, + title: "1.00 U" + ), + ] + + #expect(request.bestMatch(in: treatments) == nil) + } + + @Test("matches override and temp target detail types") + func matchesBandTreatmentTypes() { + let overrideRequest = TreatmentDetailRequest(kind: .override, timestamp: 4000, amount: nil) + let targetRequest = TreatmentDetailRequest(kind: .tempTarget, timestamp: 5000, amount: nil) + let treatments = [ + treatment(id: "override", type: .override, date: 4000, title: "Workout"), + treatment(id: "target", type: .tempTarget, date: 5000, title: "100 mg/dL"), + ] + + #expect(overrideRequest.bestMatch(in: treatments)?.id == "override") + #expect(targetRequest.bestMatch(in: treatments)?.id == "target") + } + + private func treatment( + id: String, + type: TreatmentType, + date: TimeInterval, + title: String + ) -> Treatment { + Treatment( + id: id, + type: type, + date: date, + title: title, + subtitle: nil, + icon: "circle.fill", + color: .blue, + bgValue: 0 + ) } }