Showing 14 changed files with 1592 additions and 373 deletions
+10 -2
USB Meter.xcodeproj/project.pbxproj
@@ -28,6 +28,7 @@
28 28
 		4383B460240EB2D000DAAEBF /* Meter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B45F240EB2D000DAAEBF /* Meter.swift */; };
29 29
 		4383B462240EB5E400DAAEBF /* AppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B461240EB5E400DAAEBF /* AppData.swift */; };
30 30
 		4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B464240EB6B200DAAEBF /* UserDefault.swift */; };
31
+		C1F000013C90000100A10001 /* ChargedDeviceTemplates.json in Resources */ = {isa = PBXBuildFile; fileRef = C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */; };
31 32
 		4383B468240F845500DAAEBF /* MacAdress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B467240F845500DAAEBF /* MacAdress.swift */; };
32 33
 		4383B46A240FE4A600DAAEBF /* MeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B469240FE4A600DAAEBF /* MeterView.swift */; };
33 34
 		438695892463F062008855A9 /* Measurements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438695882463F062008855A9 /* Measurements.swift */; };
@@ -135,6 +136,8 @@
135 136
 		4383B45F240EB2D000DAAEBF /* Meter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Meter.swift; sourceTree = "<group>"; };
136 137
 		4383B461240EB5E400DAAEBF /* AppData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppData.swift; sourceTree = "<group>"; };
137 138
 		4383B464240EB6B200DAAEBF /* UserDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefault.swift; sourceTree = "<group>"; };
139
+		C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ChargedDeviceTemplates.json; sourceTree = "<group>"; };
140
+		C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 12.xcdatamodel"; sourceTree = "<group>"; };
138 141
 		4383B467240F845500DAAEBF /* MacAdress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacAdress.swift; sourceTree = "<group>"; };
139 142
 		4383B469240FE4A600DAAEBF /* MeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterView.swift; sourceTree = "<group>"; };
140 143
 		438695882463F062008855A9 /* Measurements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Measurements.swift; sourceTree = "<group>"; };
@@ -359,6 +362,7 @@
359 362
 			isa = PBXGroup;
360 363
 			children = (
361 364
 				4383B464240EB6B200DAAEBF /* UserDefault.swift */,
365
+				C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */,
362 366
 			);
363 367
 			path = Templates;
364 368
 			sourceTree = "<group>";
@@ -686,7 +690,7 @@
686 690
 			attributes = {
687 691
 				BuildIndependentTargetsInParallel = YES;
688 692
 				LastSwiftUpdateCheck = 1130;
689
-				LastUpgradeCheck = 2630;
693
+				LastUpgradeCheck = 2640;
690 694
 				ORGANIZATIONNAME = "Bogdan Timofte";
691 695
 				TargetAttributes = {
692 696
 					43CBF65B240BF3EB00255B8B = {
@@ -731,6 +735,7 @@
731 735
 				43CBF66F240BF3ED00255B8B /* LaunchScreen.storyboard in Resources */,
732 736
 				43CBF66C240BF3ED00255B8B /* Preview Assets.xcassets in Resources */,
733 737
 				43CBF669240BF3ED00255B8B /* Assets.xcassets in Resources */,
738
+				C1F000013C90000100A10001 /* ChargedDeviceTemplates.json in Resources */,
734 739
 			);
735 740
 			runOnlyForDeploymentPostprocessing = 0;
736 741
 		};
@@ -833,6 +838,7 @@
833 838
 			isa = XCBuildConfiguration;
834 839
 			buildSettings = {
835 840
 				ALWAYS_SEARCH_USER_PATHS = NO;
841
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
836 842
 				CLANG_ANALYZER_NONNULL = YES;
837 843
 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
838 844
 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -897,6 +903,7 @@
897 903
 			isa = XCBuildConfiguration;
898 904
 			buildSettings = {
899 905
 				ALWAYS_SEARCH_USER_PATHS = NO;
906
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
900 907
 				CLANG_ANALYZER_NONNULL = YES;
901 908
 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
902 909
 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -1060,8 +1067,9 @@
1060 1067
 				C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */,
1061 1068
 				C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */,
1062 1069
 				C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */,
1070
+				C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */,
1063 1071
 			);
1064
-			currentVersion = C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */;
1072
+			currentVersion = C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */;
1065 1073
 			path = CKModel.xcdatamodeld;
1066 1074
 			sourceTree = "<group>";
1067 1075
 			versionGroupType = wrapper.xcdatamodel;
+8 -0
USB Meter/Model/AppData.swift
@@ -312,6 +312,7 @@ final class AppData : ObservableObject {
312 312
     func createDevice(
313 313
         name: String,
314 314
         deviceClass: ChargedDeviceClass,
315
+        templateID: String?,
315 316
         chargingStateAvailability: ChargingStateAvailability,
316 317
         supportsWiredCharging: Bool,
317 318
         supportsWirelessCharging: Bool,
@@ -323,6 +324,7 @@ final class AppData : ObservableObject {
323 324
         let didSave = chargeInsightsStore?.createDevice(
324 325
             name: name,
325 326
             deviceClass: deviceClass,
327
+            templateID: templateID,
326 328
             chargingStateAvailability: chargingStateAvailability,
327 329
             supportsWiredCharging: supportsWiredCharging,
328 330
             supportsWirelessCharging: supportsWirelessCharging,
@@ -342,11 +344,13 @@ final class AppData : ObservableObject {
342 344
     @discardableResult
343 345
     func createCharger(
344 346
         name: String,
347
+        templateID: String?,
345 348
         notes: String?,
346 349
         meterMACAddress: String?
347 350
     ) -> Bool {
348 351
         let didSave = chargeInsightsStore?.createCharger(
349 352
             name: name,
353
+            templateID: templateID,
350 354
             notes: notes,
351 355
             assignTo: meterMACAddress
352 356
         ) ?? false
@@ -363,6 +367,7 @@ final class AppData : ObservableObject {
363 367
         id: UUID,
364 368
         name: String,
365 369
         deviceClass: ChargedDeviceClass,
370
+        templateID: String?,
366 371
         chargingStateAvailability: ChargingStateAvailability,
367 372
         supportsWiredCharging: Bool,
368 373
         supportsWirelessCharging: Bool,
@@ -374,6 +379,7 @@ final class AppData : ObservableObject {
374 379
             id: id,
375 380
             name: name,
376 381
             deviceClass: deviceClass,
382
+            templateID: templateID,
377 383
             chargingStateAvailability: chargingStateAvailability,
378 384
             supportsWiredCharging: supportsWiredCharging,
379 385
             supportsWirelessCharging: supportsWirelessCharging,
@@ -393,11 +399,13 @@ final class AppData : ObservableObject {
393 399
     func updateCharger(
394 400
         id: UUID,
395 401
         name: String,
402
+        templateID: String?,
396 403
         notes: String?
397 404
     ) -> Bool {
398 405
         let didSave = chargeInsightsStore?.updateCharger(
399 406
             id: id,
400 407
             name: name,
408
+            templateID: templateID,
401 409
             notes: notes
402 410
         ) ?? false
403 411
 
+1 -1
USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion
@@ -3,6 +3,6 @@
3 3
 <plist version="1.0">
4 4
 <dict>
5 5
 	<key>_XCCurrentVersionName</key>
6
-	<string>USB_Meter 10.xcdatamodel</string>
6
+	<string>USB_Meter 12.xcdatamodel</string>
7 7
 </dict>
8 8
 </plist>
+122 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 12.xcdatamodel/contents
@@ -0,0 +1,122 @@
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="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
16
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
26
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
28
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
30
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
31
+        <attribute name="notes" optional="YES" attributeType="String"/>
32
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
33
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
34
+    </entity>
35
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
36
+        <attribute name="id" optional="YES" attributeType="String"/>
37
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
38
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
39
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
40
+        <attribute name="meterName" optional="YES" attributeType="String"/>
41
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
42
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
43
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
44
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
45
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
46
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
47
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
48
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
49
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
50
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
51
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
52
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
56
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
57
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
58
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
59
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
60
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
61
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
62
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
63
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
64
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
65
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
66
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
67
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
68
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
69
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
70
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
71
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
72
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
73
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
74
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
75
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
76
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
77
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
78
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
79
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
80
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
81
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
82
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
83
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
84
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
85
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
86
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
87
+    </entity>
88
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
89
+        <attribute name="id" optional="YES" attributeType="String"/>
90
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
91
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
92
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
93
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
94
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
95
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
96
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
97
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
98
+        <attribute name="label" optional="YES" attributeType="String"/>
99
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
100
+    </entity>
101
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class">
102
+        <attribute name="id" optional="YES" attributeType="String"/>
103
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
104
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
105
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
106
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
107
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
108
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
109
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
110
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
111
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
112
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
113
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
114
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
115
+    </entity>
116
+    <elements>
117
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/>
118
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="838"/>
119
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
120
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/>
121
+    </elements>
122
+</model>
+204 -5
USB Meter/Model/ChargeInsightsModel.swift
@@ -7,7 +7,7 @@
7 7
 
8 8
 import Foundation
9 9
 
10
-enum ChargedDeviceKind: String, Identifiable {
10
+enum ChargedDeviceKind: String, Identifiable, Codable {
11 11
     case device
12 12
     case charger
13 13
 
@@ -41,7 +41,7 @@ enum ChargedDeviceKind: String, Identifiable {
41 41
     }
42 42
 }
43 43
 
44
-enum ChargedDeviceClass: String, CaseIterable, Identifiable {
44
+enum ChargedDeviceClass: String, CaseIterable, Identifiable, Codable {
45 45
     case iphone
46 46
     case watch
47 47
     case powerbank
@@ -87,6 +87,45 @@ enum ChargedDeviceClass: String, CaseIterable, Identifiable {
87 87
             return "shippingbox"
88 88
         }
89 89
     }
90
+
91
+    var enforcedChargingSupport: (wired: Bool, wireless: Bool)? {
92
+        switch self {
93
+        case .watch:
94
+            return (wired: false, wireless: true)
95
+        case .powerbank:
96
+            return (wired: true, wireless: false)
97
+        case .charger:
98
+            return (wired: false, wireless: true)
99
+        case .iphone, .other:
100
+            return nil
101
+        }
102
+    }
103
+
104
+    var enforcedChargingStateAvailability: ChargingStateAvailability? {
105
+        switch self {
106
+        case .watch:
107
+            return .onOnly
108
+        case .powerbank:
109
+            return .offOnly
110
+        case .charger:
111
+            return .onOnly
112
+        case .iphone, .other:
113
+            return nil
114
+        }
115
+    }
116
+
117
+    func normalizedChargingSupport(
118
+        supportsWiredCharging: Bool,
119
+        supportsWirelessCharging: Bool
120
+    ) -> (wired: Bool, wireless: Bool) {
121
+        enforcedChargingSupport ?? (wired: supportsWiredCharging, wireless: supportsWirelessCharging)
122
+    }
123
+
124
+    func normalizedChargingStateAvailability(
125
+        _ chargingStateAvailability: ChargingStateAvailability
126
+    ) -> ChargingStateAvailability {
127
+        enforcedChargingStateAvailability ?? chargingStateAvailability
128
+    }
90 129
 }
91 130
 
92 131
 enum ChargeSessionStatus: String {
@@ -286,7 +325,7 @@ enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
286 325
     }
287 326
 }
288 327
 
289
-enum WirelessChargingProfile: String, CaseIterable, Identifiable {
328
+enum WirelessChargingProfile: String, CaseIterable, Identifiable, Codable {
290 329
     case magsafe
291 330
     case genericQi
292 331
 
@@ -311,6 +350,117 @@ enum WirelessChargingProfile: String, CaseIterable, Identifiable {
311 350
     }
312 351
 }
313 352
 
353
+enum ChargedDeviceTemplateIconSource: String, Codable {
354
+    case systemSymbol
355
+    case asset
356
+}
357
+
358
+struct ChargedDeviceTemplateIcon: Hashable, Codable {
359
+    let type: ChargedDeviceTemplateIconSource
360
+    let name: String
361
+    let fallbackSystemName: String?
362
+
363
+    static func systemSymbol(
364
+        _ name: String,
365
+        fallbackSystemName: String? = nil
366
+    ) -> ChargedDeviceTemplateIcon {
367
+        ChargedDeviceTemplateIcon(
368
+            type: .systemSymbol,
369
+            name: name,
370
+            fallbackSystemName: fallbackSystemName
371
+        )
372
+    }
373
+
374
+    func resolvedSystemSymbolName(fallbackSystemName: String) -> String {
375
+        switch type {
376
+        case .systemSymbol:
377
+            return name
378
+        case .asset:
379
+            return self.fallbackSystemName ?? fallbackSystemName
380
+        }
381
+    }
382
+}
383
+
384
+struct ChargedDeviceTemplateDefinition: Identifiable, Hashable, Codable {
385
+    let id: String
386
+    let name: String
387
+    let group: String
388
+    let kind: ChargedDeviceKind
389
+    let deviceClass: ChargedDeviceClass
390
+    let icon: ChargedDeviceTemplateIcon
391
+    let chargingStateAvailability: ChargingStateAvailability
392
+    let supportsWiredCharging: Bool
393
+    let supportsWirelessCharging: Bool
394
+    let wirelessChargingProfile: WirelessChargingProfile
395
+    let sortOrder: Int
396
+
397
+    var chargingSupportSummary: String {
398
+        switch (supportsWiredCharging, supportsWirelessCharging) {
399
+        case (true, true):
400
+            return "Wired + Wireless"
401
+        case (true, false):
402
+            return "Wired only"
403
+        case (false, true):
404
+            return "Wireless only"
405
+        case (false, false):
406
+            return "No charging transport"
407
+        }
408
+    }
409
+
410
+    var capabilitySummary: String {
411
+        var components = [chargingStateAvailability.title, chargingSupportSummary]
412
+        if supportsWirelessCharging {
413
+            components.append(wirelessChargingProfile.title)
414
+        }
415
+        return components.joined(separator: " • ")
416
+    }
417
+}
418
+
419
+private struct ChargedDeviceTemplateDocument: Codable {
420
+    let templates: [ChargedDeviceTemplateDefinition]
421
+}
422
+
423
+struct ChargedDeviceTemplateCatalog {
424
+    static let shared = ChargedDeviceTemplateCatalog()
425
+
426
+    let templates: [ChargedDeviceTemplateDefinition]
427
+    private let templatesByID: [String: ChargedDeviceTemplateDefinition]
428
+
429
+    private init(bundle: Bundle = .main) {
430
+        let loadedTemplates: [ChargedDeviceTemplateDefinition]
431
+
432
+        if let resourceURL = bundle.url(forResource: "ChargedDeviceTemplates", withExtension: "json"),
433
+           let data = try? Data(contentsOf: resourceURL),
434
+           let document = try? JSONDecoder().decode(ChargedDeviceTemplateDocument.self, from: data) {
435
+            loadedTemplates = document.templates
436
+        } else {
437
+            loadedTemplates = []
438
+        }
439
+
440
+        self.templates = loadedTemplates.sorted { lhs, rhs in
441
+            if lhs.group != rhs.group {
442
+                return lhs.group < rhs.group
443
+            }
444
+            if lhs.sortOrder != rhs.sortOrder {
445
+                return lhs.sortOrder < rhs.sortOrder
446
+            }
447
+            return lhs.name < rhs.name
448
+        }
449
+        self.templatesByID = Dictionary(uniqueKeysWithValues: self.templates.map { ($0.id, $0) })
450
+    }
451
+
452
+    func template(id: String?) -> ChargedDeviceTemplateDefinition? {
453
+        guard let id else {
454
+            return nil
455
+        }
456
+        return templatesByID[id]
457
+    }
458
+
459
+    func templates(for kind: ChargedDeviceKind) -> [ChargedDeviceTemplateDefinition] {
460
+        templates.filter { $0.kind == kind }
461
+    }
462
+}
463
+
314 464
 struct ChargeCheckpointSummary: Identifiable, Hashable {
315 465
     let id: UUID
316 466
     let sessionID: UUID
@@ -845,6 +995,8 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
845 995
     let qrIdentifier: String
846 996
     let name: String
847 997
     let deviceClass: ChargedDeviceClass
998
+    let deviceTemplateID: String?
999
+    let templateDefinition: ChargedDeviceTemplateDefinition?
848 1000
     let supportsChargingWhileOff: Bool
849 1001
     let chargingStateAvailability: ChargingStateAvailability
850 1002
     let supportsWiredCharging: Bool
@@ -883,13 +1035,21 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
883 1035
     }
884 1036
 
885 1037
     var identityTitle: String {
886
-        isCharger ? kind.title : deviceClass.title
1038
+        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
887 1039
     }
888 1040
 
889
-    var identitySymbolName: String {
1041
+    var fallbackIdentitySymbolName: String {
890 1042
         isCharger ? kind.symbolName : deviceClass.symbolName
891 1043
     }
892 1044
 
1045
+    var identityIcon: ChargedDeviceTemplateIcon {
1046
+        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1047
+    }
1048
+
1049
+    var identitySymbolName: String {
1050
+        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1051
+    }
1052
+
893 1053
     var activeSession: ChargeSessionSummary? {
894 1054
         sessions.first(where: \.isOpen)
895 1055
     }
@@ -921,6 +1081,33 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
921 1081
         chargingStateAvailability.supportedModes
922 1082
     }
923 1083
 
1084
+    var hasMultipleChargingTransports: Bool {
1085
+        supportedChargingModes.count > 1
1086
+    }
1087
+
1088
+    var hasMultipleChargingStateModes: Bool {
1089
+        supportedChargingStateModes.count > 1
1090
+    }
1091
+
1092
+    var showsWirelessProfileDetails: Bool {
1093
+        supportsWirelessCharging
1094
+            && hasMultipleChargingTransports
1095
+            && deviceClass != .watch
1096
+    }
1097
+
1098
+    var chargingSupportSummary: String {
1099
+        switch (supportsWiredCharging, supportsWirelessCharging) {
1100
+        case (true, true):
1101
+            return "Supports wired and wireless charging."
1102
+        case (true, false):
1103
+            return "Supports wired charging only."
1104
+        case (false, true):
1105
+            return "Supports wireless charging only."
1106
+        case (false, false):
1107
+            return "No charging method configured."
1108
+        }
1109
+    }
1110
+
924 1111
     func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
925 1112
         if let matchingSession = sessions.first(where: {
926 1113
             $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
@@ -958,6 +1145,16 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
958 1145
         }
959 1146
     }
960 1147
 
1148
+    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
1149
+        hasMultipleChargingTransports
1150
+            || supportedChargingModes.contains(chargingTransportMode) == false
1151
+    }
1152
+
1153
+    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
1154
+        hasMultipleChargingStateModes
1155
+            || supportedChargingStateModes.contains(chargingStateMode) == false
1156
+    }
1157
+
961 1158
     func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
962 1159
         if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
963 1160
             return explicitCurrent
@@ -1090,6 +1287,8 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
1090 1287
             qrIdentifier: qrIdentifier,
1091 1288
             name: name,
1092 1289
             deviceClass: deviceClass,
1290
+            deviceTemplateID: deviceTemplateID,
1291
+            templateDefinition: templateDefinition,
1093 1292
             supportsChargingWhileOff: supportsChargingWhileOff,
1094 1293
             chargingStateAvailability: chargingStateAvailability,
1095 1294
             supportsWiredCharging: supportsWiredCharging,
+328 -88
USB Meter/Model/ChargeInsightsStore.swift
@@ -72,6 +72,7 @@ final class ChargeInsightsStore {
72 72
     func createDevice(
73 73
         name: String,
74 74
         deviceClass: ChargedDeviceClass,
75
+        templateID: String?,
75 76
         chargingStateAvailability: ChargingStateAvailability,
76 77
         supportsWiredCharging: Bool,
77 78
         supportsWirelessCharging: Bool,
@@ -82,8 +83,14 @@ final class ChargeInsightsStore {
82 83
     ) -> Bool {
83 84
         guard deviceClass.kind == .device else { return false }
84 85
         let normalizedName = normalizedText(name)
86
+        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
87
+        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
88
+            supportsWiredCharging: supportsWiredCharging,
89
+            supportsWirelessCharging: supportsWirelessCharging
90
+        )
91
+        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
85 92
         guard !normalizedName.isEmpty else { return false }
86
-        guard supportsWiredCharging || supportsWirelessCharging else { return false }
93
+        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
87 94
 
88 95
         var didSave = false
89 96
         context.performAndWait {
@@ -96,10 +103,11 @@ final class ChargeInsightsStore {
96 103
             object.setValue(UUID().uuidString, forKey: "id")
97 104
             object.setValue(normalizedName, forKey: "name")
98 105
             object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
99
-            object.setValue(chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
100
-            object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
101
-            object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
102
-            object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
106
+            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
107
+            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
108
+            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
109
+            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
110
+            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
103 111
             object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
104 112
             object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
105 113
             object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
@@ -117,10 +125,13 @@ final class ChargeInsightsStore {
117 125
     @discardableResult
118 126
     func createCharger(
119 127
         name: String,
128
+        templateID: String?,
120 129
         notes: String?,
121 130
         assignTo meterMACAddress: String?
122 131
     ) -> Bool {
123 132
         let normalizedName = normalizedText(name)
133
+        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .charger)
134
+        let chargerTemplateConfiguration = resolvedChargerTemplateConfiguration(templateID: normalizedTemplateID)
124 135
         guard !normalizedName.isEmpty else { return false }
125 136
 
126 137
         var didSave = false
@@ -134,11 +145,12 @@ final class ChargeInsightsStore {
134 145
             object.setValue(UUID().uuidString, forKey: "id")
135 146
             object.setValue(normalizedName, forKey: "name")
136 147
             object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
137
-            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
138
-            object.setValue(false, forKey: "supportsChargingWhileOff")
139
-            object.setValue(false, forKey: "supportsWiredCharging")
140
-            object.setValue(true, forKey: "supportsWirelessCharging")
141
-            object.setValue(WirelessChargingProfile.genericQi.rawValue, forKey: "wirelessChargingProfileRawValue")
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")
142 154
             object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
143 155
             object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
144 156
             object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
@@ -157,6 +169,7 @@ final class ChargeInsightsStore {
157 169
         id: UUID,
158 170
         name: String,
159 171
         deviceClass: ChargedDeviceClass,
172
+        templateID: String?,
160 173
         chargingStateAvailability: ChargingStateAvailability,
161 174
         supportsWiredCharging: Bool,
162 175
         supportsWirelessCharging: Bool,
@@ -166,8 +179,14 @@ final class ChargeInsightsStore {
166 179
     ) -> Bool {
167 180
         guard deviceClass.kind == .device else { return false }
168 181
         let normalizedName = normalizedText(name)
182
+        let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability)
183
+        let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
184
+            supportsWiredCharging: supportsWiredCharging,
185
+            supportsWirelessCharging: supportsWirelessCharging
186
+        )
187
+        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device)
169 188
         guard !normalizedName.isEmpty else { return false }
170
-        guard supportsWiredCharging || supportsWirelessCharging else { return false }
189
+        guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
171 190
 
172 191
         var didSave = false
173 192
         context.performAndWait {
@@ -186,10 +205,11 @@ final class ChargeInsightsStore {
186 205
 
187 206
             object.setValue(normalizedName, forKey: "name")
188 207
             object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
189
-            object.setValue(chargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
190
-            object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
191
-            object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
192
-            object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
208
+            object.setValue(normalizedTemplateID, forKey: "deviceTemplateID")
209
+            object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue")
210
+            object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
211
+            object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging")
212
+            object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging")
193 213
             object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
194 214
             object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
195 215
             object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
@@ -197,12 +217,12 @@ final class ChargeInsightsStore {
197 217
             object.setValue(normalizedOptionalText(notes), forKey: "notes")
198 218
             object.setValue(now, forKey: "updatedAt")
199 219
 
200
-            let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
220
+            let supportsChargingWhileOff = normalizedChargingStateAvailability.supportsChargingWhileOff
201 221
             let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
202 222
             let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
203
-                || previousChargingStateAvailability != chargingStateAvailability
204
-                || previousSupportsWiredCharging != supportsWiredCharging
205
-                || previousSupportsWirelessCharging != supportsWirelessCharging
223
+                || previousChargingStateAvailability != normalizedChargingStateAvailability
224
+                || previousSupportsWiredCharging != normalizedChargingSupport.wired
225
+                || previousSupportsWirelessCharging != normalizedChargingSupport.wireless
206 226
 
207 227
             if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
208 228
                 let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
@@ -221,12 +241,12 @@ final class ChargeInsightsStore {
221 241
 
222 242
                     let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
223 243
                         chargingTransportMode(for: session),
224
-                        supportsWiredCharging: supportsWiredCharging,
225
-                        supportsWirelessCharging: supportsWirelessCharging
244
+                        supportsWiredCharging: normalizedChargingSupport.wired,
245
+                        supportsWirelessCharging: normalizedChargingSupport.wireless
226 246
                     )
227 247
                     let resolvedSessionChargingStateMode = resolvedChargingStateMode(
228 248
                         chargingStateMode(for: session),
229
-                        availability: chargingStateAvailability
249
+                        availability: normalizedChargingStateAvailability
230 250
                     )
231 251
                     let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:))
232 252
 
@@ -258,9 +278,12 @@ final class ChargeInsightsStore {
258 278
     func updateCharger(
259 279
         id: UUID,
260 280
         name: String,
281
+        templateID: String?,
261 282
         notes: String?
262 283
     ) -> Bool {
263 284
         let normalizedName = normalizedText(name)
285
+        let normalizedTemplateID = normalizedTemplateID(templateID, kind: .charger)
286
+        let chargerTemplateConfiguration = resolvedChargerTemplateConfiguration(templateID: normalizedTemplateID)
264 287
         guard !normalizedName.isEmpty else { return false }
265 288
 
266 289
         var didSave = false
@@ -273,8 +296,15 @@ final class ChargeInsightsStore {
273 296
             }
274 297
 
275 298
             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")
276 305
             object.setValue(normalizedOptionalText(notes), forKey: "notes")
277 306
             object.setValue(Date(), forKey: "updatedAt")
307
+            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
278 308
             didSave = saveContext()
279 309
         }
280 310
 
@@ -877,6 +907,11 @@ final class ChargeInsightsStore {
877 907
                     return nil
878 908
                 }
879 909
 
910
+                let chargingStateAvailability = chargingStateAvailability(for: device)
911
+                let supportsWiredCharging = supportsWiredCharging(for: device)
912
+                let supportsWirelessCharging = supportsWirelessCharging(for: device)
913
+                let templateDefinition = templateDefinition(for: device)
914
+
880 915
                 let sessionObjects = relevantSessionObjects(
881 916
                     for: id.uuidString,
882 917
                     deviceClass: deviceClass,
@@ -912,10 +947,12 @@ final class ChargeInsightsStore {
912 947
                     qrIdentifier: qrIdentifier,
913 948
                     name: name,
914 949
                     deviceClass: deviceClass,
915
-                    supportsChargingWhileOff: boolValue(device, key: "supportsChargingWhileOff"),
916
-                    chargingStateAvailability: chargingStateAvailability(for: device),
917
-                    supportsWiredCharging: supportsWiredCharging(for: device),
918
-                    supportsWirelessCharging: supportsWirelessCharging(for: device),
950
+                    deviceTemplateID: stringValue(device, key: "deviceTemplateID"),
951
+                    templateDefinition: templateDefinition,
952
+                    supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff,
953
+                    chargingStateAvailability: chargingStateAvailability,
954
+                    supportsWiredCharging: supportsWiredCharging,
955
+                    supportsWirelessCharging: supportsWirelessCharging,
919 956
                     wirelessChargingProfile: wirelessChargingProfile(for: device),
920 957
                     configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
921 958
                     learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
@@ -1769,8 +1806,8 @@ final class ChargeInsightsStore {
1769 1806
             return
1770 1807
         }
1771 1808
 
1772
-        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1773 1809
         let chargingStateAvailability = chargingStateAvailability(for: chargedDevice)
1810
+        let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff
1774 1811
         let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
1775 1812
         let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1776 1813
         let sessions = relevantSessionObjects(
@@ -1871,50 +1908,25 @@ final class ChargeInsightsStore {
1871 1908
         var groupedChargeByBin: [Int: [Double]] = [:]
1872 1909
 
1873 1910
         for session in sessions where session.status == .completed {
1874
-            var points = session.checkpoints
1875
-
1876
-            if let startBatteryPercent = session.startBatteryPercent {
1877
-                points.append(
1878
-                    ChargeCheckpointSummary(
1879
-                        id: UUID(),
1880
-                        sessionID: session.id,
1881
-                        chargedDeviceID: session.chargedDeviceID,
1882
-                        timestamp: session.startedAt,
1883
-                        batteryPercent: startBatteryPercent,
1884
-                        measuredEnergyWh: 0,
1885
-                        measuredChargeAh: 0,
1886
-                        currentAmps: 0,
1887
-                        voltageVolts: nil,
1888
-                        label: ChargeCheckpointFlag.initial.rawValue
1889
-                    )
1890
-                )
1911
+            let anchors = normalizedTypicalCurveAnchors(for: session)
1912
+            guard anchors.count >= 2 else {
1913
+                continue
1891 1914
             }
1892 1915
 
1893
-            if let endBatteryPercent = session.endBatteryPercent {
1894
-                points.append(
1895
-                    ChargeCheckpointSummary(
1896
-                        id: UUID(),
1897
-                        sessionID: session.id,
1898
-                        chargedDeviceID: session.chargedDeviceID,
1899
-                        timestamp: session.endedAt ?? session.lastObservedAt,
1900
-                        batteryPercent: endBatteryPercent,
1901
-                        measuredEnergyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
1902
-                        measuredChargeAh: session.measuredChargeAh,
1903
-                        currentAmps: 0,
1904
-                        voltageVolts: nil,
1905
-                        label: ChargeCheckpointFlag.final.rawValue
1906
-                    )
1907
-                )
1908
-            }
1916
+            for percentBin in stride(from: 0, through: 100, by: 10) {
1917
+                guard let interpolatedPoint = interpolatedTypicalCurvePoint(
1918
+                    for: Double(percentBin),
1919
+                    anchors: anchors
1920
+                ) else {
1921
+                    continue
1922
+                }
1909 1923
 
1910
-            for point in points {
1911
-                let percentBin = Int((point.batteryPercent / 10).rounded(.toNearestOrEven)) * 10
1912
-                groupedEnergyByBin[percentBin, default: []].append(point.measuredEnergyWh)
1913
-                groupedChargeByBin[percentBin, default: []].append(point.measuredChargeAh)
1924
+                groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh)
1925
+                groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh)
1914 1926
             }
1915 1927
         }
1916 1928
 
1917
-        return groupedEnergyByBin.keys.sorted().compactMap { percentBin in
1929
+        let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
1918 1930
             guard
1919 1931
                 let energies = groupedEnergyByBin[percentBin],
1920 1932
                 let charges = groupedChargeByBin[percentBin],
@@ -1931,6 +1943,153 @@ final class ChargeInsightsStore {
1931 1943
                 sampleCount: min(energies.count, charges.count)
1932 1944
             )
1933 1945
         }
1946
+
1947
+        var runningMaximumEnergyWh = 0.0
1948
+        var runningMaximumChargeAh = 0.0
1949
+
1950
+        return averagedPoints.map { point in
1951
+            runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh)
1952
+            runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh)
1953
+            return TypicalChargeCurvePoint(
1954
+                percentBin: point.percentBin,
1955
+                averageEnergyWh: runningMaximumEnergyWh,
1956
+                averageChargeAh: runningMaximumChargeAh,
1957
+                sampleCount: point.sampleCount
1958
+            )
1959
+        }
1960
+    }
1961
+
1962
+    private func normalizedTypicalCurveAnchors(
1963
+        for session: ChargeSessionSummary
1964
+    ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
1965
+        struct Anchor {
1966
+            let percent: Double
1967
+            let energyWh: Double
1968
+            let chargeAh: Double
1969
+            let timestamp: Date
1970
+        }
1971
+
1972
+        var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
1973
+            guard checkpoint.batteryPercent.isFinite,
1974
+                  checkpoint.measuredEnergyWh.isFinite,
1975
+                  checkpoint.measuredChargeAh.isFinite,
1976
+                  checkpoint.batteryPercent >= 0,
1977
+                  checkpoint.batteryPercent <= 100,
1978
+                  checkpoint.measuredEnergyWh >= 0,
1979
+                  checkpoint.measuredChargeAh >= 0 else {
1980
+                return nil
1981
+            }
1982
+
1983
+            return Anchor(
1984
+                percent: checkpoint.batteryPercent,
1985
+                energyWh: checkpoint.measuredEnergyWh,
1986
+                chargeAh: checkpoint.measuredChargeAh,
1987
+                timestamp: checkpoint.timestamp
1988
+            )
1989
+        }
1990
+
1991
+        if let startBatteryPercent = session.startBatteryPercent,
1992
+           startBatteryPercent.isFinite,
1993
+           startBatteryPercent >= 0,
1994
+           startBatteryPercent <= 100 {
1995
+            anchors.append(
1996
+                Anchor(
1997
+                    percent: startBatteryPercent,
1998
+                    energyWh: 0,
1999
+                    chargeAh: 0,
2000
+                    timestamp: session.startedAt
2001
+                )
2002
+            )
2003
+        }
2004
+
2005
+        if let endBatteryPercent = session.endBatteryPercent,
2006
+           endBatteryPercent.isFinite,
2007
+           endBatteryPercent >= 0,
2008
+           endBatteryPercent <= 100 {
2009
+            anchors.append(
2010
+                Anchor(
2011
+                    percent: endBatteryPercent,
2012
+                    energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
2013
+                    chargeAh: session.measuredChargeAh,
2014
+                    timestamp: session.endedAt ?? session.lastObservedAt
2015
+                )
2016
+            )
2017
+        }
2018
+
2019
+        let sortedAnchors = anchors.sorted { lhs, rhs in
2020
+            if lhs.percent != rhs.percent {
2021
+                return lhs.percent < rhs.percent
2022
+            }
2023
+            if lhs.energyWh != rhs.energyWh {
2024
+                return lhs.energyWh < rhs.energyWh
2025
+            }
2026
+            return lhs.timestamp < rhs.timestamp
2027
+        }
2028
+
2029
+        var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = []
2030
+
2031
+        for anchor in sortedAnchors {
2032
+            if let lastIndex = collapsedAnchors.indices.last,
2033
+               abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
2034
+                collapsedAnchors[lastIndex] = (
2035
+                    percent: collapsedAnchors[lastIndex].percent,
2036
+                    energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh),
2037
+                    chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh)
2038
+                )
2039
+            } else {
2040
+                collapsedAnchors.append(
2041
+                    (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh)
2042
+                )
2043
+            }
2044
+        }
2045
+
2046
+        var runningMaximumEnergyWh = 0.0
2047
+        var runningMaximumChargeAh = 0.0
2048
+
2049
+        return collapsedAnchors.map { anchor in
2050
+            runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh)
2051
+            runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh)
2052
+            return (
2053
+                percent: anchor.percent,
2054
+                energyWh: runningMaximumEnergyWh,
2055
+                chargeAh: runningMaximumChargeAh
2056
+            )
2057
+        }
2058
+    }
2059
+
2060
+    private func interpolatedTypicalCurvePoint(
2061
+        for percent: Double,
2062
+        anchors: [(percent: Double, energyWh: Double, chargeAh: Double)]
2063
+    ) -> (energyWh: Double, chargeAh: Double)? {
2064
+        guard
2065
+            let firstAnchor = anchors.first,
2066
+            let lastAnchor = anchors.last,
2067
+            percent >= firstAnchor.percent,
2068
+            percent <= lastAnchor.percent
2069
+        else {
2070
+            return nil
2071
+        }
2072
+
2073
+        if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
2074
+            return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh)
2075
+        }
2076
+
2077
+        guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
2078
+              upperIndex > 0 else {
2079
+            return nil
2080
+        }
2081
+
2082
+        let lowerAnchor = anchors[upperIndex - 1]
2083
+        let upperAnchor = anchors[upperIndex]
2084
+        let span = upperAnchor.percent - lowerAnchor.percent
2085
+        guard span > 0.000_1 else {
2086
+            return nil
2087
+        }
2088
+
2089
+        let ratio = (percent - lowerAnchor.percent) / span
2090
+        let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio)
2091
+        let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio)
2092
+        return (energyWh: energyWh, chargeAh: chargeAh)
1934 2093
     }
1935 2094
 
1936 2095
     private func makeSessionSummary(
@@ -2330,41 +2489,118 @@ final class ChargeInsightsStore {
2330 2489
         )
2331 2490
     }
2332 2491
 
2333
-    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2334
-        if chargedDevice.value(forKey: "supportsWiredCharging") == nil {
2335
-            return true
2492
+    private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
2493
+        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other
2494
+    }
2495
+
2496
+    private func normalizedTemplateID(
2497
+        _ templateID: String?,
2498
+        kind: ChargedDeviceKind
2499
+    ) -> String? {
2500
+        guard let templateID,
2501
+              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2502
+              templateDefinition.kind == kind else {
2503
+            return nil
2336 2504
         }
2337
-        return boolValue(chargedDevice, key: "supportsWiredCharging")
2505
+        return templateDefinition.id
2338 2506
     }
2339 2507
 
2340
-    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2341
-        if chargedDevice.value(forKey: "supportsWirelessCharging") == nil {
2342
-            return false
2508
+    private func resolvedChargerTemplateConfiguration(
2509
+        templateID: String?
2510
+    ) -> (
2511
+        chargingStateAvailability: ChargingStateAvailability,
2512
+        supportsWiredCharging: Bool,
2513
+        supportsWirelessCharging: Bool,
2514
+        wirelessChargingProfile: WirelessChargingProfile
2515
+    ) {
2516
+        guard let templateID,
2517
+              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2518
+              templateDefinition.kind == .charger else {
2519
+            return (
2520
+                chargingStateAvailability: .onOnly,
2521
+                supportsWiredCharging: false,
2522
+                supportsWirelessCharging: true,
2523
+                wirelessChargingProfile: .genericQi
2524
+            )
2343 2525
         }
2344
-        return boolValue(chargedDevice, key: "supportsWirelessCharging")
2526
+
2527
+        let normalizedChargingStateAvailability = templateDefinition.deviceClass.normalizedChargingStateAvailability(
2528
+            templateDefinition.chargingStateAvailability
2529
+        )
2530
+        let normalizedChargingSupport = templateDefinition.deviceClass.normalizedChargingSupport(
2531
+            supportsWiredCharging: templateDefinition.supportsWiredCharging,
2532
+            supportsWirelessCharging: templateDefinition.supportsWirelessCharging
2533
+        )
2534
+
2535
+        return (
2536
+            chargingStateAvailability: normalizedChargingStateAvailability,
2537
+            supportsWiredCharging: normalizedChargingSupport.wired,
2538
+            supportsWirelessCharging: normalizedChargingSupport.wireless,
2539
+            wirelessChargingProfile: templateDefinition.wirelessChargingProfile
2540
+        )
2345 2541
     }
2346 2542
 
2347
-    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2348
-        if let rawValue = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue"),
2349
-           let availability = ChargingStateAvailability(rawValue: rawValue) {
2350
-            return availability
2543
+    private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
2544
+        guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"),
2545
+              let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID),
2546
+              templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
2547
+            return nil
2351 2548
         }
2352
-        return ChargingStateAvailability.fallback(
2549
+        return templateDefinition
2550
+    }
2551
+
2552
+    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
2553
+        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2554
+            ? true
2555
+            : boolValue(chargedDevice, key: "supportsWiredCharging")
2556
+        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2557
+            ? false
2558
+            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2559
+        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2560
+            supportsWiredCharging: persistedWiredCharging,
2561
+            supportsWirelessCharging: persistedWirelessCharging
2562
+        ).wired
2563
+    }
2564
+
2565
+    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
2566
+        let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil
2567
+            ? true
2568
+            : boolValue(chargedDevice, key: "supportsWiredCharging")
2569
+        let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil
2570
+            ? false
2571
+            : boolValue(chargedDevice, key: "supportsWirelessCharging")
2572
+        return deviceClass(for: chargedDevice).normalizedChargingSupport(
2573
+            supportsWiredCharging: persistedWiredCharging,
2574
+            supportsWirelessCharging: persistedWirelessCharging
2575
+        ).wireless
2576
+    }
2577
+
2578
+    private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
2579
+        let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue")
2580
+            .flatMap(ChargingStateAvailability.init(rawValue:))
2581
+            ?? ChargingStateAvailability.fallback(
2353 2582
             for: boolValue(chargedDevice, key: "supportsChargingWhileOff")
2354 2583
         )
2584
+        return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability)
2355 2585
     }
2356 2586
 
2357 2587
     private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
2588
+        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2589
+           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2590
+            let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue")
2591
+                .flatMap(ChargingStateMode.init(rawValue:))
2592
+                ?? .on
2593
+            return resolvedChargingStateMode(
2594
+                persistedChargingStateMode,
2595
+                availability: chargingStateAvailability(for: chargedDevice)
2596
+            )
2597
+        }
2598
+
2358 2599
         if let rawValue = stringValue(session, key: "chargingStateRawValue"),
2359 2600
            let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
2360 2601
             return chargingStateMode
2361 2602
         }
2362 2603
 
2363
-        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2364
-           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2365
-            return chargingStateAvailability(for: chargedDevice).supportedModes.first ?? .on
2366
-        }
2367
-
2368 2604
         return .on
2369 2605
     }
2370 2606
 
@@ -2648,13 +2884,17 @@ final class ChargeInsightsStore {
2648 2884
     }
2649 2885
 
2650 2886
     private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
2651
-        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
2652
-            return persistedChargingTransportMode
2653
-        }
2654
-
2655 2887
         if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2656 2888
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2657
-            return fallbackChargingTransportMode(for: chargedDevice)
2889
+            return resolvedPreferredChargingTransportMode(
2890
+                chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired,
2891
+                supportsWiredCharging: supportsWiredCharging(for: chargedDevice),
2892
+                supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice)
2893
+            )
2894
+        }
2895
+
2896
+        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
2897
+            return persistedChargingTransportMode
2658 2898
         }
2659 2899
 
2660 2900
         return .wired
+242 -0
USB Meter/Templates/ChargedDeviceTemplates.json
@@ -0,0 +1,242 @@
1
+{
2
+  "templates": [
3
+    {
4
+      "id": "apple-iphone",
5
+      "name": "iPhone",
6
+      "group": "Apple",
7
+      "kind": "device",
8
+      "deviceClass": "iphone",
9
+      "icon": {
10
+        "type": "systemSymbol",
11
+        "name": "iphone",
12
+        "fallbackSystemName": "smartphone"
13
+      },
14
+      "chargingStateAvailability": "onOrOff",
15
+      "supportsWiredCharging": true,
16
+      "supportsWirelessCharging": true,
17
+      "wirelessChargingProfile": "magsafe",
18
+      "sortOrder": 10
19
+    },
20
+    {
21
+      "id": "apple-ipad",
22
+      "name": "iPad",
23
+      "group": "Apple",
24
+      "kind": "device",
25
+      "deviceClass": "iphone",
26
+      "icon": {
27
+        "type": "systemSymbol",
28
+        "name": "ipad",
29
+        "fallbackSystemName": "rectangle"
30
+      },
31
+      "chargingStateAvailability": "onOrOff",
32
+      "supportsWiredCharging": true,
33
+      "supportsWirelessCharging": false,
34
+      "wirelessChargingProfile": "genericQi",
35
+      "sortOrder": 20
36
+    },
37
+    {
38
+      "id": "apple-watch",
39
+      "name": "Apple Watch",
40
+      "group": "Apple",
41
+      "kind": "device",
42
+      "deviceClass": "watch",
43
+      "icon": {
44
+        "type": "systemSymbol",
45
+        "name": "applewatch",
46
+        "fallbackSystemName": "watch.analog"
47
+      },
48
+      "chargingStateAvailability": "onOnly",
49
+      "supportsWiredCharging": false,
50
+      "supportsWirelessCharging": true,
51
+      "wirelessChargingProfile": "genericQi",
52
+      "sortOrder": 30
53
+    },
54
+    {
55
+      "id": "apple-airpods",
56
+      "name": "AirPods",
57
+      "group": "Apple",
58
+      "kind": "device",
59
+      "deviceClass": "other",
60
+      "icon": {
61
+        "type": "systemSymbol",
62
+        "name": "airpods",
63
+        "fallbackSystemName": "earbuds.case"
64
+      },
65
+      "chargingStateAvailability": "onOnly",
66
+      "supportsWiredCharging": true,
67
+      "supportsWirelessCharging": true,
68
+      "wirelessChargingProfile": "genericQi",
69
+      "sortOrder": 40
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
+    {
106
+      "id": "generic-phone",
107
+      "name": "Phone",
108
+      "group": "Generic",
109
+      "kind": "device",
110
+      "deviceClass": "iphone",
111
+      "icon": {
112
+        "type": "systemSymbol",
113
+        "name": "smartphone",
114
+        "fallbackSystemName": "rectangle.portrait"
115
+      },
116
+      "chargingStateAvailability": "onOrOff",
117
+      "supportsWiredCharging": true,
118
+      "supportsWirelessCharging": true,
119
+      "wirelessChargingProfile": "genericQi",
120
+      "sortOrder": 110
121
+    },
122
+    {
123
+      "id": "generic-tablet",
124
+      "name": "Tablet",
125
+      "group": "Generic",
126
+      "kind": "device",
127
+      "deviceClass": "other",
128
+      "icon": {
129
+        "type": "systemSymbol",
130
+        "name": "rectangle",
131
+        "fallbackSystemName": "rectangle"
132
+      },
133
+      "chargingStateAvailability": "onOrOff",
134
+      "supportsWiredCharging": true,
135
+      "supportsWirelessCharging": false,
136
+      "wirelessChargingProfile": "genericQi",
137
+      "sortOrder": 120
138
+    },
139
+    {
140
+      "id": "generic-watch",
141
+      "name": "Watch",
142
+      "group": "Generic",
143
+      "kind": "device",
144
+      "deviceClass": "watch",
145
+      "icon": {
146
+        "type": "systemSymbol",
147
+        "name": "watch.analog",
148
+        "fallbackSystemName": "clock"
149
+      },
150
+      "chargingStateAvailability": "onOnly",
151
+      "supportsWiredCharging": false,
152
+      "supportsWirelessCharging": true,
153
+      "wirelessChargingProfile": "genericQi",
154
+      "sortOrder": 130
155
+    },
156
+    {
157
+      "id": "generic-laptop",
158
+      "name": "Laptop",
159
+      "group": "Generic",
160
+      "kind": "device",
161
+      "deviceClass": "other",
162
+      "icon": {
163
+        "type": "systemSymbol",
164
+        "name": "laptopcomputer",
165
+        "fallbackSystemName": "display"
166
+      },
167
+      "chargingStateAvailability": "onOrOff",
168
+      "supportsWiredCharging": true,
169
+      "supportsWirelessCharging": false,
170
+      "wirelessChargingProfile": "genericQi",
171
+      "sortOrder": 140
172
+    },
173
+    {
174
+      "id": "generic-powerbank",
175
+      "name": "Powerbank",
176
+      "group": "Generic",
177
+      "kind": "device",
178
+      "deviceClass": "powerbank",
179
+      "icon": {
180
+        "type": "systemSymbol",
181
+        "name": "battery.100.bolt",
182
+        "fallbackSystemName": "battery.100.bolt"
183
+      },
184
+      "chargingStateAvailability": "offOnly",
185
+      "supportsWiredCharging": true,
186
+      "supportsWirelessCharging": false,
187
+      "wirelessChargingProfile": "genericQi",
188
+      "sortOrder": 150
189
+    },
190
+    {
191
+      "id": "generic-audio-accessory",
192
+      "name": "Audio Accessory",
193
+      "group": "Generic",
194
+      "kind": "device",
195
+      "deviceClass": "other",
196
+      "icon": {
197
+        "type": "systemSymbol",
198
+        "name": "earbuds.case",
199
+        "fallbackSystemName": "headphones"
200
+      },
201
+      "chargingStateAvailability": "onOnly",
202
+      "supportsWiredCharging": true,
203
+      "supportsWirelessCharging": true,
204
+      "wirelessChargingProfile": "genericQi",
205
+      "sortOrder": 160
206
+    },
207
+    {
208
+      "id": "generic-device",
209
+      "name": "Other Device",
210
+      "group": "Generic",
211
+      "kind": "device",
212
+      "deviceClass": "other",
213
+      "icon": {
214
+        "type": "systemSymbol",
215
+        "name": "shippingbox",
216
+        "fallbackSystemName": "shippingbox"
217
+      },
218
+      "chargingStateAvailability": "onOnly",
219
+      "supportsWiredCharging": true,
220
+      "supportsWirelessCharging": false,
221
+      "wirelessChargingProfile": "genericQi",
222
+      "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
+    }
241
+  ]
242
+}
+201 -51
USB Meter/Views/ChargedDevices/BatteryCheckpointEditorSheetView.swift
@@ -16,10 +16,29 @@ struct BatteryCheckpointEditorContentView: View {
16 16
     let measuredChargeAhOverride: Double?
17 17
     let onCancel: (() -> Void)?
18 18
     let onSaved: (() -> Void)?
19
+    let showsHeader: Bool
19 20
 
20 21
     @State private var batteryPercent = ""
21 22
     @State private var showsWarningPopover = false
22 23
 
24
+    init(
25
+        sessionID: UUID,
26
+        message: String,
27
+        effectiveEnergyWhOverride: Double?,
28
+        measuredChargeAhOverride: Double?,
29
+        onCancel: (() -> Void)?,
30
+        onSaved: (() -> Void)?,
31
+        showsHeader: Bool = true
32
+    ) {
33
+        self.sessionID = sessionID
34
+        self.message = message
35
+        self.effectiveEnergyWhOverride = effectiveEnergyWhOverride
36
+        self.measuredChargeAhOverride = measuredChargeAhOverride
37
+        self.onCancel = onCancel
38
+        self.onSaved = onSaved
39
+        self.showsHeader = showsHeader
40
+    }
41
+
23 42
     private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
24 43
         guard let percent = normalizedBatteryPercent else {
25 44
             return nil
@@ -47,67 +66,99 @@ struct BatteryCheckpointEditorContentView: View {
47 66
 
48 67
     var body: some View {
49 68
         VStack(alignment: .leading, spacing: 12) {
50
-            HStack(spacing: 8) {
51
-                Text("Checkpoint")
52
-                Spacer(minLength: 0)
53
-                if let plausibilityWarning {
54
-                    Button {
55
-                        showsWarningPopover.toggle()
56
-                    } label: {
57
-                        Image(systemName: "exclamationmark.triangle.fill")
58
-                            .font(.body.weight(.semibold))
59
-                            .foregroundColor(.orange)
60
-                    }
61
-                    .buttonStyle(.plain)
62
-                    .accessibilityLabel(plausibilityWarning.title)
63
-                    .popover(isPresented: $showsWarningPopover, arrowEdge: .top) {
64
-                        VStack(alignment: .leading, spacing: 10) {
65
-                            Text(plausibilityWarning.title)
66
-                                .font(.headline)
67
-                            Text(plausibilityWarning.message)
68
-                                .font(.body)
69
-                                .fixedSize(horizontal: false, vertical: true)
70
-                        }
71
-                        .padding(16)
72
-                        .frame(width: 320, alignment: .leading)
73
-                    }
69
+            if showsHeader {
70
+                HStack(spacing: 8) {
71
+                    Text("Checkpoint")
72
+                    Spacer(minLength: 0)
73
+                    ContextInfoButton(
74
+                        title: "Checkpoint",
75
+                        message: message
76
+                    )
74 77
                 }
75
-                ContextInfoButton(
76
-                    title: "Checkpoint",
77
-                    message: message
78
-                )
79 78
             }
80 79
 
81
-            VStack(alignment: .leading, spacing: 10) {
82
-                TextField("Battery %", text: $batteryPercent)
83
-                    .keyboardType(.decimalPad)
84
-                    .textFieldStyle(.roundedBorder)
85
-            }
80
+            compactEditorRow
81
+        }
82
+    }
86 83
 
87
-            HStack(spacing: 10) {
88
-                if let onCancel {
89
-                    Button("Cancel") {
90
-                        onCancel()
91
-                    }
92
-                    .frame(maxWidth: .infinity)
93
-                    .padding(.vertical, 10)
94
-                    .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
95
-                    .buttonStyle(.plain)
96
-                }
84
+    private var compactEditorRow: some View {
85
+        HStack(spacing: 8) {
86
+            TextField("Battery %", text: $batteryPercent)
87
+                .keyboardType(.decimalPad)
88
+                .textFieldStyle(.roundedBorder)
89
+                .frame(width: 104)
90
+                .onSubmit(saveCheckpoint)
97 91
 
98
-                Button("Save Checkpoint") {
99
-                    saveCheckpoint()
92
+            if let plausibilityWarning {
93
+                Button {
94
+                    showsWarningPopover.toggle()
95
+                } label: {
96
+                    Image(systemName: "exclamationmark.triangle.fill")
97
+                        .font(.body.weight(.semibold))
98
+                        .foregroundColor(.orange)
100 99
                 }
101
-                .frame(maxWidth: .infinity)
102
-                .padding(.vertical, 10)
103
-                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
104 100
                 .buttonStyle(.plain)
105
-                .disabled(!canSave)
106
-                .opacity(canSave ? 1 : 0.6)
101
+                .accessibilityLabel(plausibilityWarning.title)
102
+                .popover(isPresented: $showsWarningPopover, arrowEdge: .top) {
103
+                    VStack(alignment: .leading, spacing: 10) {
104
+                        Text(plausibilityWarning.title)
105
+                            .font(.headline)
106
+                        Text(plausibilityWarning.message)
107
+                            .font(.body)
108
+                            .fixedSize(horizontal: false, vertical: true)
109
+                    }
110
+                    .padding(16)
111
+                    .frame(width: 320, alignment: .leading)
112
+                }
107 113
             }
114
+
115
+            if let onCancel {
116
+                inlineActionButton(
117
+                    systemName: "xmark",
118
+                    tint: .secondary,
119
+                    fillOpacity: 0.12,
120
+                    strokeOpacity: 0.18,
121
+                    isEnabled: true,
122
+                    action: onCancel
123
+                )
124
+            }
125
+
126
+            inlineActionButton(
127
+                systemName: "checkmark",
128
+                tint: .green,
129
+                fillOpacity: 0.16,
130
+                strokeOpacity: 0.22,
131
+                isEnabled: canSave,
132
+                action: saveCheckpoint
133
+            )
108 134
         }
109 135
     }
110 136
 
137
+    private func inlineActionButton(
138
+        systemName: String,
139
+        tint: Color,
140
+        fillOpacity: Double,
141
+        strokeOpacity: Double,
142
+        isEnabled: Bool,
143
+        action: @escaping () -> Void
144
+    ) -> some View {
145
+        Button(action: action) {
146
+            Image(systemName: systemName)
147
+                .font(.caption.weight(.semibold))
148
+                .frame(width: 30, height: 30)
149
+                .contentShape(Rectangle())
150
+        }
151
+        .meterCard(
152
+            tint: tint,
153
+            fillOpacity: fillOpacity,
154
+            strokeOpacity: strokeOpacity,
155
+            cornerRadius: 10
156
+        )
157
+        .buttonStyle(.plain)
158
+        .disabled(!isEnabled)
159
+        .opacity(isEnabled ? 1 : 0.6)
160
+    }
161
+
111 162
     private func saveCheckpoint() {
112 163
         guard let percent = normalizedBatteryPercent else {
113 164
             return
@@ -124,6 +175,104 @@ struct BatteryCheckpointEditorContentView: View {
124 175
     }
125 176
 }
126 177
 
178
+struct BatteryCheckpointSectionView: View {
179
+    let sessionID: UUID
180
+    let checkpoints: [ChargeCheckpointSummary]
181
+    let message: String
182
+    let canAddCheckpoint: Bool
183
+    let requirementMessage: String?
184
+    let effectiveEnergyWhOverride: Double?
185
+    let measuredChargeAhOverride: Double?
186
+    let onDelete: (ChargeCheckpointSummary) -> Void
187
+
188
+    @State private var showsInlineCheckpointEditor = false
189
+
190
+    private var displayedCheckpoints: [ChargeCheckpointSummary] {
191
+        Array(checkpoints.suffix(6).reversed())
192
+    }
193
+
194
+    var body: some View {
195
+        VStack(alignment: .leading, spacing: 8) {
196
+            HStack(alignment: .center, spacing: 8) {
197
+                Text("Battery Checkpoints")
198
+                    .font(.subheadline.weight(.semibold))
199
+
200
+                ContextInfoButton(
201
+                    title: "Battery Checkpoints",
202
+                    message: message
203
+                )
204
+
205
+                Spacer(minLength: 12)
206
+
207
+                if canAddCheckpoint {
208
+                    if showsInlineCheckpointEditor {
209
+                        BatteryCheckpointEditorContentView(
210
+                            sessionID: sessionID,
211
+                            message: message,
212
+                            effectiveEnergyWhOverride: effectiveEnergyWhOverride,
213
+                            measuredChargeAhOverride: measuredChargeAhOverride,
214
+                            onCancel: { showsInlineCheckpointEditor = false },
215
+                            onSaved: { showsInlineCheckpointEditor = false },
216
+                            showsHeader: false
217
+                        )
218
+                    } else {
219
+                        Button {
220
+                            showsInlineCheckpointEditor = true
221
+                        } label: {
222
+                            Image(systemName: "plus")
223
+                                .font(.caption.weight(.semibold))
224
+                                .frame(width: 30, height: 30)
225
+                                .contentShape(Rectangle())
226
+                        }
227
+                        .meterCard(
228
+                            tint: .green,
229
+                            fillOpacity: 0.12,
230
+                            strokeOpacity: 0.18,
231
+                            cornerRadius: 10
232
+                        )
233
+                        .buttonStyle(.plain)
234
+                        .help("Add checkpoint")
235
+                    }
236
+                }
237
+            }
238
+
239
+            ForEach(displayedCheckpoints, id: \.id) { checkpoint in
240
+                HStack {
241
+                    Text(checkpoint.timestamp.format())
242
+                        .font(.caption2)
243
+                        .foregroundColor(.secondary)
244
+                    Text(checkpoint.flag.title)
245
+                        .font(.caption2.weight(.semibold))
246
+                        .foregroundColor(.secondary)
247
+                    Spacer()
248
+                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
249
+                        .font(.caption.weight(.semibold))
250
+                    Text("•")
251
+                        .foregroundColor(.secondary)
252
+                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
253
+                        .font(.caption2)
254
+                        .foregroundColor(.secondary)
255
+                    Button {
256
+                        onDelete(checkpoint)
257
+                    } label: {
258
+                        Image(systemName: "trash")
259
+                            .font(.caption.weight(.semibold))
260
+                            .foregroundColor(.red)
261
+                    }
262
+                    .buttonStyle(.plain)
263
+                    .help("Delete checkpoint")
264
+                }
265
+            }
266
+
267
+            if !canAddCheckpoint, let requirementMessage {
268
+                Text(requirementMessage)
269
+                    .font(.caption2)
270
+                    .foregroundColor(.secondary)
271
+            }
272
+        }
273
+    }
274
+}
275
+
127 276
 struct BatteryCheckpointEditorSheetView: View {
128 277
     @EnvironmentObject private var appData: AppData
129 278
     @EnvironmentObject private var meter: Meter
@@ -144,7 +293,8 @@ struct BatteryCheckpointEditorSheetView: View {
144 293
                             effectiveEnergyWhOverride: nil,
145 294
                             measuredChargeAhOverride: nil,
146 295
                             onCancel: { dismiss() },
147
-                            onSaved: { dismiss() }
296
+                            onSaved: { dismiss() },
297
+                            showsHeader: true
148 298
                         )
149 299
                     }
150 300
                 } else {
+78 -101
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -16,7 +16,6 @@ struct ChargedDeviceDetailView: View {
16 16
     @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
17 17
     @State private var pendingSessionStopRequest: DeviceSessionStopRequest?
18 18
     @State private var deleteConfirmationVisibility = false
19
-    @State private var showsInlineCheckpointEditor = false
20 19
 
21 20
     let chargedDeviceID: UUID
22 21
 
@@ -142,9 +141,6 @@ struct ChargedDeviceDetailView: View {
142 141
         } message: {
143 142
             Text(deletionMessage)
144 143
         }
145
-        .onChange(of: appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession?.id) { _ in
146
-            showsInlineCheckpointEditor = false
147
-        }
148 144
     }
149 145
 
150 146
     private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
@@ -152,8 +148,11 @@ struct ChargedDeviceDetailView: View {
152 148
             ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
153 149
 
154 150
             VStack(alignment: .leading, spacing: 10) {
155
-                Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
156
-                    .font(.title3.weight(.bold))
151
+                ChargedDeviceIdentityLabelView(
152
+                    chargedDevice: chargedDevice,
153
+                    iconPointSize: 22
154
+                )
155
+                .font(.title3.weight(.bold))
157 156
 
158 157
                 Text(chargedDevice.identityTitle)
159 158
                     .font(.subheadline.weight(.semibold))
@@ -198,15 +197,19 @@ struct ChargedDeviceDetailView: View {
198 197
 
199 198
     @ViewBuilder
200 199
     private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
201
-        MeterInfoRowView(
202
-            label: "Charge Modes",
203
-            value: chargedDevice.chargingStateAvailability.title
204
-        )
205
-        MeterInfoRowView(
206
-            label: "Charging Support",
207
-            value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
208
-        )
209
-        if chargedDevice.supportsWirelessCharging {
200
+        if chargedDevice.hasMultipleChargingStateModes {
201
+            MeterInfoRowView(
202
+                label: "Charge Modes",
203
+                value: chargedDevice.chargingStateAvailability.title
204
+            )
205
+        }
206
+        if chargedDevice.hasMultipleChargingTransports {
207
+            MeterInfoRowView(
208
+                label: "Charging Support",
209
+                value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
210
+            )
211
+        }
212
+        if chargedDevice.showsWirelessProfileDetails {
210 213
             MeterInfoRowView(
211 214
                 label: "Wireless Profile",
212 215
                 value: chargedDevice.wirelessChargingProfile.title
@@ -215,7 +218,7 @@ struct ChargedDeviceDetailView: View {
215 218
 
216 219
         ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
217 220
             MeterInfoRowView(
218
-                label: "\(sessionKind.shortTitle) Stop Current",
221
+                label: completionCurrentLabel(for: chargedDevice, sessionKind: sessionKind),
219 222
                 value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind)
220 223
             )
221 224
         }
@@ -224,27 +227,28 @@ struct ChargedDeviceDetailView: View {
224 227
             value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
225 228
         )
226 229
         if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
227
-            MeterInfoRowView(
228
-                label: "Wired Capacity",
229
-                value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
230
-            )
230
+            if chargedDevice.hasMultipleChargingTransports {
231
+                MeterInfoRowView(
232
+                    label: "Wired Capacity",
233
+                    value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
234
+                )
235
+            }
231 236
         }
232 237
         if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
233
-            MeterInfoRowView(
234
-                label: "Wireless Capacity",
235
-                value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
236
-            )
238
+            if chargedDevice.hasMultipleChargingTransports {
239
+                MeterInfoRowView(
240
+                    label: "Wireless Capacity",
241
+                    value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
242
+                )
243
+            }
237 244
         }
238
-        if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor {
245
+        if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor,
246
+           chargedDevice.showsWirelessProfileDetails {
239 247
             MeterInfoRowView(
240 248
                 label: "Wireless Efficiency",
241 249
                 value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%"
242 250
             )
243 251
         }
244
-        MeterInfoRowView(
245
-            label: "End-of-Charge Current",
246
-            value: chargedDevice.minimumCurrentAmps.map { "\($0.format(decimalDigits: 2)) A" } ?? "Learning"
247
-        )
248 252
         MeterInfoRowView(
249 253
             label: "Charge Sessions",
250 254
             value: "\(chargedDevice.sessionCount)"
@@ -436,36 +440,18 @@ struct ChargedDeviceDetailView: View {
436 440
                 .foregroundColor(.secondary)
437 441
             }
438 442
 
439
-            if !activeSession.checkpoints.isEmpty {
440
-                checkpointList(
441
-                    checkpoints: Array(activeSession.checkpoints.suffix(6).reversed())
442
-                )
443
-            }
444
-
445
-            if appData.canAddBatteryCheckpoint(to: activeSession.id) {
446
-                Button(showsInlineCheckpointEditor ? "Hide Checkpoint Editor" : "Add Battery Checkpoint") {
447
-                    showsInlineCheckpointEditor.toggle()
443
+            BatteryCheckpointSectionView(
444
+                sessionID: activeSession.id,
445
+                checkpoints: activeSession.checkpoints,
446
+                message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.",
447
+                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: activeSession.id),
448
+                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: activeSession.id),
449
+                effectiveEnergyWhOverride: nil,
450
+                measuredChargeAhOverride: nil,
451
+                onDelete: { checkpoint in
452
+                    pendingCheckpointDeletion = checkpoint
448 453
                 }
449
-                .frame(maxWidth: .infinity)
450
-                .padding(.vertical, 10)
451
-                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
452
-                .buttonStyle(.plain)
453
-
454
-                if showsInlineCheckpointEditor {
455
-                    BatteryCheckpointEditorContentView(
456
-                        sessionID: activeSession.id,
457
-                        message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.",
458
-                        effectiveEnergyWhOverride: nil,
459
-                        measuredChargeAhOverride: nil,
460
-                        onCancel: { showsInlineCheckpointEditor = false },
461
-                        onSaved: { showsInlineCheckpointEditor = false }
462
-                    )
463
-                }
464
-            } else if let checkpointEditingMessage = appData.batteryCheckpointCaptureRequirementMessage(for: activeSession.id) {
465
-                Text(checkpointEditingMessage)
466
-                    .font(.caption2)
467
-                    .foregroundColor(.secondary)
468
-            }
454
+            )
469 455
 
470 456
             Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
471 457
                 targetNotificationEditorVisibility = true
@@ -550,11 +536,13 @@ struct ChargedDeviceDetailView: View {
550 536
                         .font(.caption)
551 537
                         .foregroundColor(.secondary)
552 538
                     Spacer()
553
-                    Text(point.chargingTransportMode.title)
554
-                        .font(.caption2)
555
-                        .foregroundColor(.secondary)
556
-                    Text("•")
557
-                        .foregroundColor(.secondary)
539
+                    if chargedDevice.shouldShowChargingTransport(point.chargingTransportMode) {
540
+                        Text(point.chargingTransportMode.title)
541
+                            .font(.caption2)
542
+                            .foregroundColor(.secondary)
543
+                        Text("•")
544
+                            .foregroundColor(.secondary)
545
+                    }
558 546
                     Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
559 547
                         .font(.footnote.weight(.semibold))
560 548
                 }
@@ -565,41 +553,6 @@ struct ChargedDeviceDetailView: View {
565 553
         .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
566 554
     }
567 555
 
568
-    private func checkpointList(checkpoints: [ChargeCheckpointSummary]) -> some View {
569
-        VStack(alignment: .leading, spacing: 8) {
570
-            Text("Battery Checkpoints")
571
-                .font(.subheadline.weight(.semibold))
572
-
573
-            ForEach(checkpoints, id: \.id) { checkpoint in
574
-                HStack {
575
-                    Text(checkpoint.timestamp.format())
576
-                        .font(.caption2)
577
-                        .foregroundColor(.secondary)
578
-                    Text(checkpoint.flag.title)
579
-                        .font(.caption2.weight(.semibold))
580
-                        .foregroundColor(.secondary)
581
-                    Spacer()
582
-                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
583
-                        .font(.caption.weight(.semibold))
584
-                    Text("•")
585
-                        .foregroundColor(.secondary)
586
-                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
587
-                        .font(.caption2)
588
-                        .foregroundColor(.secondary)
589
-                    Button {
590
-                        pendingCheckpointDeletion = checkpoint
591
-                    } label: {
592
-                        Image(systemName: "trash")
593
-                            .font(.caption.weight(.semibold))
594
-                            .foregroundColor(.red)
595
-                    }
596
-                    .buttonStyle(.plain)
597
-                    .help("Delete checkpoint")
598
-                }
599
-            }
600
-        }
601
-    }
602
-
603 556
     private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
604 557
         if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
605 558
             return activeSession
@@ -780,7 +733,7 @@ struct ChargedDeviceDetailView: View {
780 733
                        let chargerID = session.chargerID,
781 734
                        let charger = appData.chargedDeviceSummary(id: chargerID) {
782 735
                         MeterInfoRowView(
783
-                            label: "Wireless Charger",
736
+                            label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger",
784 737
                             value: charger.name
785 738
                         )
786 739
                     }
@@ -806,6 +759,7 @@ struct ChargedDeviceDetailView: View {
806 759
 
807 760
     private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
808 761
         var components: [String] = []
762
+        let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID)
809 763
 
810 764
         if let batteryDeltaPercent = session.batteryDeltaPercent {
811 765
             components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
@@ -815,8 +769,12 @@ struct ChargedDeviceDetailView: View {
815 769
             components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
816 770
         }
817 771
 
818
-        components.append(session.chargingTransportMode.title)
819
-        components.append(session.chargingStateMode.title)
772
+        if chargedDevice?.shouldShowChargingTransport(session.chargingTransportMode) != false {
773
+            components.append(session.chargingTransportMode.title)
774
+        }
775
+        if chargedDevice?.shouldShowChargingStateMode(session.chargingStateMode) != false {
776
+            components.append(session.chargingStateMode.title)
777
+        }
820 778
         components.append(session.sourceMode.title)
821 779
         return components.joined(separator: " • ")
822 780
     }
@@ -907,6 +865,25 @@ struct ChargedDeviceDetailView: View {
907 865
         return "Learning"
908 866
     }
909 867
 
868
+    private func completionCurrentLabel(
869
+        for chargedDevice: ChargedDeviceSummary,
870
+        sessionKind: ChargeSessionKind
871
+    ) -> String {
872
+        let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode)
873
+        let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode)
874
+
875
+        switch (showsTransport, showsState) {
876
+        case (true, true):
877
+            return "\(sessionKind.shortTitle) Stop Current"
878
+        case (true, false):
879
+            return "\(sessionKind.chargingTransportMode.title) Stop Current"
880
+        case (false, true):
881
+            return "\(sessionKind.chargingStateMode.title) Stop Current"
882
+        case (false, false):
883
+            return "Stop Current"
884
+        }
885
+    }
886
+
910 887
     private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
911 888
         chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
912 889
             chargedDevice.supportedChargingStateModes.map { chargingStateMode in
+228 -35
USB Meter/Views/ChargedDevices/ChargedDeviceEditorSheetView.swift
@@ -17,6 +17,8 @@ struct ChargedDeviceEditorSheetView: View {
17 17
 
18 18
     @State private var name: String
19 19
     @State private var deviceClass: ChargedDeviceClass
20
+    @State private var selectedTemplateID: String?
21
+    @State private var lastAppliedTemplateID: String?
20 22
     @State private var chargingStateAvailability: ChargingStateAvailability
21 23
     @State private var supportsWiredCharging: Bool
22 24
     @State private var supportsWirelessCharging: Bool
@@ -36,13 +38,21 @@ struct ChargedDeviceEditorSheetView: View {
36 38
         self.kind = resolvedKind
37 39
 
38 40
         let initialDeviceClass = chargedDevice?.deviceClass ?? (resolvedKind == .charger ? .charger : .iphone)
41
+        let initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability(
42
+            chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass)
43
+        )
44
+        let initialChargingSupport = initialDeviceClass.normalizedChargingSupport(
45
+            supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? true,
46
+            supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? true
47
+        )
48
+        let initialTemplateID = chargedDevice?.deviceTemplateID
39 49
         _name = State(initialValue: chargedDevice?.name ?? "")
40 50
         _deviceClass = State(initialValue: initialDeviceClass)
41
-        _chargingStateAvailability = State(
42
-            initialValue: chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass)
43
-        )
44
-        _supportsWiredCharging = State(initialValue: chargedDevice?.supportsWiredCharging ?? true)
45
-        _supportsWirelessCharging = State(initialValue: chargedDevice?.supportsWirelessCharging ?? true)
51
+        _selectedTemplateID = State(initialValue: initialTemplateID)
52
+        _lastAppliedTemplateID = State(initialValue: initialTemplateID)
53
+        _chargingStateAvailability = State(initialValue: initialChargingStateAvailability)
54
+        _supportsWiredCharging = State(initialValue: initialChargingSupport.wired)
55
+        _supportsWirelessCharging = State(initialValue: initialChargingSupport.wireless)
46 56
         _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi)
47 57
         _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice))
48 58
         _notes = State(initialValue: chargedDevice?.notes ?? "")
@@ -52,6 +62,7 @@ struct ChargedDeviceEditorSheetView: View {
52 62
         NavigationView {
53 63
             Form {
54 64
                 identitySection
65
+                templateSection
55 66
 
56 67
                 if kind == .device {
57 68
                     deviceChargeBehaviourSection
@@ -84,13 +95,20 @@ struct ChargedDeviceEditorSheetView: View {
84 95
             guard kind == .device else {
85 96
                 return
86 97
             }
87
-            applySuggestedChargingSupport(for: newValue)
98
+            applyDeviceClassRules(for: newValue)
99
+        }
100
+        .onChange(of: selectedTemplateID) { newValue in
101
+            applyTemplateSelection(
102
+                previousTemplateID: lastAppliedTemplateID,
103
+                newTemplateID: newValue
104
+            )
105
+            lastAppliedTemplateID = newValue
88 106
         }
89 107
         .onAppear {
90
-            guard kind == .device, chargedDevice == nil else {
108
+            guard kind == .device else {
91 109
                 return
92 110
             }
93
-            applySuggestedChargingSupport(for: deviceClass)
111
+            applyDeviceClassRules(for: deviceClass)
94 112
         }
95 113
     }
96 114
 
@@ -116,6 +134,45 @@ struct ChargedDeviceEditorSheetView: View {
116 134
         }
117 135
     }
118 136
 
137
+    private var templateSection: some View {
138
+        Section(
139
+            header: ContextInfoHeader(
140
+                title: "Template",
141
+                message: "Templates load from a JSON catalog and provide the starting icon and charging profile for common devices and chargers."
142
+            )
143
+        ) {
144
+            Picker("Template", selection: $selectedTemplateID) {
145
+                Text("Custom")
146
+                    .tag(String?.none)
147
+
148
+                ForEach(groupedTemplates, id: \.group) { group in
149
+                    Section(group.group) {
150
+                        ForEach(group.templates) { template in
151
+                            Text(template.name)
152
+                                .tag(template.id as String?)
153
+                        }
154
+                    }
155
+                }
156
+            }
157
+
158
+            if let selectedTemplate {
159
+                ChargedDeviceTemplateLabelView(
160
+                    template: selectedTemplate,
161
+                    iconPointSize: 18
162
+                )
163
+                .font(.subheadline.weight(.semibold))
164
+
165
+                Text(selectedTemplate.capabilitySummary)
166
+                    .font(.caption)
167
+                    .foregroundColor(.secondary)
168
+            } else {
169
+                Text("Choose a template when you want a predefined icon and a starting charging setup.")
170
+                    .font(.caption)
171
+                    .foregroundColor(.secondary)
172
+            }
173
+        }
174
+    }
175
+
119 176
     private var deviceChargeBehaviourSection: some View {
120 177
         Section(
121 178
             header: ContextInfoHeader(
@@ -123,10 +180,20 @@ struct ChargedDeviceEditorSheetView: View {
123 180
                 message: "Choose whether sessions for this device are recorded only while it is on, only while it is off, or explicitly in either state."
124 181
             )
125 182
         ) {
126
-            Picker("Session Modes", selection: $chargingStateAvailability) {
127
-                ForEach(ChargingStateAvailability.allCases) { availability in
128
-                    Text(availability.title)
129
-                        .tag(availability)
183
+            if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
184
+                VStack(alignment: .leading, spacing: 6) {
185
+                    Label(enforcedChargingStateAvailability.title, systemImage: "lock.fill")
186
+                        .font(.subheadline.weight(.semibold))
187
+                    Text(enforcedChargingStateAvailability.description)
188
+                        .font(.caption)
189
+                        .foregroundColor(.secondary)
190
+                }
191
+            } else {
192
+                Picker("Session Modes", selection: $chargingStateAvailability) {
193
+                    ForEach(ChargingStateAvailability.allCases) { availability in
194
+                        Text(availability.title)
195
+                            .tag(availability)
196
+                    }
130 197
                 }
131 198
             }
132 199
         }
@@ -139,10 +206,27 @@ struct ChargedDeviceEditorSheetView: View {
139 206
                 message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate."
140 207
             )
141 208
         ) {
142
-            Toggle("Supports wired charging", isOn: $supportsWiredCharging)
143
-            Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
209
+            if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
210
+                VStack(alignment: .leading, spacing: 6) {
211
+                    Label(
212
+                        Self.chargingSupportDescription(
213
+                            supportsWiredCharging: enforcedChargingSupport.wired,
214
+                            supportsWirelessCharging: enforcedChargingSupport.wireless
215
+                        ),
216
+                        systemImage: "lock.fill"
217
+                    )
218
+                    .font(.subheadline.weight(.semibold))
219
+
220
+                    Text("This device class is fixed so sessions cannot be recorded with an impossible charging transport.")
221
+                        .font(.caption)
222
+                        .foregroundColor(.secondary)
223
+                }
224
+            } else {
225
+                Toggle("Supports wired charging", isOn: $supportsWiredCharging)
226
+                Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
227
+            }
144 228
 
145
-            if supportsWirelessCharging {
229
+            if showsWirelessProfilePicker {
146 230
                 Picker("Wireless profile", selection: $wirelessChargingProfile) {
147 231
                     ForEach(WirelessChargingProfile.allCases) { profile in
148 232
                         Text(profile.title)
@@ -175,7 +259,7 @@ struct ChargedDeviceEditorSheetView: View {
175 259
                 ForEach(applicableSessionKinds) { sessionKind in
176 260
                     VStack(alignment: .leading, spacing: 6) {
177 261
                         TextField(
178
-                            "\(sessionKind.shortTitle) completion current (A)",
262
+                            completionCurrentFieldLabel(for: sessionKind),
179 263
                             text: completionCurrentTextBinding(for: sessionKind)
180 264
                         )
181 265
                         .keyboardType(.decimalPad)
@@ -224,6 +308,26 @@ struct ChargedDeviceEditorSheetView: View {
224 308
             && !hasInvalidCompletionCurrentEntry
225 309
     }
226 310
 
311
+    private var availableTemplates: [ChargedDeviceTemplateDefinition] {
312
+        ChargedDeviceTemplateCatalog.shared.templates(for: kind)
313
+    }
314
+
315
+    private var groupedTemplates: [(group: String, templates: [ChargedDeviceTemplateDefinition])] {
316
+        Dictionary(grouping: availableTemplates, by: \.group)
317
+            .keys
318
+            .sorted()
319
+            .map { group in
320
+                (
321
+                    group: group,
322
+                    templates: availableTemplates.filter { $0.group == group }
323
+                )
324
+            }
325
+    }
326
+
327
+    private var selectedTemplate: ChargedDeviceTemplateDefinition? {
328
+        ChargedDeviceTemplateCatalog.shared.template(id: selectedTemplateID)
329
+    }
330
+
227 331
     private var supportedChargingModes: [ChargingTransportMode] {
228 332
         var modes: [ChargingTransportMode] = []
229 333
         if supportsWiredCharging {
@@ -246,6 +350,12 @@ struct ChargedDeviceEditorSheetView: View {
246 350
         }
247 351
     }
248 352
 
353
+    private var showsWirelessProfilePicker: Bool {
354
+        supportsWirelessCharging
355
+            && deviceClass != .watch
356
+            && supportedChargingModes.count > 1
357
+    }
358
+
249 359
     private var parsedCompletionCurrents: [ChargeSessionKind: Double] {
250 360
         applicableSessionKinds.reduce(into: [ChargeSessionKind: Double]()) { result, sessionKind in
251 361
             guard let value = parsedOptionalCurrent(completionCurrentTexts[sessionKind] ?? "") else {
@@ -278,11 +388,13 @@ struct ChargedDeviceEditorSheetView: View {
278 388
                 didSave = appData.updateCharger(
279 389
                     id: chargedDevice.id,
280 390
                     name: name,
391
+                    templateID: selectedTemplateID,
281 392
                     notes: notes
282 393
                 )
283 394
             } else {
284 395
                 didSave = appData.createCharger(
285 396
                     name: name,
397
+                    templateID: selectedTemplateID,
286 398
                     notes: notes,
287 399
                     meterMACAddress: meterMACAddress
288 400
                 )
@@ -294,6 +406,7 @@ struct ChargedDeviceEditorSheetView: View {
294 406
                     id: chargedDevice.id,
295 407
                     name: name,
296 408
                     deviceClass: deviceClass,
409
+                    templateID: selectedTemplateID,
297 410
                     chargingStateAvailability: chargingStateAvailability,
298 411
                     supportsWiredCharging: supportsWiredCharging,
299 412
                     supportsWirelessCharging: supportsWirelessCharging,
@@ -305,6 +418,7 @@ struct ChargedDeviceEditorSheetView: View {
305 418
                 didSave = appData.createDevice(
306 419
                     name: name,
307 420
                     deviceClass: deviceClass,
421
+                    templateID: selectedTemplateID,
308 422
                     chargingStateAvailability: chargingStateAvailability,
309 423
                     supportsWiredCharging: supportsWiredCharging,
310 424
                     supportsWirelessCharging: supportsWirelessCharging,
@@ -321,29 +435,76 @@ struct ChargedDeviceEditorSheetView: View {
321 435
         }
322 436
     }
323 437
 
324
-    private func applySuggestedChargingSupport(for deviceClass: ChargedDeviceClass) {
325
-        if chargedDevice != nil {
438
+    private func applyTemplateSelection(
439
+        previousTemplateID: String?,
440
+        newTemplateID: String?
441
+    ) {
442
+        guard let newTemplate = ChargedDeviceTemplateCatalog.shared.template(id: newTemplateID) else {
326 443
             return
327 444
         }
328 445
 
329
-        chargingStateAvailability = Self.suggestedChargingStateAvailability(for: deviceClass)
446
+        let previousTemplate = ChargedDeviceTemplateCatalog.shared.template(id: previousTemplateID)
447
+        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
448
+        if trimmedName.isEmpty || trimmedName == previousTemplate?.name {
449
+            name = newTemplate.name
450
+        }
330 451
 
331
-        switch deviceClass {
332
-        case .iphone:
333
-            supportsWiredCharging = true
334
-            supportsWirelessCharging = true
335
-        case .watch:
336
-            supportsWiredCharging = false
337
-            supportsWirelessCharging = true
338
-        case .powerbank:
339
-            supportsWiredCharging = true
340
-            supportsWirelessCharging = false
341
-        case .charger:
342
-            supportsWiredCharging = false
343
-            supportsWirelessCharging = true
344
-        case .other:
345
-            supportsWiredCharging = true
346
-            supportsWirelessCharging = false
452
+        deviceClass = newTemplate.deviceClass
453
+        chargingStateAvailability = newTemplate.deviceClass.normalizedChargingStateAvailability(
454
+            newTemplate.chargingStateAvailability
455
+        )
456
+
457
+        let normalizedChargingSupport = newTemplate.deviceClass.normalizedChargingSupport(
458
+            supportsWiredCharging: newTemplate.supportsWiredCharging,
459
+            supportsWirelessCharging: newTemplate.supportsWirelessCharging
460
+        )
461
+        supportsWiredCharging = normalizedChargingSupport.wired
462
+        supportsWirelessCharging = normalizedChargingSupport.wireless
463
+        wirelessChargingProfile = newTemplate.wirelessChargingProfile
464
+    }
465
+
466
+    private func applyDeviceClassRules(for deviceClass: ChargedDeviceClass) {
467
+        if let selectedTemplate {
468
+            chargingStateAvailability = deviceClass.normalizedChargingStateAvailability(
469
+                selectedTemplate.chargingStateAvailability
470
+            )
471
+            let normalizedChargingSupport = deviceClass.normalizedChargingSupport(
472
+                supportsWiredCharging: selectedTemplate.supportsWiredCharging,
473
+                supportsWirelessCharging: selectedTemplate.supportsWirelessCharging
474
+            )
475
+            supportsWiredCharging = normalizedChargingSupport.wired
476
+            supportsWirelessCharging = normalizedChargingSupport.wireless
477
+            wirelessChargingProfile = selectedTemplate.wirelessChargingProfile
478
+            return
479
+        }
480
+
481
+        if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
482
+            chargingStateAvailability = enforcedChargingStateAvailability
483
+        } else if chargedDevice == nil {
484
+            chargingStateAvailability = Self.suggestedChargingStateAvailability(for: deviceClass)
485
+        }
486
+
487
+        if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
488
+            supportsWiredCharging = enforcedChargingSupport.wired
489
+            supportsWirelessCharging = enforcedChargingSupport.wireless
490
+        } else if chargedDevice == nil {
491
+            switch deviceClass {
492
+            case .iphone:
493
+                supportsWiredCharging = true
494
+                supportsWirelessCharging = true
495
+            case .watch:
496
+                supportsWiredCharging = false
497
+                supportsWirelessCharging = true
498
+            case .powerbank:
499
+                supportsWiredCharging = true
500
+                supportsWirelessCharging = false
501
+            case .charger:
502
+                supportsWiredCharging = false
503
+                supportsWirelessCharging = true
504
+            case .other:
505
+                supportsWiredCharging = true
506
+                supportsWirelessCharging = false
507
+            }
347 508
         }
348 509
     }
349 510
 
@@ -360,6 +521,22 @@ struct ChargedDeviceEditorSheetView: View {
360 521
         return value
361 522
     }
362 523
 
524
+    private func completionCurrentFieldLabel(for sessionKind: ChargeSessionKind) -> String {
525
+        let showsTransport = supportedChargingModes.count > 1
526
+        let showsState = chargingStateAvailability.supportedModes.count > 1
527
+
528
+        switch (showsTransport, showsState) {
529
+        case (true, true):
530
+            return "\(sessionKind.shortTitle) completion current (A)"
531
+        case (true, false):
532
+            return "\(sessionKind.chargingTransportMode.title) completion current (A)"
533
+        case (false, true):
534
+            return "\(sessionKind.chargingStateMode.title) completion current (A)"
535
+        case (false, false):
536
+            return "Stop current (A)"
537
+        }
538
+    }
539
+
363 540
     private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
364 541
         guard let chargedDevice else {
365 542
             return [:]
@@ -379,6 +556,22 @@ struct ChargedDeviceEditorSheetView: View {
379 556
         return value.format(decimalDigits: 2)
380 557
     }
381 558
 
559
+    private static func chargingSupportDescription(
560
+        supportsWiredCharging: Bool,
561
+        supportsWirelessCharging: Bool
562
+    ) -> String {
563
+        switch (supportsWiredCharging, supportsWirelessCharging) {
564
+        case (true, true):
565
+            return "Supports wired and wireless charging"
566
+        case (true, false):
567
+            return "Supports wired charging only"
568
+        case (false, true):
569
+            return "Supports wireless charging only"
570
+        case (false, false):
571
+            return "No charging method configured"
572
+        }
573
+    }
574
+
382 575
     private static func suggestedChargingStateAvailability(for deviceClass: ChargedDeviceClass) -> ChargingStateAvailability {
383 576
         switch deviceClass {
384 577
         case .iphone:
+81 -3
USB Meter/Views/ChargedDevices/ChargedDeviceLibrarySheetView.swift
@@ -6,6 +6,7 @@
6 6
 //
7 7
 
8 8
 import SwiftUI
9
+import UIKit
9 10
 
10 11
 enum ChargedDeviceLibraryMode {
11 12
     case device
@@ -186,9 +187,12 @@ private struct ChargedDeviceLibraryRowView: View {
186 187
 
187 188
             VStack(alignment: .leading, spacing: 6) {
188 189
                 HStack {
189
-                    Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
190
-                        .font(.headline)
191
-                        .foregroundColor(.primary)
190
+                    ChargedDeviceIdentityLabelView(
191
+                        chargedDevice: chargedDevice,
192
+                        iconPointSize: 17
193
+                    )
194
+                    .font(.headline)
195
+                    .foregroundColor(.primary)
192 196
                     Spacer()
193 197
                     if isSelected {
194 198
                         Image(systemName: "checkmark.circle.fill")
@@ -240,3 +244,77 @@ private struct ChargedDeviceLibraryRowView: View {
240 244
         .padding(.vertical, 4)
241 245
     }
242 246
 }
247
+
248
+struct ChargedDeviceIdentityLabelView: View {
249
+    let chargedDevice: ChargedDeviceSummary
250
+    var iconPointSize: CGFloat = 15
251
+
252
+    var body: some View {
253
+        HStack(alignment: .firstTextBaseline, spacing: 8) {
254
+            ChargedDeviceTemplateIconView(
255
+                icon: chargedDevice.identityIcon,
256
+                fallbackSystemName: chargedDevice.fallbackIdentitySymbolName,
257
+                pointSize: iconPointSize
258
+            )
259
+            Text(chargedDevice.name)
260
+        }
261
+    }
262
+}
263
+
264
+struct ChargedDeviceTemplateLabelView: View {
265
+    let template: ChargedDeviceTemplateDefinition
266
+    var iconPointSize: CGFloat = 15
267
+
268
+    var body: some View {
269
+        HStack(alignment: .firstTextBaseline, spacing: 8) {
270
+            ChargedDeviceTemplateIconView(
271
+                icon: template.icon,
272
+                fallbackSystemName: template.deviceClass.symbolName,
273
+                pointSize: iconPointSize
274
+            )
275
+            Text(template.name)
276
+        }
277
+    }
278
+}
279
+
280
+struct ChargedDeviceTemplateIconView: View {
281
+    let icon: ChargedDeviceTemplateIcon
282
+    let fallbackSystemName: String
283
+    var pointSize: CGFloat = 15
284
+
285
+    var body: some View {
286
+        Group {
287
+            if let assetName = resolvedAssetName {
288
+                Image(assetName)
289
+                    .renderingMode(.template)
290
+                    .resizable()
291
+                    .scaledToFit()
292
+            } else {
293
+                Image(systemName: resolvedSystemSymbolName)
294
+                    .font(.system(size: pointSize))
295
+            }
296
+        }
297
+        .frame(width: pointSize + 2, height: pointSize + 2)
298
+    }
299
+
300
+    private var resolvedAssetName: String? {
301
+        guard icon.type == .asset, UIImage(named: icon.name) != nil else {
302
+            return nil
303
+        }
304
+        return icon.name
305
+    }
306
+
307
+    private var resolvedSystemSymbolName: String {
308
+        let candidate = icon.resolvedSystemSymbolName(fallbackSystemName: fallbackSystemName)
309
+        if UIImage(systemName: candidate) != nil {
310
+            return candidate
311
+        }
312
+
313
+        if let fallbackSystemName = icon.fallbackSystemName,
314
+           UIImage(systemName: fallbackSystemName) != nil {
315
+            return fallbackSystemName
316
+        }
317
+
318
+        return fallbackSystemName
319
+    }
320
+}
+5 -2
USB Meter/Views/ChargedDevices/SidebarChargedDevicesSectionView.swift
@@ -63,8 +63,11 @@ private struct ChargedDeviceSidebarCardView: View {
63 63
 
64 64
             VStack(alignment: .leading, spacing: 6) {
65 65
                 HStack {
66
-                    Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
67
-                        .font(.headline)
66
+                    ChargedDeviceIdentityLabelView(
67
+                        chargedDevice: chargedDevice,
68
+                        iconPointSize: 17
69
+                    )
70
+                    .font(.headline)
68 71
                     if chargedDevice.activeSession != nil {
69 72
                         Spacer()
70 73
                         Text("Live")
+74 -81
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -12,6 +12,11 @@ struct MeterChargeRecordTabView: View {
12 12
 }
13 13
 
14 14
 struct MeterChargeRecordContentView: View {
15
+    private struct SessionMetricRow {
16
+        let label: String
17
+        let value: String
18
+    }
19
+
15 20
     private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
16 21
         case known
17 22
         case unknown
@@ -45,7 +50,6 @@ struct MeterChargeRecordContentView: View {
45 50
     @State private var initialCheckpointMode: InitialCheckpointMode = .known
46 51
     @State private var initialCheckpoint = ""
47 52
     @State private var showsMeterTotalsInfo = false
48
-    @State private var showsInlineCheckpointEditor = false
49 53
 
50 54
     private enum SessionStartRequirement: Identifiable {
51 55
         case existingSession
@@ -192,7 +196,6 @@ struct MeterChargeRecordContentView: View {
192 196
         }
193 197
         .onChange(of: openChargeSession?.id) { _ in
194 198
             syncDraftSelections()
195
-            showsInlineCheckpointEditor = false
196 199
         }
197 200
     }
198 201
 
@@ -418,8 +421,11 @@ struct MeterChargeRecordContentView: View {
418 421
                 )
419 422
 
420 423
                 VStack(alignment: .leading, spacing: 8) {
421
-                    Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
422
-                        .font(.headline)
424
+                    ChargedDeviceIdentityLabelView(
425
+                        chargedDevice: chargedDevice,
426
+                        iconPointSize: 17
427
+                    )
428
+                    .font(.headline)
423 429
 
424 430
                     Text(chargedDevice.identityTitle)
425 431
                         .font(.caption.weight(.semibold))
@@ -429,7 +435,7 @@ struct MeterChargeRecordContentView: View {
429 435
                         .font(.caption2)
430 436
                         .foregroundColor(.secondary)
431 437
 
432
-                    Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
438
+                    Text(chargedDevice.chargingSupportSummary)
433 439
                         .font(.caption2)
434 440
                         .foregroundColor(.secondary)
435 441
 
@@ -625,8 +631,11 @@ struct MeterChargeRecordContentView: View {
625 631
                     )
626 632
 
627 633
                     VStack(alignment: .leading, spacing: 6) {
628
-                        Label(selectedCharger.name, systemImage: selectedCharger.identitySymbolName)
629
-                            .font(.subheadline.weight(.semibold))
634
+                        ChargedDeviceIdentityLabelView(
635
+                            chargedDevice: selectedCharger,
636
+                            iconPointSize: 15
637
+                        )
638
+                        .font(.subheadline.weight(.semibold))
630 639
 
631 640
                         if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
632 641
                             Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
@@ -674,8 +683,11 @@ struct MeterChargeRecordContentView: View {
674 683
                     )
675 684
 
676 685
                     VStack(alignment: .leading, spacing: 6) {
677
-                        Label(selectedCharger.name, systemImage: selectedCharger.identitySymbolName)
678
-                            .font(.subheadline.weight(.semibold))
686
+                        ChargedDeviceIdentityLabelView(
687
+                            chargedDevice: selectedCharger,
688
+                            iconPointSize: 15
689
+                        )
690
+                        .font(.subheadline.weight(.semibold))
679 691
 
680 692
                         Text(
681 693
                             selectedCharger.latestStandbyPowerMeasurement.map {
@@ -728,6 +740,10 @@ struct MeterChargeRecordContentView: View {
728 740
         let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
729 741
         let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession)
730 742
         let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id)
743
+        let metricRows = sessionMetricRows(
744
+            for: openChargeSession,
745
+            displayedEnergyWh: displayedEnergyWh
746
+        )
731 747
         return VStack(alignment: .leading, spacing: 12) {
732 748
             HStack(spacing: 8) {
733 749
                 Text("Charging Monitor")
@@ -739,14 +755,8 @@ struct MeterChargeRecordContentView: View {
739 755
             }
740 756
 
741 757
             ChargeRecordMetricsTableView(
742
-                labels: ["Type", "Mode", "Energy", "Duration", "Auto Stop"],
743
-                values: [
744
-                    openChargeSession.chargingTransportMode.title,
745
-                    openChargeSession.chargingStateMode.title,
746
-                    "\(displayedEnergyWh.format(decimalDigits: 3)) Wh",
747
-                    formatDuration(max(openChargeSession.effectiveDuration, 0)),
748
-                    autoStopLabel(for: openChargeSession)
749
-                ]
758
+                labels: metricRows.map(\.label),
759
+                values: metricRows.map(\.value)
750 760
             )
751 761
 
752 762
             if openChargeSession.stopThresholdAmps > 0 {
@@ -795,36 +805,18 @@ struct MeterChargeRecordContentView: View {
795 805
                     .foregroundColor(.secondary)
796 806
             }
797 807
 
798
-            if !openChargeSession.checkpoints.isEmpty {
799
-                checkpointList(
800
-                    checkpoints: Array(openChargeSession.checkpoints.suffix(6).reversed())
801
-                )
802
-            }
803
-
804
-            if canAddCheckpoint {
805
-                Button(showsInlineCheckpointEditor ? "Hide Checkpoint Editor" : "Add Battery Checkpoint") {
806
-                    showsInlineCheckpointEditor.toggle()
807
-                }
808
-                .frame(maxWidth: .infinity)
809
-                .padding(.vertical, 10)
810
-                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
811
-                .buttonStyle(.plain)
812
-
813
-                if showsInlineCheckpointEditor {
814
-                    BatteryCheckpointEditorContentView(
815
-                        sessionID: openChargeSession.id,
816
-                        message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
817
-                        effectiveEnergyWhOverride: displayedEnergyWh,
818
-                        measuredChargeAhOverride: displayedChargeAh,
819
-                        onCancel: { showsInlineCheckpointEditor = false },
820
-                        onSaved: { showsInlineCheckpointEditor = false }
821
-                    )
808
+            BatteryCheckpointSectionView(
809
+                sessionID: openChargeSession.id,
810
+                checkpoints: openChargeSession.checkpoints,
811
+                message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
812
+                canAddCheckpoint: canAddCheckpoint,
813
+                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: openChargeSession.id),
814
+                effectiveEnergyWhOverride: displayedEnergyWh,
815
+                measuredChargeAhOverride: displayedChargeAh,
816
+                onDelete: { checkpoint in
817
+                    pendingCheckpointDeletion = checkpoint
822 818
                 }
823
-            } else if let checkpointEditingMessage = appData.batteryCheckpointCaptureRequirementMessage(for: openChargeSession.id) {
824
-                Text(checkpointEditingMessage)
825
-                    .font(.caption)
826
-                    .foregroundColor(.secondary)
827
-            }
819
+            )
828 820
 
829 821
             Button(openChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
830 822
                 targetNotificationEditorVisibility = true
@@ -1022,6 +1014,42 @@ struct MeterChargeRecordContentView: View {
1022 1014
         return "Learning"
1023 1015
     }
1024 1016
 
1017
+    private func sessionMetricRows(
1018
+        for session: ChargeSessionSummary,
1019
+        displayedEnergyWh: Double
1020
+    ) -> [SessionMetricRow] {
1021
+        var rows: [SessionMetricRow] = []
1022
+
1023
+        if shouldShowChargingTransport(for: session) {
1024
+            rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title))
1025
+        }
1026
+
1027
+        if shouldShowChargingState(for: session) {
1028
+            rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title))
1029
+        }
1030
+
1031
+        rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh"))
1032
+        rows.append(SessionMetricRow(label: "Duration", value: formatDuration(max(session.effectiveDuration, 0))))
1033
+        rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session)))
1034
+        return rows
1035
+    }
1036
+
1037
+    private func shouldShowChargingTransport(for session: ChargeSessionSummary) -> Bool {
1038
+        guard let selectedChargedDevice else {
1039
+            return true
1040
+        }
1041
+        return selectedChargedDevice.supportedChargingModes.count > 1
1042
+            || selectedChargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1043
+    }
1044
+
1045
+    private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool {
1046
+        guard let selectedChargedDevice else {
1047
+            return true
1048
+        }
1049
+        return selectedChargedDevice.supportedChargingStateModes.count > 1
1050
+            || selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1051
+    }
1052
+
1025 1053
     private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1026 1054
         let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1027 1055
         guard session.status.isOpen else {
@@ -1056,41 +1084,6 @@ struct MeterChargeRecordContentView: View {
1056 1084
         return storedChargeAh
1057 1085
     }
1058 1086
 
1059
-    private func checkpointList(checkpoints: [ChargeCheckpointSummary]) -> some View {
1060
-        VStack(alignment: .leading, spacing: 8) {
1061
-            Text("Battery Checkpoints")
1062
-                .font(.subheadline.weight(.semibold))
1063
-
1064
-            ForEach(checkpoints, id: \.id) { checkpoint in
1065
-                HStack {
1066
-                    Text(checkpoint.timestamp.format())
1067
-                        .font(.caption2)
1068
-                        .foregroundColor(.secondary)
1069
-                    Text(checkpoint.flag.title)
1070
-                        .font(.caption2.weight(.semibold))
1071
-                        .foregroundColor(.secondary)
1072
-                    Spacer()
1073
-                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
1074
-                        .font(.caption.weight(.semibold))
1075
-                    Text("•")
1076
-                        .foregroundColor(.secondary)
1077
-                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
1078
-                        .font(.caption2)
1079
-                        .foregroundColor(.secondary)
1080
-                    Button {
1081
-                        pendingCheckpointDeletion = checkpoint
1082
-                    } label: {
1083
-                        Image(systemName: "trash")
1084
-                            .font(.caption.weight(.semibold))
1085
-                            .foregroundColor(.red)
1086
-                    }
1087
-                    .buttonStyle(.plain)
1088
-                    .help("Delete checkpoint")
1089
-                }
1090
-            }
1091
-        }
1092
-    }
1093
-
1094 1087
     private func formatDuration(_ duration: TimeInterval) -> String {
1095 1088
         let totalSeconds = Int(duration.rounded(.down))
1096 1089
         let hours = totalSeconds / 3600
+10 -4
USB Meter/Views/MeterDetailView.swift
@@ -136,8 +136,11 @@ struct MeterDetailView: View {
136 136
                             ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 52)
137 137
 
138 138
                             VStack(alignment: .leading, spacing: 4) {
139
-                                Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
140
-                                    .font(.subheadline.weight(.semibold))
139
+                                ChargedDeviceIdentityLabelView(
140
+                                    chargedDevice: chargedDevice,
141
+                                    iconPointSize: 15
142
+                                )
143
+                                .font(.subheadline.weight(.semibold))
141 144
                                 Text(chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Capacity: learning")
142 145
                                     .font(.caption)
143 146
                                     .foregroundColor(.secondary)
@@ -179,8 +182,11 @@ struct MeterDetailView: View {
179 182
                             ChargedDeviceQRCodeView(qrIdentifier: charger.qrIdentifier, side: 52)
180 183
 
181 184
                             VStack(alignment: .leading, spacing: 4) {
182
-                                Label(charger.name, systemImage: charger.identitySymbolName)
183
-                                    .font(.subheadline.weight(.semibold))
185
+                                ChargedDeviceIdentityLabelView(
186
+                                    chargedDevice: charger,
187
+                                    iconPointSize: 15
188
+                                )
189
+                                .font(.subheadline.weight(.semibold))
184 190
                                 Text(charger.chargerMaximumPowerWatts.map { "Max power: \($0.format(decimalDigits: 2)) W" } ?? "Wireless charger")
185 191
                                     .font(.caption)
186 192
                                     .foregroundColor(.secondary)