@@ -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; |
@@ -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 |
|
@@ -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> |
@@ -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> |
|
@@ -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, |
@@ -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 |
@@ -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 |
+} |
|
@@ -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 {
|
@@ -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
|
@@ -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: |
@@ -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 |
+} |
|
@@ -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")
|
@@ -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 |
@@ -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) |