Showing 10 changed files with 300 additions and 34 deletions
+4 -0
USB Meter.xcodeproj/project.pbxproj
@@ -12,6 +12,7 @@
12 12
 		4308CF882417770D0002E80B /* DataGroupsSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF872417770D0002E80B /* DataGroupsSheetView.swift */; };
13 13
 		430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430CB4FB245E07EB006525C2 /* ChevronView.swift */; };
14 14
 		430CB4FD245E07EB006525C3 /* AdaptiveTabBarPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430CB4FE245E07EB006525C4 /* AdaptiveTabBarPresentation.swift */; };
15
+		3DB80493A78F47DB8613585C /* SidebarToggleToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E3D2C71D8334EC9838A0ADE /* SidebarToggleToolbar.swift */; };
15 16
 		4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311E639241384960080EA59 /* DeviceHelpView.swift */; };
16 17
 		432EA6442445A559006FC905 /* ChartContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432EA6432445A559006FC905 /* ChartContext.swift */; };
17 18
 		4347F01D28D717C1007EE7B1 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 4347F01C28D717C1007EE7B1 /* CryptoSwift */; };
@@ -132,6 +133,7 @@
132 133
 		4308CF872417770D0002E80B /* DataGroupsSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupsSheetView.swift; sourceTree = "<group>"; };
133 134
 		430CB4FB245E07EB006525C2 /* ChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronView.swift; sourceTree = "<group>"; };
134 135
 		430CB4FE245E07EB006525C4 /* AdaptiveTabBarPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveTabBarPresentation.swift; sourceTree = "<group>"; };
136
+		9E3D2C71D8334EC9838A0ADE /* SidebarToggleToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarToggleToolbar.swift; sourceTree = "<group>"; };
135 137
 		4311E639241384960080EA59 /* DeviceHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelpView.swift; sourceTree = "<group>"; };
136 138
 		432EA6432445A559006FC905 /* ChartContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartContext.swift; sourceTree = "<group>"; };
137 139
 		4351E7BA24685ACD00E798A3 /* CGPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGPoint.swift; sourceTree = "<group>"; };
@@ -627,6 +629,7 @@
627 629
 				430CB4FE245E07EB006525C4 /* AdaptiveTabBarPresentation.swift */,
628 630
 				4360A34C241CBB3800B464F9 /* RSSIView.swift */,
629 631
 				430CB4FB245E07EB006525C2 /* ChevronView.swift */,
632
+				9E3D2C71D8334EC9838A0ADE /* SidebarToggleToolbar.swift */,
630 633
 			);
631 634
 			path = Generic;
632 635
 			sourceTree = "<group>";
@@ -903,6 +906,7 @@
903 906
 				B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */,
904 907
 				4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */,
905 908
 				430CB4FD245E07EB006525C3 /* AdaptiveTabBarPresentation.swift in Sources */,
909
+				3DB80493A78F47DB8613585C /* SidebarToggleToolbar.swift in Sources */,
906 910
 				437D47D12415F91B00B7768E /* MeterLiveContentView.swift in Sources */,
907 911
 				4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */,
908 912
 				3407A133FADB8858DC2A1FED /* MeterNameStore.swift in Sources */,
+9 -0
USB Meter/Model/AppData.swift
@@ -731,6 +731,15 @@ final class AppData : ObservableObject {
731 731
         return didSave
732 732
     }
733 733
 
734
+    @discardableResult
735
+    func commitSessionTrim(sessionID: UUID) -> Bool {
736
+        let didSave = chargeInsightsStore?.commitSessionTrim(sessionID: sessionID) ?? false
737
+        if didSave {
738
+            reloadChargedDevices()
739
+        }
740
+        return didSave
741
+    }
742
+
734 743
     @discardableResult
735 744
     func flushChargeInsights() -> Bool {
736 745
         let didFlushObservations = flushAllPendingChargeObservations()
+140 -0
USB Meter/Model/ChargeInsightsStore.swift
@@ -953,6 +953,146 @@ final class ChargeInsightsStore {
953 953
         return didSave
954 954
     }
955 955
 
956
+    @discardableResult
957
+    func commitSessionTrim(sessionID: UUID) -> Bool {
958
+        var didSave = false
959
+        context.performAndWait {
960
+            guard let session = fetchSessionObject(id: sessionID.uuidString),
961
+                  statusValue(session, key: "statusRawValue")?.isOpen == false else {
962
+                return
963
+            }
964
+
965
+            guard dateValue(session, key: "trimStart") != nil
966
+                    || dateValue(session, key: "trimEnd") != nil else {
967
+                return
968
+            }
969
+
970
+            let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast
971
+            let sessionEnd = dateValue(session, key: "endedAt")
972
+                ?? dateValue(session, key: "lastObservedAt")
973
+                ?? sessionStart
974
+
975
+            let effectiveStart = min(max(dateValue(session, key: "trimStart") ?? sessionStart, sessionStart), sessionEnd)
976
+            let effectiveEnd = max(
977
+                min(dateValue(session, key: "trimEnd") ?? sessionEnd, sessionEnd),
978
+                effectiveStart
979
+            )
980
+
981
+            let sampleObjects = fetchSessionSampleObjects(forSessionID: sessionID.uuidString)
982
+            let allSamples = sampleObjects
983
+                .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
984
+                    guard let timestamp = dateValue(obj, key: "timestamp") else { return nil }
985
+                    return (
986
+                        timestamp: timestamp,
987
+                        energy: doubleValue(obj, key: "measuredEnergyWh"),
988
+                        charge: doubleValue(obj, key: "measuredChargeAh")
989
+                    )
990
+                }
991
+                .sorted { $0.timestamp < $1.timestamp }
992
+
993
+            let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
994
+            let endSample = allSamples.last { $0.timestamp <= effectiveEnd }
995
+            let baselineEnergy = baselineSample?.energy ?? 0
996
+            let baselineCharge = baselineSample?.charge ?? 0
997
+            let committedEnergy = endSample.map { max($0.energy - baselineEnergy, 0) }
998
+                ?? doubleValue(session, key: "measuredEnergyWh")
999
+            let committedCharge = endSample.map { max($0.charge - baselineCharge, 0) }
1000
+                ?? doubleValue(session, key: "measuredChargeAh")
1001
+
1002
+            var retainedSamples: [(current: Double, power: Double, voltage: Double?)] = []
1003
+            for sample in sampleObjects {
1004
+                guard let timestamp = dateValue(sample, key: "timestamp") else {
1005
+                    context.delete(sample)
1006
+                    continue
1007
+                }
1008
+
1009
+                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
1010
+                    context.delete(sample)
1011
+                    continue
1012
+                }
1013
+
1014
+                let rebasedEnergy = max(doubleValue(sample, key: "measuredEnergyWh") - baselineEnergy, 0)
1015
+                let rebasedCharge = max(doubleValue(sample, key: "measuredChargeAh") - baselineCharge, 0)
1016
+                let elapsed = max(timestamp.timeIntervalSince(effectiveStart), 0)
1017
+                let rebasedBucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
1018
+
1019
+                sample.setValue("\(sessionID.uuidString)-\(rebasedBucketIndex)", forKey: "id")
1020
+                sample.setValue(rebasedBucketIndex, forKey: "bucketIndex")
1021
+                sample.setValue(rebasedEnergy, forKey: "measuredEnergyWh")
1022
+                sample.setValue(rebasedCharge, forKey: "measuredChargeAh")
1023
+                sample.setValue(Date(), forKey: "updatedAt")
1024
+
1025
+                retainedSamples.append(
1026
+                    (
1027
+                        current: doubleValue(sample, key: "averageCurrentAmps"),
1028
+                        power: doubleValue(sample, key: "averagePowerWatts"),
1029
+                        voltage: optionalDoubleValue(sample, key: "averageVoltageVolts")
1030
+                    )
1031
+                )
1032
+            }
1033
+
1034
+            for checkpoint in fetchCheckpointObjects(forSessionID: sessionID.uuidString) {
1035
+                guard let timestamp = dateValue(checkpoint, key: "timestamp") else {
1036
+                    context.delete(checkpoint)
1037
+                    continue
1038
+                }
1039
+
1040
+                guard timestamp >= effectiveStart && timestamp <= effectiveEnd else {
1041
+                    context.delete(checkpoint)
1042
+                    continue
1043
+                }
1044
+
1045
+                let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
1046
+                checkpoint.setValue(
1047
+                    max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0),
1048
+                    forKey: "measuredEnergyWh"
1049
+                )
1050
+                checkpoint.setValue(
1051
+                    max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0),
1052
+                    forKey: "measuredChargeAh"
1053
+                )
1054
+            }
1055
+
1056
+            if !retainedSamples.isEmpty {
1057
+                let positiveCurrents = retainedSamples.map { $0.current }.filter { $0 > 0 }
1058
+                session.setValue(positiveCurrents.min(), forKey: "minimumObservedCurrentAmps")
1059
+                session.setValue(retainedSamples.map { $0.current }.max(), forKey: "maximumObservedCurrentAmps")
1060
+                session.setValue(retainedSamples.map { $0.power }.max(), forKey: "maximumObservedPowerWatts")
1061
+                session.setValue(retainedSamples.compactMap { $0.voltage }.max(), forKey: "maximumObservedVoltageVolts")
1062
+                session.setValue(
1063
+                    retainedSamples.contains { $0.power > 0.05 || $0.current > 0.01 },
1064
+                    forKey: "hasObservedChargeFlow"
1065
+                )
1066
+            } else {
1067
+                session.setValue(nil, forKey: "minimumObservedCurrentAmps")
1068
+                session.setValue(nil, forKey: "maximumObservedCurrentAmps")
1069
+                session.setValue(nil, forKey: "maximumObservedPowerWatts")
1070
+                session.setValue(nil, forKey: "maximumObservedVoltageVolts")
1071
+                session.setValue(committedEnergy > 0 || committedCharge > 0, forKey: "hasObservedChargeFlow")
1072
+            }
1073
+
1074
+            session.setValue(effectiveStart, forKey: "startedAt")
1075
+            session.setValue(effectiveEnd, forKey: "lastObservedAt")
1076
+            if dateValue(session, key: "endedAt") != nil {
1077
+                session.setValue(effectiveEnd, forKey: "endedAt")
1078
+            }
1079
+            session.setValue(committedEnergy, forKey: "measuredEnergyWh")
1080
+            session.setValue(committedCharge, forKey: "measuredChargeAh")
1081
+            session.setValue(nil, forKey: "trimStart")
1082
+            session.setValue(nil, forKey: "trimEnd")
1083
+            session.setValue(Date(), forKey: "updatedAt")
1084
+
1085
+            refreshCheckpointDerivedValues(for: session)
1086
+
1087
+            if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
1088
+                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1089
+            }
1090
+
1091
+            didSave = saveContext()
1092
+        }
1093
+        return didSave
1094
+    }
1095
+
956 1096
     @discardableResult
957 1097
     func deleteChargeSession(id sessionID: UUID) -> Bool {
958 1098
         var didSave = false
+1 -0
USB Meter/Views/ChargedDevices/Details/ChargedDeviceDetailView.swift
@@ -39,6 +39,7 @@ struct ChargedDeviceDetailView: View {
39 39
                     .navigationTitle("Device")
40 40
             }
41 41
         }
42
+        .sidebarToggleToolbarItem()
42 43
         .sheet(isPresented: $editorVisibility) {
43 44
             if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
44 45
                 if chargedDevice.isCharger {
+53 -3
USB Meter/Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift
@@ -45,6 +45,7 @@ struct ChargeSessionDetailView: View {
45 45
     @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
46 46
     @State private var pendingSessionDeletion: ChargeSessionSummary?
47 47
     @State private var pendingSessionStopRequest: ChargeSessionStopRequest?
48
+    @State private var pendingTrimCommitSession: ChargeSessionSummary?
48 49
     @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
49 50
     @State private var trimBannerDismissedForSessionID: UUID?
50 51
     @State private var showingInlineTargetEditor = false
@@ -151,12 +152,23 @@ struct ChargeSessionDetailView: View {
151 152
                 secondaryButton: .cancel()
152 153
             )
153 154
         }
155
+        .alert(item: $pendingTrimCommitSession) { session in
156
+            Alert(
157
+                title: Text("Save Trim Permanently?"),
158
+                message: Text("Samples and checkpoints outside \(session.effectiveTrimStart.format()) - \(session.effectiveTrimEnd.format()) will be deleted. Reset Trim will no longer restore them."),
159
+                primaryButton: .destructive(Text("Save Trim")) {
160
+                    _ = appData.commitSessionTrim(sessionID: session.id)
161
+                },
162
+                secondaryButton: .cancel()
163
+            )
164
+        }
154 165
         .onAppear {
155 166
             syncMonitoringRestore()
156 167
             runTrimDetection()
157 168
         }
158 169
         .onChange(of: session?.id) { _ in
159 170
             pendingSessionStopRequest = nil
171
+            pendingTrimCommitSession = nil
160 172
             detectedTrimWindow = nil
161 173
             trimBannerDismissedForSessionID = nil
162 174
             showingInlineTargetEditor = false
@@ -207,8 +219,6 @@ struct ChargeSessionDetailView: View {
207 219
 
208 220
                     if session.status.isOpen {
209 221
                         followerNoticeCard(session)
210
-                    } else {
211
-                        managementCard(session)
212 222
                     }
213 223
                 }
214 224
             }
@@ -223,6 +233,18 @@ struct ChargeSessionDetailView: View {
223 233
             .ignoresSafeArea()
224 234
         )
225 235
         .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details")
236
+        .toolbar {
237
+            ToolbarItemGroup(placement: .primaryAction) {
238
+                if session.status.isOpen == false {
239
+                    Button(role: .destructive) {
240
+                        pendingSessionDeletion = session
241
+                    } label: {
242
+                        Image(systemName: "trash")
243
+                    }
244
+                    .help("Delete session")
245
+                }
246
+            }
247
+        }
226 248
     }
227 249
 
228 250
     private var unavailableState: some View {
@@ -1316,7 +1338,12 @@ struct ChargeSessionDetailView: View {
1316 1338
                     confirmTitle: "Finish",
1317 1339
                     explanation: "The selected chart window will be saved as this session's active charging window before the session is closed."
1318 1340
                 )
1319
-            }
1341
+            },
1342
+            onCommitTrim: (session.status.isOpen == false && session.isTrimmed)
1343
+                ? {
1344
+                    pendingTrimCommitSession = session
1345
+                }
1346
+                : nil
1320 1347
         )
1321 1348
     }
1322 1349
 
@@ -1699,6 +1726,7 @@ struct ChargeSessionChartCardView: View {
1699 1726
     let controlMode: ChargeSessionChartControlMode
1700 1727
     let onSetTrim: (Date?, Date?) -> Void
1701 1728
     let onStopWithTrim: (Date?, Date?) -> Void
1729
+    let onCommitTrim: (() -> Void)?
1702 1730
 
1703 1731
     @StateObject private var storedMeasurements = Measurements()
1704 1732
 
@@ -1761,6 +1789,28 @@ struct ChargeSessionChartCardView: View {
1761 1789
             )
1762 1790
             .environmentObject(chartMeasurements)
1763 1791
             .frame(maxWidth: .infinity, alignment: .topLeading)
1792
+
1793
+            if let onCommitTrim {
1794
+                Divider()
1795
+
1796
+                HStack(alignment: .center, spacing: 10) {
1797
+                    Label("Save trim permanently", systemImage: "internaldrive")
1798
+                        .font(.caption.weight(.semibold))
1799
+                        .foregroundColor(.secondary)
1800
+
1801
+                    Spacer(minLength: 0)
1802
+
1803
+                    Button {
1804
+                        onCommitTrim()
1805
+                    } label: {
1806
+                        Label("Save Trim", systemImage: "checkmark.seal")
1807
+                            .font(.caption.weight(.semibold))
1808
+                    }
1809
+                    .buttonStyle(.borderedProminent)
1810
+                    .controlSize(.small)
1811
+                    .tint(.red)
1812
+                }
1813
+            }
1764 1814
         }
1765 1815
         .padding(18)
1766 1816
         .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
+1 -0
USB Meter/Views/ChargedDevices/Sidebar/SidebarChargedDeviceLibraryView.swift
@@ -38,6 +38,7 @@ struct SidebarChargedDeviceLibraryView: View {
38 38
                 Button("New") { showingNewEditor = true }
39 39
             }
40 40
         }
41
+        .sidebarToggleToolbarItem()
41 42
         .sheet(isPresented: $showingNewEditor) { newEditorSheet }
42 43
         .sheet(item: $editingChargedDevice) { device in editEditorSheet(device) }
43 44
         .confirmationDialog(
+31 -0
USB Meter/Views/Components/Generic/SidebarToggleToolbar.swift
@@ -0,0 +1,31 @@
1
+//
2
+//  SidebarToggleToolbar.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+extension View {
9
+    /// Adds a sidebar toggle button to the leading toolbar on Mac Catalyst.
10
+    /// No-op on other platforms. Apply to any top-level detail view reachable
11
+    /// from the sidebar so users can restore the sidebar if it was hidden.
12
+    func sidebarToggleToolbarItem() -> some View {
13
+        #if targetEnvironment(macCatalyst)
14
+        return self.toolbar {
15
+            ToolbarItem(placement: .navigationBarLeading) {
16
+                Button {
17
+                    UIApplication.shared.sendAction(
18
+                        Selector(("toggleSidebar:")),
19
+                        to: nil, from: nil, for: nil
20
+                    )
21
+                } label: {
22
+                    Image(systemName: "sidebar.left")
23
+                }
24
+                .help("Show Sidebar")
25
+            }
26
+        }
27
+        #else
28
+        return self
29
+        #endif
30
+    }
31
+}
+1 -0
USB Meter/Views/DeviceHelpView.swift
@@ -43,6 +43,7 @@ struct DeviceHelpView: View {
43 43
             .ignoresSafeArea()
44 44
         )
45 45
         .navigationTitle("Device Help")
46
+        .sidebarToggleToolbarItem()
46 47
     }
47 48
 
48 49
     private func helpCard(title: String, body: String) -> some View {
+59 -31
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -2102,7 +2102,8 @@ private struct TimeRangeSelectorView: View {
2102 2102
         let trackHeight = Self.trackHeight(compactLayout: compactLayout)
2103 2103
         let axisLabelsHeight: CGFloat = compactLayout ? 18 : 20
2104 2104
         let spacing: CGFloat = compactLayout ? 6 : 8
2105
-        return rowHeight + spacing + rowHeight + spacing + trackHeight + spacing + axisLabelsHeight
2105
+        // Single row of controls instead of two
2106
+        return rowHeight + spacing + trackHeight + spacing + axisLabelsHeight
2106 2107
     }
2107 2108
 
2108 2109
     private var cornerRadius: CGFloat {
@@ -2117,8 +2118,9 @@ private struct TimeRangeSelectorView: View {
2117 2118
         let coversFullRange = selectionCoversFullRange(currentRange)
2118 2119
 
2119 2120
         VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
2120
-            if !coversFullRange || isPinnedToPresent {
2121
-                HStack(spacing: 8) {
2121
+            HStack(spacing: 8) {
2122
+                // Alignment controls
2123
+                if !coversFullRange || isPinnedToPresent {
2122 2124
                     alignmentButton(
2123 2125
                         systemName: "arrow.left.to.line.compact",
2124 2126
                         isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
@@ -2133,19 +2135,16 @@ private struct TimeRangeSelectorView: View {
2133 2135
                         accessibilityLabel: "Align selection to present"
2134 2136
                     )
2135 2137
 
2136
-                    Spacer(minLength: 0)
2137
-
2138 2138
                     if isPinnedToPresent {
2139 2139
                         trackingModeToggleButton()
2140 2140
                     }
2141 2141
                 }
2142
-            }
2143 2142
 
2144
-            HStack(spacing: 8) {
2143
+                Spacer(minLength: 0)
2144
+
2145
+                // Trim/Save actions
2145 2146
                 if !coversFullRange {
2146
-                    actionButton(
2147
-                        title: configuration.keepAction.title,
2148
-                        shortTitle: configuration.keepAction.shortTitle,
2147
+                    iconButton(
2149 2148
                         systemName: configuration.keepAction.systemName,
2150 2149
                         tone: configuration.keepAction.tone,
2151 2150
                         action: {
@@ -2153,11 +2152,10 @@ private struct TimeRangeSelectorView: View {
2153 2152
                             resetSelectionState()
2154 2153
                         }
2155 2154
                     )
2155
+                    .help(configuration.keepAction.title)
2156 2156
 
2157 2157
                     if let removeAction = configuration.removeAction {
2158
-                        actionButton(
2159
-                            title: removeAction.title,
2160
-                            shortTitle: removeAction.shortTitle,
2158
+                        iconButton(
2161 2159
                             systemName: removeAction.systemName,
2162 2160
                             tone: removeAction.tone,
2163 2161
                             action: {
@@ -2165,27 +2163,26 @@ private struct TimeRangeSelectorView: View {
2165 2163
                                 resetSelectionState()
2166 2164
                             }
2167 2165
                         )
2166
+                        .help(removeAction.title)
2168 2167
                     }
2169
-                }
2170
-
2171
-                Spacer(minLength: 0)
2172 2168
 
2173
-                actionButton(
2174
-                    title: configuration.resetAction.title,
2175
-                    shortTitle: configuration.resetAction.shortTitle,
2176
-                    systemName: configuration.resetAction.systemName,
2177
-                    tone: configuration.resetAction.tone,
2178
-                    action: {
2179
-                        showResetConfirmation = true
2169
+                    // Reset action (only show when there's a trim to reset)
2170
+                    iconButton(
2171
+                        systemName: configuration.resetAction.systemName,
2172
+                        tone: configuration.resetAction.tone,
2173
+                        action: {
2174
+                            showResetConfirmation = true
2175
+                        }
2176
+                    )
2177
+                    .help(configuration.resetAction.title)
2178
+                    .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2179
+                        Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2180
+                            configuration.resetAction.handler()
2181
+                            resetSelectionState()
2182
+                        }
2183
+                        Button("Cancel", role: .cancel) {}
2180 2184
                     }
2181
-                )
2182
-            }
2183
-            .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
2184
-                Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
2185
-                    configuration.resetAction.handler()
2186
-                    resetSelectionState()
2187 2185
                 }
2188
-                Button("Cancel", role: .cancel) {}
2189 2186
             }
2190 2187
 
2191 2188
             GeometryReader { geometry in
@@ -2356,6 +2353,37 @@ private struct TimeRangeSelectorView: View {
2356 2353
         )
2357 2354
     }
2358 2355
 
2356
+    private func iconButton(
2357
+        systemName: String,
2358
+        tone: MeasurementChartSelectorActionTone,
2359
+        action: @escaping () -> Void
2360
+    ) -> some View {
2361
+        let foregroundColor: Color = {
2362
+            switch tone {
2363
+            case .reversible, .destructive:
2364
+                return toneColor(for: tone)
2365
+            case .destructiveProminent:
2366
+                return .white
2367
+            }
2368
+        }()
2369
+
2370
+        return Button(action: action) {
2371
+            Image(systemName: systemName)
2372
+                .font(.subheadline.weight(.semibold))
2373
+                .frame(width: symbolButtonSize, height: symbolButtonSize)
2374
+        }
2375
+        .buttonStyle(.plain)
2376
+        .foregroundColor(foregroundColor)
2377
+        .background(
2378
+            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2379
+                .fill(actionButtonBackground(for: tone))
2380
+        )
2381
+        .overlay(
2382
+            RoundedRectangle(cornerRadius: max(symbolButtonSize / 3, 6), style: .continuous)
2383
+                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2384
+        )
2385
+    }
2386
+
2359 2387
     private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
2360 2388
         switch tone {
2361 2389
         case .reversible:
@@ -2392,7 +2420,7 @@ private struct TimeRangeSelectorView: View {
2392 2420
         case .keepDuration:
2393 2421
             return "arrow.left.and.right"
2394 2422
         case .keepStartTimestamp:
2395
-            return "arrow.left.to.line.compact"
2423
+            return "arrow.right"
2396 2424
         }
2397 2425
     }
2398 2426
 
+1 -0
USB Meter/Views/MeterMappingDebugView.swift
@@ -60,6 +60,7 @@ struct MeterMappingDebugView: View {
60 60
                 reload()
61 61
             }
62 62
         }
63
+        .sidebarToggleToolbarItem()
63 64
     }
64 65
 
65 66
     private func reload() {