@@ -0,0 +1,199 @@ |
||
| 1 |
+# External Contributions |
|
| 2 |
+ |
|
| 3 |
+This document tracks contributions made by collaborators outside the core project. |
|
| 4 |
+Its purpose is to support ongoing evaluation of each contributor's work quality, |
|
| 5 |
+patterns, and reliability over time. |
|
| 6 |
+ |
|
| 7 |
+--- |
|
| 8 |
+ |
|
| 9 |
+## Contributor A — anonim, prieten al autorului |
|
| 10 |
+ |
|
| 11 |
+Toate intervențiile cunoscute sunt în `AppData.swift`, `ChargeInsightsStore.swift`, și `Meter.swift`. |
|
| 12 |
+ |
|
| 13 |
+--- |
|
| 14 |
+ |
|
| 15 |
+### Intervenție 1 — Fix lag UI la formulare (aprilie 2026) |
|
| 16 |
+ |
|
| 17 |
+**Context:** La conectarea unui contor live, formularele din UI (typing, picker) |
|
| 18 |
+deveneau iresponsive. `reloadChargedDevices()` rula sincron pe main thread la fiecare |
|
| 19 |
+snapshot BLE (~1/s), blocând UIKit. |
|
| 20 |
+ |
|
| 21 |
+**Commit:** `4f79218` *(înglobat cu Intervenția 2)* |
|
| 22 |
+ |
|
| 23 |
+**Modificări introduse:** |
|
| 24 |
+ |
|
| 25 |
+- `chargeInsightsReadStore` — context separat de citire (`privateQueueConcurrencyType`) |
|
| 26 |
+- `chargedDevicesReloadQueue` — fetch mutat pe background queue |
|
| 27 |
+- `chargedDevicesReloadGeneration` — counter de invalidare pentru fetch-uri async depășite |
|
| 28 |
+ |
|
| 29 |
+**Bug introdus (descoperit ulterior):** |
|
| 30 |
+ |
|
| 31 |
+Pattern-ul generation+invalidare era prea agresiv. Contorul era incrementat la fiecare |
|
| 32 |
+apel `reloadChargedDevices()`, inclusiv cele declanșate în burst de |
|
| 33 |
+`NSManagedObjectContextObjectsDidChange`. Orice fetch async dura >0ms → generația era |
|
| 34 |
+deja depășită la finalizare → rezultatul era aruncat. Lista rămânea goală permanent |
|
| 35 |
+atât timp cât un contor live era conectat, indiferent că SQLite conținea date reale. |
|
| 36 |
+ |
|
| 37 |
+**Evaluare:** Problema identificată era reală. Soluția era funcțională în absența unui |
|
| 38 |
+contor conectat (test static), dar s-a rupt sub load real. Eroare clasică de |
|
| 39 |
+async invalidation, probabil netestat cu dispozitiv real. |
|
| 40 |
+ |
|
| 41 |
+--- |
|
| 42 |
+ |
|
| 43 |
+### Intervenție 2 — Fix starvation reload (după aprilie 2026) |
|
| 44 |
+ |
|
| 45 |
+**Context:** Lista de charged devices rămânea goală permanent când un contor era activ. |
|
| 46 |
+Cauzat de bug-ul din Intervenția 1. Autorul a identificat și corectat singur problema. |
|
| 47 |
+ |
|
| 48 |
+**Commit:** `4f79218` |
|
| 49 |
+ |
|
| 50 |
+**Modificări introduse:** |
|
| 51 |
+ |
|
| 52 |
+- Înlocuire generation counter cu pattern `inFlight`/`pending` |
|
| 53 |
+ - `chargedDevicesReloadInFlight: Bool` — un singur fetch în zbor la un moment dat |
|
| 54 |
+ - `chargedDevicesReloadPending: Bool` — cerere nouă marcată pentru după finalizare |
|
| 55 |
+- `scheduleChargedDevicesReload(delay: 0.15)` — debounce pentru burst-uri de notificări |
|
| 56 |
+ |
|
| 57 |
+**Evaluare:** Corect și complet. Soluția standard pentru problema dată. Nu introduce |
|
| 58 |
+regresii. Diagnosticul era precis (cita dimensiunile exacte din SQLite: 10 devices, |
|
| 59 |
+15 sesiuni, 49 checkpoints, 24.279 samples). |
|
| 60 |
+ |
|
| 61 |
+--- |
|
| 62 |
+ |
|
| 63 |
+### Intervenție 3 — Optimizare CPU pe Catalyst (aprilie 2026) |
|
| 64 |
+ |
|
| 65 |
+**Context:** Procesul Catalyst consuma CPU ridicat și constant. Identificat prin profiling. |
|
| 66 |
+ |
|
| 67 |
+**Commits:** `406be0e` (împreună cu ajustări ulterioare ale intervalelor) |
|
| 68 |
+ |
|
| 69 |
+**Cauze identificate de contributor:** |
|
| 70 |
+ |
|
| 71 |
+1. BLE polling continuu — `dataDumpRequest()` recursiv imediat după fiecare răspuns |
|
| 72 |
+2. Fiecare pachet BLE atingea CoreData/iCloud direct, cu reload UI frecvent |
|
| 73 |
+3. `noteMeterSeen` scria în `MeterNameStore` la fiecare observație |
|
| 74 |
+ |
|
| 75 |
+**Modificări introduse:** |
|
| 76 |
+ |
|
| 77 |
+- `Meter.swift` — `minimumLivePollingInterval = 0.4s`; înlocuit recursie imediată cu |
|
| 78 |
+ `scheduleNextLiveDataDumpRequest()` care respectă intervalul minim |
|
| 79 |
+- `AppData.swift` — coalescere snapshot-uri BLE în memorie (`pendingChargeObservationSnapshots`); |
|
| 80 |
+ flush periodic la 30s cu flush explicit la pause/stop/checkpoint/terminate; |
|
| 81 |
+ throttle `noteMeterSeen` la 15s; observer schimbat din `ObjectsDidChange` → `DidSave` |
|
| 82 |
+- `AppData.swift` — `writeContext` privat pentru `ChargeInsightsStore` |
|
| 83 |
+ (anterior store-ul scria direct pe `viewContext`, i.e. main thread) |
|
| 84 |
+- `ChargeInsightsStore.swift` — `maximumLiveIntegrationGap` 20s → 90s |
|
| 85 |
+ (aliniat cu fereastra de coalescere de 30s) |
|
| 86 |
+ |
|
| 87 |
+**Probleme rămase după livrare (rezolvate în același commit):** |
|
| 88 |
+ |
|
| 89 |
+- Work item-ul de flush era dispatchat pe `DispatchQueue.main`; apela |
|
| 90 |
+ `context.performAndWait` → bloca main thread la fiecare 30s (spike vizibil în profiler) |
|
| 91 |
+- `flushPendingChargeObservation` chema explicit `reloadChargedDevices()`, iar observer-ul |
|
| 92 |
+ DidSave chema și el `scheduleChargedDevicesReload()` — double reload per flush |
|
| 93 |
+ |
|
| 94 |
+Aceste două probleme au fost remediate separat (fix în `406be0e`): |
|
| 95 |
+flush-ul periodic mută scrierea CoreData pe `DispatchQueue.global(qos: .utility)`; |
|
| 96 |
+reload-ul explicit eliminat din flush, lăsat exclusiv pe seama observer-ului DidSave. |
|
| 97 |
+ |
|
| 98 |
+**Note privind originea problemelor rezolvate:** |
|
| 99 |
+ |
|
| 100 |
+`NSManagedObjectContextObjectsDidChange` ca observer și `viewContext` pentru scrieri |
|
| 101 |
+existau în codul proiectului **înainte** de orice intervenție a acestui contributor. |
|
| 102 |
+Intervenția 3 rezolvă probleme de design preexistente, nu regresii proprii. |
|
| 103 |
+Singura auto-corecție este `maximumLiveIntegrationGap`, ajustat pentru coalescing-ul |
|
| 104 |
+introdus tot în această intervenție. |
|
| 105 |
+ |
|
| 106 |
+**Evaluare:** Diagnostic corect pentru toate cele trei cauze. Modificările sunt |
|
| 107 |
+coerente între ele. Livrarea conținea două bug-uri minore (main thread + double reload) |
|
| 108 |
+care au necesitat remediere imediată, detectabile printr-un review atent al codului |
|
| 109 |
+fără a fi nevoie de runtime. Efectul net: CPU redus de la consum constant la 0–4% |
|
| 110 |
+cu spike-uri reziduale la ~30s (CloudKit sync inerent). |
|
| 111 |
+ |
|
| 112 |
+--- |
|
| 113 |
+ |
|
| 114 |
+## Session Trim Feature — Buffer Restore Bug (pentru externalizare) |
|
| 115 |
+ |
|
| 116 |
+### Contextul feature-ului |
|
| 117 |
+ |
|
| 118 |
+S-a implementat un feature de "Session Trim": detectare automată a ferestrei reale de încărcare (ex: Apple Watch se încarcă 3h dar sesiunea rulează 16h overnight) + editor manual pentru trim. Feature-ul este complet implementat și compilează fără erori. |
|
| 119 |
+ |
|
| 120 |
+**Există un bug pre-existent** care blochează funcționarea feature-ului și trebuie rezolvat separat. |
|
| 121 |
+ |
|
| 122 |
+--- |
|
| 123 |
+ |
|
| 124 |
+### Bug: istoricul sesiunii se pierde la fiecare restart de aplicație |
|
| 125 |
+ |
|
| 126 |
+**Simptom:** La restart cu sesiune activă, graficul afișează intervalul complet al sesiunii (ex: 16h) dar cu date doar din momentul reconectării BLE. Istoricul din Core Data nu este restaurat. |
|
| 127 |
+ |
|
| 128 |
+**Consecință pentru trim:** Banner-ul de trim nu apare (se bazează pe `aggregatedSamples` gol), editorul de trim are graficul gol. |
|
| 129 |
+ |
|
| 130 |
+--- |
|
| 131 |
+ |
|
| 132 |
+### Unde este problema |
|
| 133 |
+ |
|
| 134 |
+**`Meter.restoreChargeRecordIfNeeded(from:)`** — `USB Meter/Model/Meter.swift` |
|
| 135 |
+ |
|
| 136 |
+```swift |
|
| 137 |
+func restoreChargeRecordIfNeeded(from activeSession: ChargeSessionSummary) {
|
|
| 138 |
+ guard chargeRecordState == .waitingForStart else { return } // ← problema
|
|
| 139 |
+ guard chargeRecordStartTimestamp == nil else { return }
|
|
| 140 |
+ guard chargeRecordAH == 0, chargeRecordWH == 0, chargeRecordDuration == 0 else { return }
|
|
| 141 |
+ |
|
| 142 |
+ measurements.restorePersistedChargeSessionSamplesIfNeeded(from: activeSession) |
|
| 143 |
+ ... |
|
| 144 |
+} |
|
| 145 |
+``` |
|
| 146 |
+ |
|
| 147 |
+**Race condition:** Datele BLE ajung înaintea completării `reloadChargedDevices` (async). La primul pachet BLE, `chargeRecordState` trece `waitingForStart → active`. Când `reloadChargedDevices` finalizează și încearcă restore, primul guard blochează — permanent pentru sesiunea curentă. |
|
| 148 |
+ |
|
| 149 |
+**`Measurements.restorePersistedChargeSessionSamplesIfNeeded`** are propriul guard: returnează dacă vreun series este non-gol. Dacă se rezolvă problema de mai sus și datele BLE au umplut deja buffer-ul, acesta trebuie resetat înainte de restore — dar **numai dacă `aggregatedSamples` nu este gol**, altfel graficul rămâne gol definitiv. |
|
| 150 |
+ |
|
| 151 |
+--- |
|
| 152 |
+ |
|
| 153 |
+### Lanțul de apel la pornire |
|
| 154 |
+ |
|
| 155 |
+```text |
|
| 156 |
+SceneDelegate.activateCloudDeviceSync |
|
| 157 |
+ → AppData.activateChargeInsights // setup writeContext + readContext |
|
| 158 |
+ → AppData.scheduleChargedDevicesReload // async, ~100–500ms |
|
| 159 |
+ |
|
| 160 |
+[BLE se conectează, date ajung — pe main queue] |
|
| 161 |
+ → Meter.processData → Meter.updateChargeRecord |
|
| 162 |
+ → chargeRecordState = .active // ← guard-ul din restore va bloca |
|
| 163 |
+ |
|
| 164 |
+[reloadChargedDevices completează] |
|
| 165 |
+ → AppData.restoreChargeMonitoringStateIfNeeded(for: meter) |
|
| 166 |
+ → Meter.restoreChargeRecordIfNeeded(from: session) // ← guard blochează |
|
| 167 |
+``` |
|
| 168 |
+ |
|
| 169 |
+--- |
|
| 170 |
+ |
|
| 171 |
+### Fișiere modificate de feature (necommitted, diff disponibil) |
|
| 172 |
+ |
|
| 173 |
+| Fișier | Modificare | |
|
| 174 |
+| ------ | ---------- | |
|
| 175 |
+| `USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 15.xcdatamodel/` | Nou — adaugă `trimStart`/`trimEnd` opționale pe `ChargeSession` | |
|
| 176 |
+| `USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion` | → v15 | |
|
| 177 |
+| `USB Meter/Model/ChargingWindowDetector.swift` | Nou — detectează fereastra activă de încărcare | |
|
| 178 |
+| `USB Meter/Views/Meter/Tabs/ChargeRecord/SessionTrimEditorView.swift` | Nou — editor trim cu drag handles | |
|
| 179 |
+| `USB Meter/Model/ChargeInsightsModel.swift` | `trimStart`, `trimEnd`, `isTrimmed` pe `ChargeSessionSummary` | |
|
| 180 |
+| `USB Meter/Model/ChargeInsightsStore.swift` | `setSessionTrim(sessionID:start:end:)` — recalculează energie, șterge checkpoints | |
|
| 181 |
+| `USB Meter/Model/AppData.swift` | Wrapper `setSessionTrim` | |
|
| 182 |
+| `USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift` | Banner detecție + sheet editor | |
|
| 183 |
+| `USB Meter.xcodeproj/project.pbxproj` | Referințe fișiere noi | |
|
| 184 |
+ |
|
| 185 |
+--- |
|
| 186 |
+ |
|
| 187 |
+## Tipare observate |
|
| 188 |
+ |
|
| 189 |
+| | Interv. 1 | Interv. 2 | Interv. 3 | |
|
| 190 |
+|---|---|---|---| |
|
| 191 |
+| Diagnostic corect | Da | Da | Da | |
|
| 192 |
+| Funcționează fără load real | Da | Da | Da | |
|
| 193 |
+| Funcționează sub load real | Nu | Da | Parțial | |
|
| 194 |
+| Auto-corecție necesară | — | Da (proprie) | Parțial (externă) | |
|
| 195 |
+| Regresii introduse | Da (starvation) | Nu | Nu | |
|
| 196 |
+ |
|
| 197 |
+**Observație recurentă:** Contribuțiile funcționează corect în condiții statice dar |
|
| 198 |
+au tendința să rateze edge case-uri sub load real (contor live conectat). Testele |
|
| 199 |
+înainte de livrare par să nu includă scenariul cu dispozitiv activ. |
|
@@ -14,6 +14,8 @@ It is intended to keep the repository root focused on the app itself while prese |
||
| 14 | 14 |
Definition + measurement implications for capacity estimation. |
| 15 | 15 |
- `Project Structure and Naming.md` |
| 16 | 16 |
Naming and file-organization rules for views, features, components, and subviews. |
| 17 |
+- `External Contributions.md` |
|
| 18 |
+ Log of contributions from external collaborators, with technical evaluation per intervention. |
|
| 17 | 19 |
- `Research Resources/` |
| 18 | 20 |
External source material plus the notes derived from it. |
| 19 | 21 |
|
@@ -56,6 +56,8 @@ |
||
| 56 | 56 |
B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */; };
|
| 57 | 57 |
C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */; };
|
| 58 | 58 |
C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */; };
|
| 59 |
+ F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */; };
|
|
| 60 |
+ F20000046F5D4C95B6487F19 /* SessionTrimEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */; };
|
|
| 59 | 61 |
C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */; };
|
| 60 | 62 |
C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */; };
|
| 61 | 63 |
C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */; };
|
@@ -141,6 +143,7 @@ |
||
| 141 | 143 |
C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ChargedDeviceTemplates.json; sourceTree = "<group>"; };
|
| 142 | 144 |
C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 12.xcdatamodel"; sourceTree = "<group>"; };
|
| 143 | 145 |
E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 14.xcdatamodel"; sourceTree = "<group>"; };
|
| 146 |
+ F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 15.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 144 | 147 |
4383B467240F845500DAAEBF /* MacAdress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacAdress.swift; sourceTree = "<group>"; };
|
| 145 | 148 |
4383B469240FE4A600DAAEBF /* MeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterView.swift; sourceTree = "<group>"; };
|
| 146 | 149 |
438695882463F062008855A9 /* Measurements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Measurements.swift; sourceTree = "<group>"; };
|
@@ -173,6 +176,8 @@ |
||
| 173 | 176 |
B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerStandbyPowerWizardView.swift; sourceTree = "<group>"; };
|
| 174 | 177 |
C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsModel.swift; sourceTree = "<group>"; };
|
| 175 | 178 |
C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsStore.swift; sourceTree = "<group>"; };
|
| 179 |
+ F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargingWindowDetector.swift; sourceTree = "<group>"; };
|
|
| 180 |
+ F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTrimEditorView.swift; sourceTree = "<group>"; };
|
|
| 176 | 181 |
C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceQRCodeView.swift; sourceTree = "<group>"; };
|
| 177 | 182 |
C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceEditorSheetView.swift; sourceTree = "<group>"; };
|
| 178 | 183 |
C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceLibrarySheetView.swift; sourceTree = "<group>"; };
|
@@ -431,6 +436,7 @@ |
||
| 431 | 436 |
4383B461240EB5E400DAAEBF /* AppData.swift */, |
| 432 | 437 |
C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */, |
| 433 | 438 |
C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */, |
| 439 |
+ F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */, |
|
| 434 | 440 |
B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */, |
| 435 | 441 |
C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */, |
| 436 | 442 |
7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */, |
@@ -651,6 +657,7 @@ |
||
| 651 | 657 |
isa = PBXGroup; |
| 652 | 658 |
children = ( |
| 653 | 659 |
D28F11423C8E4A7A00A10052 /* MeterChargeRecordTabView.swift */, |
| 660 |
+ F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */, |
|
| 654 | 661 |
); |
| 655 | 662 |
path = ChargeRecord; |
| 656 | 663 |
sourceTree = "<group>"; |
@@ -780,6 +787,8 @@ |
||
| 780 | 787 |
D28F113D3C8E4A7A00A1004D /* MeterOverviewSectionView.swift in Sources */, |
| 781 | 788 |
C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */, |
| 782 | 789 |
C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */, |
| 790 |
+ F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */, |
|
| 791 |
+ F20000046F5D4C95B6487F19 /* SessionTrimEditorView.swift in Sources */, |
|
| 783 | 792 |
C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */, |
| 784 | 793 |
C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */, |
| 785 | 794 |
C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */, |
@@ -1078,8 +1087,9 @@ |
||
| 1078 | 1087 |
C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */, |
| 1079 | 1088 |
C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */, |
| 1080 | 1089 |
E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */, |
| 1090 |
+ F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */, |
|
| 1081 | 1091 |
); |
| 1082 |
- currentVersion = E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */; |
|
| 1092 |
+ currentVersion = F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */; |
|
| 1083 | 1093 |
path = CKModel.xcdatamodeld; |
| 1084 | 1094 |
sourceTree = "<group>"; |
| 1085 | 1095 |
versionGroupType = wrapper.xcdatamodel; |
@@ -80,7 +80,6 @@ class BluetoothManager : NSObject, ObservableObject {
|
||
| 80 | 80 |
if meter.name == macAddress, let syncedName = appData.meterName(for: macAddress), syncedName != macAddress {
|
| 81 | 81 |
meter.updateNameFromStore(syncedName) |
| 82 | 82 |
} |
| 83 |
- appData.restoreChargeMonitoringStateIfNeeded(for: meter) |
|
| 84 | 83 |
if peripheral.delegate == nil {
|
| 85 | 84 |
peripheral.delegate = meter.btSerial |
| 86 | 85 |
} |
@@ -3,6 +3,6 @@ |
||
| 3 | 3 |
<plist version="1.0"> |
| 4 | 4 |
<dict> |
| 5 | 5 |
<key>_XCCurrentVersionName</key> |
| 6 |
- <string>USB_Meter 14.xcdatamodel</string> |
|
| 6 |
+ <string>USB_Meter 15.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="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="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 56 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 57 |
+ <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 58 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 59 |
+ <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 60 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 61 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 62 |
+ <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 63 |
+ <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 64 |
+ <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 65 |
+ <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 66 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 67 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 68 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 69 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 70 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 71 |
+ <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 72 |
+ <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 73 |
+ <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 74 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 75 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 76 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 77 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 78 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 79 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 80 |
+ <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 81 |
+ <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 82 |
+ <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 83 |
+ <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 84 |
+ <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 85 |
+ <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 86 |
+ <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 87 |
+ <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 88 |
+ <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 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> |
|
@@ -626,9 +626,25 @@ struct ChargeSessionSummary: Identifiable, Hashable {
|
||
| 626 | 626 |
let completionConfirmationRequestedAt: Date? |
| 627 | 627 |
let completionContradictionPercent: Double? |
| 628 | 628 |
let selectedDataGroup: UInt8? |
| 629 |
+ let trimStart: Date? |
|
| 630 |
+ let trimEnd: Date? |
|
| 629 | 631 |
let checkpoints: [ChargeCheckpointSummary] |
| 630 | 632 |
let aggregatedSamples: [ChargeSessionSampleSummary] |
| 631 | 633 |
|
| 634 |
+ var effectiveTrimStart: Date { trimStart ?? startedAt }
|
|
| 635 |
+ var effectiveTrimEnd: Date { trimEnd ?? (endedAt ?? lastObservedAt) }
|
|
| 636 |
+ var isTrimmed: Bool { trimStart != nil || trimEnd != nil }
|
|
| 637 |
+ var effectiveTimeRange: ClosedRange<Date> {
|
|
| 638 |
+ let start = effectiveTrimStart |
|
| 639 |
+ let end = max(effectiveTrimEnd, start) |
|
| 640 |
+ return start...end |
|
| 641 |
+ } |
|
| 642 |
+ var displayedAggregatedSamples: [ChargeSessionSampleSummary] {
|
|
| 643 |
+ guard isTrimmed else { return aggregatedSamples }
|
|
| 644 |
+ let range = effectiveTimeRange |
|
| 645 |
+ return aggregatedSamples.filter { range.contains($0.timestamp) }
|
|
| 646 |
+ } |
|
| 647 |
+ |
|
| 632 | 648 |
var sessionKind: ChargeSessionKind {
|
| 633 | 649 |
ChargeSessionKind( |
| 634 | 650 |
chargingTransportMode: chargingTransportMode, |
@@ -651,7 +667,10 @@ struct ChargeSessionSummary: Identifiable, Hashable {
|
||
| 651 | 667 |
} |
| 652 | 668 |
|
| 653 | 669 |
var effectiveDuration: TimeInterval {
|
| 654 |
- meterObservedDuration ?? duration |
|
| 670 |
+ if isTrimmed {
|
|
| 671 |
+ return max(effectiveTrimEnd.timeIntervalSince(effectiveTrimStart), 0) |
|
| 672 |
+ } |
|
| 673 |
+ return meterObservedDuration ?? duration |
|
| 655 | 674 |
} |
| 656 | 675 |
|
| 657 | 676 |
var effectiveOrMeasuredEnergyWh: Double {
|
@@ -1403,7 +1422,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 1403 | 1422 |
Anchor( |
| 1404 | 1423 |
percent: startBatteryPercent, |
| 1405 | 1424 |
energyWh: 0, |
| 1406 |
- timestamp: session.startedAt, |
|
| 1425 |
+ timestamp: session.effectiveTrimStart, |
|
| 1407 | 1426 |
description: "session start", |
| 1408 | 1427 |
isCheckpoint: false |
| 1409 | 1428 |
) |
@@ -554,6 +554,8 @@ final class ChargeInsightsStore {
|
||
| 554 | 554 |
} |
| 555 | 555 |
|
| 556 | 556 |
var didSave = false |
| 557 |
+ var deviceIDToRefresh: String? |
|
| 558 |
+ |
|
| 557 | 559 |
context.performAndWait {
|
| 558 | 560 |
guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
| 559 | 561 |
return |
@@ -575,13 +577,18 @@ final class ChargeInsightsStore {
|
||
| 575 | 577 |
return |
| 576 | 578 |
} |
| 577 | 579 |
|
| 578 |
- if let deviceID = stringValue(session, key: "chargedDeviceID") {
|
|
| 579 |
- refreshDerivedMetrics(forChargedDeviceID: deviceID) |
|
| 580 |
- didSave = saveContext() |
|
| 581 |
- } else {
|
|
| 582 |
- didSave = true |
|
| 580 |
+ didSave = true |
|
| 581 |
+ deviceIDToRefresh = stringValue(session, key: "chargedDeviceID") |
|
| 582 |
+ } |
|
| 583 |
+ |
|
| 584 |
+ if let deviceID = deviceIDToRefresh {
|
|
| 585 |
+ context.perform { [weak self] in
|
|
| 586 |
+ guard let self else { return }
|
|
| 587 |
+ self.refreshDerivedMetrics(forChargedDeviceID: deviceID) |
|
| 588 |
+ self.saveContext() |
|
| 583 | 589 |
} |
| 584 | 590 |
} |
| 591 |
+ |
|
| 585 | 592 |
return didSave |
| 586 | 593 |
} |
| 587 | 594 |
|
@@ -0,0 +1,94 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargingWindowDetector.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import Foundation |
|
| 7 |
+ |
|
| 8 |
+enum ChargingWindowDetector {
|
|
| 9 |
+ |
|
| 10 |
+ struct DetectedWindow {
|
|
| 11 |
+ let start: Date |
|
| 12 |
+ let end: Date |
|
| 13 |
+ // How much shorter the window is vs total session (0..1). Higher = more trimming needed. |
|
| 14 |
+ let trimRatio: Double |
|
| 15 |
+ } |
|
| 16 |
+ |
|
| 17 |
+ // Power above this threshold counts as "charging activity" (Watts) |
|
| 18 |
+ private static let activityThreshold = 0.05 |
|
| 19 |
+ // A charging segment must last at least this long to be considered real |
|
| 20 |
+ private static let minimumSegmentDuration: TimeInterval = 3 * 60 |
|
| 21 |
+ // Gaps shorter than this between active segments are bridged (e.g. brief wireless drop) |
|
| 22 |
+ private static let mergeGapDuration: TimeInterval = 120 |
|
| 23 |
+ // Padding added before first and after last sample of the detected window |
|
| 24 |
+ private static let padding: TimeInterval = 30 |
|
| 25 |
+ // Only surface detection when active window is shorter than this fraction of total session |
|
| 26 |
+ // e.g. 0.30 means: show banner if active charging < 70% of total session time |
|
| 27 |
+ static let significantTrimThreshold = 0.30 |
|
| 28 |
+ |
|
| 29 |
+ static func detect( |
|
| 30 |
+ samples: [ChargeSessionSampleSummary], |
|
| 31 |
+ sessionStart: Date, |
|
| 32 |
+ sessionEnd: Date |
|
| 33 |
+ ) -> DetectedWindow? {
|
|
| 34 |
+ guard !samples.isEmpty else { return nil }
|
|
| 35 |
+ let sorted = samples.sorted { $0.timestamp < $1.timestamp }
|
|
| 36 |
+ |
|
| 37 |
+ // Build contiguous active segments |
|
| 38 |
+ struct Segment { var start: Date; var end: Date }
|
|
| 39 |
+ var segments: [Segment] = [] |
|
| 40 |
+ var segStart: Date? |
|
| 41 |
+ var segEnd: Date? |
|
| 42 |
+ |
|
| 43 |
+ for sample in sorted {
|
|
| 44 |
+ if sample.averagePowerWatts >= activityThreshold {
|
|
| 45 |
+ if segStart == nil { segStart = sample.timestamp }
|
|
| 46 |
+ segEnd = sample.timestamp |
|
| 47 |
+ } else {
|
|
| 48 |
+ if let s = segStart, let e = segEnd {
|
|
| 49 |
+ segments.append(Segment(start: s, end: e)) |
|
| 50 |
+ } |
|
| 51 |
+ segStart = nil |
|
| 52 |
+ segEnd = nil |
|
| 53 |
+ } |
|
| 54 |
+ } |
|
| 55 |
+ if let s = segStart, let e = segEnd {
|
|
| 56 |
+ segments.append(Segment(start: s, end: e)) |
|
| 57 |
+ } |
|
| 58 |
+ |
|
| 59 |
+ guard !segments.isEmpty else { return nil }
|
|
| 60 |
+ |
|
| 61 |
+ // Merge segments separated by short gaps |
|
| 62 |
+ var merged: [Segment] = [segments[0]] |
|
| 63 |
+ for seg in segments.dropFirst() {
|
|
| 64 |
+ let gap = seg.start.timeIntervalSince(merged[merged.count - 1].end) |
|
| 65 |
+ if gap <= mergeGapDuration {
|
|
| 66 |
+ merged[merged.count - 1].end = seg.end |
|
| 67 |
+ } else {
|
|
| 68 |
+ merged.append(seg) |
|
| 69 |
+ } |
|
| 70 |
+ } |
|
| 71 |
+ |
|
| 72 |
+ // Filter out short segments |
|
| 73 |
+ let significant = merged.filter {
|
|
| 74 |
+ $0.end.timeIntervalSince($0.start) >= minimumSegmentDuration |
|
| 75 |
+ } |
|
| 76 |
+ |
|
| 77 |
+ guard !significant.isEmpty else { return nil }
|
|
| 78 |
+ |
|
| 79 |
+ // Pick primary segment: the longest one |
|
| 80 |
+ let primary = significant.max { a, b in
|
|
| 81 |
+ a.end.timeIntervalSince(a.start) < b.end.timeIntervalSince(b.start) |
|
| 82 |
+ }! |
|
| 83 |
+ |
|
| 84 |
+ let windowStart = max(sessionStart, primary.start.addingTimeInterval(-padding)) |
|
| 85 |
+ let windowEnd = min(sessionEnd, primary.end.addingTimeInterval(padding)) |
|
| 86 |
+ |
|
| 87 |
+ let sessionDuration = sessionEnd.timeIntervalSince(sessionStart) |
|
| 88 |
+ let windowDuration = windowEnd.timeIntervalSince(windowStart) |
|
| 89 |
+ guard sessionDuration > 0 else { return nil }
|
|
| 90 |
+ |
|
| 91 |
+ let trimRatio = 1.0 - (windowDuration / sessionDuration) |
|
| 92 |
+ return DetectedWindow(start: windowStart, end: windowEnd, trimRatio: trimRatio) |
|
| 93 |
+ } |
|
| 94 |
+} |
|
@@ -312,14 +312,18 @@ class Measurements : ObservableObject {
|
||
| 312 | 312 |
} |
| 313 | 313 |
|
| 314 | 314 |
func restorePersistedChargeSessionSamplesIfNeeded( |
| 315 |
- from session: ChargeSessionSummary |
|
| 315 |
+ from session: ChargeSessionSummary, |
|
| 316 |
+ replacingLiveBufferIfNeeded: Bool = false |
|
| 316 | 317 |
) {
|
| 317 |
- guard power.points.isEmpty, |
|
| 318 |
- voltage.points.isEmpty, |
|
| 319 |
- current.points.isEmpty, |
|
| 320 |
- temperature.points.isEmpty, |
|
| 321 |
- energy.points.isEmpty, |
|
| 322 |
- rssi.points.isEmpty else {
|
|
| 318 |
+ let hasExistingBuffer = |
|
| 319 |
+ power.points.isEmpty == false || |
|
| 320 |
+ voltage.points.isEmpty == false || |
|
| 321 |
+ current.points.isEmpty == false || |
|
| 322 |
+ temperature.points.isEmpty == false || |
|
| 323 |
+ energy.points.isEmpty == false || |
|
| 324 |
+ rssi.points.isEmpty == false |
|
| 325 |
+ |
|
| 326 |
+ guard hasExistingBuffer == false || replacingLiveBufferIfNeeded else {
|
|
| 323 | 327 |
return |
| 324 | 328 |
} |
| 325 | 329 |
|
@@ -332,24 +336,57 @@ class Measurements : ObservableObject {
|
||
| 332 | 336 |
|
| 333 | 337 |
guard !sortedSamples.isEmpty else { return }
|
| 334 | 338 |
|
| 339 |
+ let preservedEnergyCounterValue = lastEnergyCounterValue |
|
| 340 |
+ let preservedEnergyGroupID = lastEnergyGroupID |
|
| 341 |
+ let persistedRangeUpperBound = sortedSamples.last?.timestamp |
|
| 342 |
+ if hasExistingBuffer {
|
|
| 343 |
+ flushPendingValues() |
|
| 344 |
+ } |
|
| 345 |
+ |
|
| 335 | 346 |
resetPendingAggregation() |
| 336 | 347 |
|
| 337 |
- power.replacePoints(restoredPoints(from: sortedSamples) { sample in
|
|
| 338 |
- sample.averagePowerWatts |
|
| 339 |
- }) |
|
| 340 |
- current.replacePoints(restoredPoints(from: sortedSamples) { sample in
|
|
| 341 |
- sample.averageCurrentAmps |
|
| 342 |
- }) |
|
| 343 |
- voltage.replacePoints(restoredPoints(from: sortedSamples) { sample in
|
|
| 344 |
- sample.averageVoltageVolts |
|
| 345 |
- }) |
|
| 346 |
- energy.replacePoints(restoredPoints(from: sortedSamples) { sample in
|
|
| 347 |
- sample.measuredEnergyWh |
|
| 348 |
- }) |
|
| 349 |
- temperature.resetSeries() |
|
| 350 |
- rssi.resetSeries() |
|
| 351 |
- lastEnergyCounterValue = nil |
|
| 352 |
- lastEnergyGroupID = nil |
|
| 348 |
+ power.replacePoints(mergedRestoredPoints( |
|
| 349 |
+ restored: restoredPoints(from: sortedSamples) { sample in
|
|
| 350 |
+ sample.averagePowerWatts |
|
| 351 |
+ }, |
|
| 352 |
+ existing: power.points, |
|
| 353 |
+ persistedRangeUpperBound: persistedRangeUpperBound |
|
| 354 |
+ )) |
|
| 355 |
+ current.replacePoints(mergedRestoredPoints( |
|
| 356 |
+ restored: restoredPoints(from: sortedSamples) { sample in
|
|
| 357 |
+ sample.averageCurrentAmps |
|
| 358 |
+ }, |
|
| 359 |
+ existing: current.points, |
|
| 360 |
+ persistedRangeUpperBound: persistedRangeUpperBound |
|
| 361 |
+ )) |
|
| 362 |
+ voltage.replacePoints(mergedRestoredPoints( |
|
| 363 |
+ restored: restoredPoints(from: sortedSamples) { sample in
|
|
| 364 |
+ sample.averageVoltageVolts |
|
| 365 |
+ }, |
|
| 366 |
+ existing: voltage.points, |
|
| 367 |
+ persistedRangeUpperBound: persistedRangeUpperBound |
|
| 368 |
+ )) |
|
| 369 |
+ energy.replacePoints(mergedRestoredPoints( |
|
| 370 |
+ restored: restoredPoints(from: sortedSamples) { sample in
|
|
| 371 |
+ sample.measuredEnergyWh |
|
| 372 |
+ }, |
|
| 373 |
+ existing: energy.points, |
|
| 374 |
+ persistedRangeUpperBound: persistedRangeUpperBound |
|
| 375 |
+ )) |
|
| 376 |
+ temperature.replacePoints( |
|
| 377 |
+ preservedTailPoints( |
|
| 378 |
+ from: temperature.points, |
|
| 379 |
+ after: persistedRangeUpperBound |
|
| 380 |
+ ) |
|
| 381 |
+ ) |
|
| 382 |
+ rssi.replacePoints( |
|
| 383 |
+ preservedTailPoints( |
|
| 384 |
+ from: rssi.points, |
|
| 385 |
+ after: persistedRangeUpperBound |
|
| 386 |
+ ) |
|
| 387 |
+ ) |
|
| 388 |
+ lastEnergyCounterValue = hasExistingBuffer ? preservedEnergyCounterValue : nil |
|
| 389 |
+ lastEnergyGroupID = hasExistingBuffer ? preservedEnergyGroupID : nil |
|
| 353 | 390 |
accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0 |
| 354 | 391 |
self.objectWillChange.send() |
| 355 | 392 |
} |
@@ -390,6 +427,63 @@ class Measurements : ObservableObject {
|
||
| 390 | 427 |
return restored |
| 391 | 428 |
} |
| 392 | 429 |
|
| 430 |
+ private func mergedRestoredPoints( |
|
| 431 |
+ restored: [Measurement.Point], |
|
| 432 |
+ existing: [Measurement.Point], |
|
| 433 |
+ persistedRangeUpperBound: Date? |
|
| 434 |
+ ) -> [Measurement.Point] {
|
|
| 435 |
+ var merged = restored |
|
| 436 |
+ let preservedTail = preservedTailPoints(from: existing, after: persistedRangeUpperBound) |
|
| 437 |
+ |
|
| 438 |
+ guard preservedTail.isEmpty == false else {
|
|
| 439 |
+ return merged |
|
| 440 |
+ } |
|
| 441 |
+ |
|
| 442 |
+ if let tailFirst = preservedTail.first, |
|
| 443 |
+ tailFirst.isSample, |
|
| 444 |
+ let lastRestoredSample = merged.last(where: \.isSample), |
|
| 445 |
+ lastRestoredSample.timestamp < tailFirst.timestamp {
|
|
| 446 |
+ merged.append( |
|
| 447 |
+ Measurement.Point( |
|
| 448 |
+ id: merged.count, |
|
| 449 |
+ timestamp: tailFirst.timestamp, |
|
| 450 |
+ value: merged.last?.value ?? tailFirst.value, |
|
| 451 |
+ kind: .discontinuity |
|
| 452 |
+ ) |
|
| 453 |
+ ) |
|
| 454 |
+ } |
|
| 455 |
+ |
|
| 456 |
+ merged.append(contentsOf: preservedTail.enumerated().map { offset, point in
|
|
| 457 |
+ Measurement.Point( |
|
| 458 |
+ id: merged.count + offset, |
|
| 459 |
+ timestamp: point.timestamp, |
|
| 460 |
+ value: point.value, |
|
| 461 |
+ kind: point.kind |
|
| 462 |
+ ) |
|
| 463 |
+ }) |
|
| 464 |
+ return merged |
|
| 465 |
+ } |
|
| 466 |
+ |
|
| 467 |
+ private func preservedTailPoints( |
|
| 468 |
+ from existing: [Measurement.Point], |
|
| 469 |
+ after persistedRangeUpperBound: Date? |
|
| 470 |
+ ) -> [Measurement.Point] {
|
|
| 471 |
+ guard let persistedRangeUpperBound else {
|
|
| 472 |
+ return existing |
|
| 473 |
+ } |
|
| 474 |
+ |
|
| 475 |
+ let tail = existing.filter { $0.timestamp > persistedRangeUpperBound }
|
|
| 476 |
+ guard tail.isEmpty == false else {
|
|
| 477 |
+ return [] |
|
| 478 |
+ } |
|
| 479 |
+ |
|
| 480 |
+ if let firstSampleIndex = tail.firstIndex(where: \.isSample) {
|
|
| 481 |
+ return Array(tail[firstSampleIndex...]) |
|
| 482 |
+ } |
|
| 483 |
+ |
|
| 484 |
+ return [] |
|
| 485 |
+ } |
|
| 486 |
+ |
|
| 393 | 487 |
func resetSeries() {
|
| 394 | 488 |
power.resetSeries() |
| 395 | 489 |
voltage.resetSeries() |
@@ -574,12 +574,13 @@ struct ChargedDeviceDetailView: View {
|
||
| 574 | 574 |
} |
| 575 | 575 |
|
| 576 | 576 |
private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
|
| 577 |
+ let displayedSamples = session.displayedAggregatedSamples |
|
| 577 | 578 |
let currentSeries = storedSeriesSnapshot( |
| 578 |
- from: session.aggregatedSamples, |
|
| 579 |
+ from: displayedSamples, |
|
| 579 | 580 |
minimumYSpan: 0.15 |
| 580 | 581 |
) { $0.averageCurrentAmps }
|
| 581 | 582 |
let energySeries = storedSeriesSnapshot( |
| 582 |
- from: session.aggregatedSamples, |
|
| 583 |
+ from: displayedSamples, |
|
| 583 | 584 |
minimumYSpan: 0.2 |
| 584 | 585 |
) { $0.measuredEnergyWh }
|
| 585 | 586 |
|
@@ -594,14 +595,18 @@ struct ChargedDeviceDetailView: View {
|
||
| 594 | 595 |
message: "Database storage and iCloud sync use 300 aggregated points per hour. Active sessions persist their partial aggregated curve continuously, so a reconnect or restart can restore the stored progress." |
| 595 | 596 |
) |
| 596 | 597 |
} |
| 597 |
- Text(session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.") |
|
| 598 |
+ Text(session.isTrimmed |
|
| 599 |
+ ? "Showing the saved trim window at aggregated resolution." |
|
| 600 |
+ : (session.status.isOpen |
|
| 601 |
+ ? "Open session, persisted as aggregated samples." |
|
| 602 |
+ : "Most recent persisted session at aggregated resolution.")) |
|
| 598 | 603 |
.font(.caption) |
| 599 | 604 |
.foregroundColor(.secondary) |
| 600 | 605 |
} |
| 601 | 606 |
|
| 602 | 607 |
Spacer() |
| 603 | 608 |
|
| 604 |
- Text("\(session.aggregatedSamples.count) points")
|
|
| 609 |
+ Text("\(displayedSamples.count) points")
|
|
| 605 | 610 |
.font(.caption.weight(.semibold)) |
| 606 | 611 |
.foregroundColor(.secondary) |
| 607 | 612 |
} |
@@ -787,6 +792,9 @@ struct ChargedDeviceDetailView: View {
|
||
| 787 | 792 |
if chargedDevice?.shouldShowChargingStateMode(session.chargingStateMode) != false {
|
| 788 | 793 |
components.append(session.chargingStateMode.title) |
| 789 | 794 |
} |
| 795 |
+ if session.isTrimmed {
|
|
| 796 |
+ components.append("Trimmed")
|
|
| 797 |
+ } |
|
| 790 | 798 |
components.append(session.sourceMode.title) |
| 791 | 799 |
return components.joined(separator: " • ") |
| 792 | 800 |
} |
@@ -13,6 +13,34 @@ private enum PresentTrackingMode: CaseIterable, Hashable {
|
||
| 13 | 13 |
case keepStartTimestamp |
| 14 | 14 |
} |
| 15 | 15 |
|
| 16 |
+enum MeasurementChartSelectorActionTone {
|
|
| 17 |
+ case reversible |
|
| 18 |
+ case destructive |
|
| 19 |
+ case destructiveProminent |
|
| 20 |
+} |
|
| 21 |
+ |
|
| 22 |
+struct MeasurementChartSelectionAction {
|
|
| 23 |
+ let title: String |
|
| 24 |
+ let systemName: String |
|
| 25 |
+ let tone: MeasurementChartSelectorActionTone |
|
| 26 |
+ let handler: (ClosedRange<Date>) -> Void |
|
| 27 |
+} |
|
| 28 |
+ |
|
| 29 |
+struct MeasurementChartResetAction {
|
|
| 30 |
+ let title: String |
|
| 31 |
+ let systemName: String |
|
| 32 |
+ let tone: MeasurementChartSelectorActionTone |
|
| 33 |
+ let confirmationTitle: String |
|
| 34 |
+ let confirmationButtonTitle: String |
|
| 35 |
+ let handler: () -> Void |
|
| 36 |
+} |
|
| 37 |
+ |
|
| 38 |
+struct MeasurementChartRangeSelectorConfiguration {
|
|
| 39 |
+ let keepAction: MeasurementChartSelectionAction |
|
| 40 |
+ let removeAction: MeasurementChartSelectionAction? |
|
| 41 |
+ let resetAction: MeasurementChartResetAction |
|
| 42 |
+} |
|
| 43 |
+ |
|
| 16 | 44 |
struct MeasurementChartView: View {
|
| 17 | 45 |
private enum SmoothingLevel: CaseIterable, Hashable {
|
| 18 | 46 |
case off |
@@ -99,11 +127,15 @@ struct MeasurementChartView: View {
|
||
| 99 | 127 |
let availableSize: CGSize |
| 100 | 128 |
let showsRangeSelector: Bool |
| 101 | 129 |
let rebasesEnergyToVisibleRangeStart: Bool |
| 130 |
+ let extendsTimelineToPresent: Bool |
|
| 131 |
+ let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? |
|
| 102 | 132 |
|
| 103 | 133 |
@EnvironmentObject private var measurements: Measurements |
| 104 | 134 |
@Environment(\.horizontalSizeClass) private var horizontalSizeClass |
| 105 | 135 |
@Environment(\.verticalSizeClass) private var verticalSizeClass |
| 106 | 136 |
var timeRange: ClosedRange<Date>? = nil |
| 137 |
+ let timeRangeLowerBound: Date? |
|
| 138 |
+ let timeRangeUpperBound: Date? |
|
| 107 | 139 |
|
| 108 | 140 |
@State var displayVoltage: Bool = false |
| 109 | 141 |
@State var displayCurrent: Bool = false |
@@ -131,14 +163,22 @@ struct MeasurementChartView: View {
|
||
| 131 | 163 |
compactLayout: Bool = false, |
| 132 | 164 |
availableSize: CGSize = .zero, |
| 133 | 165 |
timeRange: ClosedRange<Date>? = nil, |
| 166 |
+ timeRangeLowerBound: Date? = nil, |
|
| 167 |
+ timeRangeUpperBound: Date? = nil, |
|
| 134 | 168 |
showsRangeSelector: Bool = true, |
| 135 |
- rebasesEnergyToVisibleRangeStart: Bool = false |
|
| 169 |
+ rebasesEnergyToVisibleRangeStart: Bool = false, |
|
| 170 |
+ extendsTimelineToPresent: Bool = true, |
|
| 171 |
+ rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil |
|
| 136 | 172 |
) {
|
| 137 | 173 |
self.compactLayout = compactLayout |
| 138 | 174 |
self.availableSize = availableSize |
| 139 | 175 |
self.timeRange = timeRange |
| 176 |
+ self.timeRangeLowerBound = timeRangeLowerBound |
|
| 177 |
+ self.timeRangeUpperBound = timeRangeUpperBound |
|
| 140 | 178 |
self.showsRangeSelector = showsRangeSelector |
| 141 | 179 |
self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart |
| 180 |
+ self.extendsTimelineToPresent = extendsTimelineToPresent |
|
| 181 |
+ self.rangeSelectorConfiguration = rangeSelectorConfiguration |
|
| 142 | 182 |
} |
| 143 | 183 |
|
| 144 | 184 |
private var axisColumnWidth: CGFloat {
|
@@ -399,9 +439,7 @@ struct MeasurementChartView: View {
|
||
| 399 | 439 |
selectorTint: selectorTint, |
| 400 | 440 |
compactLayout: compactLayout, |
| 401 | 441 |
minimumSelectionSpan: minimumTimeSpan, |
| 402 |
- onKeepSelection: trimBufferToSelection, |
|
| 403 |
- onRemoveSelection: removeSelectionFromBuffer, |
|
| 404 |
- onResetBuffer: resetBuffer, |
|
| 442 |
+ configuration: resolvedRangeSelectorConfiguration(), |
|
| 405 | 443 |
selectedTimeRange: $selectedVisibleTimeRange, |
| 406 | 444 |
isPinnedToPresent: $isPinnedToPresent, |
| 407 | 445 |
presentTrackingMode: $presentTrackingMode |
@@ -423,7 +461,7 @@ struct MeasurementChartView: View {
|
||
| 423 | 461 |
.font(chartBaseFont) |
| 424 | 462 |
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
| 425 | 463 |
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
|
| 426 |
- guard timeRange == nil else { return }
|
|
| 464 |
+ guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
|
|
| 427 | 465 |
chartNow = now |
| 428 | 466 |
} |
| 429 | 467 |
} |
@@ -747,6 +785,35 @@ struct MeasurementChartView: View {
|
||
| 747 | 785 |
measurements.resetSeries() |
| 748 | 786 |
} |
| 749 | 787 |
|
| 788 |
+ private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
|
|
| 789 |
+ if let rangeSelectorConfiguration {
|
|
| 790 |
+ return rangeSelectorConfiguration |
|
| 791 |
+ } |
|
| 792 |
+ |
|
| 793 |
+ return MeasurementChartRangeSelectorConfiguration( |
|
| 794 |
+ keepAction: MeasurementChartSelectionAction( |
|
| 795 |
+ title: compactLayout ? "Keep" : "Keep Selection", |
|
| 796 |
+ systemName: "scissors", |
|
| 797 |
+ tone: .destructive, |
|
| 798 |
+ handler: trimBufferToSelection |
|
| 799 |
+ ), |
|
| 800 |
+ removeAction: MeasurementChartSelectionAction( |
|
| 801 |
+ title: compactLayout ? "Cut" : "Remove Selection", |
|
| 802 |
+ systemName: "minus.circle", |
|
| 803 |
+ tone: .destructive, |
|
| 804 |
+ handler: removeSelectionFromBuffer |
|
| 805 |
+ ), |
|
| 806 |
+ resetAction: MeasurementChartResetAction( |
|
| 807 |
+ title: compactLayout ? "Reset" : "Reset Buffer", |
|
| 808 |
+ systemName: "trash", |
|
| 809 |
+ tone: .destructiveProminent, |
|
| 810 |
+ confirmationTitle: "Reset captured measurements?", |
|
| 811 |
+ confirmationButtonTitle: "Reset buffer", |
|
| 812 |
+ handler: resetBuffer |
|
| 813 |
+ ) |
|
| 814 |
+ ) |
|
| 815 |
+ } |
|
| 816 |
+ |
|
| 750 | 817 |
private func seriesToggleFont(condensedLayout: Bool) -> Font {
|
| 751 | 818 |
if isLargeDisplay {
|
| 752 | 819 |
return .body.weight(.semibold) |
@@ -1320,11 +1387,19 @@ struct MeasurementChartView: View {
|
||
| 1320 | 1387 |
} |
| 1321 | 1388 |
|
| 1322 | 1389 |
let samplePoints = timelineSamplePoints() |
| 1323 |
- guard let lowerBound = samplePoints.first?.timestamp else {
|
|
| 1390 |
+ let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp |
|
| 1391 |
+ guard let lowerBound else {
|
|
| 1324 | 1392 |
return nil |
| 1325 | 1393 |
} |
| 1326 | 1394 |
|
| 1327 |
- let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow) |
|
| 1395 |
+ let latestSampleTimestamp = samplePoints.last?.timestamp |
|
| 1396 |
+ let resolvedUpperBound = timeRangeUpperBound ?? {
|
|
| 1397 |
+ guard extendsTimelineToPresent else {
|
|
| 1398 |
+ return latestSampleTimestamp ?? lowerBound |
|
| 1399 |
+ } |
|
| 1400 |
+ return max(latestSampleTimestamp ?? chartNow, chartNow) |
|
| 1401 |
+ }() |
|
| 1402 |
+ let upperBound = max(resolvedUpperBound, lowerBound) |
|
| 1328 | 1403 |
return normalizedTimeRange(lowerBound...upperBound) |
| 1329 | 1404 |
} |
| 1330 | 1405 |
|
@@ -1903,12 +1978,6 @@ private struct TimeRangeSelectorView: View {
|
||
| 1903 | 1978 |
case window |
| 1904 | 1979 |
} |
| 1905 | 1980 |
|
| 1906 |
- private enum ActionTone {
|
|
| 1907 |
- case reversible |
|
| 1908 |
- case destructive |
|
| 1909 |
- case destructiveProminent |
|
| 1910 |
- } |
|
| 1911 |
- |
|
| 1912 | 1981 |
private struct DragState {
|
| 1913 | 1982 |
let target: DragTarget |
| 1914 | 1983 |
let initialRange: ClosedRange<Date> |
@@ -1920,9 +1989,7 @@ private struct TimeRangeSelectorView: View {
|
||
| 1920 | 1989 |
let selectorTint: Color |
| 1921 | 1990 |
let compactLayout: Bool |
| 1922 | 1991 |
let minimumSelectionSpan: TimeInterval |
| 1923 |
- let onKeepSelection: (ClosedRange<Date>) -> Void |
|
| 1924 |
- let onRemoveSelection: (ClosedRange<Date>) -> Void |
|
| 1925 |
- let onResetBuffer: () -> Void |
|
| 1992 |
+ let configuration: MeasurementChartRangeSelectorConfiguration |
|
| 1926 | 1993 |
|
| 1927 | 1994 |
@Binding var selectedTimeRange: ClosedRange<Date>? |
| 1928 | 1995 |
@Binding var isPinnedToPresent: Bool |
@@ -1985,38 +2052,43 @@ private struct TimeRangeSelectorView: View {
|
||
| 1985 | 2052 |
HStack(spacing: 8) {
|
| 1986 | 2053 |
if !coversFullRange {
|
| 1987 | 2054 |
actionButton( |
| 1988 |
- title: compactLayout ? "Keep" : "Keep Selection", |
|
| 1989 |
- systemName: "scissors", |
|
| 1990 |
- tone: .destructive, |
|
| 2055 |
+ title: configuration.keepAction.title, |
|
| 2056 |
+ systemName: configuration.keepAction.systemName, |
|
| 2057 |
+ tone: configuration.keepAction.tone, |
|
| 1991 | 2058 |
action: {
|
| 1992 |
- onKeepSelection(currentRange) |
|
| 2059 |
+ configuration.keepAction.handler(currentRange) |
|
| 2060 |
+ resetSelectionState() |
|
| 1993 | 2061 |
} |
| 1994 | 2062 |
) |
| 1995 | 2063 |
|
| 1996 |
- actionButton( |
|
| 1997 |
- title: compactLayout ? "Cut" : "Remove Selection", |
|
| 1998 |
- systemName: "minus.circle", |
|
| 1999 |
- tone: .destructive, |
|
| 2000 |
- action: {
|
|
| 2001 |
- onRemoveSelection(currentRange) |
|
| 2002 |
- } |
|
| 2003 |
- ) |
|
| 2064 |
+ if let removeAction = configuration.removeAction {
|
|
| 2065 |
+ actionButton( |
|
| 2066 |
+ title: removeAction.title, |
|
| 2067 |
+ systemName: removeAction.systemName, |
|
| 2068 |
+ tone: removeAction.tone, |
|
| 2069 |
+ action: {
|
|
| 2070 |
+ removeAction.handler(currentRange) |
|
| 2071 |
+ resetSelectionState() |
|
| 2072 |
+ } |
|
| 2073 |
+ ) |
|
| 2074 |
+ } |
|
| 2004 | 2075 |
} |
| 2005 | 2076 |
|
| 2006 | 2077 |
Spacer(minLength: 0) |
| 2007 | 2078 |
|
| 2008 | 2079 |
actionButton( |
| 2009 |
- title: compactLayout ? "Reset" : "Reset Buffer", |
|
| 2010 |
- systemName: "trash", |
|
| 2011 |
- tone: .destructiveProminent, |
|
| 2080 |
+ title: configuration.resetAction.title, |
|
| 2081 |
+ systemName: configuration.resetAction.systemName, |
|
| 2082 |
+ tone: configuration.resetAction.tone, |
|
| 2012 | 2083 |
action: {
|
| 2013 | 2084 |
showResetConfirmation = true |
| 2014 | 2085 |
} |
| 2015 | 2086 |
) |
| 2016 | 2087 |
} |
| 2017 |
- .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
|
|
| 2018 |
- Button("Reset buffer", role: .destructive) {
|
|
| 2019 |
- onResetBuffer() |
|
| 2088 |
+ .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
|
|
| 2089 |
+ Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
|
|
| 2090 |
+ configuration.resetAction.handler() |
|
| 2091 |
+ resetSelectionState() |
|
| 2020 | 2092 |
} |
| 2021 | 2093 |
Button("Cancel", role: .cancel) {}
|
| 2022 | 2094 |
} |
@@ -2164,7 +2236,7 @@ private struct TimeRangeSelectorView: View {
|
||
| 2164 | 2236 |
private func actionButton( |
| 2165 | 2237 |
title: String, |
| 2166 | 2238 |
systemName: String, |
| 2167 |
- tone: ActionTone, |
|
| 2239 |
+ tone: MeasurementChartSelectorActionTone, |
|
| 2168 | 2240 |
action: @escaping () -> Void |
| 2169 | 2241 |
) -> some View {
|
| 2170 | 2242 |
let foregroundColor: Color = {
|
@@ -2194,7 +2266,7 @@ private struct TimeRangeSelectorView: View {
|
||
| 2194 | 2266 |
) |
| 2195 | 2267 |
} |
| 2196 | 2268 |
|
| 2197 |
- private func toneColor(for tone: ActionTone) -> Color {
|
|
| 2269 |
+ private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
|
|
| 2198 | 2270 |
switch tone {
|
| 2199 | 2271 |
case .reversible: |
| 2200 | 2272 |
return selectorTint |
@@ -2203,7 +2275,7 @@ private struct TimeRangeSelectorView: View {
|
||
| 2203 | 2275 |
} |
| 2204 | 2276 |
} |
| 2205 | 2277 |
|
| 2206 |
- private func actionButtonBackground(for tone: ActionTone) -> Color {
|
|
| 2278 |
+ private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
|
|
| 2207 | 2279 |
switch tone {
|
| 2208 | 2280 |
case .reversible: |
| 2209 | 2281 |
return selectorTint.opacity(0.12) |
@@ -2214,7 +2286,7 @@ private struct TimeRangeSelectorView: View {
|
||
| 2214 | 2286 |
} |
| 2215 | 2287 |
} |
| 2216 | 2288 |
|
| 2217 |
- private func actionButtonBorder(for tone: ActionTone) -> Color {
|
|
| 2289 |
+ private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
|
|
| 2218 | 2290 |
switch tone {
|
| 2219 | 2291 |
case .reversible: |
| 2220 | 2292 |
return selectorTint.opacity(0.22) |
@@ -2529,6 +2601,11 @@ private struct TimeRangeSelectorView: View {
|
||
| 2529 | 2601 |
isPinnedToPresent = pinToPresent |
| 2530 | 2602 |
} |
| 2531 | 2603 |
|
| 2604 |
+ private func resetSelectionState() {
|
|
| 2605 |
+ selectedTimeRange = nil |
|
| 2606 |
+ isPinnedToPresent = false |
|
| 2607 |
+ } |
|
| 2608 |
+ |
|
| 2532 | 2609 |
private func selectionTouchesPresent( |
| 2533 | 2610 |
_ range: ClosedRange<Date> |
| 2534 | 2611 |
) -> Bool {
|
@@ -0,0 +1,378 @@ |
||
| 1 |
+// |
|
| 2 |
+// SessionTrimEditorView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SessionTrimEditorView: View {
|
|
| 9 |
+ |
|
| 10 |
+ let session: ChargeSessionSummary |
|
| 11 |
+ let liveTimeRange: ClosedRange<Date>? |
|
| 12 |
+ let onApply: (Date?, Date?) -> Void |
|
| 13 |
+ let onDismiss: () -> Void |
|
| 14 |
+ |
|
| 15 |
+ @State private var trimStart: Date |
|
| 16 |
+ @State private var trimEnd: Date |
|
| 17 |
+ |
|
| 18 |
+ private var fullStart: Date { liveTimeRange?.lowerBound ?? session.startedAt }
|
|
| 19 |
+ private var fullEnd: Date { liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt) }
|
|
| 20 |
+ private var sessionDuration: TimeInterval { max(fullEnd.timeIntervalSince(fullStart), 1) }
|
|
| 21 |
+ |
|
| 22 |
+ private var startFraction: Double {
|
|
| 23 |
+ trimStart.timeIntervalSince(fullStart) / sessionDuration |
|
| 24 |
+ } |
|
| 25 |
+ private var endFraction: Double {
|
|
| 26 |
+ trimEnd.timeIntervalSince(fullStart) / sessionDuration |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ // Energy preview from cumulative sample values |
|
| 30 |
+ private var previewEnergyWh: Double {
|
|
| 31 |
+ let sorted = session.aggregatedSamples.sorted { $0.timestamp < $1.timestamp }
|
|
| 32 |
+ let baseline = sorted.last { $0.timestamp <= trimStart }
|
|
| 33 |
+ guard let endSample = sorted.last(where: { $0.timestamp <= trimEnd }) else { return 0 }
|
|
| 34 |
+ return max(endSample.measuredEnergyWh - (baseline?.measuredEnergyWh ?? 0), 0) |
|
| 35 |
+ } |
|
| 36 |
+ |
|
| 37 |
+ private var trimmedDuration: TimeInterval {
|
|
| 38 |
+ max(trimEnd.timeIntervalSince(trimStart), 0) |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ private var checkpointsToRemove: [ChargeCheckpointSummary] {
|
|
| 42 |
+ session.checkpoints.filter { $0.timestamp < trimStart || $0.timestamp > trimEnd }
|
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ private var isModified: Bool {
|
|
| 46 |
+ trimStart != (session.trimStart ?? fullStart) || |
|
| 47 |
+ trimEnd != (session.trimEnd ?? fullEnd) |
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ init( |
|
| 51 |
+ session: ChargeSessionSummary, |
|
| 52 |
+ detectedWindow: ChargingWindowDetector.DetectedWindow? = nil, |
|
| 53 |
+ liveTimeRange: ClosedRange<Date>? = nil, |
|
| 54 |
+ onApply: @escaping (Date?, Date?) -> Void, |
|
| 55 |
+ onDismiss: @escaping () -> Void |
|
| 56 |
+ ) {
|
|
| 57 |
+ self.session = session |
|
| 58 |
+ self.liveTimeRange = liveTimeRange |
|
| 59 |
+ self.onApply = onApply |
|
| 60 |
+ self.onDismiss = onDismiss |
|
| 61 |
+ |
|
| 62 |
+ let fullStart = liveTimeRange?.lowerBound ?? session.startedAt |
|
| 63 |
+ let fullEnd = liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt) |
|
| 64 |
+ let start = session.trimStart |
|
| 65 |
+ ?? detectedWindow?.start |
|
| 66 |
+ ?? fullStart |
|
| 67 |
+ let end = session.trimEnd |
|
| 68 |
+ ?? detectedWindow?.end |
|
| 69 |
+ ?? fullEnd |
|
| 70 |
+ |
|
| 71 |
+ _trimStart = State(initialValue: start) |
|
| 72 |
+ _trimEnd = State(initialValue: end) |
|
| 73 |
+ } |
|
| 74 |
+ |
|
| 75 |
+ var body: some View {
|
|
| 76 |
+ VStack(spacing: 0) {
|
|
| 77 |
+ header |
|
| 78 |
+ ScrollView {
|
|
| 79 |
+ VStack(spacing: 16) {
|
|
| 80 |
+ chartWithHandles |
|
| 81 |
+ rangeControls |
|
| 82 |
+ previewMetrics |
|
| 83 |
+ if !checkpointsToRemove.isEmpty {
|
|
| 84 |
+ checkpointWarning |
|
| 85 |
+ } |
|
| 86 |
+ } |
|
| 87 |
+ .padding(16) |
|
| 88 |
+ } |
|
| 89 |
+ applyBar |
|
| 90 |
+ } |
|
| 91 |
+ .background(Color(.systemGroupedBackground).ignoresSafeArea()) |
|
| 92 |
+ } |
|
| 93 |
+ |
|
| 94 |
+ // MARK: - Header |
|
| 95 |
+ |
|
| 96 |
+ private var header: some View {
|
|
| 97 |
+ HStack {
|
|
| 98 |
+ Button("Cancel", action: onDismiss)
|
|
| 99 |
+ .foregroundColor(.secondary) |
|
| 100 |
+ Spacer() |
|
| 101 |
+ Text("Trim Session")
|
|
| 102 |
+ .font(.headline) |
|
| 103 |
+ Spacer() |
|
| 104 |
+ Button("Reset") {
|
|
| 105 |
+ withAnimation(.spring(response: 0.3)) {
|
|
| 106 |
+ trimStart = fullStart |
|
| 107 |
+ trimEnd = fullEnd |
|
| 108 |
+ } |
|
| 109 |
+ } |
|
| 110 |
+ .foregroundColor(.orange) |
|
| 111 |
+ .disabled(trimStart == fullStart && trimEnd == fullEnd) |
|
| 112 |
+ } |
|
| 113 |
+ .padding(.horizontal, 18) |
|
| 114 |
+ .padding(.vertical, 14) |
|
| 115 |
+ .background(.regularMaterial) |
|
| 116 |
+ } |
|
| 117 |
+ |
|
| 118 |
+ // MARK: - Chart with trim overlay |
|
| 119 |
+ |
|
| 120 |
+ private var chartWithHandles: some View {
|
|
| 121 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 122 |
+ HStack(spacing: 6) {
|
|
| 123 |
+ Image(systemName: "scissors") |
|
| 124 |
+ .foregroundColor(.blue) |
|
| 125 |
+ Text("Session Window")
|
|
| 126 |
+ .font(.headline) |
|
| 127 |
+ } |
|
| 128 |
+ |
|
| 129 |
+ GeometryReader { geo in
|
|
| 130 |
+ let chartW = geo.size.width |
|
| 131 |
+ ZStack(alignment: .topLeading) {
|
|
| 132 |
+ // Background chart — full session |
|
| 133 |
+ MeasurementChartView( |
|
| 134 |
+ compactLayout: true, |
|
| 135 |
+ availableSize: geo.size, |
|
| 136 |
+ timeRange: fullStart...fullEnd, |
|
| 137 |
+ showsRangeSelector: false, |
|
| 138 |
+ rebasesEnergyToVisibleRangeStart: false |
|
| 139 |
+ ) |
|
| 140 |
+ |
|
| 141 |
+ // Dimmed region before trimStart |
|
| 142 |
+ Rectangle() |
|
| 143 |
+ .fill(Color.black.opacity(0.35)) |
|
| 144 |
+ .frame(width: max(startFraction * chartW, 0)) |
|
| 145 |
+ .allowsHitTesting(false) |
|
| 146 |
+ |
|
| 147 |
+ // Dimmed region after trimEnd |
|
| 148 |
+ let endX = endFraction * chartW |
|
| 149 |
+ Rectangle() |
|
| 150 |
+ .fill(Color.black.opacity(0.35)) |
|
| 151 |
+ .frame(width: max(chartW - endX, 0)) |
|
| 152 |
+ .offset(x: endX) |
|
| 153 |
+ .allowsHitTesting(false) |
|
| 154 |
+ |
|
| 155 |
+ // Start handle |
|
| 156 |
+ trimHandle( |
|
| 157 |
+ color: .green, |
|
| 158 |
+ symbol: "arrow.right.to.line", |
|
| 159 |
+ xFraction: startFraction, |
|
| 160 |
+ chartWidth: chartW, |
|
| 161 |
+ onDrag: { dx in
|
|
| 162 |
+ let newFrac = max(0, min(startFraction + dx / chartW, endFraction - 0.01)) |
|
| 163 |
+ trimStart = fullStart.addingTimeInterval(newFrac * sessionDuration) |
|
| 164 |
+ } |
|
| 165 |
+ ) |
|
| 166 |
+ |
|
| 167 |
+ // End handle |
|
| 168 |
+ trimHandle( |
|
| 169 |
+ color: .red, |
|
| 170 |
+ symbol: "arrow.left.to.line", |
|
| 171 |
+ xFraction: endFraction, |
|
| 172 |
+ chartWidth: chartW, |
|
| 173 |
+ onDrag: { dx in
|
|
| 174 |
+ let newFrac = min(1, max(endFraction + dx / chartW, startFraction + 0.01)) |
|
| 175 |
+ trimEnd = fullStart.addingTimeInterval(newFrac * sessionDuration) |
|
| 176 |
+ } |
|
| 177 |
+ ) |
|
| 178 |
+ } |
|
| 179 |
+ .clipped() |
|
| 180 |
+ } |
|
| 181 |
+ .frame(height: 260) |
|
| 182 |
+ } |
|
| 183 |
+ .padding(16) |
|
| 184 |
+ .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground))) |
|
| 185 |
+ } |
|
| 186 |
+ |
|
| 187 |
+ @ViewBuilder |
|
| 188 |
+ private func trimHandle( |
|
| 189 |
+ color: Color, |
|
| 190 |
+ symbol: String, |
|
| 191 |
+ xFraction: Double, |
|
| 192 |
+ chartWidth: CGFloat, |
|
| 193 |
+ onDrag: @escaping (CGFloat) -> Void |
|
| 194 |
+ ) -> some View {
|
|
| 195 |
+ let xPos = CGFloat(xFraction) * chartWidth |
|
| 196 |
+ |
|
| 197 |
+ ZStack(alignment: .top) {
|
|
| 198 |
+ // Vertical line |
|
| 199 |
+ Rectangle() |
|
| 200 |
+ .fill(color) |
|
| 201 |
+ .frame(width: 2) |
|
| 202 |
+ .frame(maxHeight: .infinity) |
|
| 203 |
+ .offset(x: xPos - 1) |
|
| 204 |
+ .allowsHitTesting(false) |
|
| 205 |
+ |
|
| 206 |
+ // Drag knob |
|
| 207 |
+ Circle() |
|
| 208 |
+ .fill(color) |
|
| 209 |
+ .frame(width: 28, height: 28) |
|
| 210 |
+ .overlay( |
|
| 211 |
+ Image(systemName: symbol) |
|
| 212 |
+ .font(.system(size: 11, weight: .bold)) |
|
| 213 |
+ .foregroundColor(.white) |
|
| 214 |
+ ) |
|
| 215 |
+ .shadow(radius: 3) |
|
| 216 |
+ .offset(x: xPos - 14) |
|
| 217 |
+ .gesture( |
|
| 218 |
+ DragGesture(minimumDistance: 0, coordinateSpace: .local) |
|
| 219 |
+ .onChanged { value in
|
|
| 220 |
+ onDrag(value.translation.width) |
|
| 221 |
+ } |
|
| 222 |
+ ) |
|
| 223 |
+ } |
|
| 224 |
+ } |
|
| 225 |
+ |
|
| 226 |
+ // MARK: - Range controls |
|
| 227 |
+ |
|
| 228 |
+ private var rangeControls: some View {
|
|
| 229 |
+ VStack(spacing: 12) {
|
|
| 230 |
+ rangeRow( |
|
| 231 |
+ label: "Start", |
|
| 232 |
+ color: .green, |
|
| 233 |
+ symbol: "arrow.right.to.line", |
|
| 234 |
+ date: $trimStart, |
|
| 235 |
+ sliderValue: Binding( |
|
| 236 |
+ get: { startFraction },
|
|
| 237 |
+ set: { v in
|
|
| 238 |
+ let clamped = max(0, min(v, endFraction - 0.01)) |
|
| 239 |
+ trimStart = fullStart.addingTimeInterval(clamped * sessionDuration) |
|
| 240 |
+ } |
|
| 241 |
+ ) |
|
| 242 |
+ ) |
|
| 243 |
+ rangeRow( |
|
| 244 |
+ label: "End", |
|
| 245 |
+ color: .red, |
|
| 246 |
+ symbol: "arrow.left.to.line", |
|
| 247 |
+ date: $trimEnd, |
|
| 248 |
+ sliderValue: Binding( |
|
| 249 |
+ get: { endFraction },
|
|
| 250 |
+ set: { v in
|
|
| 251 |
+ let clamped = min(1, max(v, startFraction + 0.01)) |
|
| 252 |
+ trimEnd = fullStart.addingTimeInterval(clamped * sessionDuration) |
|
| 253 |
+ } |
|
| 254 |
+ ) |
|
| 255 |
+ ) |
|
| 256 |
+ } |
|
| 257 |
+ .padding(16) |
|
| 258 |
+ .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground))) |
|
| 259 |
+ } |
|
| 260 |
+ |
|
| 261 |
+ private func rangeRow( |
|
| 262 |
+ label: String, |
|
| 263 |
+ color: Color, |
|
| 264 |
+ symbol: String, |
|
| 265 |
+ date: Binding<Date>, |
|
| 266 |
+ sliderValue: Binding<Double> |
|
| 267 |
+ ) -> some View {
|
|
| 268 |
+ VStack(spacing: 6) {
|
|
| 269 |
+ HStack {
|
|
| 270 |
+ Image(systemName: symbol) |
|
| 271 |
+ .foregroundColor(color) |
|
| 272 |
+ .frame(width: 20) |
|
| 273 |
+ Text(label) |
|
| 274 |
+ .font(.subheadline.weight(.semibold)) |
|
| 275 |
+ Spacer() |
|
| 276 |
+ Text(date.wrappedValue.format()) |
|
| 277 |
+ .font(.caption.monospacedDigit()) |
|
| 278 |
+ .foregroundColor(.secondary) |
|
| 279 |
+ } |
|
| 280 |
+ Slider(value: sliderValue, in: 0...1) |
|
| 281 |
+ .tint(color) |
|
| 282 |
+ } |
|
| 283 |
+ } |
|
| 284 |
+ |
|
| 285 |
+ // MARK: - Preview metrics |
|
| 286 |
+ |
|
| 287 |
+ private var previewMetrics: some View {
|
|
| 288 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 289 |
+ HStack(spacing: 6) {
|
|
| 290 |
+ Image(systemName: "waveform.path.ecg") |
|
| 291 |
+ .foregroundColor(.teal) |
|
| 292 |
+ Text("Trimmed Metrics")
|
|
| 293 |
+ .font(.headline) |
|
| 294 |
+ } |
|
| 295 |
+ |
|
| 296 |
+ let columns = [GridItem(.flexible()), GridItem(.flexible())] |
|
| 297 |
+ LazyVGrid(columns: columns, spacing: 8) {
|
|
| 298 |
+ previewCell(label: "Energy", value: "\(previewEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue) |
|
| 299 |
+ previewCell(label: "Duration", value: formatDuration(trimmedDuration), tint: .teal) |
|
| 300 |
+ } |
|
| 301 |
+ } |
|
| 302 |
+ .padding(16) |
|
| 303 |
+ .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground))) |
|
| 304 |
+ } |
|
| 305 |
+ |
|
| 306 |
+ private func previewCell(label: String, value: String, tint: Color) -> some View {
|
|
| 307 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 308 |
+ Text(label) |
|
| 309 |
+ .font(.caption) |
|
| 310 |
+ .foregroundColor(.secondary) |
|
| 311 |
+ Text(value) |
|
| 312 |
+ .font(.system(.subheadline, design: .rounded).weight(.semibold)) |
|
| 313 |
+ .foregroundColor(tint) |
|
| 314 |
+ .monospacedDigit() |
|
| 315 |
+ } |
|
| 316 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 317 |
+ .padding(10) |
|
| 318 |
+ .background(RoundedRectangle(cornerRadius: 10).fill(tint.opacity(0.10))) |
|
| 319 |
+ } |
|
| 320 |
+ |
|
| 321 |
+ // MARK: - Checkpoint warning |
|
| 322 |
+ |
|
| 323 |
+ private var checkpointWarning: some View {
|
|
| 324 |
+ HStack(alignment: .top, spacing: 10) {
|
|
| 325 |
+ Image(systemName: "exclamationmark.triangle.fill") |
|
| 326 |
+ .foregroundColor(.orange) |
|
| 327 |
+ VStack(alignment: .leading, spacing: 3) {
|
|
| 328 |
+ Text("\(checkpointsToRemove.count) checkpoint\(checkpointsToRemove.count == 1 ? "" : "s") outside the selected window will be removed.")
|
|
| 329 |
+ .font(.subheadline) |
|
| 330 |
+ ForEach(checkpointsToRemove) { cp in
|
|
| 331 |
+ Text("• \(cp.timestamp.format()) — \(cp.batteryPercent.format(decimalDigits: 0))%")
|
|
| 332 |
+ .font(.caption) |
|
| 333 |
+ .foregroundColor(.secondary) |
|
| 334 |
+ } |
|
| 335 |
+ } |
|
| 336 |
+ } |
|
| 337 |
+ .padding(14) |
|
| 338 |
+ .background( |
|
| 339 |
+ RoundedRectangle(cornerRadius: 12) |
|
| 340 |
+ .fill(Color.orange.opacity(0.12)) |
|
| 341 |
+ .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.orange.opacity(0.25), lineWidth: 1)) |
|
| 342 |
+ ) |
|
| 343 |
+ } |
|
| 344 |
+ |
|
| 345 |
+ // MARK: - Apply bar |
|
| 346 |
+ |
|
| 347 |
+ private var applyBar: some View {
|
|
| 348 |
+ VStack(spacing: 0) {
|
|
| 349 |
+ Divider() |
|
| 350 |
+ Button {
|
|
| 351 |
+ let newStart = trimStart == fullStart ? nil : trimStart |
|
| 352 |
+ let newEnd = trimEnd == fullEnd ? nil : trimEnd |
|
| 353 |
+ onApply(newStart, newEnd) |
|
| 354 |
+ } label: {
|
|
| 355 |
+ Label("Apply Trim", systemImage: "scissors")
|
|
| 356 |
+ .font(.body.weight(.semibold)) |
|
| 357 |
+ .frame(maxWidth: .infinity) |
|
| 358 |
+ .padding(.vertical, 14) |
|
| 359 |
+ } |
|
| 360 |
+ .buttonStyle(.borderedProminent) |
|
| 361 |
+ .tint(.blue) |
|
| 362 |
+ .disabled(!isModified) |
|
| 363 |
+ .padding(16) |
|
| 364 |
+ } |
|
| 365 |
+ .background(.regularMaterial) |
|
| 366 |
+ } |
|
| 367 |
+ |
|
| 368 |
+ // MARK: - Helpers |
|
| 369 |
+ |
|
| 370 |
+ private func formatDuration(_ duration: TimeInterval) -> String {
|
|
| 371 |
+ let totalSeconds = Int(duration.rounded(.down)) |
|
| 372 |
+ let hours = totalSeconds / 3600 |
|
| 373 |
+ let minutes = (totalSeconds % 3600) / 60 |
|
| 374 |
+ let seconds = totalSeconds % 60 |
|
| 375 |
+ if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) }
|
|
| 376 |
+ return String(format: "%02d:%02d", minutes, seconds) |
|
| 377 |
+ } |
|
| 378 |
+} |
|