Showing 2 changed files with 185 additions and 140 deletions
+119 -94
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -181,6 +181,21 @@ struct MeasurementChartView: View {
181 181
         self.rangeSelectorConfiguration = rangeSelectorConfiguration
182 182
     }
183 183
 
184
+    static func prefersCompactEmbeddedLayout(forWidth width: CGFloat) -> Bool {
185
+        width < 760
186
+    }
187
+
188
+    static func embeddedPlotReferenceHeight(compactLayout: Bool) -> CGFloat {
189
+        compactLayout ? 290 : 350
190
+    }
191
+
192
+    static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
193
+        let compactLayout = prefersCompactEmbeddedLayout(forWidth: width)
194
+        let plotHeight = embeddedPlotReferenceHeight(compactLayout: compactLayout)
195
+        guard showsRangeSelector else { return plotHeight }
196
+        return plotHeight + TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compactLayout)
197
+    }
198
+
184 199
     private var axisColumnWidth: CGFloat {
185 200
         if compactLayout {
186 201
             return 38
@@ -335,120 +350,122 @@ struct MeasurementChartView: View {
335 350
                 VStack(alignment: .leading, spacing: 12) {
336 351
                     chartToggleBar()
337 352
 
338
-                    GeometryReader { geometry in
339
-                        let reservedBottomHeight =
340
-                            xAxisHeight
341
-                            + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0)
342
-                        let plotHeight = max(
343
-                            geometry.size.height - reservedBottomHeight,
344
-                            compactLayout ? 180 : 220
345
-                        )
353
+                    VStack(spacing: compactLayout ? 8 : 10) {
354
+                        GeometryReader { geometry in
355
+                            let reservedBottomHeight =
356
+                                xAxisHeight
357
+                                + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0)
358
+                            let plotHeight = max(
359
+                                geometry.size.height - reservedBottomHeight,
360
+                                compactLayout ? 180 : 220
361
+                            )
362
+
363
+                            VStack(spacing: 6) {
364
+                                HStack(spacing: chartSectionSpacing) {
365
+                                    primaryAxisView(
366
+                                        height: plotHeight,
367
+                                        powerSeries: powerSeries,
368
+                                        energySeries: energySeries,
369
+                                        voltageSeries: voltageSeries,
370
+                                        currentSeries: currentSeries
371
+                                    )
372
+                                    .frame(width: axisColumnWidth, height: plotHeight)
373
+
374
+                                    ZStack {
375
+                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
376
+                                            .fill(Color.primary.opacity(0.05))
377
+
378
+                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
379
+                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
380
+
381
+                                        horizontalGuides(context: primarySeries.context)
382
+                                        verticalGuides(context: primarySeries.context)
383
+                                        discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
384
+                                        renderedChart(
385
+                                            powerSeries: powerSeries,
386
+                                            energySeries: energySeries,
387
+                                            voltageSeries: voltageSeries,
388
+                                            currentSeries: currentSeries,
389
+                                            temperatureSeries: temperatureSeries
390
+                                        )
391
+                                    }
392
+                                    .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
393
+                                    .frame(maxWidth: .infinity)
394
+                                    .frame(height: plotHeight)
346 395
 
347
-                        VStack(spacing: 6) {
348
-                            HStack(spacing: chartSectionSpacing) {
349
-                                primaryAxisView(
350
-                                    height: plotHeight,
351
-                                    powerSeries: powerSeries,
352
-                                    energySeries: energySeries,
353
-                                    voltageSeries: voltageSeries,
354
-                                    currentSeries: currentSeries
355
-                                )
356
-                                .frame(width: axisColumnWidth, height: plotHeight)
357
-
358
-                                ZStack {
359
-                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
360
-                                        .fill(Color.primary.opacity(0.05))
361
-
362
-                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
363
-                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
364
-
365
-                                    horizontalGuides(context: primarySeries.context)
366
-                                    verticalGuides(context: primarySeries.context)
367
-                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
368
-                                    renderedChart(
396
+                                    secondaryAxisView(
397
+                                        height: plotHeight,
369 398
                                         powerSeries: powerSeries,
370 399
                                         energySeries: energySeries,
371 400
                                         voltageSeries: voltageSeries,
372 401
                                         currentSeries: currentSeries,
373 402
                                         temperatureSeries: temperatureSeries
374 403
                                     )
404
+                                    .frame(width: axisColumnWidth, height: plotHeight)
375 405
                                 }
376
-                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
377
-                                .frame(maxWidth: .infinity)
378
-                                .frame(height: plotHeight)
379
-
380
-                                secondaryAxisView(
381
-                                    height: plotHeight,
382
-                                    powerSeries: powerSeries,
383
-                                    energySeries: energySeries,
384
-                                    voltageSeries: voltageSeries,
385
-                                    currentSeries: currentSeries,
386
-                                    temperatureSeries: temperatureSeries
387
-                                )
388
-                                .frame(width: axisColumnWidth, height: plotHeight)
389
-                            }
390
-                            .overlay(alignment: .bottom) {
391
-                                if originControlsPlacement == .aboveXAxisLegend {
392
-                                    scaleControlsPill(
393
-                                        voltageSeries: voltageSeries,
394
-                                        currentSeries: currentSeries
395
-                                    )
396
-                                    .padding(.bottom, compactLayout ? 6 : 10)
406
+                                .overlay(alignment: .bottom) {
407
+                                    if originControlsPlacement == .aboveXAxisLegend {
408
+                                        scaleControlsPill(
409
+                                            voltageSeries: voltageSeries,
410
+                                            currentSeries: currentSeries
411
+                                        )
412
+                                        .padding(.bottom, compactLayout ? 6 : 10)
413
+                                    }
397 414
                                 }
398
-                            }
399 415
 
400
-                            switch originControlsPlacement {
401
-                            case .aboveXAxisLegend:
402
-                                xAxisLabelsView(context: primarySeries.context)
403
-                                    .frame(height: xAxisHeight)
404
-                            case .overXAxisLegend:
405
-                                xAxisLabelsView(context: primarySeries.context)
406
-                                    .frame(height: xAxisHeight)
407
-                                    .overlay(alignment: .center) {
416
+                                switch originControlsPlacement {
417
+                                case .aboveXAxisLegend:
418
+                                    xAxisLabelsView(context: primarySeries.context)
419
+                                        .frame(height: xAxisHeight)
420
+                                case .overXAxisLegend:
421
+                                    xAxisLabelsView(context: primarySeries.context)
422
+                                        .frame(height: xAxisHeight)
423
+                                        .overlay(alignment: .center) {
424
+                                            scaleControlsPill(
425
+                                                voltageSeries: voltageSeries,
426
+                                                currentSeries: currentSeries
427
+                                            )
428
+                                            .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
429
+                                        }
430
+                                case .belowXAxisLegend:
431
+                                    xAxisLabelsView(context: primarySeries.context)
432
+                                        .frame(height: xAxisHeight)
433
+
434
+                                    HStack {
435
+                                        Spacer(minLength: 0)
408 436
                                         scaleControlsPill(
409 437
                                             voltageSeries: voltageSeries,
410 438
                                             currentSeries: currentSeries
411 439
                                         )
412
-                                        .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
440
+                                        Spacer(minLength: 0)
413 441
                                     }
414
-                            case .belowXAxisLegend:
415
-                                xAxisLabelsView(context: primarySeries.context)
416
-                                    .frame(height: xAxisHeight)
417
-
418
-                                HStack {
419
-                                    Spacer(minLength: 0)
420
-                                    scaleControlsPill(
421
-                                        voltageSeries: voltageSeries,
422
-                                        currentSeries: currentSeries
423
-                                    )
424
-                                    Spacer(minLength: 0)
425 442
                                 }
426 443
                             }
427
-
428
-                            if showsRangeSelector,
429
-                               let availableTimeRange,
430
-                               let selectorSeries,
431
-                               shouldShowRangeSelector(
444
+                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
445
+                        }
446
+                        .frame(height: plotSectionHeight)
447
+
448
+                        if showsRangeSelector,
449
+                           let availableTimeRange,
450
+                           let selectorSeries,
451
+                           shouldShowRangeSelector(
452
+                            availableTimeRange: availableTimeRange,
453
+                            series: selectorSeries
454
+                           ) {
455
+                            TimeRangeSelectorView(
456
+                                points: selectorSeries.points,
457
+                                context: selectorSeries.context,
432 458
                                 availableTimeRange: availableTimeRange,
433
-                                series: selectorSeries
434
-                               ) {
435
-                                TimeRangeSelectorView(
436
-                                    points: selectorSeries.points,
437
-                                    context: selectorSeries.context,
438
-                                    availableTimeRange: availableTimeRange,
439
-                                    selectorTint: selectorTint,
440
-                                    compactLayout: compactLayout,
441
-                                    minimumSelectionSpan: minimumTimeSpan,
442
-                                    configuration: resolvedRangeSelectorConfiguration(),
443
-                                    selectedTimeRange: $selectedVisibleTimeRange,
444
-                                    isPinnedToPresent: $isPinnedToPresent,
445
-                                    presentTrackingMode: $presentTrackingMode
446
-                                )
447
-                            }
459
+                                selectorTint: selectorTint,
460
+                                compactLayout: compactLayout,
461
+                                minimumSelectionSpan: minimumTimeSpan,
462
+                                configuration: resolvedRangeSelectorConfiguration(),
463
+                                selectedTimeRange: $selectedVisibleTimeRange,
464
+                                isPinnedToPresent: $isPinnedToPresent,
465
+                                presentTrackingMode: $presentTrackingMode
466
+                            )
448 467
                         }
449
-                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
450 468
                     }
451
-                    .frame(height: plotSectionHeight)
452 469
                 }
453 470
             } else {
454 471
                 VStack(alignment: .leading, spacing: 12) {
@@ -2009,6 +2026,14 @@ private struct TimeRangeSelectorView: View {
2009 2026
         compactLayout ? 72 : 86
2010 2027
     }
2011 2028
 
2029
+    static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
2030
+        let rowHeight: CGFloat = compactLayout ? 28 : 32
2031
+        let trackHeight: CGFloat = compactLayout ? 72 : 86
2032
+        let boundaryHeight: CGFloat = compactLayout ? 16 : 18
2033
+        let spacing: CGFloat = compactLayout ? 6 : 8
2034
+        return rowHeight + spacing + rowHeight + spacing + trackHeight + spacing + boundaryHeight
2035
+    }
2036
+
2012 2037
     private var cornerRadius: CGFloat {
2013 2038
         compactLayout ? 14 : 16
2014 2039
     }
+66 -46
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -15,6 +15,17 @@ struct MeterChargeRecordTabView: View, Equatable {
15 15
     }
16 16
 }
17 17
 
18
+private struct SessionChartWidthPreferenceKey: PreferenceKey {
19
+    static let defaultValue: CGFloat = 760
20
+
21
+    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
22
+        let next = nextValue()
23
+        if next > 0 {
24
+            value = next
25
+        }
26
+    }
27
+}
28
+
18 29
 struct MeterChargeRecordContentView: View {
19 30
     private struct SessionMetricRow {
20 31
         let label: String
@@ -115,6 +126,7 @@ struct MeterChargeRecordContentView: View {
115 126
     @State private var activeMode: ActiveMode = .chargeSession
116 127
     @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
117 128
     @State private var trimBannerDismissedForSessionID: UUID?
129
+    @State private var sessionChartWidth: CGFloat = 760
118 130
 
119 131
     private var shouldShowTrimBanner: Bool {
120 132
         guard let session = openChargeSession,
@@ -1380,7 +1392,12 @@ struct MeterChargeRecordContentView: View {
1380 1392
     }
1381 1393
 
1382 1394
     private func sessionChartCard(timeRange: ClosedRange<Date>?, session: ChargeSessionSummary) -> some View {
1383
-        VStack(alignment: .leading, spacing: 12) {
1395
+        let hasRangeSelector = session.aggregatedSamples.isEmpty == false
1396
+        let chartWidth = max(sessionChartWidth, 1)
1397
+        let compactChartLayout = MeasurementChartView.prefersCompactEmbeddedLayout(forWidth: chartWidth)
1398
+        let plotReferenceHeight = MeasurementChartView.embeddedPlotReferenceHeight(compactLayout: compactChartLayout)
1399
+
1400
+        return VStack(alignment: .leading, spacing: 12) {
1384 1401
             HStack(spacing: 8) {
1385 1402
                 Image(systemName: "chart.xyaxis.line")
1386 1403
                     .foregroundColor(.blue)
@@ -1395,53 +1412,56 @@ struct MeterChargeRecordContentView: View {
1395 1412
                 Spacer(minLength: 0)
1396 1413
             }
1397 1414
 
1398
-            GeometryReader { geometry in
1399
-                let chartWidth = max(geometry.size.width, 1)
1400
-                let compactChartLayout = chartWidth < 760
1401
-                let chartHeight = compactChartLayout ? 290.0 : 350.0
1402
-
1403
-                MeasurementChartView(
1404
-                    compactLayout: compactChartLayout,
1405
-                    availableSize: CGSize(width: chartWidth, height: chartHeight),
1406
-                    timeRange: timeRange,
1407
-                    timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower,
1408
-                    timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper,
1409
-                    showsRangeSelector: session.aggregatedSamples.isEmpty == false,
1410
-                    rebasesEnergyToVisibleRangeStart: true,
1411
-                    extendsTimelineToPresent: false,
1412
-                    rangeSelectorConfiguration: session.aggregatedSamples.isEmpty
1413
-                        ? nil
1414
-                        : MeasurementChartRangeSelectorConfiguration(
1415
-                            keepAction: MeasurementChartSelectionAction(
1416
-                                title: compactChartLayout ? "Keep" : "Keep Selection",
1417
-                                systemName: "scissors",
1418
-                                tone: .destructive,
1419
-                                handler: { range in
1420
-                                    _ = appData.setSessionTrim(
1421
-                                        sessionID: session.id,
1422
-                                        start: range.lowerBound,
1423
-                                        end: range.upperBound
1424
-                                    )
1425
-                                    trimBannerDismissedForSessionID = session.id
1426
-                                }
1427
-                            ),
1428
-                            removeAction: nil,
1429
-                            resetAction: MeasurementChartResetAction(
1430
-                                title: compactChartLayout ? "Reset" : "Reset Trim",
1431
-                                systemName: "arrow.counterclockwise",
1432
-                                tone: .reversible,
1433
-                                confirmationTitle: "Reset session trim?",
1434
-                                confirmationButtonTitle: "Reset trim",
1435
-                                handler: {
1436
-                                    _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil)
1437
-                                }
1438
-                            )
1415
+            MeasurementChartView(
1416
+                compactLayout: compactChartLayout,
1417
+                availableSize: CGSize(width: chartWidth, height: plotReferenceHeight),
1418
+                timeRange: timeRange,
1419
+                timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower,
1420
+                timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper,
1421
+                showsRangeSelector: hasRangeSelector,
1422
+                rebasesEnergyToVisibleRangeStart: true,
1423
+                extendsTimelineToPresent: false,
1424
+                rangeSelectorConfiguration: hasRangeSelector
1425
+                    ? MeasurementChartRangeSelectorConfiguration(
1426
+                        keepAction: MeasurementChartSelectionAction(
1427
+                            title: compactChartLayout ? "Keep" : "Keep Selection",
1428
+                            systemName: "scissors",
1429
+                            tone: .destructive,
1430
+                            handler: { range in
1431
+                                _ = appData.setSessionTrim(
1432
+                                    sessionID: session.id,
1433
+                                    start: range.lowerBound,
1434
+                                    end: range.upperBound
1435
+                                )
1436
+                                trimBannerDismissedForSessionID = session.id
1437
+                            }
1438
+                        ),
1439
+                        removeAction: nil,
1440
+                        resetAction: MeasurementChartResetAction(
1441
+                            title: compactChartLayout ? "Reset" : "Reset Trim",
1442
+                            systemName: "arrow.counterclockwise",
1443
+                            tone: .reversible,
1444
+                            confirmationTitle: "Reset session trim?",
1445
+                            confirmationButtonTitle: "Reset trim",
1446
+                            handler: {
1447
+                                _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil)
1448
+                            }
1439 1449
                         )
1440
-                )
1441
-                .environmentObject(usbMeter.chargeRecordMeasurements)
1442
-                .frame(maxWidth: .infinity, alignment: .topLeading)
1450
+                    )
1451
+                    : nil
1452
+            )
1453
+            .environmentObject(usbMeter.chargeRecordMeasurements)
1454
+            .frame(maxWidth: .infinity, alignment: .topLeading)
1455
+            .frame(height: MeasurementChartView.embeddedContentHeight(width: chartWidth, showsRangeSelector: hasRangeSelector))
1456
+            .background(
1457
+                GeometryReader { geometry in
1458
+                    Color.clear.preference(key: SessionChartWidthPreferenceKey.self, value: geometry.size.width)
1459
+                }
1460
+            )
1461
+            .onPreferenceChange(SessionChartWidthPreferenceKey.self) { width in
1462
+                guard width > 0, abs(width - sessionChartWidth) > 0.5 else { return }
1463
+                sessionChartWidth = width
1443 1464
             }
1444
-            .frame(height: 350)
1445 1465
         }
1446 1466
         .padding(18)
1447 1467
         .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)