Showing 8 changed files with 663 additions and 193 deletions
+8 -23
USB Meter/AppDelegate.swift
@@ -203,29 +203,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
203 203
 
204 204
         container.loadPersistentStores { storeDescription, error in
205 205
             if let error = error as NSError? {
206
-                NSLog("Core Data store load failed: %@", error.localizedDescription)
207
-
208
-                if let storeURL = storeDescription.url {
209
-                    let coordinator = container.persistentStoreCoordinator
210
-                    do {
211
-                        try coordinator.destroyPersistentStore(
212
-                            at: storeURL,
213
-                            ofType: storeDescription.type,
214
-                            options: nil
215
-                        )
216
-                        try coordinator.addPersistentStore(
217
-                            ofType: storeDescription.type,
218
-                            configurationName: nil,
219
-                            at: storeURL,
220
-                            options: storeDescription.options
221
-                        )
222
-                        NSLog("Recovered CloudKit store by recreating it at %@", storeURL.path)
223
-                        return
224
-                    } catch {
225
-                        NSLog("Core Data recovery attempt failed: %@", error.localizedDescription)
226
-                    }
227
-                }
228
-
206
+                // Log the error but do NOT destroy the store — wiping local data and
207
+                // waiting for a full CloudKit re-sync is far worse than a degraded launch.
208
+                NSLog(
209
+                    "Core Data store load failed (url=%@): %@ — %@",
210
+                    storeDescription.url?.path ?? "unknown",
211
+                    error.localizedDescription,
212
+                    error.userInfo
213
+                )
229 214
                 #if DEBUG
230 215
                 fatalError("Unresolved Core Data error \(error), \(error.userInfo)")
231 216
                 #endif
+165 -32
USB Meter/Model/ChargeInsightsModel.swift
@@ -748,7 +748,7 @@ struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
748 748
     }
749 749
 }
750 750
 
751
-struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable {
751
+struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
752 752
     let index: Int
753 753
     let lowerBoundWatts: Double
754 754
     let upperBoundWatts: Double
@@ -758,6 +758,22 @@ struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable {
758 758
     var id: Int { index }
759 759
 }
760 760
 
761
+enum HistogramResolution: Int, CaseIterable, Identifiable {
762
+    case x1 = 1
763
+    case x2 = 2
764
+    case x4 = 4
765
+
766
+    var id: Int { rawValue }
767
+
768
+    var label: String {
769
+        switch self {
770
+        case .x1: return "1×"
771
+        case .x2: return "2×"
772
+        case .x4: return "4×"
773
+        }
774
+    }
775
+}
776
+
761 777
 struct ChargerStandbyPowerMeasurementStatistics: Hashable {
762 778
     let sampleCount: Int
763 779
     let observedDuration: TimeInterval
@@ -821,42 +837,129 @@ struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
821 837
     let averageVoltageVolts: Double
822 838
     let stabilityDeltaWatts: Double
823 839
     let stabilityToleranceWatts: Double
824
-    let powerSamplesWatts: [Double]
825
-
826
-    var duration: TimeInterval {
827
-        endedAt.timeIntervalSince(startedAt)
828
-    }
829
-
830
-    var projectedDailyEnergyWh: Double {
831
-        averagePowerWatts * 24
832
-    }
833
-
834
-    var projectedWeeklyEnergyWh: Double {
835
-        averagePowerWatts * 24 * 7
836
-    }
837
-
838
-    var projectedMonthlyEnergyWh: Double {
839
-        averagePowerWatts * 24 * 30
840
-    }
841
-
842
-    var projectedYearlyEnergyWh: Double {
843
-        averagePowerWatts * 24 * 365
844
-    }
845
-
846
-    var isStable: Bool {
847
-        stabilizedAt != nil
840
+    /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display.
841
+    let storedHistogram: [ChargerStandbyPowerDistributionBin]
842
+
843
+    // MARK: - Codable (with migration from legacy powerSamplesWatts)
844
+
845
+    private enum CodingKeys: String, CodingKey {
846
+        case id, chargerID, meterMACAddress, meterName, meterModel
847
+        case startedAt, endedAt, sampleCount, stabilizedAt
848
+        case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts
849
+        case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts
850
+        case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts
851
+        case stabilityDeltaWatts, stabilityToleranceWatts
852
+        case storedHistogram
853
+        case powerSamplesWatts // legacy – decode only
854
+    }
855
+
856
+    init(
857
+        id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?,
858
+        startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?,
859
+        averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double,
860
+        minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double,
861
+        coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double,
862
+        stabilityDeltaWatts: Double, stabilityToleranceWatts: Double,
863
+        storedHistogram: [ChargerStandbyPowerDistributionBin]
864
+    ) {
865
+        self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress
866
+        self.meterName = meterName; self.meterModel = meterModel
867
+        self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount
868
+        self.stabilizedAt = stabilizedAt
869
+        self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts
870
+        self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts
871
+        self.maximumPowerWatts = maximumPowerWatts
872
+        self.standardDeviationPowerWatts = standardDeviationPowerWatts
873
+        self.coefficientOfVariation = coefficientOfVariation
874
+        self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts
875
+        self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts
876
+        self.storedHistogram = storedHistogram
877
+    }
878
+
879
+    init(from decoder: Decoder) throws {
880
+        let c = try decoder.container(keyedBy: CodingKeys.self)
881
+        id = try c.decode(UUID.self, forKey: .id)
882
+        chargerID = try c.decode(UUID.self, forKey: .chargerID)
883
+        meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress)
884
+        meterName = try c.decodeIfPresent(String.self, forKey: .meterName)
885
+        meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel)
886
+        startedAt = try c.decode(Date.self, forKey: .startedAt)
887
+        endedAt = try c.decode(Date.self, forKey: .endedAt)
888
+        sampleCount = try c.decode(Int.self, forKey: .sampleCount)
889
+        stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt)
890
+        averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts)
891
+        recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts)
892
+        medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts)
893
+        minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts)
894
+        maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts)
895
+        standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts)
896
+        coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation)
897
+        averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps)
898
+        averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts)
899
+        stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts)
900
+        stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts)
901
+
902
+        let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram)
903
+        if let decodedBins, !decodedBins.isEmpty {
904
+            storedHistogram = decodedBins
905
+        } else {
906
+            // Migrate from legacy raw samples format
907
+            let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? []
908
+            let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded())))
909
+            storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram(
910
+                for: samples,
911
+                preferredBinCount: base * HistogramResolution.x4.rawValue
912
+            )
913
+        }
848 914
     }
849 915
 
850
-    var histogram: [ChargerStandbyPowerDistributionBin] {
851
-        ChargerStandbyPowerMeasurementAnalyzer.histogram(for: powerSamplesWatts)
916
+    func encode(to encoder: Encoder) throws {
917
+        var c = encoder.container(keyedBy: CodingKeys.self)
918
+        try c.encode(id, forKey: .id)
919
+        try c.encode(chargerID, forKey: .chargerID)
920
+        try c.encode(meterMACAddress, forKey: .meterMACAddress)
921
+        try c.encodeIfPresent(meterName, forKey: .meterName)
922
+        try c.encodeIfPresent(meterModel, forKey: .meterModel)
923
+        try c.encode(startedAt, forKey: .startedAt)
924
+        try c.encode(endedAt, forKey: .endedAt)
925
+        try c.encode(sampleCount, forKey: .sampleCount)
926
+        try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt)
927
+        try c.encode(averagePowerWatts, forKey: .averagePowerWatts)
928
+        try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts)
929
+        try c.encode(medianPowerWatts, forKey: .medianPowerWatts)
930
+        try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts)
931
+        try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts)
932
+        try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts)
933
+        try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation)
934
+        try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps)
935
+        try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts)
936
+        try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts)
937
+        try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts)
938
+        try c.encode(storedHistogram, forKey: .storedHistogram)
939
+    }
940
+
941
+    // MARK: - Computed
942
+
943
+    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
944
+    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
945
+    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
946
+    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
947
+    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
948
+    var isStable: Bool { stabilizedAt != nil }
949
+
950
+    /// Returns the histogram downsampled to the requested resolution.
951
+    /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue.
952
+    func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
953
+        let factor = HistogramResolution.x4.rawValue / resolution.rawValue
954
+        return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor)
852 955
     }
853 956
 }
854 957
 
855 958
 enum ChargerStandbyPowerMeasurementAnalyzer {
856 959
     static let minimumStableSampleCount = 45
857
-    static let recentSampleWindow = 20
858
-    static let minimumStabilityToleranceWatts = 0.003
859
-    static let relativeStabilityTolerance = 0.01
960
+    static let recentSampleWindow = 40
961
+    static let minimumStabilityToleranceWatts = 0.010
962
+    static let relativeStabilityTolerance = 0.05
860 963
 
861 964
     static func statistics(
862 965
         from samples: [ChargerStandbyPowerSample],
@@ -884,6 +987,9 @@ enum ChargerStandbyPowerMeasurementAnalyzer {
884 987
             abs(averagePower) * relativeStabilityTolerance
885 988
         )
886 989
 
990
+        let baseBinCount = min(18, max(8, Int(Double(powerValues.count).squareRoot().rounded())))
991
+        let liveHistogram = histogram(for: powerValues, preferredBinCount: baseBinCount * HistogramResolution.x4.rawValue)
992
+
887 993
         return ChargerStandbyPowerMeasurementStatistics(
888 994
             sampleCount: powerValues.count,
889 995
             observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
@@ -898,7 +1004,7 @@ enum ChargerStandbyPowerMeasurementAnalyzer {
898 1004
             averageVoltageVolts: mean(voltageValues),
899 1005
             stabilityDeltaWatts: stabilityDelta,
900 1006
             stabilityToleranceWatts: stabilityTolerance,
901
-            histogram: histogram(for: powerValues)
1007
+            histogram: liveHistogram
902 1008
         )
903 1009
     }
904 1010
 
@@ -937,10 +1043,37 @@ enum ChargerStandbyPowerMeasurementAnalyzer {
937 1043
             averageVoltageVolts: statistics.averageVoltageVolts,
938 1044
             stabilityDeltaWatts: statistics.stabilityDeltaWatts,
939 1045
             stabilityToleranceWatts: statistics.stabilityToleranceWatts,
940
-            powerSamplesWatts: samples.map(\.powerWatts)
1046
+            storedHistogram: statistics.histogram
941 1047
         )
942 1048
     }
943 1049
 
1050
+    /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1.
1051
+    static func downsample(
1052
+        _ bins: [ChargerStandbyPowerDistributionBin],
1053
+        factor: Int
1054
+    ) -> [ChargerStandbyPowerDistributionBin] {
1055
+        guard factor > 1, !bins.isEmpty else { return bins }
1056
+        let totalCount = bins.reduce(0) { $0 + $1.count }
1057
+        var result: [ChargerStandbyPowerDistributionBin] = []
1058
+        var inputIndex = 0
1059
+        var outputIndex = 0
1060
+        while inputIndex < bins.count {
1061
+            let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)])
1062
+            let mergedCount = group.reduce(0) { $0 + $1.count }
1063
+            let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0
1064
+            result.append(ChargerStandbyPowerDistributionBin(
1065
+                index: outputIndex,
1066
+                lowerBoundWatts: group.first!.lowerBoundWatts,
1067
+                upperBoundWatts: group.last!.upperBoundWatts,
1068
+                count: mergedCount,
1069
+                relativeFrequency: relFreq
1070
+            ))
1071
+            inputIndex += factor
1072
+            outputIndex += 1
1073
+        }
1074
+        return result
1075
+    }
1076
+
944 1077
     static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
945 1078
         let finiteValues = values.filter(\.isFinite)
946 1079
         guard finiteValues.isEmpty == false else {
+27 -11
USB Meter/Views/ChargedDevices/SidebarChargedDevicesSectionView.swift
@@ -13,21 +13,27 @@ struct SidebarChargedDevicesSectionView: View {
13 13
     let chargedDevices: [ChargedDeviceSummary]
14 14
     let emptyStateText: String
15 15
     let tint: Color
16
+    let isExpanded: Bool
17
+    let onToggle: () -> Void
16 18
     let onAdd: () -> Void
17 19
 
18 20
     var body: some View {
19 21
         Section(header: headerView) {
20
-            // Library overview row — navigates to the full management library
21
-            NavigationLink(destination: SidebarChargedDeviceLibraryView(mode: mode)) {
22
-                libraryRow
23
-            }
24
-            .buttonStyle(.plain)
25
-
26
-            ForEach(chargedDevices) { chargedDevice in
27
-                NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
28
-                    ChargedDeviceSidebarCardView(chargedDevice: chargedDevice)
22
+            if isExpanded {
23
+                // Library overview row — navigates to the full management library
24
+                NavigationLink(destination: SidebarChargedDeviceLibraryView(mode: mode)) {
25
+                    libraryRow
29 26
                 }
30 27
                 .buttonStyle(.plain)
28
+                .transition(.opacity.combined(with: .move(edge: .top)))
29
+
30
+                ForEach(chargedDevices) { chargedDevice in
31
+                    NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
32
+                        ChargedDeviceSidebarCardView(chargedDevice: chargedDevice)
33
+                    }
34
+                    .buttonStyle(.plain)
35
+                    .transition(.opacity.combined(with: .move(edge: .top)))
36
+                }
31 37
             }
32 38
         }
33 39
     }
@@ -58,8 +64,18 @@ struct SidebarChargedDevicesSectionView: View {
58 64
 
59 65
     private var headerView: some View {
60 66
         HStack(alignment: .firstTextBaseline, spacing: 10) {
61
-            Text(title)
62
-                .font(.headline)
67
+            Button(action: onToggle) {
68
+                HStack(alignment: .firstTextBaseline, spacing: 4) {
69
+                    Image(systemName: "chevron.right")
70
+                        .font(.caption.weight(.semibold))
71
+                        .foregroundColor(.secondary)
72
+                        .rotationEffect(.degrees(isExpanded ? 90 : 0))
73
+                        .animation(.easeInOut(duration: 0.22), value: isExpanded)
74
+                    Text(title)
75
+                        .font(.headline)
76
+                }
77
+            }
78
+            .buttonStyle(.plain)
63 79
             Spacer()
64 80
             Button(action: onAdd) {
65 81
                 Image(systemName: "plus.circle.fill")
+6 -1
USB Meter/Views/Meter/Components/MeterInfoCardView.swift
@@ -5,21 +5,24 @@
5 5
 
6 6
 import SwiftUI
7 7
 
8
-struct MeterInfoCardView<Content: View>: View {
8
+struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
9 9
     let title: String
10 10
     let infoMessage: String?
11 11
     let tint: Color
12
+    @ViewBuilder var trailingActions: TrailingActions
12 13
     @ViewBuilder var content: Content
13 14
 
14 15
     init(
15 16
         title: String,
16 17
         infoMessage: String? = nil,
17 18
         tint: Color,
19
+        @ViewBuilder trailingActions: () -> TrailingActions = { EmptyView() },
18 20
         @ViewBuilder content: () -> Content
19 21
     ) {
20 22
         self.title = title
21 23
         self.infoMessage = infoMessage
22 24
         self.tint = tint
25
+        self.trailingActions = trailingActions()
23 26
         self.content = content()
24 27
     }
25 28
 
@@ -31,6 +34,8 @@ struct MeterInfoCardView<Content: View>: View {
31 34
                 if let infoMessage {
32 35
                     ContextInfoButton(title: title, message: infoMessage)
33 36
                 }
37
+                Spacer(minLength: 0)
38
+                trailingActions
34 39
             }
35 40
             content
36 41
         }
+260 -33
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -161,14 +161,11 @@ struct MeterChargeRecordContentView: View {
161 161
                 if let openChargeSession {
162 162
                     chargingMonitorCard(openChargeSession)
163 163
 
164
-                    if showsMeterTotalsCard {
165
-                        meterTotalsCard
166
-                    }
167
-
168 164
                     if let range = sessionChartTimeRange {
169 165
                         sessionChartCard(timeRange: range, session: openChargeSession)
170 166
                     }
171 167
                 } else {
168
+                    liveMeterStripView
172 169
                     modePicker
173 170
 
174 171
                     switch activeMode {
@@ -177,10 +174,6 @@ struct MeterChargeRecordContentView: View {
177 174
                     case .standbyPower:
178 175
                         standbyPowerCard
179 176
                     }
180
-
181
-                    if showsMeterTotalsCard {
182
-                        meterTotalsCard
183
-                    }
184 177
                 }
185 178
             }
186 179
             .padding()
@@ -593,15 +586,29 @@ struct MeterChargeRecordContentView: View {
593 586
         .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
594 587
     }
595 588
 
589
+    // MARK: - Live Meter Strip (idle state)
590
+
591
+    private var liveMeterStripView: some View {
592
+        let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
593
+        return LazyVGrid(columns: columns, spacing: 8) {
594
+            metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow)
595
+            metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue)
596
+            metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal)
597
+        }
598
+    }
599
+
596 600
     // MARK: - Charging Monitor Card
597 601
 
598 602
     private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
599 603
         let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
600 604
         let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession)
601 605
         let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id)
602
-        let metricRows = sessionMetricRows(for: openChargeSession, displayedEnergyWh: displayedEnergyWh)
606
+        let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
607
+            for: openChargeSession,
608
+            effectiveEnergyWhOverride: displayedEnergyWh
609
+        )
603 610
 
604
-        return VStack(alignment: .leading, spacing: 12) {
611
+        return VStack(alignment: .leading, spacing: 14) {
605 612
             // Header
606 613
             HStack {
607 614
                 if let device = selectedChargedDevice {
@@ -619,9 +626,46 @@ struct MeterChargeRecordContentView: View {
619 626
                     .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
620 627
             }
621 628
 
622
-            ChargeRecordMetricsTableView(
623
-                labels: metricRows.map(\.label),
624
-                values: metricRows.map(\.value)
629
+            // Orphaned session warning — device was deleted from library
630
+            if selectedChargedDevice == nil {
631
+                VStack(alignment: .leading, spacing: 8) {
632
+                    Label("Device removed from library", systemImage: "exclamationmark.triangle.fill")
633
+                        .font(.subheadline.weight(.semibold))
634
+                        .foregroundColor(.orange)
635
+                    Text("The device associated with this session no longer exists. Stop the session to close it.")
636
+                        .font(.caption)
637
+                        .foregroundColor(.secondary)
638
+                    Button("Stop Session") {
639
+                        finalCheckpointMode = .skip
640
+                        finalCheckpointText = ""
641
+                        _ = appData.stopChargeSession(
642
+                            sessionID: openChargeSession.id,
643
+                            finalBatteryPercent: nil
644
+                        )
645
+                    }
646
+                    .frame(maxWidth: .infinity)
647
+                    .padding(.vertical, 9)
648
+                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 12)
649
+                    .buttonStyle(.plain)
650
+                }
651
+                .padding(14)
652
+                .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
653
+            }
654
+
655
+            // Battery prediction gauge
656
+            if let batteryPrediction {
657
+                batteryGaugeSection(
658
+                    prediction: batteryPrediction,
659
+                    session: openChargeSession,
660
+                    displayedEnergyWh: displayedEnergyWh
661
+                )
662
+            }
663
+
664
+            // Metrics grid
665
+            sessionMetricsGrid(
666
+                for: openChargeSession,
667
+                displayedEnergyWh: displayedEnergyWh,
668
+                hasPrediction: batteryPrediction != nil
625 669
             )
626 670
 
627 671
             if openChargeSession.stopThresholdAmps > 0 {
@@ -630,21 +674,6 @@ struct MeterChargeRecordContentView: View {
630 674
                     .foregroundColor(.secondary)
631 675
             }
632 676
 
633
-            if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
634
-                for: openChargeSession,
635
-                effectiveEnergyWhOverride: displayedEnergyWh
636
-            ) {
637
-                HStack(spacing: 6) {
638
-                    Image(systemName: "battery.75percent")
639
-                        .foregroundColor(.green)
640
-                    Text("Predicted: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
641
-                        .font(.caption.weight(.semibold))
642
-                    Text("· \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh est.")
643
-                        .font(.caption2)
644
-                        .foregroundColor(.secondary)
645
-                }
646
-            }
647
-
648 677
             if let sessionWarning = sessionWarning(for: openChargeSession) {
649 678
                 Label(sessionWarning, systemImage: "exclamationmark.triangle")
650 679
                     .font(.caption)
@@ -679,16 +708,12 @@ struct MeterChargeRecordContentView: View {
679 708
 
680 709
             targetSectionView(
681 710
                 for: openChargeSession,
682
-                predictedPercent: selectedChargedDevice?.batteryLevelPrediction(
683
-                    for: openChargeSession,
684
-                    effectiveEnergyWhOverride: displayedEnergyWh
685
-                )?.predictedPercent
711
+                predictedPercent: batteryPrediction?.predictedPercent
686 712
             )
687 713
 
688 714
             if showingStopConfirm {
689 715
                 stopConfirmPanel(for: openChargeSession)
690 716
             } else {
691
-                // Session controls
692 717
                 HStack(spacing: 10) {
693 718
                     if openChargeSession.status == .active {
694 719
                         Button("Pause") {
@@ -724,6 +749,208 @@ struct MeterChargeRecordContentView: View {
724 749
         .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
725 750
     }
726 751
 
752
+    // MARK: - Battery Gauge Section
753
+
754
+    private func batteryGaugeSection(
755
+        prediction: BatteryLevelPrediction,
756
+        session: ChargeSessionSummary,
757
+        displayedEnergyWh: Double
758
+    ) -> some View {
759
+        let percent = prediction.predictedPercent
760
+        let color = batteryColor(for: percent)
761
+        let duration = max(session.effectiveDuration, 0)
762
+        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
763
+            ? displayedEnergyWh / duration
764
+            : nil
765
+
766
+        let etaToFull: String? = {
767
+            guard let rate = rateWhPerSec, rate > 0.0001, percent < 98 else { return nil }
768
+            let remaining = max(prediction.estimatedCapacityWh - displayedEnergyWh, 0)
769
+            let seconds = remaining / rate
770
+            return seconds > 120 ? formatETA(seconds) : nil
771
+        }()
772
+
773
+        let etaToTarget: String? = {
774
+            guard let target = session.targetBatteryPercent, target > percent + 1,
775
+                  let rate = rateWhPerSec, rate > 0.0001 else { return nil }
776
+            let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
777
+            let remaining = max(targetEnergyWh - displayedEnergyWh, 0)
778
+            let seconds = remaining / rate
779
+            return seconds > 120 ? formatETA(seconds) : nil
780
+        }()
781
+
782
+        return VStack(spacing: 10) {
783
+            HStack(alignment: .lastTextBaseline, spacing: 8) {
784
+                HStack(alignment: .lastTextBaseline, spacing: 3) {
785
+                    Text("\(Int(percent.rounded()))")
786
+                        .font(.system(size: 52, weight: .bold, design: .rounded))
787
+                        .foregroundColor(color)
788
+                        .monospacedDigit()
789
+                    Text("%")
790
+                        .font(.title2.weight(.semibold))
791
+                        .foregroundColor(color.opacity(0.8))
792
+                }
793
+                Spacer()
794
+                VStack(alignment: .trailing, spacing: 2) {
795
+                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
796
+                        .font(.callout.weight(.bold))
797
+                        .foregroundColor(.orange)
798
+                        .monospacedDigit()
799
+                    Text("est. capacity")
800
+                        .font(.caption2)
801
+                        .foregroundColor(.secondary)
802
+                }
803
+            }
804
+
805
+            batteryProgressBar(
806
+                percent: percent,
807
+                startPercent: session.startBatteryPercent,
808
+                targetPercent: session.targetBatteryPercent
809
+            )
810
+
811
+            HStack(spacing: 14) {
812
+                if let etaToFull {
813
+                    VStack(alignment: .leading, spacing: 1) {
814
+                        HStack(spacing: 4) {
815
+                            Image(systemName: "clock.fill")
816
+                                .font(.caption)
817
+                                .foregroundColor(.green)
818
+                            Text(etaToFull)
819
+                                .font(.caption.weight(.bold))
820
+                        }
821
+                        Text("to full")
822
+                            .font(.caption2)
823
+                            .foregroundColor(.secondary)
824
+                    }
825
+                }
826
+                if let etaToTarget, let target = session.targetBatteryPercent {
827
+                    VStack(alignment: .leading, spacing: 1) {
828
+                        HStack(spacing: 4) {
829
+                            Image(systemName: "bell.badge.fill")
830
+                                .font(.caption)
831
+                                .foregroundColor(.indigo)
832
+                            Text(etaToTarget)
833
+                                .font(.caption.weight(.bold))
834
+                        }
835
+                        Text("to \(Int(target.rounded()))%")
836
+                            .font(.caption2)
837
+                            .foregroundColor(.secondary)
838
+                    }
839
+                }
840
+                Spacer()
841
+                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
842
+                    .font(.caption2)
843
+                    .foregroundColor(.secondary)
844
+                    .multilineTextAlignment(.trailing)
845
+            }
846
+        }
847
+        .padding(14)
848
+        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
849
+    }
850
+
851
+    private func batteryProgressBar(
852
+        percent: Double,
853
+        startPercent: Double?,
854
+        targetPercent: Double?
855
+    ) -> some View {
856
+        let color = batteryColor(for: percent)
857
+        return GeometryReader { geo in
858
+            let width = geo.size.width
859
+            ZStack(alignment: .leading) {
860
+                Capsule()
861
+                    .fill(Color.primary.opacity(0.10))
862
+                Rectangle()
863
+                    .fill(
864
+                        LinearGradient(
865
+                            colors: [color.opacity(0.6), color],
866
+                            startPoint: .leading,
867
+                            endPoint: .trailing
868
+                        )
869
+                    )
870
+                    .frame(width: max(width * CGFloat(percent / 100), 4))
871
+                    .animation(.easeInOut(duration: 0.4), value: percent)
872
+                if let start = startPercent, start > 2, start < 98 {
873
+                    Rectangle()
874
+                        .fill(Color.white.opacity(0.55))
875
+                        .frame(width: 2, height: 20)
876
+                        .offset(x: width * CGFloat(start / 100) - 1)
877
+                }
878
+                if let target = targetPercent {
879
+                    Rectangle()
880
+                        .fill(Color.indigo.opacity(0.9))
881
+                        .frame(width: 2.5, height: 20)
882
+                        .offset(x: width * CGFloat(target / 100) - 1.25)
883
+                }
884
+            }
885
+            .clipShape(Capsule())
886
+        }
887
+        .frame(height: 20)
888
+    }
889
+
890
+    private func batteryColor(for percent: Double) -> Color {
891
+        if percent >= 75 { return .green }
892
+        if percent >= 35 { return .orange }
893
+        return .red
894
+    }
895
+
896
+    private func formatETA(_ seconds: TimeInterval) -> String {
897
+        let totalMinutes = Int(seconds / 60)
898
+        if totalMinutes < 60 { return "\(totalMinutes)m" }
899
+        let hours = totalMinutes / 60
900
+        let minutes = totalMinutes % 60
901
+        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
902
+    }
903
+
904
+    // MARK: - Session Metrics Grid
905
+
906
+    private func sessionMetricsGrid(
907
+        for session: ChargeSessionSummary,
908
+        displayedEnergyWh: Double,
909
+        hasPrediction: Bool
910
+    ) -> some View {
911
+        let capacityFallback: Double? = hasPrediction ? nil : (
912
+            session.capacityEstimateWh
913
+                ?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
914
+                ?? selectedChargedDevice?.estimatedBatteryCapacityWh
915
+        )
916
+        let columns = [GridItem(.flexible()), GridItem(.flexible())]
917
+
918
+        return LazyVGrid(columns: columns, spacing: 8) {
919
+            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
920
+            metricCell(label: "Duration", value: formatDuration(max(session.effectiveDuration, 0)), tint: .teal)
921
+
922
+            if shouldShowChargingTransport(for: session) {
923
+                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
924
+            }
925
+            if shouldShowChargingState(for: session) {
926
+                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
927
+            }
928
+
929
+            metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary)
930
+
931
+            if let capacity = capacityFallback {
932
+                metricCell(label: "Est. Capacity", value: "\(capacity.format(decimalDigits: 2)) Wh", tint: .orange)
933
+            }
934
+        }
935
+    }
936
+
937
+    private func metricCell(label: String, value: String, tint: Color) -> some View {
938
+        VStack(alignment: .leading, spacing: 3) {
939
+            Text(label)
940
+                .font(.caption2)
941
+                .foregroundColor(.secondary)
942
+            Text(value)
943
+                .font(.subheadline.weight(.semibold))
944
+                .lineLimit(1)
945
+                .minimumScaleFactor(0.7)
946
+                .monospacedDigit()
947
+        }
948
+        .frame(maxWidth: .infinity, alignment: .leading)
949
+        .padding(.horizontal, 12)
950
+        .padding(.vertical, 10)
951
+        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
952
+    }
953
+
727 954
     private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
728 955
         VStack(alignment: .leading, spacing: 10) {
729 956
             Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
+129 -63
USB Meter/Views/Meter/Tabs/Live/ChargerStandbyPowerWizardView.swift
@@ -6,6 +6,7 @@
6 6
 //
7 7
 
8 8
 import SwiftUI
9
+import UniformTypeIdentifiers
9 10
 
10 11
 struct ChargerStandbyPowerWizardView: View {
11 12
     @EnvironmentObject private var appData: AppData
@@ -337,7 +338,7 @@ struct ChargerStandbyPowerWizardView: View {
337 338
                     projectedYearlyEnergyWh: statistics.projectedYearlyEnergyWh
338 339
                 )
339 340
 
340
-                distributionCard(
341
+                StandbyPowerDistributionCard(
341 342
                     histogram: statistics.histogram,
342 343
                     averagePowerWatts: statistics.averagePowerWatts,
343 344
                     standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
@@ -432,41 +433,6 @@ struct ChargerStandbyPowerWizardView: View {
432 433
         }
433 434
     }
434 435
 
435
-    private func distributionCard(
436
-        histogram: [ChargerStandbyPowerDistributionBin],
437
-        averagePowerWatts: Double,
438
-        standardDeviationPowerWatts: Double,
439
-        tint: Color
440
-    ) -> some View {
441
-        MeterInfoCardView(
442
-            title: "Distribution",
443
-            infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and deviation.",
444
-            tint: tint
445
-        ) {
446
-            StandbyPowerHistogramView(
447
-                histogram: histogram,
448
-                averagePowerWatts: averagePowerWatts,
449
-                standardDeviationPowerWatts: standardDeviationPowerWatts,
450
-                tint: tint
451
-            )
452
-            .frame(height: 220)
453
-
454
-            if let firstBin = histogram.first, let lastBin = histogram.last {
455
-                let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
456
-                HStack {
457
-                    Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
458
-                    Spacer()
459
-                    Text("\(midpointWatts.format(decimalDigits: 3)) W")
460
-                    Spacer()
461
-                    Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
462
-                }
463
-                .font(.caption)
464
-                .foregroundColor(.secondary)
465
-                .monospacedDigit()
466
-            }
467
-        }
468
-    }
469
-
470 436
     private func statisticsCard(
471 437
         averagePowerWatts: Double,
472 438
         medianPowerWatts: Double,
@@ -514,6 +480,124 @@ struct ChargerStandbyPowerWizardView: View {
514 480
 
515 481
 }
516 482
 
483
+// MARK: - Distribution card with resolution picker and CSV export
484
+
485
+private struct StandbyPowerDistributionCard: View {
486
+    let histogram: [ChargerStandbyPowerDistributionBin]
487
+    let averagePowerWatts: Double
488
+    let standardDeviationPowerWatts: Double
489
+    let tint: Color
490
+    var showExport: Bool = false
491
+
492
+    private func resolution(for width: CGFloat) -> HistogramResolution {
493
+        if width >= 600 { return .x4 }
494
+        if width >= 360 { return .x2 }
495
+        return .x1
496
+    }
497
+
498
+    private func displayedHistogram(width: CGFloat) -> [ChargerStandbyPowerDistributionBin] {
499
+        let factor = HistogramResolution.x4.rawValue / resolution(for: width).rawValue
500
+        return ChargerStandbyPowerMeasurementAnalyzer.downsample(histogram, factor: factor)
501
+    }
502
+
503
+    private var csvString: String {
504
+        var lines = ["Bin,Lower Bound (W),Upper Bound (W),Count,Relative Frequency (%)"]
505
+        for bin in histogram {
506
+            lines.append(
507
+                "\(bin.index + 1),"
508
+                + String(format: "%.6f", bin.lowerBoundWatts) + ","
509
+                + String(format: "%.6f", bin.upperBoundWatts) + ","
510
+                + "\(bin.count),"
511
+                + String(format: "%.4f", bin.relativeFrequency * 100)
512
+            )
513
+        }
514
+        return lines.joined(separator: "\n")
515
+    }
516
+
517
+    var body: some View {
518
+        MeterInfoCardView(
519
+            title: "Value Spread",
520
+            infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and standard deviation.",
521
+            tint: tint,
522
+            trailingActions: {
523
+                if showExport {
524
+                    if #available(iOS 16, *) {
525
+                        ShareLink(
526
+                            item: DistributionCSVExport(content: csvString),
527
+                            preview: SharePreview("distribution.csv")
528
+                        ) {
529
+                            Image(systemName: "square.and.arrow.up")
530
+                                .font(.subheadline.weight(.medium))
531
+                                .foregroundStyle(.secondary)
532
+                        }
533
+                    } else {
534
+                        Button {
535
+                            exportCSVLegacy(csvString)
536
+                        } label: {
537
+                            Image(systemName: "square.and.arrow.up")
538
+                                .font(.subheadline.weight(.medium))
539
+                                .foregroundStyle(.secondary)
540
+                        }
541
+                    }
542
+                }
543
+            }
544
+        ) {
545
+            GeometryReader { proxy in
546
+                let bins = displayedHistogram(width: proxy.size.width)
547
+                StandbyPowerHistogramView(
548
+                    histogram: bins,
549
+                    averagePowerWatts: averagePowerWatts,
550
+                    standardDeviationPowerWatts: standardDeviationPowerWatts,
551
+                    tint: tint
552
+                )
553
+
554
+                if let firstBin = bins.first, let lastBin = bins.last {
555
+                    let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
556
+                    VStack {
557
+                        Spacer()
558
+                        HStack {
559
+                            Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
560
+                            Spacer()
561
+                            Text("\(midpointWatts.format(decimalDigits: 3)) W")
562
+                            Spacer()
563
+                            Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
564
+                        }
565
+                        .font(.caption)
566
+                        .foregroundColor(.secondary)
567
+                        .monospacedDigit()
568
+                    }
569
+                }
570
+            }
571
+            .frame(height: 240)
572
+        }
573
+    }
574
+
575
+    private func exportCSVLegacy(_ csv: String) {
576
+        guard let windowScene = UIApplication.shared.connectedScenes
577
+            .compactMap({ $0 as? UIWindowScene }).first,
578
+              let rootVC = windowScene.windows.first?.rootViewController else { return }
579
+        let activityVC = UIActivityViewController(
580
+            activityItems: [csv],
581
+            applicationActivities: nil
582
+        )
583
+        rootVC.present(activityVC, animated: true)
584
+    }
585
+}
586
+
587
+@available(iOS 16, *)
588
+struct DistributionCSVExport: Transferable {
589
+    let content: String
590
+
591
+    static var transferRepresentation: some TransferRepresentation {
592
+        DataRepresentation(exportedContentType: .commaSeparatedText) { export in
593
+            Data(export.content.utf8)
594
+        }
595
+        .suggestedFileName("distribution")
596
+    }
597
+}
598
+
599
+// MARK: - Histogram bars + Gaussian curve
600
+
517 601
 private struct StandbyPowerHistogramView: View {
518 602
     let histogram: [ChargerStandbyPowerDistributionBin]
519 603
     let averagePowerWatts: Double
@@ -758,6 +842,7 @@ struct ChargerStandbyPowerMeasurementDetailView: View {
758 842
                 .ignoresSafeArea()
759 843
                 )
760 844
                 .navigationTitle("Measurement")
845
+                .navigationBarTitleDisplayMode(.inline)
761 846
                 .toolbar {
762 847
                     ToolbarItem(placement: .primaryAction) {
763 848
                         Button(role: .destructive) {
@@ -789,6 +874,7 @@ struct ChargerStandbyPowerMeasurementDetailView: View {
789 874
                 Text("This measurement is no longer available.")
790 875
                     .foregroundColor(.secondary)
791 876
                     .navigationTitle("Measurement")
877
+                    .navigationBarTitleDisplayMode(.inline)
792 878
             }
793 879
         }
794 880
     }
@@ -874,33 +960,13 @@ private struct ChargerStandbyPowerMeasurementSnapshotView: View {
874 960
     }
875 961
 
876 962
     private var distributionCard: some View {
877
-        MeterInfoCardView(
878
-            title: "Distribution",
879
-            infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and deviation.",
880
-            tint: .orange
881
-        ) {
882
-            StandbyPowerHistogramView(
883
-                histogram: measurement.histogram,
884
-                averagePowerWatts: measurement.averagePowerWatts,
885
-                standardDeviationPowerWatts: measurement.standardDeviationPowerWatts,
886
-                tint: .orange
887
-            )
888
-            .frame(height: 220)
889
-
890
-            if let firstBin = measurement.histogram.first, let lastBin = measurement.histogram.last {
891
-                let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2
892
-                HStack {
893
-                    Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
894
-                    Spacer()
895
-                    Text("\(midpointWatts.format(decimalDigits: 3)) W")
896
-                    Spacer()
897
-                    Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
898
-                }
899
-                .font(.caption)
900
-                .foregroundColor(.secondary)
901
-                .monospacedDigit()
902
-            }
903
-        }
963
+        StandbyPowerDistributionCard(
964
+            histogram: measurement.storedHistogram,
965
+            averagePowerWatts: measurement.averagePowerWatts,
966
+            standardDeviationPowerWatts: measurement.standardDeviationPowerWatts,
967
+            tint: .orange,
968
+            showExport: true
969
+        )
904 970
     }
905 971
 
906 972
     private var statisticsCard: some View {
+47 -30
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarUSBMetersSectionView.swift
@@ -13,34 +13,41 @@ struct SidebarUSBMetersSectionView: View {
13 13
     let scanStartedAt: Date?
14 14
     let now: Date
15 15
     let noDevicesHelpDelay: TimeInterval
16
+    let isExpanded: Bool
17
+    let onToggle: () -> Void
16 18
     let onAddMeter: () -> Void
17 19
 
18 20
     var body: some View {
19 21
         Section(header: usbSectionHeader) {
20
-            if meters.isEmpty {
21
-                Text(devicesEmptyStateText)
22
-                    .font(.footnote)
23
-                    .foregroundColor(.secondary)
24
-                    .frame(maxWidth: .infinity, alignment: .leading)
25
-                    .padding(18)
26
-                    .meterCard(
27
-                        tint: isWaitingForFirstDiscovery ? .blue : .secondary,
28
-                        fillOpacity: 0.14,
29
-                        strokeOpacity: 0.20
30
-                    )
31
-            } else {
32
-                ForEach(meters) { meterSummary in
33
-                    if let meter = meterSummary.meter {
34
-                        NavigationLink(destination: MeterView().environmentObject(meter)) {
35
-                            MeterRowView()
36
-                                .environmentObject(meter)
37
-                        }
38
-                        .buttonStyle(.plain)
39
-                    } else {
40
-                        NavigationLink(destination: MeterDetailView(meterSummary: meterSummary)) {
41
-                            MeterCardView(meterSummary: meterSummary)
22
+            if isExpanded {
23
+                if meters.isEmpty {
24
+                    Text(devicesEmptyStateText)
25
+                        .font(.footnote)
26
+                        .foregroundColor(.secondary)
27
+                        .frame(maxWidth: .infinity, alignment: .leading)
28
+                        .padding(18)
29
+                        .meterCard(
30
+                            tint: isWaitingForFirstDiscovery ? .blue : .secondary,
31
+                            fillOpacity: 0.14,
32
+                            strokeOpacity: 0.20
33
+                        )
34
+                        .transition(.opacity.combined(with: .move(edge: .top)))
35
+                } else {
36
+                    ForEach(meters) { meterSummary in
37
+                        if let meter = meterSummary.meter {
38
+                            NavigationLink(destination: MeterView().environmentObject(meter)) {
39
+                                MeterRowView()
40
+                                    .environmentObject(meter)
41
+                            }
42
+                            .buttonStyle(.plain)
43
+                            .transition(.opacity.combined(with: .move(edge: .top)))
44
+                        } else {
45
+                            NavigationLink(destination: MeterDetailView(meterSummary: meterSummary)) {
46
+                                MeterCardView(meterSummary: meterSummary)
47
+                            }
48
+                            .buttonStyle(.plain)
49
+                            .transition(.opacity.combined(with: .move(edge: .top)))
42 50
                         }
43
-                        .buttonStyle(.plain)
44 51
                     }
45 52
                 }
46 53
             }
@@ -69,16 +76,26 @@ struct SidebarUSBMetersSectionView: View {
69 76
 
70 77
     private var usbSectionHeader: some View {
71 78
         HStack(alignment: .firstTextBaseline) {
72
-            VStack(alignment: .leading, spacing: 2) {
73
-                Text("USB & Known Meters")
74
-                    .font(.headline)
75
-                if meters.isEmpty == false {
76
-                    Text(sectionSubtitleText)
77
-                        .font(.caption)
79
+            Button(action: onToggle) {
80
+                HStack(alignment: .firstTextBaseline, spacing: 4) {
81
+                    Image(systemName: "chevron.right")
82
+                        .font(.caption.weight(.semibold))
78 83
                         .foregroundColor(.secondary)
79
-                        .lineLimit(1)
84
+                        .rotationEffect(.degrees(isExpanded ? 90 : 0))
85
+                        .animation(.easeInOut(duration: 0.22), value: isExpanded)
86
+                    VStack(alignment: .leading, spacing: 2) {
87
+                        Text("USB & Known Meters")
88
+                            .font(.headline)
89
+                        if meters.isEmpty == false {
90
+                            Text(sectionSubtitleText)
91
+                                .font(.caption)
92
+                                .foregroundColor(.secondary)
93
+                                .lineLimit(1)
94
+                        }
95
+                    }
80 96
                 }
81 97
             }
98
+            .buttonStyle(.plain)
82 99
             Spacer()
83 100
             Button(action: onAddMeter) {
84 101
                 Image(systemName: "plus.circle.fill")
+21 -0
USB Meter/Views/Sidebar/SidebarView.swift
@@ -25,6 +25,9 @@ private enum SidebarCreationSheet: Identifiable {
25 25
 
26 26
 struct SidebarView: View {
27 27
     @EnvironmentObject private var appData: AppData
28
+    @State private var isUSBMetersExpanded = true
29
+    @State private var isDevicesExpanded = true
30
+    @State private var isChargersExpanded = true
28 31
     @State private var isHelpExpanded = false
29 32
     @State private var dismissedAutoHelpReason: SidebarHelpReason?
30 33
     @State private var now = Date()
@@ -77,6 +80,12 @@ struct SidebarView: View {
77 80
                 scanStartedAt: appData.bluetoothManager.scanStartedAt,
78 81
                 now: now,
79 82
                 noDevicesHelpDelay: noDevicesHelpDelay,
83
+                isExpanded: isUSBMetersExpanded,
84
+                onToggle: {
85
+                    withAnimation(.easeInOut(duration: 0.22)) {
86
+                        isUSBMetersExpanded.toggle()
87
+                    }
88
+                },
80 89
                 onAddMeter: { creationSheet = .meter }
81 90
             )
82 91
 
@@ -86,6 +95,12 @@ struct SidebarView: View {
86 95
                 chargedDevices: appData.deviceSummaries,
87 96
                 emptyStateText: "No devices yet. Open Charge Record on a live meter or use the add button here to create one and start learning capacity.",
88 97
                 tint: .orange,
98
+                isExpanded: isDevicesExpanded,
99
+                onToggle: {
100
+                    withAnimation(.easeInOut(duration: 0.22)) {
101
+                        isDevicesExpanded.toggle()
102
+                    }
103
+                },
89 104
                 onAdd: { creationSheet = .device }
90 105
             )
91 106
 
@@ -95,6 +110,12 @@ struct SidebarView: View {
95 110
                 chargedDevices: appData.chargerSummaries,
96 111
                 emptyStateText: "No chargers yet. Add one here so wireless sessions can track both the charged device and the charger being used.",
97 112
                 tint: .pink,
113
+                isExpanded: isChargersExpanded,
114
+                onToggle: {
115
+                    withAnimation(.easeInOut(duration: 0.22)) {
116
+                        isChargersExpanded.toggle()
117
+                    }
118
+                },
98 119
                 onAdd: { creationSheet = .charger }
99 120
             )
100 121
         }