@@ -0,0 +1,15 @@ |
||
| 1 |
+# Politica de organizare a view-urilor Charger |
|
| 2 |
+ |
|
| 3 |
+- View-urile care reprezintă flows sau ecrane specifice încărcătoarelor trebuie plasate în folderul `Views/Chargers/`. |
|
| 4 |
+- Acest folder este destinat doar charger-only views, nu pentru view-uri generale ale device-urilor sau pentru bara laterală. |
|
| 5 |
+- Dacă un view se adresează doar funcționalității de charger (editare charger, wizard standby power, configurații charger), atunci el trebuie să fie în `Views/Chargers/`. |
|
| 6 |
+- Nu se folosesc foldere `Chargers` sub `Views/Meter/`, `Views/ChargedDevices/` sau `Views/Sidebar/`. |
|
| 7 |
+ |
|
| 8 |
+## Exemplu |
|
| 9 |
+ |
|
| 10 |
+- `ChargerEditorSheetView.swift` → `USB Meter/Views/Chargers/ChargerEditorSheetView.swift` |
|
| 11 |
+- `ChargerStandbyPowerWizardView.swift` → `USB Meter/Views/Chargers/ChargerStandbyPowerWizardView.swift` |
|
| 12 |
+ |
|
| 13 |
+## Motiv |
|
| 14 |
+ |
|
| 15 |
+Separarea charger-only views într-un folder dedicat reduce ambiguitatea și face structura `Views/` mai predictibilă pentru toate flow-urile aplicației. |
|
@@ -102,7 +102,6 @@ Views/ChargedDevices/ |
||
| 102 | 102 |
ChargedDeviceIdentityViews.swift |
| 103 | 103 |
ChargedDeviceLibraryRowView.swift |
| 104 | 104 |
ChargedDeviceQRCodeView.swift |
| 105 |
- ChargedDeviceSidebarCardView.swift |
|
| 106 | 105 |
Details/ |
| 107 | 106 |
ChargedDeviceDetailView.swift |
| 108 | 107 |
Sessions/ |
@@ -118,11 +117,21 @@ Views/ChargedDevices/ |
||
| 118 | 117 |
ChargerEditorSheetView.swift |
| 119 | 118 |
Library/ |
| 120 | 119 |
ChargedDeviceLibrarySheetView.swift |
| 121 |
- Sidebar/ |
|
| 122 |
- SidebarChargedDeviceLibraryView.swift |
|
| 123 |
- SidebarChargedDevicesSectionView.swift |
|
| 124 | 120 |
``` |
| 125 | 121 |
|
| 122 |
+Views/Sidebar/ |
|
| 123 |
+ ChargedDeviceSidebarCardView.swift |
|
| 124 |
+ SidebarChargedDeviceLibraryView.swift |
|
| 125 |
+ SidebarChargedDevicesSectionView.swift |
|
| 126 |
+ |
|
| 127 |
+Views/Chargers/ |
|
| 128 |
+ ChargerEditorSheetView.swift |
|
| 129 |
+ ChargerStandbyPowerWizardView.swift |
|
| 130 |
+ |
|
| 131 |
+Note: sidebar-specific views for the app live in `USB Meter/Views/Sidebar/`, not under feature-specific subfolders. |
|
| 132 |
+ |
|
| 133 |
+Note: charger-specific views live in `USB Meter/Views/Chargers/` when they represent charger-only screens or flows. There is no `USB Meter/Views/Sidebar/Chargers`; sidebar charger views are still part of the shared `Views/Sidebar/` section because the sidebar is a global navigation area. |
|
| 134 |
+ |
|
| 126 | 135 |
## Refactor Examples |
| 127 | 136 |
|
| 128 | 137 |
- `Connection/` -> `Home/` |
@@ -0,0 +1,21 @@ |
||
| 1 |
+# Politica de organizare a view-urilor Sidebar |
|
| 2 |
+ |
|
| 3 |
+- View-urile care fac parte din sidebar trebuie plasate în folderul `Views/Sidebar/`. |
|
| 4 |
+- Organizarea trebuie să urmeze structura de navigație, nu modelul de date. |
|
| 5 |
+- Când căut un element al sidebar-ului în folderul `Sidebar`, este de așteptat să-l găsesc acolo. |
|
| 6 |
+- Nu se folosesc subfoldere `Sidebar` în interiorul altor feature folders, cum ar fi `Views/ChargedDevices/Sidebar/`. |
|
| 7 |
+- Folderul `Components/` este rezervat pentru componente reutilizabile care nu sunt legate direct de structura sidebar-ului. |
|
| 8 |
+ |
|
| 9 |
+## Exemplu |
|
| 10 |
+ |
|
| 11 |
+`ChargedDeviceSidebarCardView.swift`, `SidebarChargedDeviceLibraryView.swift`, și `SidebarChargedDevicesSectionView.swift` sunt view-uri utilizate direct în sidebar și, prin urmare, se mută din: |
|
| 12 |
+ |
|
| 13 |
+- `USB Meter/Views/ChargedDevices/Components/` / `USB Meter/Views/ChargedDevices/Sidebar/` |
|
| 14 |
+ |
|
| 15 |
+în: |
|
| 16 |
+ |
|
| 17 |
+- `USB Meter/Views/Sidebar/` |
|
| 18 |
+ |
|
| 19 |
+## Motiv |
|
| 20 |
+ |
|
| 21 |
+Această decizie accelerează navigarea dezvoltatorilor și reduce căutările inutile. Când un view aparține unei secțiuni de navigație specifice, el trebuie să fie imediat vizibil în ierarhia de foldere care reprezintă acea navigație. |
|
@@ -228,6 +228,7 @@ |
||
| 228 | 228 |
E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 14.xcdatamodel"; sourceTree = "<group>"; };
|
| 229 | 229 |
F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 15.xcdatamodel"; sourceTree = "<group>"; };
|
| 230 | 230 |
F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 16.xcdatamodel"; sourceTree = "<group>"; };
|
| 231 |
+ F10000036F5D4C95B6487F18 /* USB_Meter 17.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 17.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 231 | 232 |
F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargingWindowDetector.swift; sourceTree = "<group>"; };
|
| 232 | 233 |
/* End PBXFileReference section */ |
| 233 | 234 |
|
@@ -486,6 +487,7 @@ |
||
| 486 | 487 |
C10000203C8E4A7A00A10020 /* ChargedDevices */, |
| 487 | 488 |
56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */, |
| 488 | 489 |
437D47CF2415F8CF00B7768E /* Meter */, |
| 490 |
+ F1F1F1F1F1F1F1F1F1F1F1F1 /* Chargers */, |
|
| 489 | 491 |
D28F10023C8E4A7A00A10002 /* Components */, |
| 490 | 492 |
4311E639241384960080EA59 /* DeviceHelpView.swift */, |
| 491 | 493 |
); |
@@ -513,6 +515,9 @@ |
||
| 513 | 515 |
children = ( |
| 514 | 516 |
AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */, |
| 515 | 517 |
43BE08E12F78F49500250EEC /* SidebarList */, |
| 518 |
+ CD0001033FA0000000000003 /* ChargedDeviceSidebarCardView.swift */, |
|
| 519 |
+ CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */, |
|
| 520 |
+ C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */, |
|
| 516 | 521 |
); |
| 517 | 522 |
path = Sidebar; |
| 518 | 523 |
sourceTree = "<group>"; |
@@ -524,7 +529,6 @@ |
||
| 524 | 529 |
CD0000113FA0000000000011 /* Details */, |
| 525 | 530 |
CD0000123FA0000000000012 /* Sessions */, |
| 526 | 531 |
CD0000133FA0000000000013 /* Sheets */, |
| 527 |
- CD0000173FA0000000000017 /* Sidebar */, |
|
| 528 | 532 |
); |
| 529 | 533 |
path = ChargedDevices; |
| 530 | 534 |
sourceTree = "<group>"; |
@@ -537,7 +541,6 @@ |
||
| 537 | 541 |
CD0001013FA0000000000001 /* ChargedDeviceIdentityViews.swift */, |
| 538 | 542 |
CD0001023FA0000000000002 /* ChargedDeviceLibraryRowView.swift */, |
| 539 | 543 |
C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */, |
| 540 |
- CD0001033FA0000000000003 /* ChargedDeviceSidebarCardView.swift */, |
|
| 541 | 544 |
); |
| 542 | 545 |
path = Components; |
| 543 | 546 |
sourceTree = "<group>"; |
@@ -573,7 +576,6 @@ |
||
| 573 | 576 |
isa = PBXGroup; |
| 574 | 577 |
children = ( |
| 575 | 578 |
C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */, |
| 576 |
- CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */, |
|
| 577 | 579 |
); |
| 578 | 580 |
path = Editors; |
| 579 | 581 |
sourceTree = "<group>"; |
@@ -595,13 +597,13 @@ |
||
| 595 | 597 |
path = ChargeSession; |
| 596 | 598 |
sourceTree = "<group>"; |
| 597 | 599 |
}; |
| 598 |
- CD0000173FA0000000000017 /* Sidebar */ = {
|
|
| 600 |
+ F1F1F1F1F1F1F1F1F1F1F1F1 /* Chargers */ = {
|
|
| 599 | 601 |
isa = PBXGroup; |
| 600 | 602 |
children = ( |
| 601 |
- CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */, |
|
| 602 |
- C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */, |
|
| 603 |
+ CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */, |
|
| 604 |
+ B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */, |
|
| 603 | 605 |
); |
| 604 |
- path = Sidebar; |
|
| 606 |
+ path = Chargers; |
|
| 605 | 607 |
sourceTree = "<group>"; |
| 606 | 608 |
}; |
| 607 | 609 |
D28F10013C8E4A7A00A10001 /* Sheets */ = {
|
@@ -660,7 +662,6 @@ |
||
| 660 | 662 |
isa = PBXGroup; |
| 661 | 663 |
children = ( |
| 662 | 664 |
D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */, |
| 663 |
- B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */, |
|
| 664 | 665 |
D28F11253C8E4A7A00A10035 /* Subviews */, |
| 665 | 666 |
); |
| 666 | 667 |
path = Live; |
@@ -1194,8 +1195,9 @@ |
||
| 1194 | 1195 |
E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */, |
| 1195 | 1196 |
F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */, |
| 1196 | 1197 |
F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */, |
| 1198 |
+ F10000036F5D4C95B6487F18 /* USB_Meter 17.xcdatamodel */, |
|
| 1197 | 1199 |
); |
| 1198 |
- currentVersion = F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */; |
|
| 1200 |
+ currentVersion = F10000036F5D4C95B6487F18 /* USB_Meter 17.xcdatamodel */; |
|
| 1199 | 1201 |
path = CKModel.xcdatamodeld; |
| 1200 | 1202 |
sourceTree = "<group>"; |
| 1201 | 1203 |
versionGroupType = wrapper.xcdatamodel; |
@@ -3,6 +3,6 @@ |
||
| 3 | 3 |
<plist version="1.0"> |
| 4 | 4 |
<dict> |
| 5 | 5 |
<key>_XCCurrentVersionName</key> |
| 6 |
- <string>USB_Meter 16.xcdatamodel</string> |
|
| 6 |
+ <string>USB_Meter 17.xcdatamodel</string> |
|
| 7 | 7 |
</dict> |
| 8 | 8 |
</plist> |
@@ -0,0 +1,126 @@ |
||
| 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="notes" optional="YES" attributeType="String"/> |
|
| 31 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 32 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 33 |
+ </entity> |
|
| 34 |
+ <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class"> |
|
| 35 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 36 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 37 |
+ <attribute name="chargerID" optional="YES" attributeType="String"/> |
|
| 38 |
+ <attribute name="meterMACAddress" optional="YES" attributeType="String"/> |
|
| 39 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 40 |
+ <attribute name="meterModel" optional="YES" attributeType="String"/> |
|
| 41 |
+ <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 42 |
+ <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 43 |
+ <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 44 |
+ <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 45 |
+ <attribute name="statusRawValue" optional="YES" attributeType="String"/> |
|
| 46 |
+ <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/> |
|
| 47 |
+ <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 48 |
+ <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/> |
|
| 49 |
+ <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 50 |
+ <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 51 |
+ <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 52 |
+ <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 53 |
+ <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 54 |
+ <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 55 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 56 |
+ <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 57 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 58 |
+ <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 59 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 60 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 61 |
+ <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 62 |
+ <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 63 |
+ <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 64 |
+ <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 65 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 66 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 67 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 68 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 69 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 70 |
+ <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 71 |
+ <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 72 |
+ <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 73 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 74 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 75 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 76 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 77 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 78 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 79 |
+ <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 80 |
+ <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 81 |
+ <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 82 |
+ <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 83 |
+ <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 84 |
+ <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 85 |
+ <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 86 |
+ <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 87 |
+ <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 88 |
+ <attribute name="wasConflictHealed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> |
|
| 89 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 90 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 91 |
+ </entity> |
|
| 92 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 93 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 94 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 95 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 96 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 97 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 98 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 99 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 100 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 101 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 102 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 103 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 104 |
+ </entity> |
|
| 105 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class"> |
|
| 106 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 107 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 108 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 109 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 110 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 111 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 112 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 113 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 114 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 115 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 116 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 117 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 118 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 119 |
+ </entity> |
|
| 120 |
+ <elements> |
|
| 121 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/> |
|
| 122 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="883"/> |
|
| 123 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 124 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 125 |
+ </elements> |
|
| 126 |
+</model> |
|
@@ -1269,7 +1269,6 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 1269 | 1269 |
let wirelessMinimumCurrentAmps: Double? |
| 1270 | 1270 |
let wiredEstimatedBatteryCapacityWh: Double? |
| 1271 | 1271 |
let wirelessEstimatedBatteryCapacityWh: Double? |
| 1272 |
- let lastAssociatedMeterMAC: String? |
|
| 1273 | 1272 |
let createdAt: Date |
| 1274 | 1273 |
let updatedAt: Date |
| 1275 | 1274 |
let sessions: [ChargeSessionSummary] |
@@ -1562,7 +1561,6 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 1562 | 1561 |
wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps, |
| 1563 | 1562 |
wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh, |
| 1564 | 1563 |
wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh, |
| 1565 |
- lastAssociatedMeterMAC: lastAssociatedMeterMAC, |
|
| 1566 | 1564 |
createdAt: createdAt, |
| 1567 | 1565 |
updatedAt: updatedAt, |
| 1568 | 1566 |
sessions: sessions, |
@@ -1,1105 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// ChargedDeviceDetailView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Codex on 10/04/2026. |
|
| 6 |
-// |
|
| 7 |
- |
|
| 8 |
-import SwiftUI |
|
| 9 |
- |
|
| 10 |
-struct ChargedDeviceDetailView: View {
|
|
| 11 |
- private enum DetailTab: Hashable {
|
|
| 12 |
- case overview |
|
| 13 |
- case standby |
|
| 14 |
- case sessions |
|
| 15 |
- case trends |
|
| 16 |
- case settings |
|
| 17 |
- } |
|
| 18 |
- |
|
| 19 |
- @EnvironmentObject private var appData: AppData |
|
| 20 |
- @Environment(\.dismiss) private var dismiss |
|
| 21 |
- |
|
| 22 |
- @State private var editorVisibility = false |
|
| 23 |
- @State private var deleteConfirmationVisibility = false |
|
| 24 |
- @State private var selectedTab: DetailTab = .overview |
|
| 25 |
- @State private var sessionSelectMode = false |
|
| 26 |
- @State private var selectedSessionIDs: Set<UUID> = [] |
|
| 27 |
- @State private var pendingBatchDeletion = false |
|
| 28 |
- |
|
| 29 |
- let chargedDeviceID: UUID |
|
| 30 |
- |
|
| 31 |
- var body: some View {
|
|
| 32 |
- Group {
|
|
| 33 |
- if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
|
|
| 34 |
- tabbedDetailView(chargedDevice) |
|
| 35 |
- .navigationTitle(chargedDevice.name) |
|
| 36 |
- .navigationBarTitleDisplayMode(.inline) |
|
| 37 |
- } else {
|
|
| 38 |
- Text("This device is no longer available.")
|
|
| 39 |
- .foregroundColor(.secondary) |
|
| 40 |
- .navigationTitle("Device")
|
|
| 41 |
- .navigationBarTitleDisplayMode(.inline) |
|
| 42 |
- } |
|
| 43 |
- } |
|
| 44 |
- .sidebarToggleToolbarItem() |
|
| 45 |
- .sheet(isPresented: $editorVisibility) {
|
|
| 46 |
- if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
|
|
| 47 |
- if chargedDevice.isCharger {
|
|
| 48 |
- ChargerEditorSheetView(chargedDevice: chargedDevice) |
|
| 49 |
- .environmentObject(appData) |
|
| 50 |
- } else {
|
|
| 51 |
- ChargedDeviceEditorSheetView( |
|
| 52 |
- meterMACAddress: nil, |
|
| 53 |
- chargedDevice: chargedDevice |
|
| 54 |
- ) |
|
| 55 |
- .environmentObject(appData) |
|
| 56 |
- } |
|
| 57 |
- } |
|
| 58 |
- } |
|
| 59 |
- .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
|
|
| 60 |
- Button("Delete", role: .destructive) {
|
|
| 61 |
- if appData.deleteChargedDevice(id: chargedDeviceID) {
|
|
| 62 |
- dismiss() |
|
| 63 |
- } |
|
| 64 |
- } |
|
| 65 |
- Button("Cancel", role: .cancel) {}
|
|
| 66 |
- } message: {
|
|
| 67 |
- Text(deletionMessage) |
|
| 68 |
- } |
|
| 69 |
- .confirmationDialog( |
|
| 70 |
- "Delete \(selectedSessionIDs.count) Session\(selectedSessionIDs.count == 1 ? "" : "s")?", |
|
| 71 |
- isPresented: $pendingBatchDeletion, |
|
| 72 |
- titleVisibility: .visible |
|
| 73 |
- ) {
|
|
| 74 |
- Button("Delete", role: .destructive, action: deleteSelectedSessions)
|
|
| 75 |
- Button("Cancel", role: .cancel) {}
|
|
| 76 |
- } message: {
|
|
| 77 |
- Text("Deleting these sessions also recalculates capacity and every derived metric that used them.")
|
|
| 78 |
- } |
|
| 79 |
- } |
|
| 80 |
- |
|
| 81 |
- private func tabbedDetailView(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 82 |
- GeometryReader { proxy in
|
|
| 83 |
- let tabs = availableTabs(for: chargedDevice) |
|
| 84 |
- let displayedTab = displayedTab(from: tabs) |
|
| 85 |
- let tabBarPresentation = AdaptiveTabBarPresentation.standard(for: proxy.size) |
|
| 86 |
- |
|
| 87 |
- VStack(spacing: 0) {
|
|
| 88 |
- ChargedDeviceDetailTabBarView( |
|
| 89 |
- tabs: tabs, |
|
| 90 |
- selection: $selectedTab, |
|
| 91 |
- tint: tint(for: chargedDevice), |
|
| 92 |
- presentation: tabBarPresentation, |
|
| 93 |
- title: title(for:), |
|
| 94 |
- systemImage: systemImage(for:) |
|
| 95 |
- ) |
|
| 96 |
- |
|
| 97 |
- Group {
|
|
| 98 |
- if displayedTab == .sessions {
|
|
| 99 |
- sessionsTabLayout(chargedDevice) |
|
| 100 |
- } else {
|
|
| 101 |
- ScrollView {
|
|
| 102 |
- tabContent(displayedTab, chargedDevice: chargedDevice) |
|
| 103 |
- .padding() |
|
| 104 |
- } |
|
| 105 |
- } |
|
| 106 |
- } |
|
| 107 |
- .id(displayedTab) |
|
| 108 |
- .transition(.opacity.combined(with: .move(edge: .trailing))) |
|
| 109 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
|
| 110 |
- } |
|
| 111 |
- .animation(.easeInOut(duration: 0.22), value: displayedTab) |
|
| 112 |
- .animation(.easeInOut(duration: 0.22), value: tabs) |
|
| 113 |
- .onChange(of: selectedTab) { _ in
|
|
| 114 |
- sessionSelectMode = false |
|
| 115 |
- selectedSessionIDs.removeAll() |
|
| 116 |
- } |
|
| 117 |
- } |
|
| 118 |
- .background(detailBackground(for: chargedDevice)) |
|
| 119 |
- .onAppear {
|
|
| 120 |
- ensureSelectedTabExists(for: chargedDevice) |
|
| 121 |
- } |
|
| 122 |
- .onChange(of: chargedDevice.isCharger) { _ in
|
|
| 123 |
- ensureSelectedTabExists(for: chargedDevice) |
|
| 124 |
- } |
|
| 125 |
- } |
|
| 126 |
- |
|
| 127 |
- @ViewBuilder |
|
| 128 |
- private func tabContent(_ tab: DetailTab, chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 129 |
- VStack(spacing: 18) {
|
|
| 130 |
- switch tab {
|
|
| 131 |
- case .overview: |
|
| 132 |
- overviewTab(chargedDevice) |
|
| 133 |
- case .standby: |
|
| 134 |
- standbyTab(chargedDevice) |
|
| 135 |
- case .sessions: |
|
| 136 |
- sessionsTab(chargedDevice) |
|
| 137 |
- case .trends: |
|
| 138 |
- trendsTab(chargedDevice) |
|
| 139 |
- case .settings: |
|
| 140 |
- settingsTab(chargedDevice) |
|
| 141 |
- } |
|
| 142 |
- } |
|
| 143 |
- } |
|
| 144 |
- |
|
| 145 |
- @ViewBuilder |
|
| 146 |
- private func overviewTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 147 |
- headerCard(chargedDevice) |
|
| 148 |
- insightsCard(chargedDevice) |
|
| 149 |
- |
|
| 150 |
- if let activeSession = chargedDevice.activeSession {
|
|
| 151 |
- activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice) |
|
| 152 |
- } |
|
| 153 |
- } |
|
| 154 |
- |
|
| 155 |
- @ViewBuilder |
|
| 156 |
- private func standbyTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 157 |
- standbyPowerCard(chargedDevice) |
|
| 158 |
- } |
|
| 159 |
- |
|
| 160 |
- @ViewBuilder |
|
| 161 |
- private func sessionsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 162 |
- if let activeSession = chargedDevice.activeSession {
|
|
| 163 |
- activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice) |
|
| 164 |
- } |
|
| 165 |
- |
|
| 166 |
- let sessions = closedSessions(for: chargedDevice) |
|
| 167 |
- if !sessions.isEmpty {
|
|
| 168 |
- sessionListCard(sessions, chargedDevice: chargedDevice) |
|
| 169 |
- } else if chargedDevice.activeSession == nil {
|
|
| 170 |
- emptyStateCard( |
|
| 171 |
- title: "No Sessions", |
|
| 172 |
- message: "Charging sessions will appear here after this device is used in a recording.", |
|
| 173 |
- tint: .teal |
|
| 174 |
- ) |
|
| 175 |
- } |
|
| 176 |
- } |
|
| 177 |
- |
|
| 178 |
- @ViewBuilder |
|
| 179 |
- private func trendsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 180 |
- if !chargedDevice.capacityHistory.isEmpty {
|
|
| 181 |
- capacityEvolutionCard(chargedDevice) |
|
| 182 |
- } |
|
| 183 |
- |
|
| 184 |
- if !chargedDevice.typicalCurve.isEmpty {
|
|
| 185 |
- typicalCurveCard(chargedDevice) |
|
| 186 |
- } |
|
| 187 |
- |
|
| 188 |
- if chargedDevice.capacityHistory.isEmpty && chargedDevice.typicalCurve.isEmpty {
|
|
| 189 |
- emptyStateCard( |
|
| 190 |
- title: "Learning Trends", |
|
| 191 |
- message: "Capacity history and charge curves will appear after enough completed sessions are available.", |
|
| 192 |
- tint: .blue |
|
| 193 |
- ) |
|
| 194 |
- } |
|
| 195 |
- } |
|
| 196 |
- |
|
| 197 |
- @ViewBuilder |
|
| 198 |
- private func settingsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 199 |
- settingsCard(chargedDevice) |
|
| 200 |
- } |
|
| 201 |
- |
|
| 202 |
- @ViewBuilder |
|
| 203 |
- private func sessionsTabLayout(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 204 |
- let allSessions = chargedDevice.sessions.sorted { lhs, rhs in
|
|
| 205 |
- let lOpen = lhs.status.isOpen, rOpen = rhs.status.isOpen |
|
| 206 |
- if lOpen != rOpen { return lOpen }
|
|
| 207 |
- return lhs.startedAt > rhs.startedAt |
|
| 208 |
- } |
|
| 209 |
- let totalEnergyWh = allSessions.reduce(0.0) { $0 + $1.effectiveOrMeasuredEnergyWh }
|
|
| 210 |
- let totalDuration = allSessions.reduce(0.0) { $0 + max($1.effectiveDuration, 0) }
|
|
| 211 |
- |
|
| 212 |
- VStack(spacing: 0) {
|
|
| 213 |
- // Fixed non-scrolling header |
|
| 214 |
- VStack(spacing: 10) {
|
|
| 215 |
- sessionsSummaryStrip( |
|
| 216 |
- count: allSessions.count, |
|
| 217 |
- totalEnergyWh: totalEnergyWh, |
|
| 218 |
- totalDuration: totalDuration, |
|
| 219 |
- hasActive: chargedDevice.activeSession != nil |
|
| 220 |
- ) |
|
| 221 |
- |
|
| 222 |
- if !allSessions.isEmpty {
|
|
| 223 |
- HStack(spacing: 12) {
|
|
| 224 |
- if sessionSelectMode && !selectedSessionIDs.isEmpty {
|
|
| 225 |
- Text("\(selectedSessionIDs.count) selected")
|
|
| 226 |
- .font(.subheadline) |
|
| 227 |
- .foregroundColor(.secondary) |
|
| 228 |
- .transition(.opacity.combined(with: .move(edge: .leading))) |
|
| 229 |
- } |
|
| 230 |
- Spacer() |
|
| 231 |
- if sessionSelectMode && !selectedSessionIDs.isEmpty {
|
|
| 232 |
- Button {
|
|
| 233 |
- pendingBatchDeletion = true |
|
| 234 |
- } label: {
|
|
| 235 |
- Image(systemName: "trash").foregroundColor(.red) |
|
| 236 |
- } |
|
| 237 |
- .transition(.opacity.combined(with: .scale)) |
|
| 238 |
- } |
|
| 239 |
- Button(sessionSelectMode ? "Cancel" : "Select") {
|
|
| 240 |
- withAnimation(.easeInOut(duration: 0.2)) {
|
|
| 241 |
- sessionSelectMode.toggle() |
|
| 242 |
- if !sessionSelectMode { selectedSessionIDs.removeAll() }
|
|
| 243 |
- } |
|
| 244 |
- } |
|
| 245 |
- } |
|
| 246 |
- .animation(.easeInOut(duration: 0.2), value: sessionSelectMode) |
|
| 247 |
- .animation(.easeInOut(duration: 0.2), value: selectedSessionIDs.isEmpty) |
|
| 248 |
- } |
|
| 249 |
- } |
|
| 250 |
- .padding() |
|
| 251 |
- |
|
| 252 |
- // Scrollable session list |
|
| 253 |
- if allSessions.isEmpty {
|
|
| 254 |
- emptyStateCard( |
|
| 255 |
- title: "No Sessions", |
|
| 256 |
- message: "Charging sessions will appear here after this device is used in a recording.", |
|
| 257 |
- tint: .teal |
|
| 258 |
- ) |
|
| 259 |
- .padding([.horizontal, .bottom]) |
|
| 260 |
- } else {
|
|
| 261 |
- ScrollView {
|
|
| 262 |
- VStack(spacing: 10) {
|
|
| 263 |
- ForEach(allSessions, id: \.id) { session in
|
|
| 264 |
- sessionListItem(session, chargedDevice: chargedDevice) |
|
| 265 |
- } |
|
| 266 |
- } |
|
| 267 |
- .padding([.horizontal, .bottom]) |
|
| 268 |
- } |
|
| 269 |
- } |
|
| 270 |
- } |
|
| 271 |
- } |
|
| 272 |
- |
|
| 273 |
- private func sessionsSummaryStrip( |
|
| 274 |
- count: Int, |
|
| 275 |
- totalEnergyWh: Double, |
|
| 276 |
- totalDuration: TimeInterval, |
|
| 277 |
- hasActive: Bool |
|
| 278 |
- ) -> some View {
|
|
| 279 |
- HStack(spacing: 0) {
|
|
| 280 |
- summaryCell(value: "\(count)", label: count == 1 ? "session" : "sessions") |
|
| 281 |
- Divider().frame(height: 30) |
|
| 282 |
- summaryCell(value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh", label: "energy") |
|
| 283 |
- Divider().frame(height: 30) |
|
| 284 |
- summaryCell(value: formatAccumulatedDuration(totalDuration), label: "duration") |
|
| 285 |
- if hasActive {
|
|
| 286 |
- Divider().frame(height: 30) |
|
| 287 |
- HStack(spacing: 4) {
|
|
| 288 |
- Circle().fill(Color.green).frame(width: 6, height: 6) |
|
| 289 |
- Text("Live")
|
|
| 290 |
- .font(.caption2.weight(.semibold)) |
|
| 291 |
- .foregroundColor(.green) |
|
| 292 |
- } |
|
| 293 |
- .frame(maxWidth: .infinity) |
|
| 294 |
- } |
|
| 295 |
- } |
|
| 296 |
- .padding(.vertical, 8) |
|
| 297 |
- .padding(.horizontal, 12) |
|
| 298 |
- .meterCard(tint: .teal, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 14) |
|
| 299 |
- } |
|
| 300 |
- |
|
| 301 |
- private func summaryCell(value: String, label: String) -> some View {
|
|
| 302 |
- VStack(spacing: 2) {
|
|
| 303 |
- Text(value) |
|
| 304 |
- .font(.subheadline.weight(.bold)) |
|
| 305 |
- .foregroundColor(.primary) |
|
| 306 |
- .monospacedDigit() |
|
| 307 |
- .lineLimit(1) |
|
| 308 |
- .minimumScaleFactor(0.7) |
|
| 309 |
- Text(label) |
|
| 310 |
- .font(.caption2) |
|
| 311 |
- .foregroundColor(.secondary) |
|
| 312 |
- } |
|
| 313 |
- .frame(maxWidth: .infinity) |
|
| 314 |
- } |
|
| 315 |
- |
|
| 316 |
- private func formatAccumulatedDuration(_ duration: TimeInterval) -> String {
|
|
| 317 |
- let formatter = DateComponentsFormatter() |
|
| 318 |
- formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 319 |
- formatter.unitsStyle = .abbreviated |
|
| 320 |
- formatter.zeroFormattingBehavior = .dropAll |
|
| 321 |
- return formatter.string(from: duration) ?? "0m" |
|
| 322 |
- } |
|
| 323 |
- |
|
| 324 |
- private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 325 |
- HStack(alignment: .top, spacing: 18) {
|
|
| 326 |
- ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118) |
|
| 327 |
- |
|
| 328 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 329 |
- ChargedDeviceIdentityLabelView( |
|
| 330 |
- chargedDevice: chargedDevice, |
|
| 331 |
- iconPointSize: 22 |
|
| 332 |
- ) |
|
| 333 |
- .font(.title3.weight(.bold)) |
|
| 334 |
- |
|
| 335 |
- Text(chargedDevice.identityTitle) |
|
| 336 |
- .font(.subheadline.weight(.semibold)) |
|
| 337 |
- .foregroundColor(.secondary) |
|
| 338 |
- |
|
| 339 |
- if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
|
|
| 340 |
- Text("Default meter: \(meterMAC)")
|
|
| 341 |
- .font(.caption) |
|
| 342 |
- .foregroundColor(.secondary) |
|
| 343 |
- } |
|
| 344 |
- |
|
| 345 |
- Text(chargedDevice.qrIdentifier) |
|
| 346 |
- .font(.caption2.monospaced()) |
|
| 347 |
- .foregroundColor(.secondary) |
|
| 348 |
- .textSelection(.enabled) |
|
| 349 |
- } |
|
| 350 |
- |
|
| 351 |
- Spacer(minLength: 0) |
|
| 352 |
- } |
|
| 353 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 354 |
- .padding(18) |
|
| 355 |
- .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20) |
|
| 356 |
- } |
|
| 357 |
- |
|
| 358 |
- private func settingsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 359 |
- MeterInfoCardView(title: "Settings", tint: tint(for: chargedDevice)) {
|
|
| 360 |
- MeterInfoRowView( |
|
| 361 |
- label: "Kind", |
|
| 362 |
- value: chargedDevice.isCharger ? "Charger" : chargedDevice.deviceClass.title |
|
| 363 |
- ) |
|
| 364 |
- MeterInfoRowView(label: "Template", value: chargedDevice.identityTitle) |
|
| 365 |
- MeterInfoRowView(label: "QR ID", value: chargedDevice.qrIdentifier) |
|
| 366 |
- |
|
| 367 |
- if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
|
|
| 368 |
- MeterInfoRowView(label: "Default Meter", value: meterMAC) |
|
| 369 |
- } |
|
| 370 |
- |
|
| 371 |
- MeterInfoRowView(label: "Created", value: chargedDevice.createdAt.format()) |
|
| 372 |
- MeterInfoRowView(label: "Updated", value: chargedDevice.updatedAt.format()) |
|
| 373 |
- |
|
| 374 |
- Divider() |
|
| 375 |
- |
|
| 376 |
- Button(action: showEditor) {
|
|
| 377 |
- Label("Edit \(chargedDevice.isCharger ? "Charger" : "Device")", systemImage: "pencil")
|
|
| 378 |
- .font(.subheadline.weight(.semibold)) |
|
| 379 |
- .frame(maxWidth: .infinity) |
|
| 380 |
- .padding(.vertical, 10) |
|
| 381 |
- .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 382 |
- } |
|
| 383 |
- .buttonStyle(.plain) |
|
| 384 |
- |
|
| 385 |
- Button(role: .destructive, action: showDeleteConfirmation) {
|
|
| 386 |
- Label("Delete \(chargedDevice.isCharger ? "Charger" : "Device")", systemImage: "trash")
|
|
| 387 |
- .font(.subheadline.weight(.semibold)) |
|
| 388 |
- .frame(maxWidth: .infinity) |
|
| 389 |
- .padding(.vertical, 10) |
|
| 390 |
- .meterCard(tint: .red, fillOpacity: 0.10, strokeOpacity: 0.18, cornerRadius: 14) |
|
| 391 |
- } |
|
| 392 |
- .buttonStyle(.plain) |
|
| 393 |
- } |
|
| 394 |
- } |
|
| 395 |
- |
|
| 396 |
- private func emptyStateCard(title: String, message: String, tint: Color) -> some View {
|
|
| 397 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 398 |
- Text(title) |
|
| 399 |
- .font(.headline) |
|
| 400 |
- Text(message) |
|
| 401 |
- .font(.footnote) |
|
| 402 |
- .foregroundColor(.secondary) |
|
| 403 |
- .fixedSize(horizontal: false, vertical: true) |
|
| 404 |
- } |
|
| 405 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 406 |
- .padding(18) |
|
| 407 |
- .meterCard(tint: tint, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18) |
|
| 408 |
- } |
|
| 409 |
- |
|
| 410 |
- private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 411 |
- MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
|
|
| 412 |
- if chargedDevice.isCharger {
|
|
| 413 |
- chargerInsights(chargedDevice) |
|
| 414 |
- } else {
|
|
| 415 |
- deviceInsights(chargedDevice) |
|
| 416 |
- } |
|
| 417 |
- |
|
| 418 |
- if let notes = chargedDevice.notes, !notes.isEmpty {
|
|
| 419 |
- Divider() |
|
| 420 |
- Text(notes) |
|
| 421 |
- .font(.footnote) |
|
| 422 |
- .foregroundColor(.secondary) |
|
| 423 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 424 |
- } |
|
| 425 |
- } |
|
| 426 |
- } |
|
| 427 |
- |
|
| 428 |
- @ViewBuilder |
|
| 429 |
- private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 430 |
- if chargedDevice.hasMultipleChargingStateModes {
|
|
| 431 |
- MeterInfoRowView( |
|
| 432 |
- label: "Charge Modes", |
|
| 433 |
- value: chargedDevice.chargingStateAvailability.title |
|
| 434 |
- ) |
|
| 435 |
- } |
|
| 436 |
- if chargedDevice.hasMultipleChargingTransports {
|
|
| 437 |
- MeterInfoRowView( |
|
| 438 |
- label: "Charging Support", |
|
| 439 |
- value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ") |
|
| 440 |
- ) |
|
| 441 |
- } |
|
| 442 |
- if chargedDevice.showsWirelessProfileDetails {
|
|
| 443 |
- MeterInfoRowView( |
|
| 444 |
- label: "Wireless Profile", |
|
| 445 |
- value: chargedDevice.wirelessChargingProfile.title |
|
| 446 |
- ) |
|
| 447 |
- } |
|
| 448 |
- |
|
| 449 |
- ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
|
|
| 450 |
- MeterInfoRowView( |
|
| 451 |
- label: completionCurrentLabel(for: chargedDevice, sessionKind: sessionKind), |
|
| 452 |
- value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind) |
|
| 453 |
- ) |
|
| 454 |
- } |
|
| 455 |
- MeterInfoRowView( |
|
| 456 |
- label: "Estimated Capacity", |
|
| 457 |
- value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
|
|
| 458 |
- ) |
|
| 459 |
- if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
|
|
| 460 |
- if chargedDevice.hasMultipleChargingTransports {
|
|
| 461 |
- MeterInfoRowView( |
|
| 462 |
- label: "Wired Capacity", |
|
| 463 |
- value: "\(wiredCapacity.format(decimalDigits: 2)) Wh" |
|
| 464 |
- ) |
|
| 465 |
- } |
|
| 466 |
- } |
|
| 467 |
- if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
|
|
| 468 |
- if chargedDevice.hasMultipleChargingTransports {
|
|
| 469 |
- MeterInfoRowView( |
|
| 470 |
- label: "Wireless Capacity", |
|
| 471 |
- value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh" |
|
| 472 |
- ) |
|
| 473 |
- } |
|
| 474 |
- } |
|
| 475 |
- if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor, |
|
| 476 |
- chargedDevice.showsWirelessProfileDetails {
|
|
| 477 |
- MeterInfoRowView( |
|
| 478 |
- label: "Wireless Efficiency", |
|
| 479 |
- value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%" |
|
| 480 |
- ) |
|
| 481 |
- } |
|
| 482 |
- MeterInfoRowView( |
|
| 483 |
- label: "Charge Sessions", |
|
| 484 |
- value: "\(chargedDevice.sessionCount)" |
|
| 485 |
- ) |
|
| 486 |
- } |
|
| 487 |
- |
|
| 488 |
- @ViewBuilder |
|
| 489 |
- private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 490 |
- if let chargerType = chargedDevice.chargerType {
|
|
| 491 |
- MeterInfoRowView( |
|
| 492 |
- label: "Type", |
|
| 493 |
- value: chargerType.title |
|
| 494 |
- ) |
|
| 495 |
- } |
|
| 496 |
- if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
|
|
| 497 |
- MeterInfoRowView( |
|
| 498 |
- label: "Observed Voltages", |
|
| 499 |
- value: chargedDevice.chargerObservedVoltageSelections |
|
| 500 |
- .map { "\($0.format(decimalDigits: 1)) V" }
|
|
| 501 |
- .joined(separator: ", ") |
|
| 502 |
- ) |
|
| 503 |
- } |
|
| 504 |
- if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
|
|
| 505 |
- MeterInfoRowView( |
|
| 506 |
- label: "Idle Current", |
|
| 507 |
- value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A" |
|
| 508 |
- ) |
|
| 509 |
- } |
|
| 510 |
- if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
|
|
| 511 |
- MeterInfoRowView( |
|
| 512 |
- label: "Efficiency", |
|
| 513 |
- value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%" |
|
| 514 |
- ) |
|
| 515 |
- } |
|
| 516 |
- if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
|
|
| 517 |
- MeterInfoRowView( |
|
| 518 |
- label: "Max Power", |
|
| 519 |
- value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W" |
|
| 520 |
- ) |
|
| 521 |
- } |
|
| 522 |
- if let latestStandbyPowerMeasurement = chargedDevice.latestStandbyPowerMeasurement {
|
|
| 523 |
- MeterInfoRowView( |
|
| 524 |
- label: "Standby Power", |
|
| 525 |
- value: "\(latestStandbyPowerMeasurement.averagePowerWatts.format(decimalDigits: 3)) W" |
|
| 526 |
- ) |
|
| 527 |
- MeterInfoRowView( |
|
| 528 |
- label: "Standby Projection", |
|
| 529 |
- value: standbyEnergyLabel(latestStandbyPowerMeasurement.projectedYearlyEnergyWh) + " / year" |
|
| 530 |
- ) |
|
| 531 |
- } |
|
| 532 |
- MeterInfoRowView( |
|
| 533 |
- label: "Wireless Sessions", |
|
| 534 |
- value: "\(chargedDevice.sessionCount)" |
|
| 535 |
- ) |
|
| 536 |
- |
|
| 537 |
- } |
|
| 538 |
- |
|
| 539 |
- private func standbyPowerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 540 |
- let latestMeasurement = chargedDevice.latestStandbyPowerMeasurement |
|
| 541 |
- |
|
| 542 |
- return MeterInfoCardView( |
|
| 543 |
- title: "Standby Power", |
|
| 544 |
- tint: .orange |
|
| 545 |
- ) {
|
|
| 546 |
- if standbyMeasurementMeters.isEmpty {
|
|
| 547 |
- Text("Connect a meter first. Standby measurement is launched from a live meter feed, so only currently available meters can be selected here.")
|
|
| 548 |
- .font(.footnote) |
|
| 549 |
- .foregroundColor(.secondary) |
|
| 550 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 551 |
- } else {
|
|
| 552 |
- NavigationLink( |
|
| 553 |
- destination: ChargerStandbyPowerWizardView( |
|
| 554 |
- preferredChargerID: chargedDevice.id, |
|
| 555 |
- locksChargerSelection: true |
|
| 556 |
- ) |
|
| 557 |
- ) {
|
|
| 558 |
- Label("New Measurement", systemImage: "plus.circle.fill")
|
|
| 559 |
- .font(.subheadline.weight(.semibold)) |
|
| 560 |
- .foregroundColor(.orange) |
|
| 561 |
- } |
|
| 562 |
- .buttonStyle(.plain) |
|
| 563 |
- } |
|
| 564 |
- |
|
| 565 |
- if let latestMeasurement {
|
|
| 566 |
- Divider() |
|
| 567 |
- |
|
| 568 |
- NavigationLink( |
|
| 569 |
- destination: ChargerStandbyPowerMeasurementDetailView( |
|
| 570 |
- chargerID: chargedDevice.id, |
|
| 571 |
- measurementID: latestMeasurement.id |
|
| 572 |
- ) |
|
| 573 |
- ) {
|
|
| 574 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 575 |
- HStack {
|
|
| 576 |
- Text("Latest Measurement")
|
|
| 577 |
- .font(.subheadline.weight(.semibold)) |
|
| 578 |
- .foregroundColor(.primary) |
|
| 579 |
- Spacer() |
|
| 580 |
- Text("\(latestMeasurement.averagePowerWatts.format(decimalDigits: 3)) W")
|
|
| 581 |
- .font(.subheadline.weight(.bold)) |
|
| 582 |
- .foregroundColor(.primary) |
|
| 583 |
- .monospacedDigit() |
|
| 584 |
- } |
|
| 585 |
- |
|
| 586 |
- Text( |
|
| 587 |
- "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year" |
|
| 588 |
- ) |
|
| 589 |
- .font(.caption) |
|
| 590 |
- .foregroundColor(.secondary) |
|
| 591 |
- } |
|
| 592 |
- } |
|
| 593 |
- .buttonStyle(.plain) |
|
| 594 |
- } |
|
| 595 |
- |
|
| 596 |
- if chargedDevice.standbyPowerMeasurements.isEmpty == false {
|
|
| 597 |
- Divider() |
|
| 598 |
- |
|
| 599 |
- NavigationLink( |
|
| 600 |
- destination: ChargerStandbyPowerMeasurementsView(chargerID: chargedDevice.id) |
|
| 601 |
- ) {
|
|
| 602 |
- Label("View Saved Measurements", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
|
|
| 603 |
- .font(.subheadline.weight(.semibold)) |
|
| 604 |
- .foregroundColor(.blue) |
|
| 605 |
- } |
|
| 606 |
- .buttonStyle(.plain) |
|
| 607 |
- } |
|
| 608 |
- } |
|
| 609 |
- } |
|
| 610 |
- |
|
| 611 |
- private func activeSessionSummaryCard( |
|
| 612 |
- _ activeSession: ChargeSessionSummary, |
|
| 613 |
- chargedDevice: ChargedDeviceSummary |
|
| 614 |
- ) -> some View {
|
|
| 615 |
- NavigationLink( |
|
| 616 |
- destination: ChargeSessionDetailView( |
|
| 617 |
- chargedDeviceID: chargedDevice.id, |
|
| 618 |
- sessionID: activeSession.id |
|
| 619 |
- ) |
|
| 620 |
- ) {
|
|
| 621 |
- VStack(alignment: .leading, spacing: 14) {
|
|
| 622 |
- HStack(alignment: .firstTextBaseline) {
|
|
| 623 |
- VStack(alignment: .leading, spacing: 4) {
|
|
| 624 |
- Text("Current Session")
|
|
| 625 |
- .font(.headline) |
|
| 626 |
- .foregroundColor(.primary) |
|
| 627 |
- Text(activeSession.status.title) |
|
| 628 |
- .font(.caption.weight(.semibold)) |
|
| 629 |
- .foregroundColor(statusTint(for: activeSession)) |
|
| 630 |
- } |
|
| 631 |
- |
|
| 632 |
- Spacer() |
|
| 633 |
- |
|
| 634 |
- Image(systemName: "chevron.right") |
|
| 635 |
- .font(.caption.weight(.semibold)) |
|
| 636 |
- .foregroundColor(.secondary) |
|
| 637 |
- } |
|
| 638 |
- |
|
| 639 |
- LazyVGrid(columns: activeSessionSummaryColumns, spacing: 8) {
|
|
| 640 |
- activeSessionMetricCell( |
|
| 641 |
- label: "Energy", |
|
| 642 |
- value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", |
|
| 643 |
- tint: .teal |
|
| 644 |
- ) |
|
| 645 |
- activeSessionMetricCell( |
|
| 646 |
- label: "Duration", |
|
| 647 |
- value: sessionDurationText(activeSession), |
|
| 648 |
- tint: .orange |
|
| 649 |
- ) |
|
| 650 |
- if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
|
|
| 651 |
- activeSessionMetricCell( |
|
| 652 |
- label: "Max Power", |
|
| 653 |
- value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W", |
|
| 654 |
- tint: .blue |
|
| 655 |
- ) |
|
| 656 |
- } |
|
| 657 |
- if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
|
|
| 658 |
- activeSessionMetricCell( |
|
| 659 |
- label: "Battery", |
|
| 660 |
- value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%", |
|
| 661 |
- tint: .green |
|
| 662 |
- ) |
|
| 663 |
- } else if let targetBatteryPercent = activeSession.targetBatteryPercent {
|
|
| 664 |
- activeSessionMetricCell( |
|
| 665 |
- label: "Target", |
|
| 666 |
- value: "\(targetBatteryPercent.format(decimalDigits: 0))%", |
|
| 667 |
- tint: .indigo |
|
| 668 |
- ) |
|
| 669 |
- } |
|
| 670 |
- } |
|
| 671 |
- |
|
| 672 |
- Text("Started \(activeSession.startedAt.format())")
|
|
| 673 |
- .font(.caption) |
|
| 674 |
- .foregroundColor(.secondary) |
|
| 675 |
- } |
|
| 676 |
- } |
|
| 677 |
- .buttonStyle(.plain) |
|
| 678 |
- .padding(18) |
|
| 679 |
- .meterCard(tint: statusTint(for: activeSession), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) |
|
| 680 |
- } |
|
| 681 |
- |
|
| 682 |
- private var activeSessionSummaryColumns: [GridItem] {
|
|
| 683 |
- [ |
|
| 684 |
- GridItem(.flexible(minimum: 92), spacing: 8), |
|
| 685 |
- GridItem(.flexible(minimum: 92), spacing: 8) |
|
| 686 |
- ] |
|
| 687 |
- } |
|
| 688 |
- |
|
| 689 |
- private func activeSessionMetricCell(label: String, value: String, tint: Color) -> some View {
|
|
| 690 |
- VStack(alignment: .leading, spacing: 4) {
|
|
| 691 |
- Text(label) |
|
| 692 |
- .font(.caption2) |
|
| 693 |
- .foregroundColor(.secondary) |
|
| 694 |
- Text(value) |
|
| 695 |
- .font(.footnote.weight(.semibold)) |
|
| 696 |
- .foregroundColor(.primary) |
|
| 697 |
- .monospacedDigit() |
|
| 698 |
- .lineLimit(1) |
|
| 699 |
- .minimumScaleFactor(0.8) |
|
| 700 |
- } |
|
| 701 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 702 |
- .padding(10) |
|
| 703 |
- .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) |
|
| 704 |
- } |
|
| 705 |
- |
|
| 706 |
- private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 707 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 708 |
- Text("Capacity Evolution")
|
|
| 709 |
- .font(.headline) |
|
| 710 |
- |
|
| 711 |
- ForEach(chargedDevice.capacityHistory.suffix(6)) { point in
|
|
| 712 |
- HStack {
|
|
| 713 |
- Text(point.timestamp.format()) |
|
| 714 |
- .font(.caption) |
|
| 715 |
- .foregroundColor(.secondary) |
|
| 716 |
- Spacer() |
|
| 717 |
- if chargedDevice.shouldShowChargingTransport(point.chargingTransportMode) {
|
|
| 718 |
- Text(point.chargingTransportMode.title) |
|
| 719 |
- .font(.caption2) |
|
| 720 |
- .foregroundColor(.secondary) |
|
| 721 |
- Text("•")
|
|
| 722 |
- .foregroundColor(.secondary) |
|
| 723 |
- } |
|
| 724 |
- Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
|
|
| 725 |
- .font(.footnote.weight(.semibold)) |
|
| 726 |
- } |
|
| 727 |
- } |
|
| 728 |
- } |
|
| 729 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 730 |
- .padding(18) |
|
| 731 |
- .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) |
|
| 732 |
- } |
|
| 733 |
- |
|
| 734 |
- private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 735 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 736 |
- Text("Typical Charge Curve")
|
|
| 737 |
- .font(.headline) |
|
| 738 |
- |
|
| 739 |
- ForEach(chargedDevice.typicalCurve) { point in
|
|
| 740 |
- HStack {
|
|
| 741 |
- Text("\(point.percentBin)%")
|
|
| 742 |
- .font(.footnote.weight(.semibold)) |
|
| 743 |
- Spacer() |
|
| 744 |
- Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
|
|
| 745 |
- .font(.caption.weight(.semibold)) |
|
| 746 |
- Text("•")
|
|
| 747 |
- .foregroundColor(.secondary) |
|
| 748 |
- Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
|
|
| 749 |
- .font(.caption2) |
|
| 750 |
- .foregroundColor(.secondary) |
|
| 751 |
- } |
|
| 752 |
- } |
|
| 753 |
- } |
|
| 754 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 755 |
- .padding(18) |
|
| 756 |
- .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) |
|
| 757 |
- } |
|
| 758 |
- |
|
| 759 |
- private func sessionListCard( |
|
| 760 |
- _ sessions: [ChargeSessionSummary], |
|
| 761 |
- chargedDevice: ChargedDeviceSummary |
|
| 762 |
- ) -> some View {
|
|
| 763 |
- let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
|
|
| 764 |
- let completedCount = sessions.filter { $0.status == .completed }.count
|
|
| 765 |
- let sortedSessions = sessions.sorted { $0.startedAt > $1.startedAt }
|
|
| 766 |
- |
|
| 767 |
- return VStack(alignment: .leading, spacing: 14) {
|
|
| 768 |
- MeterInfoCardView(title: "Closed Sessions", tint: .teal) {
|
|
| 769 |
- MeterInfoRowView(label: "Sessions", value: "\(sessions.count)") |
|
| 770 |
- MeterInfoRowView(label: "Completed", value: "\(completedCount)") |
|
| 771 |
- MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh") |
|
| 772 |
- } |
|
| 773 |
- |
|
| 774 |
- HStack(spacing: 12) {
|
|
| 775 |
- if sessionSelectMode && !selectedSessionIDs.isEmpty {
|
|
| 776 |
- Text("\(selectedSessionIDs.count) selected")
|
|
| 777 |
- .font(.subheadline) |
|
| 778 |
- .foregroundColor(.secondary) |
|
| 779 |
- .transition(.opacity.combined(with: .move(edge: .leading))) |
|
| 780 |
- } |
|
| 781 |
- Spacer() |
|
| 782 |
- if sessionSelectMode && !selectedSessionIDs.isEmpty {
|
|
| 783 |
- Button {
|
|
| 784 |
- pendingBatchDeletion = true |
|
| 785 |
- } label: {
|
|
| 786 |
- Image(systemName: "trash") |
|
| 787 |
- .foregroundColor(.red) |
|
| 788 |
- } |
|
| 789 |
- .transition(.opacity.combined(with: .scale)) |
|
| 790 |
- } |
|
| 791 |
- Button(sessionSelectMode ? "Cancel" : "Select") {
|
|
| 792 |
- withAnimation(.easeInOut(duration: 0.2)) {
|
|
| 793 |
- sessionSelectMode.toggle() |
|
| 794 |
- if !sessionSelectMode { selectedSessionIDs.removeAll() }
|
|
| 795 |
- } |
|
| 796 |
- } |
|
| 797 |
- } |
|
| 798 |
- .animation(.easeInOut(duration: 0.2), value: sessionSelectMode) |
|
| 799 |
- .animation(.easeInOut(duration: 0.2), value: selectedSessionIDs.isEmpty) |
|
| 800 |
- |
|
| 801 |
- VStack(spacing: 10) {
|
|
| 802 |
- ForEach(sortedSessions, id: \.id) { session in
|
|
| 803 |
- sessionListItem(session, chargedDevice: chargedDevice) |
|
| 804 |
- } |
|
| 805 |
- } |
|
| 806 |
- } |
|
| 807 |
- } |
|
| 808 |
- |
|
| 809 |
- private func sessionListItem( |
|
| 810 |
- _ session: ChargeSessionSummary, |
|
| 811 |
- chargedDevice: ChargedDeviceSummary |
|
| 812 |
- ) -> some View {
|
|
| 813 |
- let sessionTint = statusTint(for: session) |
|
| 814 |
- let isOpen = session.status.isOpen |
|
| 815 |
- let isSelected = selectedSessionIDs.contains(session.id) |
|
| 816 |
- |
|
| 817 |
- return Group {
|
|
| 818 |
- if sessionSelectMode && !isOpen {
|
|
| 819 |
- Button {
|
|
| 820 |
- withAnimation(.easeInOut(duration: 0.15)) {
|
|
| 821 |
- if isSelected { selectedSessionIDs.remove(session.id) }
|
|
| 822 |
- else { selectedSessionIDs.insert(session.id) }
|
|
| 823 |
- } |
|
| 824 |
- } label: {
|
|
| 825 |
- sessionRowContent(session, sessionTint: sessionTint, isOpen: isOpen, isSelected: isSelected) |
|
| 826 |
- } |
|
| 827 |
- .buttonStyle(.plain) |
|
| 828 |
- } else {
|
|
| 829 |
- NavigationLink( |
|
| 830 |
- destination: ChargeSessionDetailView( |
|
| 831 |
- chargedDeviceID: chargedDevice.id, |
|
| 832 |
- sessionID: session.id |
|
| 833 |
- ) |
|
| 834 |
- ) {
|
|
| 835 |
- sessionRowContent(session, sessionTint: sessionTint, isOpen: isOpen, isSelected: false) |
|
| 836 |
- } |
|
| 837 |
- .buttonStyle(.plain) |
|
| 838 |
- } |
|
| 839 |
- } |
|
| 840 |
- } |
|
| 841 |
- |
|
| 842 |
- private func sessionRowContent( |
|
| 843 |
- _ session: ChargeSessionSummary, |
|
| 844 |
- sessionTint: Color, |
|
| 845 |
- isOpen: Bool, |
|
| 846 |
- isSelected: Bool |
|
| 847 |
- ) -> some View {
|
|
| 848 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 849 |
- HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
| 850 |
- if sessionSelectMode {
|
|
| 851 |
- Group {
|
|
| 852 |
- if isOpen {
|
|
| 853 |
- Image(systemName: "minus.circle") |
|
| 854 |
- .foregroundColor(.secondary.opacity(0.35)) |
|
| 855 |
- } else {
|
|
| 856 |
- Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") |
|
| 857 |
- .foregroundColor(isSelected ? .teal : .secondary) |
|
| 858 |
- } |
|
| 859 |
- } |
|
| 860 |
- .font(.body) |
|
| 861 |
- .transition(.opacity) |
|
| 862 |
- } |
|
| 863 |
- |
|
| 864 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 865 |
- Text(session.startedAt.format()) |
|
| 866 |
- .font(.subheadline.weight(.semibold)) |
|
| 867 |
- Text(session.status.title) |
|
| 868 |
- .font(.caption2) |
|
| 869 |
- .foregroundColor(sessionTint) |
|
| 870 |
- } |
|
| 871 |
- |
|
| 872 |
- Spacer() |
|
| 873 |
- |
|
| 874 |
- VStack(alignment: .trailing, spacing: 2) {
|
|
| 875 |
- Text("\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh")
|
|
| 876 |
- .font(.subheadline.weight(.semibold)) |
|
| 877 |
- .foregroundColor(.primary) |
|
| 878 |
- Text(sessionDurationText(session)) |
|
| 879 |
- .font(.caption) |
|
| 880 |
- .foregroundColor(.secondary) |
|
| 881 |
- } |
|
| 882 |
- } |
|
| 883 |
- |
|
| 884 |
- Divider() |
|
| 885 |
- |
|
| 886 |
- HStack(spacing: 8) {
|
|
| 887 |
- if let batteryDelta = session.batteryDeltaPercent {
|
|
| 888 |
- Label("\(batteryDelta >= 0 ? "+" : "")\(Int(batteryDelta.rounded()))% charged", systemImage: "battery.100percent")
|
|
| 889 |
- .font(.caption2) |
|
| 890 |
- .foregroundColor(.secondary) |
|
| 891 |
- } |
|
| 892 |
- |
|
| 893 |
- if let capacityWh = session.capacityEstimateWh {
|
|
| 894 |
- Text("est. \(capacityWh.format(decimalDigits: 1)) Wh")
|
|
| 895 |
- .font(.caption2) |
|
| 896 |
- .foregroundColor(.secondary) |
|
| 897 |
- } |
|
| 898 |
- |
|
| 899 |
- Spacer() |
|
| 900 |
- |
|
| 901 |
- if !session.displayedAggregatedSamples.isEmpty {
|
|
| 902 |
- Label("\(session.displayedAggregatedSamples.count) points", systemImage: "chart.xyaxis.line")
|
|
| 903 |
- .font(.caption2) |
|
| 904 |
- .foregroundColor(.secondary) |
|
| 905 |
- } |
|
| 906 |
- } |
|
| 907 |
- } |
|
| 908 |
- .padding(12) |
|
| 909 |
- .meterCard( |
|
| 910 |
- tint: sessionTint, |
|
| 911 |
- fillOpacity: isSelected ? 0.16 : (isOpen ? 0.14 : 0.08), |
|
| 912 |
- strokeOpacity: isSelected ? 0.22 : (isOpen ? 0.30 : 0.14), |
|
| 913 |
- cornerRadius: 14 |
|
| 914 |
- ) |
|
| 915 |
- } |
|
| 916 |
- |
|
| 917 |
- private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] {
|
|
| 918 |
- chargedDevice.sessions.filter { !$0.status.isOpen }
|
|
| 919 |
- } |
|
| 920 |
- |
|
| 921 |
- private func availableTabs(for chargedDevice: ChargedDeviceSummary) -> [DetailTab] {
|
|
| 922 |
- if chargedDevice.isCharger {
|
|
| 923 |
- return [.overview, .standby, .settings] |
|
| 924 |
- } |
|
| 925 |
- return [.overview, .sessions, .trends, .settings] |
|
| 926 |
- } |
|
| 927 |
- |
|
| 928 |
- private func displayedTab(from tabs: [DetailTab]) -> DetailTab {
|
|
| 929 |
- if tabs.contains(selectedTab) {
|
|
| 930 |
- return selectedTab |
|
| 931 |
- } |
|
| 932 |
- return tabs.first ?? .overview |
|
| 933 |
- } |
|
| 934 |
- |
|
| 935 |
- private func ensureSelectedTabExists(for chargedDevice: ChargedDeviceSummary) {
|
|
| 936 |
- let tabs = availableTabs(for: chargedDevice) |
|
| 937 |
- if !tabs.contains(selectedTab) {
|
|
| 938 |
- selectedTab = tabs.first ?? .overview |
|
| 939 |
- } |
|
| 940 |
- } |
|
| 941 |
- |
|
| 942 |
- private func title(for tab: DetailTab) -> String {
|
|
| 943 |
- switch tab {
|
|
| 944 |
- case .overview: |
|
| 945 |
- return "Overview" |
|
| 946 |
- case .standby: |
|
| 947 |
- return "Standby" |
|
| 948 |
- case .sessions: |
|
| 949 |
- return "Sessions" |
|
| 950 |
- case .trends: |
|
| 951 |
- return "Trends" |
|
| 952 |
- case .settings: |
|
| 953 |
- return "Settings" |
|
| 954 |
- } |
|
| 955 |
- } |
|
| 956 |
- |
|
| 957 |
- private func systemImage(for tab: DetailTab) -> String {
|
|
| 958 |
- switch tab {
|
|
| 959 |
- case .overview: |
|
| 960 |
- return "house.fill" |
|
| 961 |
- case .standby: |
|
| 962 |
- return "bolt.badge.clock" |
|
| 963 |
- case .sessions: |
|
| 964 |
- return "clock.arrow.trianglehead.counterclockwise.rotate.90" |
|
| 965 |
- case .trends: |
|
| 966 |
- return "chart.xyaxis.line" |
|
| 967 |
- case .settings: |
|
| 968 |
- return "gearshape.fill" |
|
| 969 |
- } |
|
| 970 |
- } |
|
| 971 |
- |
|
| 972 |
- private func detailBackground(for chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 973 |
- LinearGradient( |
|
| 974 |
- colors: [tint(for: chargedDevice).opacity(0.18), Color.clear], |
|
| 975 |
- startPoint: .topLeading, |
|
| 976 |
- endPoint: .bottomTrailing |
|
| 977 |
- ) |
|
| 978 |
- .ignoresSafeArea() |
|
| 979 |
- } |
|
| 980 |
- |
|
| 981 |
- private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
|
|
| 982 |
- switch chargedDevice.deviceClass {
|
|
| 983 |
- case .iphone: |
|
| 984 |
- return .blue |
|
| 985 |
- case .watch: |
|
| 986 |
- return .green |
|
| 987 |
- case .powerbank: |
|
| 988 |
- return .orange |
|
| 989 |
- case .charger: |
|
| 990 |
- return .pink |
|
| 991 |
- case .other: |
|
| 992 |
- return .secondary |
|
| 993 |
- } |
|
| 994 |
- } |
|
| 995 |
- |
|
| 996 |
- private func statusTint(for session: ChargeSessionSummary) -> Color {
|
|
| 997 |
- switch session.status {
|
|
| 998 |
- case .active: |
|
| 999 |
- return .green |
|
| 1000 |
- case .paused: |
|
| 1001 |
- return .orange |
|
| 1002 |
- case .completed: |
|
| 1003 |
- return .teal |
|
| 1004 |
- case .abandoned: |
|
| 1005 |
- return .secondary |
|
| 1006 |
- } |
|
| 1007 |
- } |
|
| 1008 |
- |
|
| 1009 |
- private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
|
|
| 1010 |
- let formatter = DateComponentsFormatter() |
|
| 1011 |
- let effectiveDuration = max(session.effectiveDuration, 0) |
|
| 1012 |
- formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 1013 |
- formatter.unitsStyle = .abbreviated |
|
| 1014 |
- formatter.zeroFormattingBehavior = .dropAll |
|
| 1015 |
- return formatter.string(from: effectiveDuration) ?? "0m" |
|
| 1016 |
- } |
|
| 1017 |
- |
|
| 1018 |
- private func standbyEnergyLabel(_ wattHours: Double) -> String {
|
|
| 1019 |
- if wattHours >= 1000 {
|
|
| 1020 |
- return "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 1021 |
- } |
|
| 1022 |
- return "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 1023 |
- } |
|
| 1024 |
- |
|
| 1025 |
- private var standbyMeasurementMeters: [AppData.MeterSummary] {
|
|
| 1026 |
- appData.meterSummaries.filter { $0.meter != nil }
|
|
| 1027 |
- } |
|
| 1028 |
- |
|
| 1029 |
- private func completionCurrentDescription( |
|
| 1030 |
- for chargedDevice: ChargedDeviceSummary, |
|
| 1031 |
- sessionKind: ChargeSessionKind |
|
| 1032 |
- ) -> String {
|
|
| 1033 |
- if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
|
|
| 1034 |
- if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind), |
|
| 1035 |
- abs(configuredCurrent - learnedCurrent) >= 0.01 {
|
|
| 1036 |
- return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned" |
|
| 1037 |
- } |
|
| 1038 |
- return "\(configuredCurrent.format(decimalDigits: 2)) A configured" |
|
| 1039 |
- } |
|
| 1040 |
- |
|
| 1041 |
- if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
|
|
| 1042 |
- return "\(learnedCurrent.format(decimalDigits: 2)) A learned" |
|
| 1043 |
- } |
|
| 1044 |
- |
|
| 1045 |
- return "Learning" |
|
| 1046 |
- } |
|
| 1047 |
- |
|
| 1048 |
- private func completionCurrentLabel( |
|
| 1049 |
- for chargedDevice: ChargedDeviceSummary, |
|
| 1050 |
- sessionKind: ChargeSessionKind |
|
| 1051 |
- ) -> String {
|
|
| 1052 |
- let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode) |
|
| 1053 |
- let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode) |
|
| 1054 |
- |
|
| 1055 |
- switch (showsTransport, showsState) {
|
|
| 1056 |
- case (true, true): |
|
| 1057 |
- return "\(sessionKind.shortTitle) Stop Current" |
|
| 1058 |
- case (true, false): |
|
| 1059 |
- return "\(sessionKind.chargingTransportMode.title) Stop Current" |
|
| 1060 |
- case (false, true): |
|
| 1061 |
- return "\(sessionKind.chargingStateMode.title) Stop Current" |
|
| 1062 |
- case (false, false): |
|
| 1063 |
- return "Stop Current" |
|
| 1064 |
- } |
|
| 1065 |
- } |
|
| 1066 |
- |
|
| 1067 |
- private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
|
|
| 1068 |
- chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
|
|
| 1069 |
- chargedDevice.supportedChargingStateModes.map { chargingStateMode in
|
|
| 1070 |
- ChargeSessionKind( |
|
| 1071 |
- chargingTransportMode: chargingTransportMode, |
|
| 1072 |
- chargingStateMode: chargingStateMode |
|
| 1073 |
- ) |
|
| 1074 |
- } |
|
| 1075 |
- } |
|
| 1076 |
- } |
|
| 1077 |
- |
|
| 1078 |
- private func deleteSelectedSessions() {
|
|
| 1079 |
- for id in selectedSessionIDs {
|
|
| 1080 |
- _ = appData.deleteChargeSession(sessionID: id) |
|
| 1081 |
- } |
|
| 1082 |
- selectedSessionIDs.removeAll() |
|
| 1083 |
- sessionSelectMode = false |
|
| 1084 |
- } |
|
| 1085 |
- |
|
| 1086 |
- private func showEditor() {
|
|
| 1087 |
- editorVisibility = true |
|
| 1088 |
- } |
|
| 1089 |
- |
|
| 1090 |
- private func showDeleteConfirmation() {
|
|
| 1091 |
- deleteConfirmationVisibility = true |
|
| 1092 |
- } |
|
| 1093 |
- |
|
| 1094 |
- private var deletionTitle: String {
|
|
| 1095 |
- appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device" |
|
| 1096 |
- } |
|
| 1097 |
- |
|
| 1098 |
- private var deletionMessage: String {
|
|
| 1099 |
- if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
|
|
| 1100 |
- return "This removes the charger from the library and unlinks it from wireless sessions that used it." |
|
| 1101 |
- } |
|
| 1102 |
- return "This removes the device and its stored charging history from the library." |
|
| 1103 |
- } |
|
| 1104 |
- |
|
| 1105 |
-} |
|