Showing 15 changed files with 714 additions and 346 deletions
+4 -0
USB Meter.xcodeproj/project.pbxproj
@@ -63,6 +63,7 @@
63 63
 		C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */; };
64 64
 		C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */; };
65 65
 		C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */; };
66
+		CE00CE013C8E4A7A00CE00CE /* ChargerEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */; };
66 67
 		C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */; };
67 68
 		D28F11013C8E4A7A00A10011 /* MeterHomeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */; };
68 69
 		D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */; };
@@ -178,6 +179,7 @@
178 179
 		C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeSessionCompletionSheetView.swift; sourceTree = "<group>"; };
179 180
 		C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarChargedDevicesSectionView.swift; sourceTree = "<group>"; };
180 181
 		C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCheckpointEditorSheetView.swift; sourceTree = "<group>"; };
182
+		CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerEditorSheetView.swift; sourceTree = "<group>"; };
181 183
 		C10000193C8E4A7A00A10019 /* USB_Meter 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 4.xcdatamodel"; sourceTree = "<group>"; };
182 184
 		C100001A3C8E4A7A00A1001A /* USB_Meter 5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 5.xcdatamodel"; sourceTree = "<group>"; };
183 185
 		C100001B3C8E4A7A00A1001B /* USB_Meter 6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 6.xcdatamodel"; sourceTree = "<group>"; };
@@ -494,6 +496,7 @@
494 496
 				C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */,
495 497
 				C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */,
496 498
 				C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */,
499
+				CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */,
497 500
 				C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */,
498 501
 			);
499 502
 			path = ChargedDevices;
@@ -783,6 +786,7 @@
783 786
 				C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */,
784 787
 				C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */,
785 788
 				C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */,
789
+				CE00CE013C8E4A7A00CE00CE /* ChargerEditorSheetView.swift in Sources */,
786 790
 				C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */,
787 791
 				B0A000113C8F000100A10011 /* ChargerStandbyPowerStore.swift in Sources */,
788 792
 				B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */,
+45 -13
USB Meter/Model/AppData.swift
@@ -43,6 +43,12 @@ final class AppData : ObservableObject {
43 43
     private var chargeInsightsRemoteObserver: AnyCancellable?
44 44
     private var chargerStandbyPowerStoreObserver: AnyCancellable?
45 45
     private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem?
46
+    private var chargeInsightsReadStore: ChargeInsightsStore?
47
+    private let chargedDevicesReloadQueue = DispatchQueue(
48
+        label: "ro.xdev.usb-meter.charged-devices-reload",
49
+        qos: .userInitiated
50
+    )
51
+    private var chargedDevicesReloadGeneration: UInt = 0
46 52
     private let meterStore = MeterNameStore.shared
47 53
     private var chargeInsightsStore: ChargeInsightsStore?
48 54
     private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
@@ -98,6 +104,14 @@ final class AppData : ObservableObject {
98 104
         context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
99 105
         chargeInsightsStore = ChargeInsightsStore(context: context)
100 106
 
107
+        if let coordinator = context.persistentStoreCoordinator {
108
+            let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
109
+            readContext.persistentStoreCoordinator = coordinator
110
+            readContext.automaticallyMergesChangesFromParent = true
111
+            readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
112
+            chargeInsightsReadStore = ChargeInsightsStore(context: readContext)
113
+        }
114
+
101 115
         chargeInsightsStoreObserver = NotificationCenter.default.publisher(
102 116
             for: .NSManagedObjectContextObjectsDidChange,
103 117
             object: context
@@ -344,13 +358,13 @@ final class AppData : ObservableObject {
344 358
     @discardableResult
345 359
     func createCharger(
346 360
         name: String,
347
-        templateID: String?,
361
+        chargerType: ChargerType,
348 362
         notes: String?,
349 363
         meterMACAddress: String?
350 364
     ) -> Bool {
351 365
         let didSave = chargeInsightsStore?.createCharger(
352 366
             name: name,
353
-            templateID: templateID,
367
+            chargerType: chargerType,
354 368
             notes: notes,
355 369
             assignTo: meterMACAddress
356 370
         ) ?? false
@@ -399,13 +413,13 @@ final class AppData : ObservableObject {
399 413
     func updateCharger(
400 414
         id: UUID,
401 415
         name: String,
402
-        templateID: String?,
416
+        chargerType: ChargerType,
403 417
         notes: String?
404 418
     ) -> Bool {
405 419
         let didSave = chargeInsightsStore?.updateCharger(
406 420
             id: id,
407 421
             name: name,
408
-            templateID: templateID,
422
+            chargerType: chargerType,
409 423
             notes: notes
410 424
         ) ?? false
411 425
 
@@ -639,7 +653,7 @@ final class AppData : ObservableObject {
639 653
         ) ?? false
640 654
 
641 655
         if didDelete {
642
-            scheduleChargedDevicesReload(delay: 0)
656
+            reloadChargedDevices()
643 657
         }
644 658
 
645 659
         return didDelete
@@ -847,14 +861,32 @@ final class AppData : ObservableObject {
847 861
         pendingChargedDevicesReloadWorkItem = nil
848 862
 
849 863
         let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
850
-        chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
851
-            chargedDevice.withStandbyPowerMeasurements(
852
-                standbyMeasurementsByChargerID[chargedDevice.id] ?? []
853
-            )
854
-        }
855
-        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
856
-        for meter in meters.values {
857
-            restoreChargeMonitoringStateIfNeeded(for: meter)
864
+        let reloadGeneration = chargedDevicesReloadGeneration &+ 1
865
+        chargedDevicesReloadGeneration = reloadGeneration
866
+        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
867
+
868
+        chargedDevicesReloadQueue.async { [weak self] in
869
+            guard let self else { return }
870
+
871
+            readStore?.resetContext()
872
+            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
873
+                chargedDevice.withStandbyPowerMeasurements(
874
+                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
875
+                )
876
+            }
877
+
878
+            DispatchQueue.main.async { [weak self] in
879
+                guard let self else { return }
880
+                guard reloadGeneration == self.chargedDevicesReloadGeneration else {
881
+                    return
882
+                }
883
+
884
+                self.chargedDevices = summaries
885
+                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
886
+                for meter in self.meters.values {
887
+                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
888
+                }
889
+            }
858 890
         }
859 891
     }
860 892
 
+125 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 13.xcdatamodel/contents
@@ -0,0 +1,125 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="deviceTemplateID" optional="YES" attributeType="String"/>
8
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
9
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
10
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
11
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
12
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
13
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
14
+        <attribute name="chargerTypeRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
16
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
17
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
26
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
27
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
28
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
30
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
31
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
32
+        <attribute name="notes" optional="YES" attributeType="String"/>
33
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
34
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
35
+    </entity>
36
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
37
+        <attribute name="id" optional="YES" attributeType="String"/>
38
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
39
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
40
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
41
+        <attribute name="meterName" optional="YES" attributeType="String"/>
42
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
43
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
44
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
45
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
46
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
47
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
48
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
49
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
50
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
51
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
52
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
53
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
56
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
57
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
58
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
59
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
60
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
61
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
62
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
63
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
64
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
65
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
66
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
67
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
68
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
69
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
70
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
71
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
72
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
73
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
74
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
75
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
76
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
77
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
78
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
79
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
80
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
81
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
82
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
83
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
84
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
85
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
86
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
87
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
88
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
89
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
90
+    </entity>
91
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
92
+        <attribute name="id" optional="YES" attributeType="String"/>
93
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
94
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
95
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
96
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
97
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
98
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
99
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
100
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
101
+        <attribute name="label" optional="YES" attributeType="String"/>
102
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
103
+    </entity>
104
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class">
105
+        <attribute name="id" optional="YES" attributeType="String"/>
106
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
107
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
108
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
109
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
110
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
111
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
112
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
113
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
114
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
115
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
116
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
117
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
118
+    </entity>
119
+    <elements>
120
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="448"/>
121
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="838"/>
122
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
123
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/>
124
+    </elements>
125
+</model>
+44 -0
USB Meter/Model/ChargeInsightsModel.swift
@@ -350,6 +350,45 @@ enum WirelessChargingProfile: String, CaseIterable, Identifiable, Codable {
350 350
     }
351 351
 }
352 352
 
353
+enum ChargerType: String, CaseIterable, Identifiable, Codable {
354
+    case appleMagSafe
355
+    case appleWatch
356
+    case genericMagSafe
357
+    case genericQi
358
+
359
+    var id: String { rawValue }
360
+
361
+    var title: String {
362
+        switch self {
363
+        case .appleMagSafe: return "Apple MagSafe Charger"
364
+        case .appleWatch: return "Apple Watch Charger"
365
+        case .genericMagSafe: return "Generic MagSafe"
366
+        case .genericQi: return "Generic Qi"
367
+        }
368
+    }
369
+
370
+    var symbolName: String {
371
+        switch self {
372
+        case .appleMagSafe: return "magsafe.batterypack"
373
+        case .appleWatch: return "applewatch.radiowaves.left.and.right"
374
+        case .genericMagSafe: return "bolt.circle"
375
+        case .genericQi: return "bolt.horizontal.circle"
376
+        }
377
+    }
378
+
379
+    /// Whether this charger type uses magnetic alignment, enabling more accurate efficiency calibration.
380
+    var supportsAlignment: Bool {
381
+        switch self {
382
+        case .appleMagSafe, .appleWatch, .genericMagSafe: return true
383
+        case .genericQi: return false
384
+        }
385
+    }
386
+
387
+    var wirelessChargingProfile: WirelessChargingProfile {
388
+        supportsAlignment ? .magsafe : .genericQi
389
+    }
390
+}
391
+
353 392
 enum ChargedDeviceTemplateIconSource: String, Codable {
354 393
     case systemSymbol
355 394
     case asset
@@ -408,6 +447,9 @@ struct ChargedDeviceTemplateDefinition: Identifiable, Hashable, Codable {
408 447
     }
409 448
 
410 449
     var capabilitySummary: String {
450
+        if kind == .charger {
451
+            return wirelessChargingProfile.title
452
+        }
411 453
         var components = [chargingStateAvailability.title, chargingSupportSummary]
412 454
         if supportsWirelessCharging {
413 455
             components.append(wirelessChargingProfile.title)
@@ -1001,6 +1043,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1001 1043
     let chargingStateAvailability: ChargingStateAvailability
1002 1044
     let supportsWiredCharging: Bool
1003 1045
     let supportsWirelessCharging: Bool
1046
+    let chargerType: ChargerType?
1004 1047
     let wirelessChargingProfile: WirelessChargingProfile
1005 1048
     let configuredCompletionCurrents: [ChargeSessionKind: Double]
1006 1049
     let learnedCompletionCurrents: [ChargeSessionKind: Double]
@@ -1293,6 +1336,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1293 1336
             chargingStateAvailability: chargingStateAvailability,
1294 1337
             supportsWiredCharging: supportsWiredCharging,
1295 1338
             supportsWirelessCharging: supportsWirelessCharging,
1339
+            chargerType: chargerType,
1296 1340
             wirelessChargingProfile: wirelessChargingProfile,
1297 1341
             configuredCompletionCurrents: configuredCompletionCurrents,
1298 1342
             learnedCompletionCurrents: learnedCompletionCurrents,
+57 -53
USB Meter/Model/ChargeInsightsStore.swift
@@ -58,6 +58,12 @@ final class ChargeInsightsStore {
58 58
         }
59 59
     }
60 60
 
61
+    func resetContext() {
62
+        context.performAndWait {
63
+            context.reset()
64
+        }
65
+    }
66
+
61 67
     @discardableResult
62 68
     func flushPendingChanges() -> Bool {
63 69
         var didSave = false
@@ -125,13 +131,11 @@ final class ChargeInsightsStore {
125 131
     @discardableResult
126 132
     func createCharger(
127 133
         name: String,
128
-        templateID: String?,
134
+        chargerType: ChargerType,
129 135
         notes: String?,
130 136
         assignTo meterMACAddress: String?
131 137
     ) -> Bool {
132 138
         let normalizedName = normalizedText(name)
133
-        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .charger)
134
-        let chargerTemplateConfiguration = resolvedChargerTemplateConfiguration(templateID: normalizedTemplateID)
135 139
         guard !normalizedName.isEmpty else { return false }
136 140
 
137 141
         var didSave = false
@@ -145,12 +149,15 @@ final class ChargeInsightsStore {
145 149
             object.setValue(UUID().uuidString, forKey: "id")
146 150
             object.setValue(normalizedName, forKey: "name")
147 151
             object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
148
-            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
149
-            object.setValue(chargerTemplateConfiguration.chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
150
-            object.setValue(chargerTemplateConfiguration.chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
151
-            object.setValue(chargerTemplateConfiguration.supportsWiredCharging, forKey: "supportsWiredCharging")
152
-            object.setValue(chargerTemplateConfiguration.supportsWirelessCharging, forKey: "supportsWirelessCharging")
153
-            object.setValue(chargerTemplateConfiguration.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
152
+            object.setValue(nil, forKey: "deviceTemplateID")
153
+            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
154
+            object.setValue(false, forKey: "supportsChargingWhileOff")
155
+            object.setValue(false, forKey: "supportsWiredCharging")
156
+            object.setValue(true, forKey: "supportsWirelessCharging")
157
+            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
158
+                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
159
+            }
160
+            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
154 161
             object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
155 162
             object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
156 163
             object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
@@ -278,12 +285,10 @@ final class ChargeInsightsStore {
278 285
     func updateCharger(
279 286
         id: UUID,
280 287
         name: String,
281
-        templateID: String?,
288
+        chargerType: ChargerType,
282 289
         notes: String?
283 290
     ) -> Bool {
284 291
         let normalizedName = normalizedText(name)
285
-        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .charger)
286
-        let chargerTemplateConfiguration = resolvedChargerTemplateConfiguration(templateID: normalizedTemplateID)
287 292
         guard !normalizedName.isEmpty else { return false }
288 293
 
289 294
         var didSave = false
@@ -296,12 +301,15 @@ final class ChargeInsightsStore {
296 301
             }
297 302
 
298 303
             object.setValue(normalizedName, forKey: "name")
299
-            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
300
-            object.setValue(chargerTemplateConfiguration.chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
301
-            object.setValue(chargerTemplateConfiguration.chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
302
-            object.setValue(chargerTemplateConfiguration.supportsWiredCharging, forKey: "supportsWiredCharging")
303
-            object.setValue(chargerTemplateConfiguration.supportsWirelessCharging, forKey: "supportsWirelessCharging")
304
-            object.setValue(chargerTemplateConfiguration.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
304
+            object.setValue(nil, forKey: "deviceTemplateID")
305
+            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
306
+            object.setValue(false, forKey: "supportsChargingWhileOff")
307
+            object.setValue(false, forKey: "supportsWiredCharging")
308
+            object.setValue(true, forKey: "supportsWirelessCharging")
309
+            if object.entity.attributesByName["chargerTypeRawValue"] != nil {
310
+                object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue")
311
+            }
312
+            object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
305 313
             object.setValue(normalizedOptionalText(notes), forKey: "notes")
306 314
             object.setValue(Date(), forKey: "updatedAt")
307 315
             refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
@@ -956,6 +964,7 @@ final class ChargeInsightsStore {
956 964
                     chargingStateAvailability: chargingStateAvailability,
957 965
                     supportsWiredCharging: supportsWiredCharging,
958 966
                     supportsWirelessCharging: supportsWirelessCharging,
967
+                    chargerType: chargerType(for: device),
959 968
                     wirelessChargingProfile: wirelessChargingProfile(for: device),
960 969
                     configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
961 970
                     learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
@@ -2535,41 +2544,6 @@ final class ChargeInsightsStore {
2535 2544
         return templateDefinition.id
2536 2545
     }
2537 2546
 
2538
-    private func resolvedChargerTemplateConfiguration(
2539
-        templateID: String?
2540
-    ) -> (
2541
-        chargingStateAvailability: ChargingStateAvailability,
2542
-        supportsWiredCharging: Bool,
2543
-        supportsWirelessCharging: Bool,
2544
-        wirelessChargingProfile: WirelessChargingProfile
2545
-    ) {
2546
-        guard let templateID,
2547
-              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2548
-              templateDefinition.kind == .charger else {
2549
-            return (
2550
-                chargingStateAvailability: .onOnly,
2551
-                supportsWiredCharging: false,
2552
-                supportsWirelessCharging: true,
2553
-                wirelessChargingProfile: .genericQi
2554
-            )
2555
-        }
2556
-
2557
-        let normalizedChargingStateAvailability = templateDefinition.deviceClass.normalizedChargingStateAvailability(
2558
-            templateDefinition.chargingStateAvailability
2559
-        )
2560
-        let normalizedChargingSupport = templateDefinition.deviceClass.normalizedChargingSupport(
2561
-            supportsWiredCharging: templateDefinition.supportsWiredCharging,
2562
-            supportsWirelessCharging: templateDefinition.supportsWirelessCharging
2563
-        )
2564
-
2565
-        return (
2566
-            chargingStateAvailability: normalizedChargingStateAvailability,
2567
-            supportsWiredCharging: normalizedChargingSupport.wired,
2568
-            supportsWirelessCharging: normalizedChargingSupport.wireless,
2569
-            wirelessChargingProfile: templateDefinition.wirelessChargingProfile
2570
-        )
2571
-    }
2572
-
2573 2547
     private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2574 2548
         guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2575 2549
               let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
@@ -2644,7 +2618,37 @@ final class ChargeInsightsStore {
2644 2618
         return availability.supportedModes.first ?? .on
2645 2619
     }
2646 2620
 
2621
+    private func chargerType(for chargedDevice: NSManagedObject) -> ChargerType? {
2622
+        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
2623
+        guard deviceClass == .charger else { return nil }
2624
+
2625
+        // Primary: chargerTypeRawValue (set on v13+)
2626
+        if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"),
2627
+           let type = ChargerType(rawValue: rawValue) {
2628
+            return type
2629
+        }
2630
+
2631
+        // Migration fallback: derive from old deviceTemplateID
2632
+        switch stringValue(chargedDevice, key: "deviceTemplateID") {
2633
+        case "apple-magsafe-charger": return .appleMagSafe
2634
+        case "apple-watch-charger": return .appleWatch
2635
+        default: break
2636
+        }
2637
+
2638
+        // Last resort: derive from wirelessChargingProfileRawValue
2639
+        if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2640
+           let profile = WirelessChargingProfile(rawValue: rawValue),
2641
+           profile == .magsafe {
2642
+            return .genericMagSafe
2643
+        }
2644
+
2645
+        return .genericQi
2646
+    }
2647
+
2647 2648
     private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
2649
+        if let type = chargerType(for: chargedDevice) {
2650
+            return type.wirelessChargingProfile
2651
+        }
2648 2652
         guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
2649 2653
               let profile = WirelessChargingProfile(rawValue: rawValue) else {
2650 2654
             return .genericQi
+1 -52
USB Meter/Templates/ChargedDeviceTemplates.json
@@ -68,40 +68,6 @@
68 68
       "wirelessChargingProfile": "genericQi",
69 69
       "sortOrder": 40
70 70
     },
71
-    {
72
-      "id": "apple-magsafe-charger",
73
-      "name": "Apple MagSafe Charger",
74
-      "group": "Apple",
75
-      "kind": "charger",
76
-      "deviceClass": "charger",
77
-      "icon": {
78
-        "type": "systemSymbol",
79
-        "name": "magsafe.batterypack",
80
-        "fallbackSystemName": "cable.connector"
81
-      },
82
-      "chargingStateAvailability": "onOnly",
83
-      "supportsWiredCharging": false,
84
-      "supportsWirelessCharging": true,
85
-      "wirelessChargingProfile": "magsafe",
86
-      "sortOrder": 50
87
-    },
88
-    {
89
-      "id": "apple-watch-charger",
90
-      "name": "Apple Watch Charger",
91
-      "group": "Apple",
92
-      "kind": "charger",
93
-      "deviceClass": "charger",
94
-      "icon": {
95
-        "type": "systemSymbol",
96
-        "name": "cable.connector",
97
-        "fallbackSystemName": "bolt.horizontal.circle"
98
-      },
99
-      "chargingStateAvailability": "onOnly",
100
-      "supportsWiredCharging": false,
101
-      "supportsWirelessCharging": true,
102
-      "wirelessChargingProfile": "genericQi",
103
-      "sortOrder": 60
104
-    },
105 71
     {
106 72
       "id": "generic-phone",
107 73
       "name": "Phone",
@@ -220,23 +186,6 @@
220 186
       "supportsWirelessCharging": false,
221 187
       "wirelessChargingProfile": "genericQi",
222 188
       "sortOrder": 170
223
-    },
224
-    {
225
-      "id": "generic-wireless-charger",
226
-      "name": "Wireless Charger",
227
-      "group": "Generic",
228
-      "kind": "charger",
229
-      "deviceClass": "charger",
230
-      "icon": {
231
-        "type": "systemSymbol",
232
-        "name": "bolt.horizontal.circle",
233
-        "fallbackSystemName": "cable.connector"
234
-      },
235
-      "chargingStateAvailability": "onOnly",
236
-      "supportsWiredCharging": false,
237
-      "supportsWirelessCharging": true,
238
-      "wirelessChargingProfile": "genericQi",
239
-      "sortOrder": 210
240 189
     }
241 190
   ]
242
-}
191
+}
+18 -6
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -82,12 +82,18 @@ struct ChargedDeviceDetailView: View {
82 82
         }
83 83
         .sheet(isPresented: $editorVisibility) {
84 84
             if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
85
-                ChargedDeviceEditorSheetView(
86
-                    meterMACAddress: nil,
87
-                    kind: chargedDevice.kind,
88
-                    chargedDevice: chargedDevice
89
-                )
90
-                .environmentObject(appData)
85
+                if chargedDevice.isCharger {
86
+                    ChargerEditorSheetView(
87
+                        appData: appData,
88
+                        chargedDevice: chargedDevice
89
+                    )
90
+                } else {
91
+                    ChargedDeviceEditorSheetView(
92
+                        meterMACAddress: nil,
93
+                        chargedDevice: chargedDevice
94
+                    )
95
+                    .environmentObject(appData)
96
+                }
91 97
             }
92 98
         }
93 99
         .sheet(isPresented: $targetNotificationEditorVisibility) {
@@ -257,6 +263,12 @@ struct ChargedDeviceDetailView: View {
257 263
 
258 264
     @ViewBuilder
259 265
     private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
266
+        if let chargerType = chargedDevice.chargerType {
267
+            MeterInfoRowView(
268
+                label: "Type",
269
+                value: chargerType.title
270
+            )
271
+        }
260 272
         if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
261 273
             MeterInfoRowView(
262 274
                 label: "Observed Voltages",
+71 -115
USB Meter/Views/ChargedDevices/ChargedDeviceEditorSheetView.swift
@@ -13,9 +13,9 @@ struct ChargedDeviceEditorSheetView: View {
13 13
 
14 14
     let meterMACAddress: String?
15 15
     let chargedDevice: ChargedDeviceSummary?
16
-    let kind: ChargedDeviceKind
17 16
 
18 17
     @State private var name: String
18
+    @State private var notes: String
19 19
     @State private var deviceClass: ChargedDeviceClass
20 20
     @State private var selectedTemplateID: String?
21 21
     @State private var lastAppliedTemplateID: String?
@@ -24,20 +24,22 @@ struct ChargedDeviceEditorSheetView: View {
24 24
     @State private var supportsWirelessCharging: Bool
25 25
     @State private var wirelessChargingProfile: WirelessChargingProfile
26 26
     @State private var completionCurrentTexts: [ChargeSessionKind: String]
27
-    @State private var notes: String
27
+
28
+    let standalone: Bool
28 29
 
29 30
     init(
30 31
         meterMACAddress: String?,
31
-        kind: ChargedDeviceKind,
32
-        chargedDevice: ChargedDeviceSummary? = nil
32
+        chargedDevice: ChargedDeviceSummary? = nil,
33
+        standalone: Bool = true
33 34
     ) {
34 35
         self.meterMACAddress = meterMACAddress
35 36
         self.chargedDevice = chargedDevice
37
+        self.standalone = standalone
36 38
 
37
-        let resolvedKind = chargedDevice?.kind ?? kind
38
-        self.kind = resolvedKind
39
+        _name = State(initialValue: chargedDevice?.name ?? "")
40
+        _notes = State(initialValue: chargedDevice?.notes ?? "")
39 41
 
40
-        let initialDeviceClass = chargedDevice?.deviceClass ?? (resolvedKind == .charger ? .charger : .iphone)
42
+        let initialDeviceClass = chargedDevice?.deviceClass ?? .iphone
41 43
         let initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability(
42 44
             chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass)
43 45
         )
@@ -46,7 +48,6 @@ struct ChargedDeviceEditorSheetView: View {
46 48
             supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? true
47 49
         )
48 50
         let initialTemplateID = chargedDevice?.deviceTemplateID
49
-        _name = State(initialValue: chargedDevice?.name ?? "")
50 51
         _deviceClass = State(initialValue: initialDeviceClass)
51 52
         _selectedTemplateID = State(initialValue: initialTemplateID)
52 53
         _lastAppliedTemplateID = State(initialValue: initialTemplateID)
@@ -55,46 +56,42 @@ struct ChargedDeviceEditorSheetView: View {
55 56
         _supportsWirelessCharging = State(initialValue: initialChargingSupport.wireless)
56 57
         _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi)
57 58
         _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice))
58
-        _notes = State(initialValue: chargedDevice?.notes ?? "")
59 59
     }
60 60
 
61 61
     var body: some View {
62
-        NavigationView {
63
-            Form {
64
-                identitySection
65
-                templateSection
66
-
67
-                if kind == .device {
68
-                    deviceChargeBehaviourSection
69
-                    deviceChargingSupportSection
70
-                    deviceCompletionSection
71
-                } else {
72
-                    chargerInformationSection
73
-                }
62
+        if standalone {
63
+            NavigationView { formContent }
64
+                .navigationViewStyle(StackNavigationViewStyle())
65
+        } else {
66
+            formContent
67
+        }
68
+    }
74 69
 
75
-                notesSection
76
-            }
77
-            .navigationTitle(editorTitle)
78
-            .navigationBarTitleDisplayMode(.inline)
79
-            .toolbar {
80
-                ToolbarItem(placement: .cancellationAction) {
81
-                    Button("Cancel") {
82
-                        dismiss()
83
-                    }
70
+    private var formContent: some View {
71
+        Form {
72
+            identitySection
73
+            templateSection
74
+            deviceChargeBehaviourSection
75
+            deviceChargingSupportSection
76
+            deviceCompletionSection
77
+            notesSection
78
+        }
79
+        .navigationTitle(editorTitle)
80
+        .navigationBarTitleDisplayMode(.inline)
81
+        .toolbar {
82
+            ToolbarItem(placement: .cancellationAction) {
83
+                Button("Cancel") {
84
+                    dismiss()
84 85
                 }
85
-                ToolbarItem(placement: .confirmationAction) {
86
-                    Button(saveButtonTitle) {
87
-                        save()
88
-                    }
89
-                    .disabled(!canSave)
86
+            }
87
+            ToolbarItem(placement: .confirmationAction) {
88
+                Button(saveButtonTitle) {
89
+                    save()
90 90
                 }
91
+                .disabled(!canSave)
91 92
             }
92 93
         }
93
-        .navigationViewStyle(StackNavigationViewStyle())
94 94
         .onChange(of: deviceClass) { newValue in
95
-            guard kind == .device else {
96
-                return
97
-            }
98 95
             applyDeviceClassRules(for: newValue)
99 96
         }
100 97
         .onChange(of: selectedTemplateID) { newValue in
@@ -105,23 +102,18 @@ struct ChargedDeviceEditorSheetView: View {
105 102
             lastAppliedTemplateID = newValue
106 103
         }
107 104
         .onAppear {
108
-            guard kind == .device else {
109
-                return
110
-            }
111 105
             applyDeviceClassRules(for: deviceClass)
112 106
         }
113 107
     }
114 108
 
115 109
     private var identitySection: some View {
116 110
         Section(header: Text("Identity")) {
117
-            TextField(kind == .charger ? "Charger name" : "Name", text: $name)
111
+            TextField("Name", text: $name)
118 112
 
119
-            if kind == .device {
120
-                Picker("Class", selection: $deviceClass) {
121
-                    ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
122
-                        Label(deviceClass.title, systemImage: deviceClass.symbolName)
123
-                            .tag(deviceClass)
124
-                    }
113
+            Picker("Class", selection: $deviceClass) {
114
+                ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
115
+                    Label(deviceClass.title, systemImage: deviceClass.symbolName)
116
+                        .tag(deviceClass)
125 117
                 }
126 118
             }
127 119
 
@@ -270,17 +262,6 @@ struct ChargedDeviceEditorSheetView: View {
270 262
         }
271 263
     }
272 264
 
273
-    private var chargerInformationSection: some View {
274
-        Section(
275
-            header: ContextInfoHeader(
276
-                title: "Charger",
277
-                message: "Chargers are edited separately from devices. Their charge-session metrics are learned automatically from wireless sessions."
278
-            )
279
-        ) {
280
-            EmptyView()
281
-        }
282
-    }
283
-
284 265
     private var notesSection: some View {
285 266
         Section(header: Text("Notes")) {
286 267
             TextField("Optional notes", text: $notes)
@@ -288,10 +269,7 @@ struct ChargedDeviceEditorSheetView: View {
288 269
     }
289 270
 
290 271
     private var editorTitle: String {
291
-        if chargedDevice == nil {
292
-            return "New \(kind.title)"
293
-        }
294
-        return "Edit \(kind.title)"
272
+        chargedDevice == nil ? "New Device" : "Edit Device"
295 273
     }
296 274
 
297 275
     private var saveButtonTitle: String {
@@ -299,17 +277,13 @@ struct ChargedDeviceEditorSheetView: View {
299 277
     }
300 278
 
301 279
     private var canSave: Bool {
302
-        let hasValidName = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
303
-        guard kind == .device else {
304
-            return hasValidName
305
-        }
306
-        return hasValidName
280
+        !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
307 281
             && (supportsWiredCharging || supportsWirelessCharging)
308 282
             && !hasInvalidCompletionCurrentEntry
309 283
     }
310 284
 
311 285
     private var availableTemplates: [ChargedDeviceTemplateDefinition] {
312
-        ChargedDeviceTemplateCatalog.shared.templates(for: kind)
286
+        ChargedDeviceTemplateCatalog.shared.templates(for: .device)
313 287
     }
314 288
 
315 289
     private var groupedTemplates: [(group: String, templates: [ChargedDeviceTemplateDefinition])] {
@@ -381,53 +355,35 @@ struct ChargedDeviceEditorSheetView: View {
381 355
     }
382 356
 
383 357
     private func save() {
358
+        let configuredCompletionCurrents = parsedCompletionCurrents
384 359
         let didSave: Bool
385 360
 
386
-        if kind == .charger {
387
-            if let chargedDevice {
388
-                didSave = appData.updateCharger(
389
-                    id: chargedDevice.id,
390
-                    name: name,
391
-                    templateID: selectedTemplateID,
392
-                    notes: notes
393
-                )
394
-            } else {
395
-                didSave = appData.createCharger(
396
-                    name: name,
397
-                    templateID: selectedTemplateID,
398
-                    notes: notes,
399
-                    meterMACAddress: meterMACAddress
400
-                )
401
-            }
361
+        if let chargedDevice {
362
+            didSave = appData.updateDevice(
363
+                id: chargedDevice.id,
364
+                name: name,
365
+                deviceClass: deviceClass,
366
+                templateID: selectedTemplateID,
367
+                chargingStateAvailability: chargingStateAvailability,
368
+                supportsWiredCharging: supportsWiredCharging,
369
+                supportsWirelessCharging: supportsWirelessCharging,
370
+                wirelessChargingProfile: wirelessChargingProfile,
371
+                configuredCompletionCurrents: configuredCompletionCurrents,
372
+                notes: notes
373
+            )
402 374
         } else {
403
-            let configuredCompletionCurrents = parsedCompletionCurrents
404
-            if let chargedDevice {
405
-                didSave = appData.updateDevice(
406
-                    id: chargedDevice.id,
407
-                    name: name,
408
-                    deviceClass: deviceClass,
409
-                    templateID: selectedTemplateID,
410
-                    chargingStateAvailability: chargingStateAvailability,
411
-                    supportsWiredCharging: supportsWiredCharging,
412
-                    supportsWirelessCharging: supportsWirelessCharging,
413
-                    wirelessChargingProfile: wirelessChargingProfile,
414
-                    configuredCompletionCurrents: configuredCompletionCurrents,
415
-                    notes: notes
416
-                )
417
-            } else {
418
-                didSave = appData.createDevice(
419
-                    name: name,
420
-                    deviceClass: deviceClass,
421
-                    templateID: selectedTemplateID,
422
-                    chargingStateAvailability: chargingStateAvailability,
423
-                    supportsWiredCharging: supportsWiredCharging,
424
-                    supportsWirelessCharging: supportsWirelessCharging,
425
-                    wirelessChargingProfile: wirelessChargingProfile,
426
-                    configuredCompletionCurrents: configuredCompletionCurrents,
427
-                    notes: notes,
428
-                    meterMACAddress: meterMACAddress
429
-                )
430
-            }
375
+            didSave = appData.createDevice(
376
+                name: name,
377
+                deviceClass: deviceClass,
378
+                templateID: selectedTemplateID,
379
+                chargingStateAvailability: chargingStateAvailability,
380
+                supportsWiredCharging: supportsWiredCharging,
381
+                supportsWirelessCharging: supportsWirelessCharging,
382
+                wirelessChargingProfile: wirelessChargingProfile,
383
+                configuredCompletionCurrents: configuredCompletionCurrents,
384
+                notes: notes,
385
+                meterMACAddress: meterMACAddress
386
+            )
431 387
         }
432 388
 
433 389
         if didSave {
@@ -581,7 +537,7 @@ struct ChargedDeviceEditorSheetView: View {
581 537
         case .powerbank:
582 538
             return .offOnly
583 539
         case .charger, .other:
584
-            return .onOnly
540
+            return .onOrOff
585 541
         }
586 542
     }
587 543
 }
+131 -65
USB Meter/Views/ChargedDevices/ChargedDeviceLibrarySheetView.swift
@@ -42,98 +42,164 @@ enum ChargedDeviceLibraryMode {
42 42
 
43 43
 struct ChargedDeviceLibrarySheetView: View {
44 44
     @EnvironmentObject private var appData: AppData
45
-
46
-    @Binding var visibility: Bool
45
+    @Environment(\.dismiss) private var dismiss
47 46
 
48 47
     let meterMACAddress: String
49 48
     let meterTint: Color
50 49
     let mode: ChargedDeviceLibraryMode
50
+    /// true = standalone sheet with own NavigationView; false = pushed into parent nav stack
51
+    let standalone: Bool
51 52
 
52
-    @State private var editorVisibility = false
53
+    @State private var showingNewEditor = false
53 54
     @State private var editingChargedDevice: ChargedDeviceSummary?
55
+    @State private var pendingDeletion: ChargedDeviceSummary?
56
+
57
+    init(
58
+        meterMACAddress: String,
59
+        meterTint: Color,
60
+        mode: ChargedDeviceLibraryMode,
61
+        standalone: Bool = true
62
+    ) {
63
+        self.meterMACAddress = meterMACAddress
64
+        self.meterTint = meterTint
65
+        self.mode = mode
66
+        self.standalone = standalone
67
+    }
54 68
 
55 69
     var body: some View {
56
-        NavigationView {
57
-            List {
58
-                if displayedChargedDevices.isEmpty {
59
-                    VStack(alignment: .leading, spacing: 10) {
60
-                        HStack(spacing: 8) {
61
-                            Text("No \(mode.title.lowercased()) yet.")
62
-                                .font(.headline)
63
-                            ContextInfoButton(
64
-                                title: mode.title,
65
-                                message: emptyStateDescription
66
-                            )
67
-                        }
70
+        if standalone {
71
+            NavigationView { listContent }
72
+                .navigationViewStyle(StackNavigationViewStyle())
73
+        } else {
74
+            listContent
75
+        }
76
+    }
77
+
78
+    private var listContent: some View {
79
+        List {
80
+            if displayedChargedDevices.isEmpty {
81
+                VStack(alignment: .leading, spacing: 10) {
82
+                    HStack(spacing: 8) {
83
+                        Text("No \(mode.title.lowercased()) yet.")
84
+                            .font(.headline)
85
+                        ContextInfoButton(
86
+                            title: mode.title,
87
+                            message: emptyStateDescription
88
+                        )
68 89
                     }
69
-                    .padding(.vertical, 10)
70
-                    .listRowBackground(Color.clear)
71
-                } else {
72
-                    ForEach(displayedChargedDevices) { chargedDevice in
90
+                }
91
+                .padding(.vertical, 10)
92
+                .listRowBackground(Color.clear)
93
+            } else {
94
+                ForEach(displayedChargedDevices) { chargedDevice in
95
+                    Button {
96
+                        select(chargedDevice)
97
+                        dismiss()
98
+                    } label: {
99
+                        ChargedDeviceLibraryRowView(
100
+                            chargedDevice: chargedDevice,
101
+                            isSelected: chargedDevice.id == selectedDeviceID
102
+                        )
103
+                    }
104
+                    .buttonStyle(.plain)
105
+                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
106
+                        Button(role: .destructive) {
107
+                            pendingDeletion = chargedDevice
108
+                        } label: {
109
+                            Label("Delete", systemImage: "trash")
110
+                        }
73 111
                         Button {
74
-                            select(chargedDevice)
75
-                            visibility = false
112
+                            editingChargedDevice = chargedDevice
76 113
                         } label: {
77
-                            ChargedDeviceLibraryRowView(
78
-                                chargedDevice: chargedDevice,
79
-                                isSelected: chargedDevice.id == selectedDeviceID
80
-                            )
114
+                            Label("Edit", systemImage: "pencil")
81 115
                         }
82
-                        .buttonStyle(.plain)
83
-                        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
84
-                            Button {
85
-                                editingChargedDevice = chargedDevice
86
-                            } label: {
87
-                                Label("Edit", systemImage: "pencil")
88
-                            }
89
-                            .tint(.blue)
116
+                        .tint(.blue)
117
+                    }
118
+                    .contextMenu {
119
+                        Button {
120
+                            editingChargedDevice = chargedDevice
121
+                        } label: {
122
+                            Label("Edit \(mode.singularTitle)", systemImage: "pencil")
90 123
                         }
91
-                        .contextMenu {
92
-                            Button {
93
-                                editingChargedDevice = chargedDevice
94
-                            } label: {
95
-                                Label("Edit \(mode.singularTitle)", systemImage: "pencil")
96
-                            }
124
+                        Button(role: .destructive) {
125
+                            pendingDeletion = chargedDevice
126
+                        } label: {
127
+                            Label("Delete \(mode.singularTitle)", systemImage: "trash")
97 128
                         }
98 129
                     }
99 130
                 }
100 131
             }
101
-            .listStyle(InsetGroupedListStyle())
102
-            .background(
103
-                LinearGradient(
104
-                    colors: [meterTint.opacity(0.14), Color.clear],
105
-                    startPoint: .topLeading,
106
-                    endPoint: .bottomTrailing
107
-                )
108
-                .ignoresSafeArea()
132
+        }
133
+        .listStyle(InsetGroupedListStyle())
134
+        .background(
135
+            LinearGradient(
136
+                colors: [meterTint.opacity(0.14), Color.clear],
137
+                startPoint: .topLeading,
138
+                endPoint: .bottomTrailing
109 139
             )
110
-            .navigationTitle(mode.title)
111
-            .navigationBarTitleDisplayMode(.inline)
112
-            .toolbar {
113
-                ToolbarItem(placement: .cancellationAction) {
114
-                    Button("Done") {
115
-                        visibility = false
116
-                    }
140
+            .ignoresSafeArea()
141
+        )
142
+        .navigationTitle(mode.title)
143
+        .navigationBarTitleDisplayMode(.inline)
144
+        .toolbar {
145
+            ToolbarItem(placement: .cancellationAction) {
146
+                if standalone {
147
+                    Button("Done") { dismiss() }
117 148
                 }
118
-                ToolbarItem(placement: .confirmationAction) {
119
-                    Button("New") {
120
-                        editorVisibility = true
121
-                    }
149
+            }
150
+            ToolbarItem(placement: .confirmationAction) {
151
+                Button("New") { showingNewEditor = true }
152
+            }
153
+        }
154
+        .sheet(isPresented: $showingNewEditor) {
155
+            newEditorSheet
156
+        }
157
+        .sheet(item: $editingChargedDevice) { device in
158
+            editEditorSheet(device)
159
+        }
160
+        .confirmationDialog(
161
+            "Delete \(pendingDeletion?.name ?? mode.singularTitle)?",
162
+            isPresented: Binding(
163
+                get: { pendingDeletion != nil },
164
+                set: { if !$0 { pendingDeletion = nil } }
165
+            ),
166
+            titleVisibility: .visible
167
+        ) {
168
+            Button("Delete", role: .destructive) {
169
+                if let device = pendingDeletion {
170
+                    _ = appData.deleteChargedDevice(id: device.id)
171
+                    pendingDeletion = nil
122 172
                 }
123 173
             }
174
+            Button("Cancel", role: .cancel) { pendingDeletion = nil }
175
+        } message: {
176
+            Text("This will permanently remove the \(mode.singularTitle.lowercased()) and all associated data.")
124 177
         }
125
-        .navigationViewStyle(StackNavigationViewStyle())
126
-        .sheet(isPresented: $editorVisibility) {
127
-            ChargedDeviceEditorSheetView(
128
-                meterMACAddress: meterMACAddress,
129
-                kind: mode.kind
178
+    }
179
+
180
+    @ViewBuilder
181
+    private var newEditorSheet: some View {
182
+        if mode == .charger {
183
+            ChargerEditorSheetView(
184
+                appData: appData,
185
+                meterMACAddress: meterMACAddress
130 186
             )
187
+        } else {
188
+            ChargedDeviceEditorSheetView(meterMACAddress: meterMACAddress)
131 189
                 .environmentObject(appData)
132 190
         }
133
-        .sheet(item: $editingChargedDevice) { chargedDevice in
191
+    }
192
+
193
+    @ViewBuilder
194
+    private func editEditorSheet(_ chargedDevice: ChargedDeviceSummary) -> some View {
195
+        if chargedDevice.isCharger {
196
+            ChargerEditorSheetView(
197
+                appData: appData,
198
+                chargedDevice: chargedDevice
199
+            )
200
+        } else {
134 201
             ChargedDeviceEditorSheetView(
135 202
                 meterMACAddress: nil,
136
-                kind: mode.kind,
137 203
                 chargedDevice: chargedDevice
138 204
             )
139 205
             .environmentObject(appData)
+117 -0
USB Meter/Views/ChargedDevices/ChargerEditorSheetView.swift
@@ -0,0 +1,117 @@
1
+//
2
+//  ChargerEditorSheetView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct ChargerEditorSheetView: View {
9
+    @Environment(\.dismiss) private var dismiss
10
+
11
+    let appData: AppData
12
+    let chargedDevice: ChargedDeviceSummary?
13
+    let meterMACAddress: String?
14
+    /// When false the view omits its own NavigationView (used as a push destination).
15
+    let standalone: Bool
16
+
17
+    @State private var name: String
18
+    @State private var chargerType: ChargerType
19
+    @State private var notes: String
20
+
21
+    init(
22
+        appData: AppData,
23
+        chargedDevice: ChargedDeviceSummary? = nil,
24
+        meterMACAddress: String? = nil,
25
+        standalone: Bool = true
26
+    ) {
27
+        self.appData = appData
28
+        self.chargedDevice = chargedDevice
29
+        self.meterMACAddress = meterMACAddress
30
+        self.standalone = standalone
31
+        _name = State(initialValue: chargedDevice?.name ?? "")
32
+        _chargerType = State(initialValue: chargedDevice?.chargerType ?? .genericQi)
33
+        _notes = State(initialValue: chargedDevice?.notes ?? "")
34
+    }
35
+
36
+    var body: some View {
37
+        if standalone {
38
+            NavigationView { formContent }
39
+                .navigationViewStyle(StackNavigationViewStyle())
40
+        } else {
41
+            formContent
42
+        }
43
+    }
44
+
45
+    private var formContent: some View {
46
+        Form {
47
+            Section(header: Text("Identity")) {
48
+                TextField("Charger name", text: $name)
49
+
50
+                if let chargedDevice {
51
+                    Text(chargedDevice.qrIdentifier)
52
+                        .font(.caption.monospaced())
53
+                        .foregroundColor(.secondary)
54
+                        .textSelection(.enabled)
55
+                }
56
+            }
57
+
58
+            Section(
59
+                header: ContextInfoHeader(
60
+                    title: "Charger Type",
61
+                    message: "MagSafe and Watch chargers use magnetic alignment, enabling accurate efficiency calibration. Standby current and efficiency are learned automatically from sessions."
62
+                )
63
+            ) {
64
+                Picker("Type", selection: $chargerType) {
65
+                    ForEach(ChargerType.allCases) { type in
66
+                        Label(type.title, systemImage: type.symbolName)
67
+                            .tag(type)
68
+                    }
69
+                }
70
+                .pickerStyle(.menu)
71
+            }
72
+
73
+            Section(header: Text("Notes")) {
74
+                TextField("Optional notes", text: $notes)
75
+            }
76
+        }
77
+        .navigationTitle(chargedDevice == nil ? "New Charger" : "Edit Charger")
78
+        .navigationBarTitleDisplayMode(.inline)
79
+        .toolbar {
80
+            ToolbarItem(placement: .cancellationAction) {
81
+                Button("Cancel") { dismiss() }
82
+            }
83
+            ToolbarItem(placement: .confirmationAction) {
84
+                Button(chargedDevice == nil ? "Save" : "Update") {
85
+                    save()
86
+                }
87
+                .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
88
+            }
89
+        }
90
+    }
91
+
92
+    private func save() {
93
+        let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
94
+        let notesValue: String? = trimmedNotes.isEmpty ? nil : trimmedNotes
95
+
96
+        let didSave: Bool
97
+        if let chargedDevice {
98
+            didSave = appData.updateCharger(
99
+                id: chargedDevice.id,
100
+                name: name,
101
+                chargerType: chargerType,
102
+                notes: notesValue
103
+            )
104
+        } else {
105
+            didSave = appData.createCharger(
106
+                name: name,
107
+                chargerType: chargerType,
108
+                notes: notesValue,
109
+                meterMACAddress: meterMACAddress
110
+            )
111
+        }
112
+
113
+        if didSave {
114
+            dismiss()
115
+        }
116
+    }
117
+}
+2 -2
USB Meter/Views/Meter/MeterView.swift
@@ -450,7 +450,7 @@ struct MeterView: View {
450 450
         case .chart:
451 451
             MeterChartTabView(size: size, isLandscape: true)
452 452
         case .chargeRecord:
453
-            MeterChargeRecordTabView()
453
+            MeterChargeRecordTabView().equatable()
454 454
         case .dataGroups:
455 455
             MeterDataGroupsTabView()
456 456
         case .settings:
@@ -485,7 +485,7 @@ struct MeterView: View {
485 485
         case .chart:
486 486
             MeterChartTabView(size: size, isLandscape: false)
487 487
         case .chargeRecord:
488
-            MeterChargeRecordTabView()
488
+            MeterChargeRecordTabView().equatable()
489 489
         case .dataGroups:
490 490
             MeterDataGroupsTabView()
491 491
         case .settings:
+50 -8
USB Meter/Views/Meter/Sheets/ChargeRecord/ChargeRecordSheetView.swift
@@ -10,19 +10,61 @@ import SwiftUI
10 10
 
11 11
 struct ChargeRecordSheetView: View {
12 12
     @Binding var visibility: Bool
13
+    @EnvironmentObject private var appData: AppData
14
+
15
+    @State private var chargedDeviceLibraryVisibility = false
16
+    @State private var chargerLibraryVisibility = false
17
+    @State private var deviceLibraryMACAddress = ""
18
+    @State private var chargerLibraryMACAddress = ""
19
+    @State private var chargedDeviceLibraryTint: Color = .orange
20
+    @State private var chargerLibraryTint: Color = .pink
13 21
 
14 22
     var body: some View {
15 23
         NavigationView {
16
-            MeterChargeRecordContentView()
17
-                .navigationTitle("Charge Record")
18
-                .navigationBarTitleDisplayMode(.inline)
19
-                .toolbar {
20
-                    ToolbarItem(placement: .cancellationAction) {
21
-                        Button("Done") {
22
-                            visibility = false
23
-                        }
24
+            MeterChargeRecordContentView(
25
+                onSelectDevice: { mac, tint in
26
+                    deviceLibraryMACAddress = mac
27
+                    chargedDeviceLibraryTint = tint
28
+                    chargedDeviceLibraryVisibility = true
29
+                },
30
+                onSelectCharger: { mac, tint in
31
+                    chargerLibraryMACAddress = mac
32
+                    chargerLibraryTint = tint
33
+                    chargerLibraryVisibility = true
34
+                }
35
+            )
36
+            .navigationTitle("Charge Record")
37
+            .navigationBarTitleDisplayMode(.inline)
38
+            .toolbar {
39
+                ToolbarItem(placement: .cancellationAction) {
40
+                    Button("Done") {
41
+                        visibility = false
24 42
                     }
25 43
                 }
44
+            }
45
+            .background(
46
+                Group {
47
+                    NavigationLink(isActive: $chargedDeviceLibraryVisibility) {
48
+                        ChargedDeviceLibrarySheetView(
49
+                            meterMACAddress: deviceLibraryMACAddress,
50
+                            meterTint: chargedDeviceLibraryTint,
51
+                            mode: .device,
52
+                            standalone: false
53
+                        )
54
+                        .environmentObject(appData)
55
+                    } label: { EmptyView() }
56
+
57
+                    NavigationLink(isActive: $chargerLibraryVisibility) {
58
+                        ChargedDeviceLibrarySheetView(
59
+                            meterMACAddress: chargerLibraryMACAddress,
60
+                            meterTint: chargerLibraryTint,
61
+                            mode: .charger,
62
+                            standalone: false
63
+                        )
64
+                        .environmentObject(appData)
65
+                    } label: { EmptyView() }
66
+                }
67
+            )
26 68
         }
27 69
         .navigationViewStyle(StackNavigationViewStyle())
28 70
     }
+47 -24
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -5,9 +5,49 @@
5 5
 
6 6
 import SwiftUI
7 7
 
8
-struct MeterChargeRecordTabView: View {
8
+struct MeterChargeRecordTabView: View, Equatable {
9
+    static func == (lhs: MeterChargeRecordTabView, rhs: MeterChargeRecordTabView) -> Bool {
10
+        true
11
+    }
12
+
13
+    @EnvironmentObject private var appData: AppData
14
+
15
+    @State private var chargedDeviceLibraryVisibility = false
16
+    @State private var chargerLibraryVisibility = false
17
+    @State private var deviceLibraryMACAddress = ""
18
+    @State private var chargerLibraryMACAddress = ""
19
+    @State private var chargedDeviceLibraryTint: Color = .orange
20
+    @State private var chargerLibraryTint: Color = .pink
21
+
9 22
     var body: some View {
10
-        MeterChargeRecordContentView()
23
+        MeterChargeRecordContentView(
24
+            onSelectDevice: { mac, tint in
25
+                deviceLibraryMACAddress = mac
26
+                chargedDeviceLibraryTint = tint
27
+                chargedDeviceLibraryVisibility = true
28
+            },
29
+            onSelectCharger: { mac, tint in
30
+                chargerLibraryMACAddress = mac
31
+                chargerLibraryTint = tint
32
+                chargerLibraryVisibility = true
33
+            }
34
+        )
35
+        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
36
+            ChargedDeviceLibrarySheetView(
37
+                meterMACAddress: deviceLibraryMACAddress,
38
+                meterTint: chargedDeviceLibraryTint,
39
+                mode: .device
40
+            )
41
+            .environmentObject(appData)
42
+        }
43
+        .sheet(isPresented: $chargerLibraryVisibility) {
44
+            ChargedDeviceLibrarySheetView(
45
+                meterMACAddress: chargerLibraryMACAddress,
46
+                meterTint: chargerLibraryTint,
47
+                mode: .charger
48
+            )
49
+            .environmentObject(appData)
50
+        }
11 51
     }
12 52
 }
13 53
 
@@ -94,11 +134,12 @@ struct MeterChargeRecordContentView: View {
94 134
         }
95 135
     }
96 136
 
137
+    let onSelectDevice: (String, Color) -> Void
138
+    let onSelectCharger: (String, Color) -> Void
139
+
97 140
     @EnvironmentObject private var appData: AppData
98 141
     @EnvironmentObject private var usbMeter: Meter
99 142
 
100
-    @State private var chargedDeviceLibraryVisibility = false
101
-    @State private var chargerLibraryVisibility = false
102 143
     @State private var showingInlineTargetEditor = false
103 144
     @State private var draftTargetText = ""
104 145
     @State private var showingStopConfirm = false
@@ -152,24 +193,6 @@ struct MeterChargeRecordContentView: View {
152 193
             )
153 194
             .ignoresSafeArea()
154 195
         )
155
-        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
156
-            ChargedDeviceLibrarySheetView(
157
-                visibility: $chargedDeviceLibraryVisibility,
158
-                meterMACAddress: meterMACAddress,
159
-                meterTint: usbMeter.color,
160
-                mode: .device
161
-            )
162
-            .environmentObject(appData)
163
-        }
164
-        .sheet(isPresented: $chargerLibraryVisibility) {
165
-            ChargedDeviceLibrarySheetView(
166
-                visibility: $chargerLibraryVisibility,
167
-                meterMACAddress: meterMACAddress,
168
-                meterTint: usbMeter.color,
169
-                mode: .charger
170
-            )
171
-            .environmentObject(appData)
172
-        }
173 196
         .alert(item: $pendingCheckpointDeletion) { checkpoint in
174 197
             Alert(
175 198
                 title: Text("Delete Battery Checkpoint"),
@@ -380,7 +403,7 @@ struct MeterChargeRecordContentView: View {
380 403
                 }
381 404
                 Spacer(minLength: 8)
382 405
                 Button(selectedChargedDevice == nil ? "Select" : "Change") {
383
-                    chargedDeviceLibraryVisibility = true
406
+                    onSelectDevice(meterMACAddress, usbMeter.color)
384 407
                 }
385 408
                 .font(.caption.weight(.semibold))
386 409
                 .buttonStyle(.bordered)
@@ -448,7 +471,7 @@ struct MeterChargeRecordContentView: View {
448 471
                     }
449 472
                     Spacer(minLength: 8)
450 473
                     Button(selectedCharger == nil ? "Select" : "Change") {
451
-                        chargerLibraryVisibility = true
474
+                        onSelectCharger(meterMACAddress, usbMeter.color)
452 475
                     }
453 476
                     .font(.caption.weight(.semibold))
454 477
                     .buttonStyle(.bordered)
+0 -1
USB Meter/Views/Meter/Tabs/Live/ChargerStandbyPowerWizardView.swift
@@ -54,7 +54,6 @@ struct ChargerStandbyPowerWizardView: View {
54 54
         .navigationTitle(navigationTitleText)
55 55
         .sheet(isPresented: $chargerLibraryVisibility) {
56 56
             ChargedDeviceLibrarySheetView(
57
-                visibility: $chargerLibraryVisibility,
58 57
                 meterMACAddress: selectedMeterSummary?.macAddress ?? "",
59 58
                 meterTint: selectedMeter?.color ?? .orange,
60 59
                 mode: .charger
+2 -7
USB Meter/Views/Sidebar/SidebarView.swift
@@ -59,16 +59,11 @@ struct SidebarView: View {
59 59
                     .environmentObject(appData)
60 60
             case .device:
61 61
                 ChargedDeviceEditorSheetView(
62
-                    meterMACAddress: nil,
63
-                    kind: .device
62
+                    meterMACAddress: nil
64 63
                 )
65 64
                 .environmentObject(appData)
66 65
             case .charger:
67
-                ChargedDeviceEditorSheetView(
68
-                    meterMACAddress: nil,
69
-                    kind: .charger
70
-                )
71
-                .environmentObject(appData)
66
+                ChargerEditorSheetView(appData: appData)
72 67
             }
73 68
         }
74 69
     }