- Sessions tab: replace 2-level navigation with inline session list + NavigationLink to detail - MeasurementChartView: consolidate selection/trim controls into single toolbar row with icon-only buttons; hide reset trim when no trim is active; fix icon conflict between align and tracking-mode buttons - ChargeSessionDetailView: move Delete Session button to navigation bar toolbar (closed sessions only); remove Administration card - SidebarToggleToolbar: new reusable modifier that adds sidebar.left button on Mac Catalyst; applied to DeviceHelpView, MeterMappingDebugView, SidebarChargedDeviceLibraryView, ChargedDeviceDetailView to prevent dead-end navigation when sidebar is hidden
@@ -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 */, |
@@ -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() |
@@ -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 |
@@ -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 {
|
@@ -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) |
@@ -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( |
@@ -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 |
+} |
|
@@ -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 {
|
@@ -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 |
|
@@ -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() {
|