Bladeren bron

Merge pull request #1329 from dnzxy/home-refactor-chart-gestures

marv-out 17 uur geleden
bovenliggende
commit
3a2865a097

+ 4 - 4
Trio.xcodeproj/project.pbxproj

@@ -67,6 +67,7 @@
 		3811DE3125C9D49500A708ED /* HomeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE2925C9D49500A708ED /* HomeProvider.swift */; };
 		3811DE3125C9D49500A708ED /* HomeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE2925C9D49500A708ED /* HomeProvider.swift */; };
 		3811DE3225C9D49500A708ED /* HomeDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE2A25C9D49500A708ED /* HomeDataFlow.swift */; };
 		3811DE3225C9D49500A708ED /* HomeDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE2A25C9D49500A708ED /* HomeDataFlow.swift */; };
 		3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE2E25C9D49500A708ED /* HomeRootView.swift */; };
 		3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE2E25C9D49500A708ED /* HomeRootView.swift */; };
+		7A11AC0DE000000000000106 /* HomeRootView+Refresh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000006 /* HomeRootView+Refresh.swift */; };
 		7A11AC0DE000000000000111 /* HomeLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000011 /* HomeLayout.swift */; };
 		7A11AC0DE000000000000111 /* HomeLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000011 /* HomeLayout.swift */; };
 		7A11AC0DE000000000000113 /* MultiUsePanelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000013 /* MultiUsePanelState.swift */; };
 		7A11AC0DE000000000000113 /* MultiUsePanelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000013 /* MultiUsePanelState.swift */; };
 		7A11AC0DE000000000000110 /* HomeRootView+BottomControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000010 /* HomeRootView+BottomControls.swift */; };
 		7A11AC0DE000000000000110 /* HomeRootView+BottomControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11AC0DE000000000000010 /* HomeRootView+BottomControls.swift */; };
@@ -361,7 +362,6 @@
 		58A3D5442C96DE11003F90FC /* TempTargetStored+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A3D5432C96DE11003F90FC /* TempTargetStored+Helper.swift */; };
 		58A3D5442C96DE11003F90FC /* TempTargetStored+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A3D5432C96DE11003F90FC /* TempTargetStored+Helper.swift */; };
 		58D08B222C8DAA8E00AA37D3 /* OverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B212C8DAA8E00AA37D3 /* OverrideView.swift */; };
 		58D08B222C8DAA8E00AA37D3 /* OverrideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B212C8DAA8E00AA37D3 /* OverrideView.swift */; };
 		58D08B302C8DEA7500AA37D3 /* ForecastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B2F2C8DEA7500AA37D3 /* ForecastView.swift */; };
 		58D08B302C8DEA7500AA37D3 /* ForecastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B2F2C8DEA7500AA37D3 /* ForecastView.swift */; };
-		58D08B322C8DF88900AA37D3 /* DummyCharts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B312C8DF88900AA37D3 /* DummyCharts.swift */; };
 		58D08B342C8DF9A700AA37D3 /* CobIobChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B332C8DF9A700AA37D3 /* CobIobChart.swift */; };
 		58D08B342C8DF9A700AA37D3 /* CobIobChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B332C8DF9A700AA37D3 /* CobIobChart.swift */; };
 		58D08B382C8DFB6000AA37D3 /* BasalChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B372C8DFB6000AA37D3 /* BasalChart.swift */; };
 		58D08B382C8DFB6000AA37D3 /* BasalChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B372C8DFB6000AA37D3 /* BasalChart.swift */; };
 		58D08B3A2C8DFECD00AA37D3 /* TempTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B392C8DFECD00AA37D3 /* TempTargets.swift */; };
 		58D08B3A2C8DFECD00AA37D3 /* TempTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D08B392C8DFECD00AA37D3 /* TempTargets.swift */; };
@@ -1092,6 +1092,7 @@
 		3811DE2925C9D49500A708ED /* HomeProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeProvider.swift; sourceTree = "<group>"; };
 		3811DE2925C9D49500A708ED /* HomeProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeProvider.swift; sourceTree = "<group>"; };
 		3811DE2A25C9D49500A708ED /* HomeDataFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeDataFlow.swift; sourceTree = "<group>"; };
 		3811DE2A25C9D49500A708ED /* HomeDataFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeDataFlow.swift; sourceTree = "<group>"; };
 		3811DE2E25C9D49500A708ED /* HomeRootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeRootView.swift; sourceTree = "<group>"; };
 		3811DE2E25C9D49500A708ED /* HomeRootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeRootView.swift; sourceTree = "<group>"; };
+		7A11AC0DE000000000000006 /* HomeRootView+Refresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeRootView+Refresh.swift"; sourceTree = "<group>"; };
 		7A11AC0DE000000000000011 /* HomeLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeLayout.swift; sourceTree = "<group>"; };
 		7A11AC0DE000000000000011 /* HomeLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeLayout.swift; sourceTree = "<group>"; };
 		7A11AC0DE000000000000013 /* MultiUsePanelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiUsePanelState.swift; sourceTree = "<group>"; };
 		7A11AC0DE000000000000013 /* MultiUsePanelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiUsePanelState.swift; sourceTree = "<group>"; };
 		7A11AC0DE000000000000010 /* HomeRootView+BottomControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeRootView+BottomControls.swift"; sourceTree = "<group>"; };
 		7A11AC0DE000000000000010 /* HomeRootView+BottomControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeRootView+BottomControls.swift"; sourceTree = "<group>"; };
@@ -1381,7 +1382,6 @@
 		58A3D5432C96DE11003F90FC /* TempTargetStored+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetStored+Helper.swift"; sourceTree = "<group>"; };
 		58A3D5432C96DE11003F90FC /* TempTargetStored+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempTargetStored+Helper.swift"; sourceTree = "<group>"; };
 		58D08B212C8DAA8E00AA37D3 /* OverrideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideView.swift; sourceTree = "<group>"; };
 		58D08B212C8DAA8E00AA37D3 /* OverrideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideView.swift; sourceTree = "<group>"; };
 		58D08B2F2C8DEA7500AA37D3 /* ForecastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastView.swift; sourceTree = "<group>"; };
 		58D08B2F2C8DEA7500AA37D3 /* ForecastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastView.swift; sourceTree = "<group>"; };
-		58D08B312C8DF88900AA37D3 /* DummyCharts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DummyCharts.swift; sourceTree = "<group>"; };
 		58D08B332C8DF9A700AA37D3 /* CobIobChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CobIobChart.swift; sourceTree = "<group>"; };
 		58D08B332C8DF9A700AA37D3 /* CobIobChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CobIobChart.swift; sourceTree = "<group>"; };
 		58D08B372C8DFB6000AA37D3 /* BasalChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalChart.swift; sourceTree = "<group>"; };
 		58D08B372C8DFB6000AA37D3 /* BasalChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalChart.swift; sourceTree = "<group>"; };
 		58D08B392C8DFECD00AA37D3 /* TempTargets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargets.swift; sourceTree = "<group>"; };
 		58D08B392C8DFECD00AA37D3 /* TempTargets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargets.swift; sourceTree = "<group>"; };
@@ -2395,6 +2395,7 @@
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
 				3811DE2E25C9D49500A708ED /* HomeRootView.swift */,
 				3811DE2E25C9D49500A708ED /* HomeRootView.swift */,
+				7A11AC0DE000000000000006 /* HomeRootView+Refresh.swift */,
 				7A11AC0DE000000000000011 /* HomeLayout.swift */,
 				7A11AC0DE000000000000011 /* HomeLayout.swift */,
 				7A11AC0DE000000000000013 /* MultiUsePanelState.swift */,
 				7A11AC0DE000000000000013 /* MultiUsePanelState.swift */,
 				7A11AC0DE000000000000010 /* HomeRootView+BottomControls.swift */,
 				7A11AC0DE000000000000010 /* HomeRootView+BottomControls.swift */,
@@ -3731,7 +3732,6 @@
 				582DF97A2C8CE209001F516D /* CarbView.swift */,
 				582DF97A2C8CE209001F516D /* CarbView.swift */,
 				58D08B212C8DAA8E00AA37D3 /* OverrideView.swift */,
 				58D08B212C8DAA8E00AA37D3 /* OverrideView.swift */,
 				58D08B2F2C8DEA7500AA37D3 /* ForecastView.swift */,
 				58D08B2F2C8DEA7500AA37D3 /* ForecastView.swift */,
-				58D08B312C8DF88900AA37D3 /* DummyCharts.swift */,
 				58D08B332C8DF9A700AA37D3 /* CobIobChart.swift */,
 				58D08B332C8DF9A700AA37D3 /* CobIobChart.swift */,
 				58D08B372C8DFB6000AA37D3 /* BasalChart.swift */,
 				58D08B372C8DFB6000AA37D3 /* BasalChart.swift */,
 				58D08B392C8DFECD00AA37D3 /* TempTargets.swift */,
 				58D08B392C8DFECD00AA37D3 /* TempTargets.swift */,
@@ -4963,7 +4963,6 @@
 				3862CC2E2743F9F700BF832C /* CalendarManager.swift in Sources */,
 				3862CC2E2743F9F700BF832C /* CalendarManager.swift in Sources */,
 				38B4F3C325E2A20B00E76A18 /* PumpSetupView.swift in Sources */,
 				38B4F3C325E2A20B00E76A18 /* PumpSetupView.swift in Sources */,
 				38E4453C274E411700EC9A94 /* Disk+Codable.swift in Sources */,
 				38E4453C274E411700EC9A94 /* Disk+Codable.swift in Sources */,
-				58D08B322C8DF88900AA37D3 /* DummyCharts.swift in Sources */,
 				19E1F7EF29D08EBA005C8D20 /* IconConfigRootWiew.swift in Sources */,
 				19E1F7EF29D08EBA005C8D20 /* IconConfigRootWiew.swift in Sources */,
 				1967DFC229D053D300759F30 /* IconImage.swift in Sources */,
 				1967DFC229D053D300759F30 /* IconImage.swift in Sources */,
 				382C134B25F14E3700715CE1 /* BGTargets.swift in Sources */,
 				382C134B25F14E3700715CE1 /* BGTargets.swift in Sources */,
@@ -5179,6 +5178,7 @@
 				3B2A3BC12E2B19C600658FB9 /* DynamicISF.swift in Sources */,
 				3B2A3BC12E2B19C600658FB9 /* DynamicISF.swift in Sources */,
 				CEE9A6592BBB418300EB5194 /* CalibrationsDataFlow.swift in Sources */,
 				CEE9A6592BBB418300EB5194 /* CalibrationsDataFlow.swift in Sources */,
 				3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */,
 				3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */,
+				7A11AC0DE000000000000106 /* HomeRootView+Refresh.swift in Sources */,
 				7A11AC0DE000000000000111 /* HomeLayout.swift in Sources */,
 				7A11AC0DE000000000000111 /* HomeLayout.swift in Sources */,
 				7A11AC0DE000000000000113 /* MultiUsePanelState.swift in Sources */,
 				7A11AC0DE000000000000113 /* MultiUsePanelState.swift in Sources */,
 				7A11AC0DE000000000000110 /* HomeRootView+BottomControls.swift in Sources */,
 				7A11AC0DE000000000000110 /* HomeRootView+BottomControls.swift in Sources */,

+ 118 - 46
Trio/Sources/Helpers/MainChartHelper.swift

@@ -39,6 +39,32 @@ enum MainChartHelper {
     }
     }
 
 
     enum Config {
     enum Config {
+        /// How far back the chart's `startMarker` is anchored — the fixed 24 h
+        /// history window loaded on every open. Independent of the currently
+        /// visible viewport, which the user can pinch-zoom within this range.
+        static let chartHistorySeconds: TimeInterval = 24 * 3600
+        /// Visible x-axis window seeded on first launch of the chart (matches the old 6 h default).
+        static let defaultVisibleSeconds: TimeInterval = 6 * 3600
+        /// Tightest pinch-in zoom.
+        static let minVisibleSeconds: TimeInterval = 3 * 3600
+        /// Widest pinch-out zoom.
+        static let maxVisibleSeconds: TimeInterval = 24 * 3600
+        /// Double-tap cycles the visible window through these presets.
+        static let zoomPresets: [TimeInterval] = [6 * 3600, 12 * 3600, 24 * 3600]
+        /// Geometric grid for pinch commits (~4 % per step). Every committed zoom step
+        /// re-lays the full-width canvas, so this bounds a halving of the visible window
+        /// to roughly 18 re-layouts instead of hundreds.
+        static let zoomStepRatio: Double = 1.04
+        /// How far (pt) a one-finger touch may travel and still count as a stationary
+        /// press-to-inspect; beyond this the touch becomes a pan.
+        static let inspectMovementTolerance: CGFloat = 10
+        /// Width (pt) of the strips at the viewport edges where a scrubbing finger makes
+        /// the chart auto-pan to reveal more data; pan speed scales with edge depth.
+        static let edgePanZoneWidth: CGFloat = 44
+        /// How long (s) a one-finger touch must rest before the inspect popover appears.
+        /// Without this, every drag briefly triggered inspect on touch-down — and each
+        /// selection change re-lays the canvas, stalling the pan as it starts.
+        static let inspectHoldDelay: TimeInterval = 0.15
         static let bolusSize: CGFloat = 5
         static let bolusSize: CGFloat = 5
         static let bolusScale: CGFloat = 1.8
         static let bolusScale: CGFloat = 1.8
         static let carbsSize: CGFloat = 5
         static let carbsSize: CGFloat = 5
@@ -49,6 +75,30 @@ enum MainChartHelper {
         static let minGlucose = 45
         static let minGlucose = 45
     }
     }
 
 
+    /// Visual scaling applied to IOB values on the shared COB/IOB axis (COB is usually
+    /// much larger than IOB). Single source of truth for the chart marks, the y-domain,
+    /// and the shell's selection overlay.
+    static func scaledIobAmount<T: Numeric & Comparable>(_ rawAmount: T) -> T
+        where T: ExpressibleByIntegerLiteral
+    {
+        rawAmount > 0 ? rawAmount * 8 : rawAmount * 9
+    }
+
+    /// The combined y-domain of the COB/IOB chart. Used by both the canvas chart and the
+    /// shell's selection overlay, which must agree exactly on the value-to-pixel mapping.
+    static func cobIobYDomain(
+        minCob: Decimal,
+        maxCob: Decimal,
+        minIob: Decimal,
+        maxIob: Decimal
+    ) -> ClosedRange<Double> {
+        let iobMin = scaledIobAmount(minIob)
+        let iobMax = scaledIobAmount(maxIob)
+        let minValue = min(minCob, iobMin)
+        let maxValue = max(maxCob, iobMax)
+        return Double(minValue) ... Double(maxValue)
+    }
+
     static func bolusOffset(units: GlucoseUnits) -> Decimal {
     static func bolusOffset(units: GlucoseUnits) -> Decimal {
         units == .mgdL ? 20 : (20 / 18)
         units == .mgdL ? 20 : (20 / 18)
     }
     }
@@ -94,7 +144,7 @@ enum MainChartHelper {
 
 
 // MARK: - Rule Marks and Charts configurations
 // MARK: - Rule Marks and Charts configurations
 
 
-extension MainChartView {
+extension MainChartCanvas {
     func drawCurrentTimeMarker() -> some ChartContent {
     func drawCurrentTimeMarker() -> some ChartContent {
         RuleMark(
         RuleMark(
             x: .value(
             x: .value(
@@ -105,34 +155,72 @@ extension MainChartView {
         ).lineStyle(.init(lineWidth: 2, dash: [3])).foregroundStyle(Color(.systemGray2))
         ).lineStyle(.init(lineWidth: 2, dash: [3])).foregroundStyle(Color(.systemGray2))
     }
     }
 
 
-    func drawStartRuleMark() -> some ChartContent {
-        RuleMark(
-            x: .value(
-                "",
-                state.startMarker,
-                unit: .second
+    /// High and low threshold lines. Horizontal rules span the whole x-domain, so they stay
+    /// visually static while the chart scrolls. (Moved here from the deleted static-axis
+    /// overlay chart.)
+    @ChartContentBuilder func drawThresholdLines() -> some ChartContent {
+        if thresholdLines {
+            // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
+            let hardCodedLow = Decimal(55)
+            let hardCodedHigh = Decimal(220)
+            let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
+
+            let highColor = Trio.getDynamicGlucoseColor(
+                glucoseValue: highGlucose,
+                highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+                lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+                targetGlucose: currentGlucoseTarget,
+                glucoseColorScheme: glucoseColorScheme
             )
             )
-        ).foregroundStyle(Color.clear)
+            let lowColor = Trio.getDynamicGlucoseColor(
+                glucoseValue: lowGlucose,
+                highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+                lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+                targetGlucose: currentGlucoseTarget,
+                glucoseColorScheme: glucoseColorScheme
+            )
+
+            RuleMark(y: .value("High", units == .mgdL ? highGlucose : highGlucose.asMmolL))
+                .foregroundStyle(highColor)
+                .lineStyle(.init(lineWidth: 1, dash: [5]))
+            RuleMark(y: .value("Low", units == .mgdL ? lowGlucose : lowGlucose.asMmolL))
+                .foregroundStyle(lowColor)
+                .lineStyle(.init(lineWidth: 1, dash: [5]))
+        }
     }
     }
 
 
-    func drawEndRuleMark() -> some ChartContent {
-        RuleMark(
-            x: .value(
-                "",
-                state.endMarker,
-                unit: .second
-            )
-        ).foregroundStyle(Color.clear)
+    /// X-axis grid/label stride for the current continuous zoom level. Same ladder as the
+    /// old presets: up to 6 h visible -> 1 h, up to 12 h -> 2 h, wider -> 4 h.
+    var xAxisStrideHours: Int {
+        let visibleHours = visibleSeconds / 3600
+        if visibleHours <= 6 { return 1 }
+        if visibleHours <= 12 { return 2 }
+        return 4
     }
     }
 
 
-    func basalChartPlotStyle(_ plotContent: ChartPlotContent) -> some View {
-        plotContent
-            .rotationEffect(.degrees(180))
-            .scaleEffect(x: -1, y: 1)
+    /// Calendar-hour axis mark dates for the given range, anchored to absolute time
+    /// (multiples of `xAxisStrideHours` counted from midnight, DST-safe via `Calendar`),
+    /// unlike `.stride(by: .hour, count:)`, which anchors its sequence to the domain start.
+    func hourAxisMarks(over range: ClosedRange<Date>) -> [Date] {
+        let strideHours = xAxisStrideHours
+        var components = calendar.dateComponents([.year, .month, .day, .hour], from: range.lowerBound)
+        let hour = components.hour ?? 0
+        components.hour = hour - hour % strideHours
+        guard var mark = calendar.date(from: components) else { return [] }
+
+        var marks: [Date] = []
+        while mark <= range.upperBound {
+            if mark >= range.lowerBound {
+                marks.append(mark)
+            }
+            guard let next = calendar.date(byAdding: .hour, value: strideHours, to: mark) else { break }
+            mark = next
+        }
+        return marks
     }
     }
 
 
     var mainChartXAxis: some AxisContent {
     var mainChartXAxis: some AxisContent {
-        AxisMarks(values: .stride(by: .hour, count: screenHours > 6 ? (screenHours > 12 ? 4 : 2) : 1)) { _ in
+        AxisMarks(values: hourAxisMarks(over: state.startMarker ... state.endMarker)) { _ in
             if displayXgridLines {
             if displayXgridLines {
                 AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
                 AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
             } else {
             } else {
@@ -141,8 +229,11 @@ extension MainChartView {
         }
         }
     }
     }
 
 
+    /// Grid lines PLUS hour labels. Used only by the bottom (COB/IOB) pane so the time
+    /// labels render exactly once for the whole stack; the basal and glucose panes use
+    /// `mainChartXAxis` (grid lines only) at the same absolute-anchored mark dates.
     var basalChartXAxis: some AxisContent {
     var basalChartXAxis: some AxisContent {
-        AxisMarks(values: .stride(by: .hour, count: screenHours > 6 ? (screenHours > 12 ? 4 : 2) : 1)) { _ in
+        AxisMarks(values: hourAxisMarks(over: state.startMarker ... state.endMarker)) { _ in
             if displayXgridLines {
             if displayXgridLines {
                 AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
                 AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
             } else {
             } else {
@@ -153,27 +244,12 @@ extension MainChartView {
         }
         }
     }
     }
 
 
-    var mainChartYAxis: some AxisContent {
-        AxisMarks(position: .trailing) { value in
-
-            if displayYgridLines {
-                AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
-            } else {
-                AxisGridLine(stroke: .init(lineWidth: 0, dash: [2, 3]))
-            }
-
-            if let glucoseValue = value.as(Double.self), glucoseValue > 0 {
-                /// fix offset between the two charts...
-                if units == .mmolL {
-                    AxisTick(length: 7, stroke: .init(lineWidth: 7)).foregroundStyle(Color.clear)
-                }
-                AxisValueLabel().font(.footnote).foregroundStyle(Color.primary)
-            }
-        }
-    }
-
     var cobIobChartYAxis: some AxisContent {
     var cobIobChartYAxis: some AxisContent {
-        AxisMarks(position: .trailing) { _ in
+        // Only two y-grid lines — at the top and bottom of the pane — instead of
+        // automatic marks: the values are exactly the bounds of the same combined
+        // COB/IOB domain the chart is scaled to.
+        let domain = combinedYDomain()
+        return AxisMarks(position: .trailing, values: [domain.lowerBound, domain.upperBound]) { _ in
             if displayYgridLines {
             if displayYgridLines {
                 AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
                 AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
             } else {
             } else {
@@ -181,8 +257,4 @@ extension MainChartView {
             }
             }
         }
         }
     }
     }
-
-    func fullWidth(viewWidth: CGFloat) -> CGFloat {
-        viewWidth * CGFloat(hours) / CGFloat(min(max(screenHours, 2), 24))
-    }
 }
 }

+ 1 - 1
Trio/Sources/Modules/Home/HomeStateModel+Setup/StartEndMarkerSetup.swift

@@ -3,7 +3,7 @@ import Foundation
 extension Home.StateModel {
 extension Home.StateModel {
     // Update start and  end marker to fix scroll update problem with x axis
     // Update start and  end marker to fix scroll update problem with x axis
     func updateStartEndMarkers() {
     func updateStartEndMarkers() {
-        startMarker = Date(timeIntervalSince1970: TimeInterval(NSDate().timeIntervalSince1970 - 86400))
+        startMarker = Date(timeIntervalSinceNow: -MainChartHelper.Config.chartHistorySeconds)
 
 
         let threeHourSinceNow = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
         let threeHourSinceNow = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
 
 

+ 1 - 1
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -33,7 +33,7 @@ extension Home {
 
 
         private let timer = DispatchTimer(timeInterval: 30)
         private let timer = DispatchTimer(timeInterval: 30)
         private(set) var filteredHours = 24
         private(set) var filteredHours = 24
-        var startMarker = Date(timeIntervalSinceNow: TimeInterval(hours: -24))
+        var startMarker = Date(timeIntervalSinceNow: -MainChartHelper.Config.chartHistorySeconds)
         var endMarker = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
         var endMarker = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
         var manualGlucose: [BloodGlucose] = []
         var manualGlucose: [BloodGlucose] = []
         var uploadStats = false
         var uploadStats = false

+ 50 - 55
Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift

@@ -15,14 +15,12 @@ struct BasalProfile: Hashable {
     }
     }
 }
 }
 
 
-extension MainChartView {
+extension MainChartCanvas {
     var basalChart: some View {
     var basalChart: some View {
         VStack {
         VStack {
             Chart {
             Chart {
-                drawStartRuleMark()
-                drawEndRuleMark()
                 drawCurrentTimeMarker()
                 drawCurrentTimeMarker()
-                drawTempBasals(dummy: false)
+                drawTempBasals()
                 drawBasalProfile()
                 drawBasalProfile()
                 drawSuspensions()
                 drawSuspensions()
             }.onChange(of: state.tempBasals) {
             }.onChange(of: state.tempBasals) {
@@ -32,62 +30,56 @@ extension MainChartView {
             .onChange(of: state.maxBasal) {
             .onChange(of: state.maxBasal) {
                 calculateBasals()
                 calculateBasals()
             }
             }
-            // profile loads async after first appearance; redraw the dashed line
-            .onChange(of: state.basalProfile) {
-                calculateBasals()
-            }
-            .frame(minHeight: basalHeight)
-            .frame(width: fullWidth(viewWidth: screenSize.width))
+            .frame(width: canvasWidth, height: basalHeight)
             .chartXScale(domain: state.startMarker ... state.endMarker)
             .chartXScale(domain: state.startMarker ... state.endMarker)
-            .chartXAxis { basalChartXAxis }
-            .chartXAxis(.hidden)
+            .chartXAxis { mainChartXAxis } // grid lines only; hour labels render once, on the bottom pane
             .chartYAxis(.hidden)
             .chartYAxis(.hidden)
-            .chartPlotStyle { basalChartPlotStyle($0) }
+            .chartYScale(domain: 0 ... basalDomainMax)
         }
         }
     }
     }
+
+    /// Upper bound of the basal chart's y-domain. The bars hang from the top of the plot
+    /// (drawn at `basalDomainMax - rate`), so the tallest rate spans the full strip height —
+    /// matching the old rendering, which achieved the same look by rotating and mirroring
+    /// the plot content.
+    var basalDomainMax: Double {
+        let tempMax = preparedTempBasals.map(\.rate).max() ?? 0
+        let profileMax = basalProfiles.map(\.amount).max() ?? 0
+        return max(tempMax, profileMax, 0.1)
+    }
+
+    /// Converts a basal rate to its top-anchored y value.
+    private func invertedY(_ rate: Double) -> Double {
+        basalDomainMax - rate
+    }
 }
 }
 
 
 // MARK: - Draw functions
 // MARK: - Draw functions
 
 
-extension MainChartView {
-    func drawTempBasals(dummy: Bool) -> some ChartContent {
+extension MainChartCanvas {
+    func drawTempBasals() -> some ChartContent {
         ForEach(preparedTempBasals, id: \.rate) { basal in
         ForEach(preparedTempBasals, id: \.rate) { basal in
-            if dummy {
-                RectangleMark(
-                    xStart: .value("start", basal.start),
-                    xEnd: .value("end", basal.end),
-                    yStart: .value("rate-start", 0),
-                    yEnd: .value("rate-end", basal.rate)
-                ).foregroundStyle(Color.clear)
-
-                LineMark(x: .value("Start Date", basal.start), y: .value("Amount", basal.rate))
-                    .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.clear)
-
-                LineMark(x: .value("End Date", basal.end), y: .value("Amount", basal.rate))
-                    .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.clear)
-            } else {
-                RectangleMark(
-                    xStart: .value("start", basal.start),
-                    xEnd: .value("end", basal.end),
-                    yStart: .value("rate-start", 0),
-                    yEnd: .value("rate-end", basal.rate)
-                ).foregroundStyle(
-                    .linearGradient(
-                        colors: [
-                            Color.insulin.opacity(0.6),
-                            Color.insulin.opacity(0.1)
-                        ],
-                        startPoint: .bottom,
-                        endPoint: .top
-                    )
-                ).alignsMarkStylesWithPlotArea()
+            RectangleMark(
+                xStart: .value("start", basal.start),
+                xEnd: .value("end", basal.end),
+                yStart: .value("rate-start", basalDomainMax),
+                yEnd: .value("rate-end", invertedY(basal.rate))
+            ).foregroundStyle(
+                .linearGradient(
+                    colors: [
+                        Color.insulin.opacity(0.6),
+                        Color.insulin.opacity(0.1)
+                    ],
+                    startPoint: .top,
+                    endPoint: .bottom
+                )
+            ).alignsMarkStylesWithPlotArea()
 
 
-                LineMark(x: .value("Start Date", basal.start), y: .value("Amount", basal.rate))
-                    .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
+            LineMark(x: .value("Start Date", basal.start), y: .value("Amount", invertedY(basal.rate)))
+                .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
 
 
-                LineMark(x: .value("End Date", basal.end), y: .value("Amount", basal.rate))
-                    .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
-            }
+            LineMark(x: .value("End Date", basal.end), y: .value("Amount", invertedY(basal.rate)))
+                .lineStyle(.init(lineWidth: 1)).foregroundStyle(Color.insulin)
         }
         }
     }
     }
 
 
@@ -96,12 +88,12 @@ extension MainChartView {
         ForEach(basalProfiles, id: \.self) { profile in
         ForEach(basalProfiles, id: \.self) { profile in
             LineMark(
             LineMark(
                 x: .value("Start Date", profile.startDate),
                 x: .value("Start Date", profile.startDate),
-                y: .value("Amount", profile.amount),
+                y: .value("Amount", invertedY(profile.amount)),
                 series: .value("profile", "profile")
                 series: .value("profile", "profile")
             ).lineStyle(.init(lineWidth: 2, dash: [2, 4])).foregroundStyle(Color.insulin)
             ).lineStyle(.init(lineWidth: 2, dash: [2, 4])).foregroundStyle(Color.insulin)
             LineMark(
             LineMark(
                 x: .value("End Date", profile.endDate ?? state.endMarker),
                 x: .value("End Date", profile.endDate ?? state.endMarker),
-                y: .value("Amount", profile.amount),
+                y: .value("Amount", invertedY(profile.amount)),
                 series: .value("profile", "profile")
                 series: .value("profile", "profile")
             ).lineStyle(.init(lineWidth: 2.5, dash: [2, 4])).foregroundStyle(Color.insulin)
             ).lineStyle(.init(lineWidth: 2.5, dash: [2, 4])).foregroundStyle(Color.insulin)
         }
         }
@@ -124,13 +116,16 @@ extension MainChartView {
                 )
                 )
 
 
                 let basalProfileDuringSuspension = basalProfiles.first(where: { $0.startDate <= suspensionStart })
                 let basalProfileDuringSuspension = basalProfiles.first(where: { $0.startDate <= suspensionStart })
-                let suspensionMarkHeight = basalProfileDuringSuspension?.amount ?? 1
+                // Clamp to the explicit y-domain: the fallback height of 1 U/hr can exceed
+                // `basalDomainMax` when no profile data is available, and unlike the old
+                // auto-scaled (flipped) plot, an explicit domain would clip the mark.
+                let suspensionMarkHeight = min(basalProfileDuringSuspension?.amount ?? 1, basalDomainMax)
 
 
                 RectangleMark(
                 RectangleMark(
                     xStart: .value("start", suspensionStart),
                     xStart: .value("start", suspensionStart),
                     xEnd: .value("end", suspensionEnd),
                     xEnd: .value("end", suspensionEnd),
-                    yStart: .value("suspend-start", 0),
-                    yEnd: .value("suspend-end", suspensionMarkHeight)
+                    yStart: .value("suspend-start", basalDomainMax),
+                    yEnd: .value("suspend-end", invertedY(suspensionMarkHeight))
                 )
                 )
                 .foregroundStyle(Color.loopGray.opacity(colorScheme == .dark ? 0.3 : 0.8))
                 .foregroundStyle(Color.loopGray.opacity(colorScheme == .dark ? 0.3 : 0.8))
             }
             }
@@ -140,7 +135,7 @@ extension MainChartView {
 
 
 // MARK: - Calculation
 // MARK: - Calculation
 
 
-extension MainChartView {
+extension MainChartCanvas {
     @MainActor func calculateTempBasals() {
     @MainActor func calculateTempBasals() {
         let now = Date()
         let now = Date()
         let suspensionTimes = state.suspendAndResumeEvents.compactMap(\.timestamp)
         let suspensionTimes = state.suspendAndResumeEvents.compactMap(\.timestamp)

+ 8 - 71
Trio/Sources/Modules/Home/View/Chart/ChartElements/CobIobChart.swift

@@ -2,94 +2,31 @@ import Charts
 import Foundation
 import Foundation
 import SwiftUI
 import SwiftUI
 
 
-extension MainChartView {
+extension MainChartCanvas {
     var cobIobChart: some View {
     var cobIobChart: some View {
         Chart {
         Chart {
             drawCurrentTimeMarker()
             drawCurrentTimeMarker()
             drawCOBIOBChart()
             drawCOBIOBChart()
-
-            if let selectedCOBValue {
-                drawSelectedInnerPoint(
-                    xValue: selectedCOBValue.deliverAt ?? Date.now,
-                    yValue: Double(selectedCOBValue.cob),
-                    axis: "COB"
-                )
-                drawSelectedOuterPoint(
-                    xValue: selectedCOBValue.deliverAt ?? Date.now,
-                    yValue: Double(selectedCOBValue.cob),
-                    axis: "IOB",
-                    color: Color.orange
-                )
-            }
-
-            if let selectedIOBValue {
-                let rawAmount = selectedIOBValue.iob?.doubleValue ?? 0
-                let amount: Double = rawAmount > 0 ? rawAmount * 8 : rawAmount * 9
-
-                drawSelectedInnerPoint(
-                    xValue: selectedIOBValue.deliverAt ?? Date.now,
-                    yValue: amount,
-                    axis: "COB"
-                )
-                drawSelectedOuterPoint(
-                    xValue: selectedIOBValue.deliverAt ?? Date.now,
-                    yValue: amount,
-                    axis: "IOB",
-                    color: Color.darkerBlue
-                )
-            }
         }
         }
         .chartForegroundStyleScale([
         .chartForegroundStyleScale([
             "COB": Color.orange,
             "COB": Color.orange,
             "IOB": Color.darkerBlue
             "IOB": Color.darkerBlue
         ])
         ])
         .chartLegend(.hidden)
         .chartLegend(.hidden)
-        .frame(minHeight: cobIobHeight)
-        .frame(width: fullWidth(viewWidth: screenSize.width))
+        .frame(width: canvasWidth, height: cobIobHeight)
         .chartXScale(domain: state.startMarker ... state.endMarker)
         .chartXScale(domain: state.startMarker ... state.endMarker)
-        .chartXSelection(value: $selection)
         .chartXAxis { basalChartXAxis }
         .chartXAxis { basalChartXAxis }
         .chartYAxis { cobIobChartYAxis }
         .chartYAxis { cobIobChartYAxis }
         .chartYScale(domain: combinedYDomain())
         .chartYScale(domain: combinedYDomain())
     }
     }
 
 
     func combinedYDomain() -> ClosedRange<Double> {
     func combinedYDomain() -> ClosedRange<Double> {
-        let iobMin = scaleIobAmountForChart(state.minValueIobChart)
-        let iobMax = scaleIobAmountForChart(state.maxValueIobChart)
-        let minValue = min(state.minValueCobChart, iobMin)
-        let maxValue = max(state.maxValueCobChart, iobMax)
-        return Double(minValue) ... Double(maxValue)
-    }
-
-    private func drawSelectedInnerPoint(xValue: Date, yValue: Double, axis: String) -> some ChartContent {
-        PointMark(
-            x: .value("Time", xValue, unit: .minute),
-            y: .value("Value", yValue)
-        )
-        .symbolSize(CGSize(width: 6, height: 6))
-        .foregroundStyle(Color.primary)
-        .position(by: .value("Axis", axis))
-    }
-
-    private func drawSelectedOuterPoint(xValue: Date, yValue: Double, axis: String, color: Color) -> some ChartContent {
-        PointMark(
-            x: .value("Time", xValue, unit: .minute),
-            y: .value("Value", yValue)
+        MainChartHelper.cobIobYDomain(
+            minCob: state.minValueCobChart,
+            maxCob: state.maxValueCobChart,
+            minIob: state.minValueIobChart,
+            maxIob: state.maxValueIobChart
         )
         )
-        .symbolSize(CGSize(width: 15, height: 15))
-        .foregroundStyle(color.opacity(0.8))
-        .position(by: .value("Axis", axis))
-    }
-
-    /// Scales IOB amounts for chart display.
-    ///
-    /// As IOB and COB share the same Y axis and COB is usually >> IOB,
-    /// we need to visually weigh IOB by multiplying it by a scaling factor:
-    ///
-    /// - Parameter rawAmount: The unscaled IOB amount
-    /// - Returns: The scaled IOB amount for visual representation
-    private func scaleIobAmountForChart<T: Numeric & Comparable>(_ rawAmount: T) -> T where T: ExpressibleByIntegerLiteral {
-        rawAmount > 0 ? rawAmount * 8 : rawAmount * 9
     }
     }
 
 
     func drawCOBIOBChart() -> some ChartContent {
     func drawCOBIOBChart() -> some ChartContent {
@@ -128,7 +65,7 @@ extension MainChartView {
             // MARK: - IOB line and area mark
             // MARK: - IOB line and area mark
 
 
             let rawAmount = item.iob?.doubleValue ?? 0
             let rawAmount = item.iob?.doubleValue ?? 0
-            let amountIOB: Double = scaleIobAmountForChart(rawAmount)
+            let amountIOB: Double = MainChartHelper.scaledIobAmount(rawAmount)
 
 
             AreaMark(x: .value("Time", date), y: .value("Amount", amountIOB))
             AreaMark(x: .value("Time", date), y: .value("Amount", amountIOB))
                 .foregroundStyle(by: .value("Type", "IOB"))
                 .foregroundStyle(by: .value("Type", "IOB"))

+ 0 - 49
Trio/Sources/Modules/Home/View/Chart/ChartElements/DummyCharts.swift

@@ -1,49 +0,0 @@
-import Charts
-import Foundation
-import SwiftUI
-
-extension MainChartView {
-    /// empty chart that just shows the Y axis and Y grid lines. Created separately from `mainChart` to allow main chart to scroll horizontally while having a fixed Y axis
-    var staticYAxisChart: some View {
-        Chart {}
-            .id("DummyMainChart")
-            .frame(
-                minHeight: mainHeight
-            )
-            .frame(width: screenSize.width - 10)
-            .chartXAxis { mainChartXAxis }
-            .chartXScale(domain: state.startMarker ... state.endMarker)
-            .chartXAxis(.hidden)
-            .chartYAxis { mainChartYAxis }
-            .chartYScale(
-                domain: units == .mgdL ? state.minYAxisValue ... state.maxYAxisValue : state.minYAxisValue.asMmolL ... state
-                    .maxYAxisValue.asMmolL
-            )
-            .chartLegend(.hidden)
-    }
-
-    var dummyBasalChart: some View {
-        Chart {}
-            .id("DummyBasalChart")
-            .frame(minHeight: basalHeight)
-            .frame(width: screenSize.width - 10)
-            .chartXAxis { basalChartXAxis }
-            .chartXAxis(.hidden)
-            .chartYAxis(.hidden)
-            .chartLegend(.hidden)
-    }
-
-    var dummyCobChart: some View {
-        Chart {}
-            .id("DummyCobChart")
-            .frame(minHeight: cobIobHeight)
-            .frame(width: screenSize.width - 10)
-            .chartXScale(domain: state.startMarker ... state.endMarker)
-            .chartXAxis { basalChartXAxis }
-            .chartXAxis(.hidden)
-            .chartYAxis { cobIobChartYAxis }
-            .chartYAxis(.hidden)
-            .chartYScale(domain: state.minValueCobChart ... state.maxValueCobChart)
-            .chartLegend(.hidden)
-    }
-}

+ 32 - 41
Trio/Sources/Modules/Home/View/Chart/ChartElements/SelectionPopoverView.swift

@@ -2,7 +2,32 @@ import Charts
 import Foundation
 import Foundation
 import SwiftUI
 import SwiftUI
 
 
-struct SelectionPopoverView: ChartContent {
+/// Color of the selection marker/readout for a glucose value. Shared by the popover card
+/// and the shell's selection overlay dot.
+func selectionMarkColor(
+    for glucose: GlucoseStored,
+    highGlucose: Decimal,
+    lowGlucose: Decimal,
+    currentGlucoseTarget: Decimal,
+    glucoseColorScheme: GlucoseColorScheme
+) -> Color {
+    let hardCodedLow = Decimal(55)
+    let hardCodedHigh = Decimal(220)
+    let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
+
+    return Trio.getDynamicGlucoseColor(
+        glucoseValue: Decimal(glucose.glucose),
+        highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+        lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+        targetGlucose: currentGlucoseTarget,
+        glucoseColorScheme: glucoseColorScheme
+    )
+}
+
+/// The selection detail card. Plain SwiftUI (no longer `ChartContent`): it is rendered by
+/// the shell's selection overlay in a fixed slot, so it can neither be clipped by the
+/// viewport nor force a canvas re-layout while scrubbing.
+struct SelectionPopoverView: View {
     let selectedGlucose: GlucoseStored
     let selectedGlucose: GlucoseStored
     let selectedIOBValue: OrefDetermination?
     let selectedIOBValue: OrefDetermination?
     let selectedCOBValue: OrefDetermination?
     let selectedCOBValue: OrefDetermination?
@@ -18,50 +43,16 @@ struct SelectionPopoverView: ChartContent {
     }
     }
 
 
     private var pointMarkColor: Color {
     private var pointMarkColor: Color {
-        let hardCodedLow = Decimal(55)
-        let hardCodedHigh = Decimal(220)
-        let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
-
-        return Trio.getDynamicGlucoseColor(
-            glucoseValue: Decimal(selectedGlucose.glucose),
-            highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
-            lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
-            targetGlucose: currentGlucoseTarget,
+        selectionMarkColor(
+            for: selectedGlucose,
+            highGlucose: highGlucose,
+            lowGlucose: lowGlucose,
+            currentGlucoseTarget: currentGlucoseTarget,
             glucoseColorScheme: glucoseColorScheme
             glucoseColorScheme: glucoseColorScheme
         )
         )
     }
     }
 
 
-    var body: some ChartContent {
-        RuleMark(x: .value("Selection", selectedGlucose.date ?? Date.now, unit: .minute))
-            .foregroundStyle(Color.tabBar)
-            .offset(yStart: isSmoothingEnabled ? 90 : 70)
-            .lineStyle(.init(lineWidth: 2))
-            .annotation(
-                position: .top,
-                alignment: .center,
-                overflowResolution: .init(x: .fit(to: .chart), y: .disabled)
-            ) {
-                selectionPopover
-            }
-
-        PointMark(
-            x: .value("Time", selectedGlucose.date ?? Date.now, unit: .minute),
-            y: .value("Value", glucoseToDisplay)
-        )
-        .zIndex(-1)
-        .symbolSize(CGSize(width: 15, height: 15))
-        .foregroundStyle(pointMarkColor)
-
-        PointMark(
-            x: .value("Time", selectedGlucose.date ?? Date.now, unit: .minute),
-            y: .value("Value", glucoseToDisplay)
-        )
-        .zIndex(-1)
-        .symbolSize(CGSize(width: 6, height: 6))
-        .foregroundStyle(Color.primary)
-    }
-
-    @ViewBuilder var selectionPopover: some View {
+    @ViewBuilder var body: some View {
         VStack(alignment: .leading) {
         VStack(alignment: .leading) {
             HStack {
             HStack {
                 Image(systemName: "clock")
                 Image(systemName: "clock")

File diff suppressed because it is too large
+ 761 - 169
Trio/Sources/Modules/Home/View/Chart/MainChartView.swift


+ 4 - 0
Trio/Sources/Modules/Home/View/HomeLayout.swift

@@ -14,6 +14,10 @@ enum HomeLayout {
     /// clear air above, between, and below the bottom-zone panels
     /// clear air above, between, and below the bottom-zone panels
     static let bottomZonePadding: CGFloat = 10
     static let bottomZonePadding: CGFloat = 10
     static var bottomZoneHeight: CGFloat { bottomPanelHeight + statsBannerHeight + 3 * bottomZonePadding }
     static var bottomZoneHeight: CGFloat { bottomPanelHeight + statsBannerHeight + 3 * bottomZonePadding }
+    /// pull distance that triggers the forced loop
+    static let refreshTriggerDistance: CGFloat = 70
+    /// indicator row height while the loop runs
+    static let refreshIndicatorHeight: CGFloat = 40
     /// minimum usable chart height; must stay below the natural SE allocation
     /// minimum usable chart height; must stay below the natural SE allocation
     /// (598 − header − meal slot − bottom zone = 238) or the bottom zone overflows
     /// (598 − header − meal slot − bottom zone = 238) or the bottom zone overflows
     static let chartMinHeight: CGFloat = 220
     static let chartMinHeight: CGFloat = 220

+ 0 - 5
Trio/Sources/Modules/Home/View/HomeRootView+Header.swift

@@ -79,11 +79,6 @@ extension Home.RootView {
             .onTapGesture {
             .onTapGesture {
                 state.isLoopStatusPresented = true
                 state.isLoopStatusPresented = true
             }
             }
-            .onLongPressGesture {
-                let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
-                impactHeavy.impactOccurred()
-                state.runLoop()
-            }
             /// eventualBG string at bottomTrailing
             /// eventualBG string at bottomTrailing
 
 
             if let eventualBG = state.enactedAndNonEnactedDeterminations.first?.eventualBG {
             if let eventualBG = state.enactedAndNonEnactedDeterminations.first?.eventualBG {

+ 91 - 0
Trio/Sources/Modules/Home/View/HomeRootView+Refresh.swift

@@ -0,0 +1,91 @@
+import SwiftUI
+import UIKit
+
+/// Pull distance via layout frames — iOS 17 fallback only.
+struct HomePullOffsetKey: PreferenceKey {
+    static var defaultValue: CGFloat = 0
+    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+        value = nextValue()
+    }
+}
+
+/// Streams the pull-down distance from the scroll geometry on iOS 18+.
+struct HomePullOffsetReader: ViewModifier {
+    let onChange: (CGFloat) -> Void
+
+    func body(content: Content) -> some View {
+        if #available(iOS 18.0, *) {
+            content.onScrollGeometryChange(for: CGFloat.self) { geometry in
+                -(geometry.contentOffset.y + geometry.contentInsets.top)
+            } action: { _, pull in
+                onChange(pull)
+            }
+        } else {
+            content
+        }
+    }
+}
+
+// MARK: - Pull-down-to-force-loop
+
+extension Home.RootView {
+    /// Pull hint while dragging; spinner while the loop runs.
+    @ViewBuilder var pullToRefreshIndicator: some View {
+        if isForcingLoop {
+            HStack(spacing: 8) {
+                ProgressView()
+                Text("Forcing loop…")
+            }
+            .font(.caption)
+            .foregroundStyle(.secondary)
+            .frame(height: HomeLayout.refreshIndicatorHeight)
+            .frame(maxWidth: .infinity)
+            .transition(.opacity)
+        } else if pullOffset > 4 {
+            let progress = min(pullOffset / HomeLayout.refreshTriggerDistance, 1)
+            HStack(spacing: 8) {
+                Image(systemName: "arrow.down")
+                    .rotationEffect(.degrees(progress * 180))
+                Text("Pull down to force loop")
+            }
+            .font(.caption)
+            .foregroundStyle(.secondary)
+            .opacity(progress)
+            .frame(height: HomeLayout.refreshIndicatorHeight)
+            .frame(maxWidth: .infinity)
+        }
+    }
+
+    /// Arms once per pull at the threshold; re-arms after the pull settles.
+    func handlePullChange(_ offset: CGFloat) {
+        pullOffset = offset
+        guard !isForcingLoop else { return }
+        if offset >= HomeLayout.refreshTriggerDistance, !isRefreshArmed {
+            isRefreshArmed = true
+            UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+            forceLoop()
+        } else if offset <= 1, isRefreshArmed {
+            isRefreshArmed = false
+        }
+    }
+
+    /// Triggers the loop heartbeat and holds the indicator while it runs.
+    private func forceLoop() {
+        withAnimation(.easeInOut(duration: 0.2)) { isForcingLoop = true }
+        state.runLoop()
+        Task {
+            let start = Date()
+            while !state.isLooping, Date().timeIntervalSince(start) < 3 {
+                try? await Task.sleep(for: .milliseconds(100))
+            }
+            while state.isLooping, Date().timeIntervalSince(start) < 30 {
+                try? await Task.sleep(for: .milliseconds(200))
+            }
+            // Minimum visible duration.
+            if Date().timeIntervalSince(start) < 1 {
+                try? await Task.sleep(for: .seconds(1))
+            }
+            withAnimation(.easeInOut(duration: 0.25)) { isForcingLoop = false }
+        }
+    }
+}

+ 43 - 20
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -32,6 +32,10 @@ extension Home {
         @State var showSnoozeSheet: Bool = false
         @State var showSnoozeSheet: Bool = false
         @State var showManualGlucose: Bool = false
         @State var showManualGlucose: Bool = false
         @State var alarmsSnoozeUntil: Date = .distantPast
         @State var alarmsSnoozeUntil: Date = .distantPast
+        // Pull-down-to-force-loop (see HomeRootView+Refresh.swift)
+        @State var pullOffset: CGFloat = 0
+        @State var isRefreshArmed = false
+        @State var isForcingLoop = false
         @State var notificationsDisabled = false
         @State var notificationsDisabled = false
 
 
         @FetchRequest(fetchRequest: OverrideStored.fetch(
         @FetchRequest(fetchRequest: OverrideStored.fetch(
@@ -65,12 +69,10 @@ extension Home {
                     geo: geo,
                     geo: geo,
                     chartHeight: chartHeight,
                     chartHeight: chartHeight,
                     units: state.units,
                     units: state.units,
-                    hours: state.filteredHours,
                     highGlucose: state.highGlucose,
                     highGlucose: state.highGlucose,
                     lowGlucose: state.lowGlucose,
                     lowGlucose: state.lowGlucose,
                     currentGlucoseTarget: state.currentGlucoseTarget,
                     currentGlucoseTarget: state.currentGlucoseTarget,
                     glucoseColorScheme: state.glucoseColorScheme,
                     glucoseColorScheme: state.glucoseColorScheme,
-                    screenHours: state.hours,
                     displayXgridLines: state.displayXgridLines,
                     displayXgridLines: state.displayXgridLines,
                     displayYgridLines: state.displayYgridLines,
                     displayYgridLines: state.displayYgridLines,
                     thresholdLines: state.thresholdLines,
                     thresholdLines: state.thresholdLines,
@@ -124,6 +126,44 @@ extension Home {
         }
         }
 
 
         @ViewBuilder func mainViewElements(_ geo: GeometryProxy) -> some View {
         @ViewBuilder func mainViewElements(_ geo: GeometryProxy) -> some View {
+            // viewport-sized content: rubber-bands for the pull-down, never scrolls
+            ScrollView(.vertical, showsIndicators: false) {
+                dashboardContent(geo)
+                    .padding(.top, isForcingLoop ? HomeLayout.refreshIndicatorHeight : 0)
+                    .animation(.easeInOut(duration: 0.25), value: isForcingLoop)
+                    .background(
+                        GeometryReader { g in
+                            Color.clear.preference(
+                                key: HomePullOffsetKey.self,
+                                value: g.frame(in: .named("homeScroll")).minY
+                            )
+                        }
+                    )
+            }
+            .coordinateSpace(name: "homeScroll")
+            .scrollBounceBehavior(.always, axes: [.vertical])
+            .modifier(HomePullOffsetReader(onChange: handlePullChange))
+            .onPreferenceChange(HomePullOffsetKey.self) { handlePullChange($0) }
+            .overlay(alignment: .top) { pullToRefreshIndicator }
+            // safe-area anchor: the tab bar can never cover the bottom controls
+            .safeAreaInset(edge: .bottom, spacing: 0) {
+                bottomControls()
+            }
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .onReceive(
+                resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled,
+                perform: {
+                    if notificationsDisabled != $0 {
+                        notificationsDisabled = $0
+                        if notificationsDisabled {
+                            debug(.default, "notificationsDisabled")
+                        }
+                    }
+                }
+            )
+        }
+
+        @ViewBuilder private func dashboardContent(_ geo: GeometryProxy) -> some View {
             VStack(spacing: 0) {
             VStack(spacing: 0) {
                 ZStack {
                 ZStack {
                     if let apsManager = state.apsManager, let bluetoothManager = apsManager.bluetoothManager,
                     if let apsManager = state.apsManager, let bluetoothManager = apsManager.bluetoothManager,
@@ -154,24 +194,7 @@ extension Home {
 
 
                 mainChart(geo: geo)
                 mainChart(geo: geo)
             }
             }
-            // fill the screen; zones stay top-aligned, chart takes the slack
-            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
-            // safe-area anchor: the tab bar can never cover the bottom controls
-            .safeAreaInset(edge: .bottom, spacing: 0) {
-                bottomControls()
-            }
-            .background(appState.trioBackgroundColor(for: colorScheme))
-            .onReceive(
-                resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled,
-                perform: {
-                    if notificationsDisabled != $0 {
-                        notificationsDisabled = $0
-                        if notificationsDisabled {
-                            debug(.default, "notificationsDisabled")
-                        }
-                    }
-                }
-            )
+            .frame(maxWidth: .infinity)
         }
         }
 
 
         @ViewBuilder func mainView() -> some View {
         @ViewBuilder func mainView() -> some View {