@@ -0,0 +1,128 @@ |
||
| 1 |
+# Charge Session Integrity and Conflict Healing |
|
| 2 |
+ |
|
| 3 |
+## Core Invariant |
|
| 4 |
+ |
|
| 5 |
+**A meter can have at most one open charge session at any given time.** |
|
| 6 |
+ |
|
| 7 |
+"Open" means `statusRawValue` is either `active` or `paused`. This invariant is used throughout the app to look up the active session for a meter without ambiguity. |
|
| 8 |
+ |
|
| 9 |
+--- |
|
| 10 |
+ |
|
| 11 |
+## How the Invariant Is Enforced at Creation |
|
| 12 |
+ |
|
| 13 |
+`ChargeInsightsStore.startSession(...)` checks for an existing open session on the same `meterMACAddress` before creating a new one: |
|
| 14 |
+ |
|
| 15 |
+```swift |
|
| 16 |
+guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
|
|
| 17 |
+ return |
|
| 18 |
+} |
|
| 19 |
+``` |
|
| 20 |
+ |
|
| 21 |
+This check is sufficient when all devices are online and share a consistent view of the CloudKit store. It is **not sufficient** when devices work offline independently. |
|
| 22 |
+ |
|
| 23 |
+--- |
|
| 24 |
+ |
|
| 25 |
+## Scenarios Where the Invariant Can Be Violated |
|
| 26 |
+ |
|
| 27 |
+### Scenario A — Forgotten session + new session on another device (primary concern) |
|
| 28 |
+ |
|
| 29 |
+1. The user starts a session on **Device A** and forgets about it (walks away, doesn't close the app). |
|
| 30 |
+2. The user picks up **Device B**, which has not yet synced from Device A. |
|
| 31 |
+3. Device B finds no open session for the meter (it doesn't know about Device A's session yet). |
|
| 32 |
+4. Device B creates a new session successfully. |
|
| 33 |
+5. When both devices come back online and CloudKit syncs, **both sessions are open** for the same meter. |
|
| 34 |
+ |
|
| 35 |
+This is the most realistic scenario. Current meters (UM25C, UM34C, TC66C) only support one BT connection at a time, so both sessions cannot be live simultaneously — but the forgotten session remains open in the database. |
|
| 36 |
+ |
|
| 37 |
+### Scenario B — Two devices simultaneously offline on meters that support multiple connections |
|
| 38 |
+ |
|
| 39 |
+If a future meter model supports multiple concurrent BT connections, two devices could legitimately start sessions for the same meter at the same time, both while offline. The healing mechanism covers this automatically, though the winner selection may be debatable in that case. |
|
| 40 |
+ |
|
| 41 |
+### Scenario C — CloudKit conflict on `statusRawValue` itself |
|
| 42 |
+ |
|
| 43 |
+`NSMergeByPropertyObjectTrumpMergePolicy` is used throughout: in-memory values win over CloudKit-synced values at the Core Data level. This means a `stopSession` write on Device A while Device B has also written to the same session record could theoretically produce inconsistent `statusRawValue` after sync. This is distinct from the duplicate-session scenario and is considered unlikely in practice, but worth noting. |
|
| 44 |
+ |
|
| 45 |
+--- |
|
| 46 |
+ |
|
| 47 |
+## Healing Mechanism |
|
| 48 |
+ |
|
| 49 |
+`ChargeInsightsStore.healDuplicateOpenSessions()` is called from `AppData.reloadChargedDevices()` on every reload cycle, which is triggered by: |
|
| 50 |
+ |
|
| 51 |
+- App startup (first device list load) |
|
| 52 |
+- Every remote CloudKit change (`NSPersistentStoreRemoteChange` notification) |
|
| 53 |
+- Every local Core Data write (via `NSManagedObjectContextDidSave`) |
|
| 54 |
+ |
|
| 55 |
+### Algorithm |
|
| 56 |
+ |
|
| 57 |
+1. Fetch all open sessions (status `active` or `paused`). |
|
| 58 |
+2. Group by `meterMACAddress`. |
|
| 59 |
+3. For any group with more than one session: |
|
| 60 |
+ - **Winner** = session with the latest `startedAt`. This represents the user's most recent explicit intent — they chose to start a new session, which means the older one was forgotten. |
|
| 61 |
+ - Tie-break: higher `measuredEnergyWh` wins (the session that observed more charging is likely more useful). |
|
| 62 |
+ - **Loser(s)**: closed with status `abandoned`, `endedAt` set to **winner's `startedAt`**. |
|
| 63 |
+4. Save, then refresh derived metrics for affected devices. |
|
| 64 |
+ |
|
| 65 |
+### Why `startedAt` instead of `updatedAt` for winner selection |
|
| 66 |
+ |
|
| 67 |
+`updatedAt` reflects the last write to the record, which could be CloudKit metadata or a background observation flush — not necessarily a user action. `startedAt` is set once at creation and directly represents the user's decision to begin a session. The session started later is the one the user *intended* to be active. |
|
| 68 |
+ |
|
| 69 |
+### Overlap prevention |
|
| 70 |
+ |
|
| 71 |
+The loser's `endedAt` is set to the winner's `startedAt` (not `Date()` / "now"). This ensures the two sessions are contiguous with no gap and no overlap, which is important for: |
|
| 72 |
+ |
|
| 73 |
+- Correct total energy accounting across a device's session history |
|
| 74 |
+- Clean chart rendering (no two sessions claiming the same time range) |
|
| 75 |
+- Capacity estimation (no double-counting of energy delivered in the same window) |
|
| 76 |
+ |
|
| 77 |
+--- |
|
| 78 |
+ |
|
| 79 |
+## Data Model |
|
| 80 |
+ |
|
| 81 |
+`ChargeSession` entity (Core Data model v16) includes: |
|
| 82 |
+ |
|
| 83 |
+| Attribute | Type | Purpose | |
|
| 84 |
+|---|---|---| |
|
| 85 |
+| `wasConflictHealed` | `Boolean, optional` | `true` on sessions closed by the healing mechanism. `nil`/`false` on normal sessions. | |
|
| 86 |
+ |
|
| 87 |
+This attribute is synced via CloudKit so all devices eventually see the flag, even if healing ran on only one of them. |
|
| 88 |
+ |
|
| 89 |
+--- |
|
| 90 |
+ |
|
| 91 |
+## UI Indicators |
|
| 92 |
+ |
|
| 93 |
+Sessions with `wasConflictHealed == true` are visually distinguished in `ChargedDeviceSessionsView`: |
|
| 94 |
+ |
|
| 95 |
+- An **orange icon** (⟳) appears next to the status badge in the session card header, with a tooltip: *"This session was automatically closed because a newer session was started on another device while offline."* |
|
| 96 |
+- The secondary info line includes **"Auto-closed (sync conflict)"** alongside other metadata (transport mode, trim, source mode). |
|
| 97 |
+ |
|
| 98 |
+The intent is to surface the event without alarming the user — the data is intact and the time range is correctly bounded. |
|
| 99 |
+ |
|
| 100 |
+--- |
|
| 101 |
+ |
|
| 102 |
+## What Is Not Covered |
|
| 103 |
+ |
|
| 104 |
+### Detection at session-start time |
|
| 105 |
+ |
|
| 106 |
+When Device B starts a session while offline, it cannot know that Device A has an open session. There is no mechanism to warn the user before the conflict occurs. Healing is entirely reactive (post-sync). |
|
| 107 |
+ |
|
| 108 |
+A proactive approach would require a "soft lock" written to CloudKit before starting a session, which introduces latency and complexity. Not implemented. |
|
| 109 |
+ |
|
| 110 |
+### Manual conflict resolution |
|
| 111 |
+ |
|
| 112 |
+Currently the loser is always automatically abandoned. There is no UI for the user to inspect the conflict and choose which session to keep, merge, or adjust. For the current meter lineup (single-connection BT), the automatic resolution is almost always correct. |
|
| 113 |
+ |
|
| 114 |
+### Overlapping sessions on the same `chargedDeviceID` across different meters |
|
| 115 |
+ |
|
| 116 |
+The invariant is enforced per `meterMACAddress`, not per `chargedDeviceID`. If the same physical device is charged on two different meters simultaneously (unlikely but possible with future hardware), both sessions would have different MAC addresses and the invariant would not catch them. The healing mechanism would not fire because there is no duplicate per MAC. |
|
| 117 |
+ |
|
| 118 |
+### Paused session + new session conflict |
|
| 119 |
+ |
|
| 120 |
+A paused session is still "open" and is treated the same as an active session for healing purposes. If healing runs on a paused + active pair, the paused session (earlier `startedAt`) becomes the loser. This is correct: the user started a new session, so the paused one was effectively abandoned. |
|
| 121 |
+ |
|
| 122 |
+--- |
|
| 123 |
+ |
|
| 124 |
+## Future Considerations |
|
| 125 |
+ |
|
| 126 |
+- If future meters support multiple concurrent connections, the winner-selection strategy (latest `startedAt`) should be re-evaluated — in that case the session with more observed data might be preferable. |
|
| 127 |
+- If session merging becomes desirable (combine energy from both conflicting sessions into one record), `wasConflictHealed` provides the signal to identify candidates. |
|
| 128 |
+- A "conflict history" section in the device detail or a one-time notification could be added to inform users when healing has run, especially if conflicts become more frequent with new hardware. |
|
@@ -0,0 +1,70 @@ |
||
| 1 |
+# Charging While Off |
|
| 2 |
+ |
|
| 3 |
+## Definition |
|
| 4 |
+ |
|
| 5 |
+A device that **can charge while off** can continue charging its battery when it is powered down (screen off, OS not running). |
|
| 6 |
+ |
|
| 7 |
+In practice, this is the *general rule* for many battery-powered devices. The important exceptions are: |
|
| 8 |
+ |
|
| 9 |
+- some devices **auto-boot when power is connected**, so they do not reliably stay off while charging |
|
| 10 |
+- some devices **cannot be turned off**, so an off-state session is not possible |
|
| 11 |
+- some devices **only charge while off**, meaning they must be powered down to accept charge |
|
| 12 |
+ |
|
| 13 |
+## How the app should decide ON vs OFF |
|
| 14 |
+ |
|
| 15 |
+When information is available, the app should decide the charging mode automatically: |
|
| 16 |
+ |
|
| 17 |
+- If the available information clearly indicates **ON** or **OFF**, use it. |
|
| 18 |
+- If the information indicates multiple plausible variants, show a **non-intrusive hint** that the user should specify whether the session is **ON** or **OFF**. |
|
| 19 |
+- If no information is available, the app assumes **ON** for devices that support **both ON and OFF charging** (for capacity learning and confidence rules). |
|
| 20 |
+ |
|
| 21 |
+## Why it matters for battery capacity estimation |
|
| 22 |
+ |
|
| 23 |
+Off-state charging sessions tend to produce the cleanest signal for estimating battery capacity (energy stored): |
|
| 24 |
+ |
|
| 25 |
+- Most of the measured input energy goes into the battery, instead of being consumed by the device itself. |
|
| 26 |
+- The result is less affected by background processes, radios, thermal throttling, screen usage, and OS-level behavior. |
|
| 27 |
+ |
|
| 28 |
+## App implications |
|
| 29 |
+ |
|
| 30 |
+### Explicit session setup |
|
| 31 |
+ |
|
| 32 |
+- Starting a charge session should be **explicit**: the user picks the device, the charging type (**wired** / **wireless**), and the charging mode (**on** / **off**) whenever the device supports both. |
|
| 33 |
+- Wireless sessions also require the user to pick the **charger** that is being used. |
|
| 34 |
+- The app should ask for an **initial battery checkpoint** before the session begins. |
|
| 35 |
+- If no stop threshold is known for the selected combination of charging type + charging mode, the session should remain **open-ended** until the user pauses or stops it. |
|
| 36 |
+ |
|
| 37 |
+### Defaults |
|
| 38 |
+ |
|
| 39 |
+- **Default assumption (for capacity learning):** if the user does not specify otherwise, a device is treated as **on/unknown-state** while charging. |
|
| 40 |
+ |
|
| 41 |
+### Stop-threshold learning for wireless charging |
|
| 42 |
+ |
|
| 43 |
+- Wireless stop-threshold learning must subtract the charger's **idle current** (no-load current). |
|
| 44 |
+- Without that idle-current measurement, the app must **not** learn a wireless end-of-charge threshold from the session. |
|
| 45 |
+- The UI should warn both in the **charger detail** and in the **session view** when that idle-current measurement is missing. |
|
| 46 |
+ |
|
| 47 |
+### Capacity estimation priority (conceptual rule) |
|
| 48 |
+ |
|
| 49 |
+When we have both: |
|
| 50 |
+ |
|
| 51 |
+- high-confidence capacity determinations from **off-state** charging, and |
|
| 52 |
+- lower-confidence determinations from **on/unknown** charging, |
|
| 53 |
+ |
|
| 54 |
+then: |
|
| 55 |
+ |
|
| 56 |
+- off-state determinations are **preferred** for establishing the device’s capacity baseline |
|
| 57 |
+- on/unknown determinations **must not overwrite** off-state determinations, **except** when they imply a **smaller capacity** |
|
| 58 |
+ - example: the user logs a large measured energy, but later battery-percent checkpoints indicate the battery can’t actually hold that much energy (or has degraded), so we allow the estimate to move downward |
|
| 59 |
+ |
|
| 60 |
+### Current implementation note |
|
| 61 |
+ |
|
| 62 |
+For devices that are **not** marked as chargeable while off, the app **does not accept sessions that end at “full”** as capacity inputs (near-full can hide unknown “top-off” time/energy while the OS is doing its own work). For devices marked as chargeable while off, full / near-full sessions remain eligible. |
|
| 63 |
+ |
|
| 64 |
+## Device-specific notes (practical guidance) |
|
| 65 |
+ |
|
| 66 |
+- **iPhone:** tends to auto-boot when connected to a charger. To record an off-state session, you may need to shut it down after connecting power. Some factors can still trigger a boot even if the phone was shut down. |
|
| 67 |
+- **Powerbank:** treat as “chargeable only while off” (no active output load). For capacity learning, prefer sessions where the powerbank is not simultaneously powering other devices. |
|
| 68 |
+- **AirPods case:** treat as “off” **only if the earbuds are not inside** the case while charging (otherwise the case is also charging the earbuds). |
|
| 69 |
+- **Apple Watch:** cannot be reliably powered down for charging; treat as **always on**. |
|
| 70 |
+- **Garmin Edge bike computers / some Garmin watches:** treat as **chargeable only while off** (they need to be powered down to charge). |
|
@@ -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. |
|
@@ -0,0 +1,139 @@ |
||
| 1 |
+# Project Structure and Naming |
|
| 2 |
+ |
|
| 3 |
+This document defines how we name SwiftUI views and how we place files in the project so the codebase matches the product language and the UI hierarchy. |
|
| 4 |
+ |
|
| 5 |
+## Core Rules |
|
| 6 |
+ |
|
| 7 |
+- Name folders after the feature or navigation surface the user sees. |
|
| 8 |
+ - Example: the Home tab lives in `Views/Meter/Tabs/Home/`, not in `Connection/`. |
|
| 9 |
+- Name the main view in a folder after the feature it owns. |
|
| 10 |
+ - Example: `MeterHomeTabView.swift` is the root view for the meter Home tab. |
|
| 11 |
+- Keep the file name and the Swift type name identical. |
|
| 12 |
+ - Example: `MeterOverviewSectionView.swift` contains `MeterOverviewSectionView`. |
|
| 13 |
+- Prefer product language over implementation history. |
|
| 14 |
+ - If the app says "Home", use `Home` in code instead of older terms like `Connection`. |
|
| 15 |
+- Use `Components/` only for reusable building blocks. |
|
| 16 |
+ - A component is used in multiple views or is clearly meant to be reused. |
|
| 17 |
+ - Example: `MeterInfoCardView` and `MeterInfoRowView`. |
|
| 18 |
+- Use `Subviews/` for views that belong to a single parent feature. |
|
| 19 |
+ - A subview is used once or is tightly coupled to one screen/tab. |
|
| 20 |
+ - Example: `Views/Meter/Tabs/Home/Subviews/MeterOverviewSectionView.swift`. |
|
| 21 |
+- Use `Sheets/` for modal flows presented from another screen. |
|
| 22 |
+ - Example: `Views/Meter/Sheets/AppHistory/AppHistorySheetView.swift`. |
|
| 23 |
+- Keep reusable components close to the narrowest shared owner. |
|
| 24 |
+ - If a component is reused only inside Meter screens, keep it in `Views/Meter/Components/`. |
|
| 25 |
+ - Do not move it to app-wide `Views/Components/` unless it is truly generic. |
|
| 26 |
+- Keep small support types next to their owner. |
|
| 27 |
+ - If a helper type exists for one view only, keep it in the same file or the same feature subfolder. |
|
| 28 |
+- Avoid vague verbs or placeholder names. |
|
| 29 |
+ - Prefer `MeterNameEditorView` over `EditNameView`. |
|
| 30 |
+ - Prefer `MeterConnectionActionView` over `ConnectionPrimaryActionView`. |
|
| 31 |
+- A folder should describe ownership, not implementation detail. |
|
| 32 |
+ - `Home/Subviews/` is better than `Connection/Components/` when the views are single-use parts of the Home tab. |
|
| 33 |
+ |
|
| 34 |
+## Naming Checklist |
|
| 35 |
+ |
|
| 36 |
+Before adding or renaming a file, check: |
|
| 37 |
+ |
|
| 38 |
+- Can someone guess the file location from the screen name? |
|
| 39 |
+- Does the type name say what the view renders? |
|
| 40 |
+- Is this reused enough to deserve `Components/`? |
|
| 41 |
+- If it is single-use, does it live under the parent feature's `Subviews/` folder? |
|
| 42 |
+- Does the code use the same words as the UI? |
|
| 43 |
+ |
|
| 44 |
+## Current Meter Tab Pattern |
|
| 45 |
+ |
|
| 46 |
+Use this structure for Meter tab work: |
|
| 47 |
+ |
|
| 48 |
+```text |
|
| 49 |
+Views/Meter/ |
|
| 50 |
+ Components/ |
|
| 51 |
+ MeasurementChartView.swift |
|
| 52 |
+ MeterInfoCardView.swift |
|
| 53 |
+ MeterInfoRowView.swift |
|
| 54 |
+ Sheets/ |
|
| 55 |
+ AppHistory/ |
|
| 56 |
+ AppHistorySheetView.swift |
|
| 57 |
+ Subviews/ |
|
| 58 |
+ AppHistorySampleView.swift |
|
| 59 |
+ ChargeRecord/ |
|
| 60 |
+ ChargeRecordSheetView.swift |
|
| 61 |
+ Subviews/ |
|
| 62 |
+ ChargeRecordMetricsTableView.swift |
|
| 63 |
+ DataGroups/ |
|
| 64 |
+ DataGroupsSheetView.swift |
|
| 65 |
+ Subviews/ |
|
| 66 |
+ DataGroupRowView.swift |
|
| 67 |
+ Tabs/ |
|
| 68 |
+ Home/ |
|
| 69 |
+ MeterHomeTabView.swift |
|
| 70 |
+ Subviews/ |
|
| 71 |
+ MeterConnectionActionView.swift |
|
| 72 |
+ MeterConnectionStatusBadgeView.swift |
|
| 73 |
+ MeterOverviewSectionView.swift |
|
| 74 |
+ Live/ |
|
| 75 |
+ MeterLiveTabView.swift |
|
| 76 |
+ Subviews/ |
|
| 77 |
+ LoadResistanceIconView.swift |
|
| 78 |
+ MeterLiveContentView.swift |
|
| 79 |
+ MeterLiveMetricRange.swift |
|
| 80 |
+ Chart/ |
|
| 81 |
+ MeterChartTabView.swift |
|
| 82 |
+ Settings/ |
|
| 83 |
+ MeterSettingsTabView.swift |
|
| 84 |
+ Subviews/ |
|
| 85 |
+ MeterCurrentScreenSummaryView.swift |
|
| 86 |
+ MeterNameEditorView.swift |
|
| 87 |
+ MeterScreenControlButtonView.swift |
|
| 88 |
+ MeterScreenControlsView.swift |
|
| 89 |
+ ScreenBrightnessEditorView.swift |
|
| 90 |
+ ScreenTimeoutEditorView.swift |
|
| 91 |
+``` |
|
| 92 |
+ |
|
| 93 |
+## Current Charged Device Pattern |
|
| 94 |
+ |
|
| 95 |
+Use the same root categories as Meter work: shared components first, screen/detail roots next, then sheets and sidebar-specific views. |
|
| 96 |
+ |
|
| 97 |
+```text |
|
| 98 |
+Views/ChargedDevices/ |
|
| 99 |
+ Components/ |
|
| 100 |
+ ChargedDeviceDetailTabBarView.swift |
|
| 101 |
+ ChargedDeviceEditorScaffoldView.swift |
|
| 102 |
+ ChargedDeviceIdentityViews.swift |
|
| 103 |
+ ChargedDeviceLibraryRowView.swift |
|
| 104 |
+ ChargedDeviceQRCodeView.swift |
|
| 105 |
+ ChargedDeviceSidebarCardView.swift |
|
| 106 |
+ Details/ |
|
| 107 |
+ ChargedDeviceDetailView.swift |
|
| 108 |
+ Sessions/ |
|
| 109 |
+ ChargedDeviceActiveSessionView.swift |
|
| 110 |
+ ChargedDeviceSessionDetailView.swift |
|
| 111 |
+ ChargedDeviceSessionsView.swift |
|
| 112 |
+ Sheets/ |
|
| 113 |
+ ChargeSession/ |
|
| 114 |
+ BatteryCheckpointEditorSheetView.swift |
|
| 115 |
+ ChargeSessionCompletionSheetView.swift |
|
| 116 |
+ Editors/ |
|
| 117 |
+ ChargedDeviceEditorSheetView.swift |
|
| 118 |
+ ChargerEditorSheetView.swift |
|
| 119 |
+ Library/ |
|
| 120 |
+ ChargedDeviceLibrarySheetView.swift |
|
| 121 |
+ Sidebar/ |
|
| 122 |
+ SidebarChargedDeviceLibraryView.swift |
|
| 123 |
+ SidebarChargedDevicesSectionView.swift |
|
| 124 |
+``` |
|
| 125 |
+ |
|
| 126 |
+## Refactor Examples |
|
| 127 |
+ |
|
| 128 |
+- `Connection/` -> `Home/` |
|
| 129 |
+- `MeterConnectionTabView` -> `MeterHomeTabView` |
|
| 130 |
+- `ConnectionHomeInfoPreviewView` -> `MeterOverviewSectionView` |
|
| 131 |
+- `ConnectionPrimaryActionView` -> `MeterConnectionActionView` |
|
| 132 |
+- `EditNameView` -> `MeterNameEditorView` |
|
| 133 |
+- `MeasurementsView` -> `AppHistorySheetView` |
|
| 134 |
+- `RecordingView` -> `ChargeRecordSheetView` |
|
| 135 |
+- `ControlView` -> `MeterScreenControlsView` |
|
| 136 |
+ |
|
| 137 |
+## Decision Rule |
|
| 138 |
+ |
|
| 139 |
+If a new name makes a teammate look in the right folder on the first try, it is probably a good name. |
|
@@ -10,6 +10,14 @@ It is intended to keep the repository root focused on the app itself while prese |
||
| 10 | 10 |
Narrative context and decisions that explain how the project got here. |
| 11 | 11 |
- `Platform Decision - iOS 15.md` |
| 12 | 12 |
App-level platform choices that affect implementation. |
| 13 |
+- `Charging While Off.md` |
|
| 14 |
+ Definition + measurement implications for capacity estimation. |
|
| 15 |
+- `Charge Session Integrity and Conflict Healing.md` |
|
| 16 |
+ The one-active-session-per-meter invariant, how it can be violated in offline sync scenarios, the healing mechanism, and what is not yet covered. |
|
| 17 |
+- `Project Structure and Naming.md` |
|
| 18 |
+ Naming and file-organization rules for views, features, components, and subviews. |
|
| 19 |
+- `External Contributions.md` |
|
| 20 |
+ Log of contributions from external collaborators, with technical evaluation per intervention. |
|
| 13 | 21 |
- `Research Resources/` |
| 14 | 22 |
External source material plus the notes derived from it. |
| 15 | 23 |
|
@@ -3,29 +3,29 @@ |
||
| 3 | 3 |
archiveVersion = 1; |
| 4 | 4 |
classes = {
|
| 5 | 5 |
}; |
| 6 |
- objectVersion = 54; |
|
| 6 |
+ objectVersion = 70; |
|
| 7 | 7 |
objects = {
|
| 8 | 8 |
|
| 9 | 9 |
/* Begin PBXBuildFile section */ |
| 10 |
+ 3407A133FADB8858DC2A1FED /* MeterNameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */; };
|
|
| 10 | 11 |
4308CF8624176CAB0002E80B /* DataGroupRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF8524176CAB0002E80B /* DataGroupRowView.swift */; };
|
| 11 |
- 4308CF882417770D0002E80B /* DataGroupsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF872417770D0002E80B /* DataGroupsView.swift */; };
|
|
| 12 |
+ 4308CF882417770D0002E80B /* DataGroupsSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF872417770D0002E80B /* DataGroupsSheetView.swift */; };
|
|
| 12 | 13 |
430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430CB4FB245E07EB006525C2 /* ChevronView.swift */; };
|
| 14 |
+ 430CB4FD245E07EB006525C3 /* AdaptiveTabBarPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430CB4FE245E07EB006525C4 /* AdaptiveTabBarPresentation.swift */; };
|
|
| 13 | 15 |
4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311E639241384960080EA59 /* DeviceHelpView.swift */; };
|
| 14 |
- 4327461B24619CED0009BE4B /* MeterRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4327461A24619CED0009BE4B /* MeterRowView.swift */; };
|
|
| 15 | 16 |
432EA6442445A559006FC905 /* ChartContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432EA6432445A559006FC905 /* ChartContext.swift */; };
|
| 16 | 17 |
4347F01D28D717C1007EE7B1 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 4347F01C28D717C1007EE7B1 /* CryptoSwift */; };
|
| 17 | 18 |
4351E7BB24685ACD00E798A3 /* CGPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4351E7BA24685ACD00E798A3 /* CGPoint.swift */; };
|
| 18 |
- 43554B2F24443939004E66F5 /* MeasurementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B2E24443939004E66F5 /* MeasurementsView.swift */; };
|
|
| 19 |
- 43554B32244449B5004E66F5 /* MeasurementPointView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B31244449B5004E66F5 /* MeasurementPointView.swift */; };
|
|
| 19 |
+ 43554B2F24443939004E66F5 /* MeasurementSeriesSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B2E24443939004E66F5 /* MeasurementSeriesSheetView.swift */; };
|
|
| 20 |
+ 43554B32244449B5004E66F5 /* MeasurementSeriesSampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B31244449B5004E66F5 /* MeasurementSeriesSampleView.swift */; };
|
|
| 20 | 21 |
43554B3424444B0E004E66F5 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43554B3324444B0E004E66F5 /* Date.swift */; };
|
| 21 |
- 43567FE92443AD7C00000282 /* ICloudDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43567FE82443AD7C00000282 /* ICloudDefault.swift */; };
|
|
| 22 | 22 |
4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4360A34C241CBB3800B464F9 /* RSSIView.swift */; };
|
| 23 |
- 4360A34F241D5CF100B464F9 /* MeterSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4360A34E241D5CF100B464F9 /* MeterSettingsView.swift */; };
|
|
| 24 |
- 437D47D12415F91B00B7768E /* LiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D02415F91B00B7768E /* LiveView.swift */; };
|
|
| 23 |
+ 437D47D12415F91B00B7768E /* MeterLiveContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D02415F91B00B7768E /* MeterLiveContentView.swift */; };
|
|
| 25 | 24 |
437D47D32415FB7E00B7768E /* Decimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D22415FB7E00B7768E /* Decimal.swift */; };
|
| 26 |
- 437D47D52415FD8C00B7768E /* RecordingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D42415FD8C00B7768E /* RecordingView.swift */; };
|
|
| 27 |
- 437D47D72415FDF300B7768E /* ControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D62415FDF300B7768E /* ControlView.swift */; };
|
|
| 25 |
+ 437D47D52415FD8C00B7768E /* ChargeRecordSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D42415FD8C00B7768E /* ChargeRecordSheetView.swift */; };
|
|
| 26 |
+ 437D47D72415FDF300B7768E /* MeterScreenControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */; };
|
|
| 28 | 27 |
437F0AB72463108F005DEBEC /* MeasurementChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F0AB62463108F005DEBEC /* MeasurementChartView.swift */; };
|
| 28 |
+ 437F0AB92463108F005DEBEC /* TimeSeriesChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F0AB82463108F005DEBEC /* TimeSeriesChart.swift */; };
|
|
| 29 | 29 |
4383B460240EB2D000DAAEBF /* Meter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B45F240EB2D000DAAEBF /* Meter.swift */; };
|
| 30 | 30 |
4383B462240EB5E400DAAEBF /* AppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B461240EB5E400DAAEBF /* AppData.swift */; };
|
| 31 | 31 |
4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B464240EB6B200DAAEBF /* UserDefault.swift */; };
|
@@ -42,16 +42,58 @@ |
||
| 42 | 42 |
439D996524234B98008DE3AA /* BluetoothRadio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439D996424234B98008DE3AA /* BluetoothRadio.swift */; };
|
| 43 | 43 |
43CBF660240BF3EB00255B8B /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF65F240BF3EB00255B8B /* AppDelegate.swift */; };
|
| 44 | 44 |
43CBF662240BF3EB00255B8B /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF661240BF3EB00255B8B /* SceneDelegate.swift */; };
|
| 45 |
- 43CBF665240BF3EB00255B8B /* CKModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF663240BF3EB00255B8B /* CKModel.xcdatamodeld */; };
|
|
| 46 | 45 |
43CBF667240BF3EB00255B8B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF666240BF3EB00255B8B /* ContentView.swift */; };
|
| 47 | 46 |
43CBF669240BF3ED00255B8B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43CBF668240BF3ED00255B8B /* Assets.xcassets */; };
|
| 48 | 47 |
43CBF66C240BF3ED00255B8B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43CBF66B240BF3ED00255B8B /* Preview Assets.xcassets */; };
|
| 49 | 48 |
43CBF66F240BF3ED00255B8B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 43CBF66D240BF3ED00255B8B /* LaunchScreen.storyboard */; };
|
| 50 | 49 |
43CBF677240C043E00255B8B /* BluetoothManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF676240C043E00255B8B /* BluetoothManager.swift */; };
|
| 51 | 50 |
43CBF681240D153000255B8B /* CBManagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CBF680240D153000255B8B /* CBManagerState.swift */; };
|
| 52 |
- 43DFBE402441A37B004A47EA /* BorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DFBE3F2441A37B004A47EA /* BorderView.swift */; };
|
|
| 53 | 51 |
43ED78AE2420A0BE00974487 /* BluetoothSerial.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED78AD2420A0BE00974487 /* BluetoothSerial.swift */; };
|
| 54 | 52 |
43F7792B2465AE1600745DF4 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F7792A2465AE1600745DF4 /* UIView.swift */; };
|
| 53 |
+ AAD5F9B12B1CAC7A00F8E4F9 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */; };
|
|
| 54 |
+ B0A000113C8F000100A10011 /* ChargerStandbyPowerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */; };
|
|
| 55 |
+ B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */; };
|
|
| 56 |
+ C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */; };
|
|
| 57 |
+ C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */; };
|
|
| 58 |
+ C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */; };
|
|
| 59 |
+ C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */; };
|
|
| 60 |
+ C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */; };
|
|
| 61 |
+ C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */; };
|
|
| 62 |
+ C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */; };
|
|
| 63 |
+ C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */; };
|
|
| 64 |
+ C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */; };
|
|
| 65 |
+ C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */; };
|
|
| 66 |
+ C100000B3C8E4A7A00A1000B /* ChargeSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001B3C8E4A7A00A1002B /* ChargeSessionDetailView.swift */; };
|
|
| 67 |
+ C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */; };
|
|
| 68 |
+ C1F000013C90000100A10001 /* ChargedDeviceTemplates.json in Resources */ = {isa = PBXBuildFile; fileRef = C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */; };
|
|
| 69 |
+ CD0002013FA0000000000001 /* ChargedDeviceIdentityViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001013FA0000000000001 /* ChargedDeviceIdentityViews.swift */; };
|
|
| 70 |
+ CD0002023FA0000000000002 /* ChargedDeviceLibraryRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001023FA0000000000002 /* ChargedDeviceLibraryRowView.swift */; };
|
|
| 71 |
+ CD0002033FA0000000000003 /* ChargedDeviceSidebarCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001033FA0000000000003 /* ChargedDeviceSidebarCardView.swift */; };
|
|
| 72 |
+ CD0002043FA0000000000004 /* ChargedDeviceEditorScaffoldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001043FA0000000000004 /* ChargedDeviceEditorScaffoldView.swift */; };
|
|
| 73 |
+ CD0002053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */; };
|
|
| 74 |
+ CD0002063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift */; };
|
|
| 75 |
+ CE00CE013C8E4A7A00CE00CE /* ChargerEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */; };
|
|
| 76 |
+ D28F11013C8E4A7A00A10011 /* MeterHomeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */; };
|
|
| 77 |
+ D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */; };
|
|
| 78 |
+ D28F11053C8E4A7A00A10015 /* MeterChartTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */; };
|
|
| 79 |
+ D28F11073C8E4A7A00A10017 /* MeterSettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */; };
|
|
| 80 |
+ D28F11113C8E4A7A00A10021 /* MeterInfoCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */; };
|
|
| 81 |
+ D28F11133C8E4A7A00A10023 /* MeterInfoRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */; };
|
|
| 82 |
+ D28F11153C8E4A7A00A10025 /* MeterNameEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */; };
|
|
| 83 |
+ D28F11173C8E4A7A00A10027 /* ScreenTimeoutEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */; };
|
|
| 84 |
+ D28F11193C8E4A7A00A10029 /* ScreenBrightnessEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */; };
|
|
| 85 |
+ D28F11213C8E4A7A00A10031 /* MeterLiveMetricRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */; };
|
|
| 86 |
+ D28F11233C8E4A7A00A10033 /* LoadResistanceIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */; };
|
|
| 87 |
+ D28F11313C8E4A7A00A10041 /* MeterScreenControlButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */; };
|
|
| 88 |
+ D28F11333C8E4A7A00A10043 /* MeterCurrentScreenSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */; };
|
|
| 89 |
+ D28F11353C8E4A7A00A10045 /* ChargeRecordMetricsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */; };
|
|
| 90 |
+ D28F11393C8E4A7A00A10049 /* MeterConnectionStatusBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */; };
|
|
| 91 |
+ D28F113B3C8E4A7A00A1004B /* MeterConnectionActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */; };
|
|
| 92 |
+ D28F113D3C8E4A7A00A1004D /* MeterOverviewSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */; };
|
|
| 93 |
+ D28F11413C8E4A7A00A10051 /* MeterChargeRecordTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11423C8E4A7A00A10052 /* MeterChargeRecordTabView.swift */; };
|
|
| 94 |
+ D28F11433C8E4A7A00A10053 /* MeterDataGroupsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */; };
|
|
| 95 |
+ E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */; };
|
|
| 96 |
+ F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */; };
|
|
| 55 | 97 |
/* End PBXBuildFile section */ |
| 56 | 98 |
|
| 57 | 99 |
/* Begin PBXFileReference section */ |
@@ -87,23 +129,22 @@ |
||
| 87 | 129 |
1C6B6BB32A2D4F5100A0B001 /* Users-Manual-4216091.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = "Users-Manual-4216091.pdf"; sourceTree = "<group>"; };
|
| 88 | 130 |
1C6B6BB42A2D4F5100A0B001 /* HM-10 and DX-BT18 Module Working Summary.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "HM-10 and DX-BT18 Module Working Summary.md"; sourceTree = "<group>"; };
|
| 89 | 131 |
4308CF8524176CAB0002E80B /* DataGroupRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupRowView.swift; sourceTree = "<group>"; };
|
| 90 |
- 4308CF872417770D0002E80B /* DataGroupsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupsView.swift; sourceTree = "<group>"; };
|
|
| 132 |
+ 4308CF872417770D0002E80B /* DataGroupsSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupsSheetView.swift; sourceTree = "<group>"; };
|
|
| 91 | 133 |
430CB4FB245E07EB006525C2 /* ChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronView.swift; sourceTree = "<group>"; };
|
| 134 |
+ 430CB4FE245E07EB006525C4 /* AdaptiveTabBarPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveTabBarPresentation.swift; sourceTree = "<group>"; };
|
|
| 92 | 135 |
4311E639241384960080EA59 /* DeviceHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelpView.swift; sourceTree = "<group>"; };
|
| 93 |
- 4327461A24619CED0009BE4B /* MeterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterRowView.swift; sourceTree = "<group>"; };
|
|
| 94 | 136 |
432EA6432445A559006FC905 /* ChartContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartContext.swift; sourceTree = "<group>"; };
|
| 95 | 137 |
4351E7BA24685ACD00E798A3 /* CGPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGPoint.swift; sourceTree = "<group>"; };
|
| 96 |
- 43554B2E24443939004E66F5 /* MeasurementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementsView.swift; sourceTree = "<group>"; };
|
|
| 97 |
- 43554B31244449B5004E66F5 /* MeasurementPointView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementPointView.swift; sourceTree = "<group>"; };
|
|
| 138 |
+ 43554B2E24443939004E66F5 /* MeasurementSeriesSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementSeriesSheetView.swift; sourceTree = "<group>"; };
|
|
| 139 |
+ 43554B31244449B5004E66F5 /* MeasurementSeriesSampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementSeriesSampleView.swift; sourceTree = "<group>"; };
|
|
| 98 | 140 |
43554B3324444B0E004E66F5 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = "<group>"; };
|
| 99 |
- 43567FE82443AD7C00000282 /* ICloudDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICloudDefault.swift; sourceTree = "<group>"; };
|
|
| 100 | 141 |
4360A34C241CBB3800B464F9 /* RSSIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSIView.swift; sourceTree = "<group>"; };
|
| 101 |
- 4360A34E241D5CF100B464F9 /* MeterSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterSettingsView.swift; sourceTree = "<group>"; };
|
|
| 102 |
- 437D47D02415F91B00B7768E /* LiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveView.swift; sourceTree = "<group>"; };
|
|
| 142 |
+ 437D47D02415F91B00B7768E /* MeterLiveContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveContentView.swift; sourceTree = "<group>"; };
|
|
| 103 | 143 |
437D47D22415FB7E00B7768E /* Decimal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Decimal.swift; sourceTree = "<group>"; };
|
| 104 |
- 437D47D42415FD8C00B7768E /* RecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingView.swift; sourceTree = "<group>"; };
|
|
| 105 |
- 437D47D62415FDF300B7768E /* ControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlView.swift; sourceTree = "<group>"; };
|
|
| 144 |
+ 437D47D42415FD8C00B7768E /* ChargeRecordSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeRecordSheetView.swift; sourceTree = "<group>"; };
|
|
| 145 |
+ 437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterScreenControlsView.swift; sourceTree = "<group>"; };
|
|
| 106 | 146 |
437F0AB62463108F005DEBEC /* MeasurementChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementChartView.swift; sourceTree = "<group>"; };
|
| 147 |
+ 437F0AB82463108F005DEBEC /* TimeSeriesChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSeriesChart.swift; sourceTree = "<group>"; };
|
|
| 107 | 148 |
4383B45F240EB2D000DAAEBF /* Meter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Meter.swift; sourceTree = "<group>"; };
|
| 108 | 149 |
4383B461240EB5E400DAAEBF /* AppData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppData.swift; sourceTree = "<group>"; };
|
| 109 | 150 |
4383B464240EB6B200DAAEBF /* UserDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefault.swift; sourceTree = "<group>"; };
|
@@ -121,7 +162,6 @@ |
||
| 121 | 162 |
43CBF65C240BF3EB00255B8B /* USB Meter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "USB Meter.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
| 122 | 163 |
43CBF65F240BF3EB00255B8B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
| 123 | 164 |
43CBF661240BF3EB00255B8B /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
| 124 |
- 43CBF664240BF3EB00255B8B /* USB_Meter.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = USB_Meter.xcdatamodel; sourceTree = "<group>"; };
|
|
| 125 | 165 |
43CBF666240BF3EB00255B8B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
| 126 | 166 |
43CBF668240BF3ED00255B8B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
| 127 | 167 |
43CBF66B240BF3ED00255B8B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
@@ -130,11 +170,69 @@ |
||
| 130 | 170 |
43CBF676240C043E00255B8B /* BluetoothManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothManager.swift; sourceTree = "<group>"; };
|
| 131 | 171 |
43CBF67A240C0D8A00255B8B /* USB Meter.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "USB Meter.entitlements"; sourceTree = "<group>"; };
|
| 132 | 172 |
43CBF680240D153000255B8B /* CBManagerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CBManagerState.swift; sourceTree = "<group>"; };
|
| 133 |
- 43DFBE3F2441A37B004A47EA /* BorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BorderView.swift; sourceTree = "<group>"; };
|
|
| 134 | 173 |
43ED78AD2420A0BE00974487 /* BluetoothSerial.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothSerial.swift; sourceTree = "<group>"; };
|
| 135 | 174 |
43F7792A2465AE1600745DF4 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
|
| 175 |
+ 56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterMappingDebugView.swift; sourceTree = "<group>"; };
|
|
| 176 |
+ 7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterNameStore.swift; sourceTree = "<group>"; };
|
|
| 177 |
+ AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
|
|
| 178 |
+ B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerStandbyPowerStore.swift; sourceTree = "<group>"; };
|
|
| 179 |
+ B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerStandbyPowerWizardView.swift; sourceTree = "<group>"; };
|
|
| 180 |
+ C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsModel.swift; sourceTree = "<group>"; };
|
|
| 181 |
+ C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsStore.swift; sourceTree = "<group>"; };
|
|
| 182 |
+ C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceQRCodeView.swift; sourceTree = "<group>"; };
|
|
| 183 |
+ C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceEditorSheetView.swift; sourceTree = "<group>"; };
|
|
| 184 |
+ C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceLibrarySheetView.swift; sourceTree = "<group>"; };
|
|
| 185 |
+ C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceDetailView.swift; sourceTree = "<group>"; };
|
|
| 186 |
+ C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarChargedDevicesSectionView.swift; sourceTree = "<group>"; };
|
|
| 187 |
+ C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCheckpointEditorSheetView.swift; sourceTree = "<group>"; };
|
|
| 188 |
+ C10000193C8E4A7A00A10019 /* USB_Meter 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 4.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 189 |
+ C100001A3C8E4A7A00A1001A /* USB_Meter 5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 5.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 190 |
+ C100001B3C8E4A7A00A1001B /* USB_Meter 6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 6.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 191 |
+ C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSessionsView.swift; sourceTree = "<group>"; };
|
|
| 192 |
+ C100001B3C8E4A7A00A1002B /* ChargeSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeSessionDetailView.swift; sourceTree = "<group>"; };
|
|
| 193 |
+ C100001C3C8E4A7A00A1001C /* USB_Meter 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 7.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 194 |
+ C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 8.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 195 |
+ C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 9.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 196 |
+ C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 10.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 197 |
+ C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeSessionCompletionSheetView.swift; sourceTree = "<group>"; };
|
|
| 198 |
+ C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ChargedDeviceTemplates.json; sourceTree = "<group>"; };
|
|
| 199 |
+ C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 12.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 200 |
+ CD0001013FA0000000000001 /* ChargedDeviceIdentityViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceIdentityViews.swift; sourceTree = "<group>"; };
|
|
| 201 |
+ CD0001023FA0000000000002 /* ChargedDeviceLibraryRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceLibraryRowView.swift; sourceTree = "<group>"; };
|
|
| 202 |
+ CD0001033FA0000000000003 /* ChargedDeviceSidebarCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSidebarCardView.swift; sourceTree = "<group>"; };
|
|
| 203 |
+ CD0001043FA0000000000004 /* ChargedDeviceEditorScaffoldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceEditorScaffoldView.swift; sourceTree = "<group>"; };
|
|
| 204 |
+ CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarChargedDeviceLibraryView.swift; sourceTree = "<group>"; };
|
|
| 205 |
+ CD0001063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceDetailTabBarView.swift; sourceTree = "<group>"; };
|
|
| 206 |
+ CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerEditorSheetView.swift; sourceTree = "<group>"; };
|
|
| 207 |
+ D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterHomeTabView.swift; sourceTree = "<group>"; };
|
|
| 208 |
+ D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveTabView.swift; sourceTree = "<group>"; };
|
|
| 209 |
+ D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterChartTabView.swift; sourceTree = "<group>"; };
|
|
| 210 |
+ D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterSettingsTabView.swift; sourceTree = "<group>"; };
|
|
| 211 |
+ D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterInfoCardView.swift; sourceTree = "<group>"; };
|
|
| 212 |
+ D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterInfoRowView.swift; sourceTree = "<group>"; };
|
|
| 213 |
+ D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterNameEditorView.swift; sourceTree = "<group>"; };
|
|
| 214 |
+ D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTimeoutEditorView.swift; sourceTree = "<group>"; };
|
|
| 215 |
+ D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenBrightnessEditorView.swift; sourceTree = "<group>"; };
|
|
| 216 |
+ D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveMetricRange.swift; sourceTree = "<group>"; };
|
|
| 217 |
+ D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadResistanceIconView.swift; sourceTree = "<group>"; };
|
|
| 218 |
+ D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterScreenControlButtonView.swift; sourceTree = "<group>"; };
|
|
| 219 |
+ D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterCurrentScreenSummaryView.swift; sourceTree = "<group>"; };
|
|
| 220 |
+ D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeRecordMetricsTableView.swift; sourceTree = "<group>"; };
|
|
| 221 |
+ D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterConnectionStatusBadgeView.swift; sourceTree = "<group>"; };
|
|
| 222 |
+ D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterConnectionActionView.swift; sourceTree = "<group>"; };
|
|
| 223 |
+ D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterOverviewSectionView.swift; sourceTree = "<group>"; };
|
|
| 224 |
+ D28F11423C8E4A7A00A10052 /* MeterChargeRecordTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterChargeRecordTabView.swift; sourceTree = "<group>"; };
|
|
| 225 |
+ D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterDataGroupsTabView.swift; sourceTree = "<group>"; };
|
|
| 226 |
+ E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 14.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 227 |
+ F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 15.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 228 |
+ F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 16.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 229 |
+ F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargingWindowDetector.swift; sourceTree = "<group>"; };
|
|
| 136 | 230 |
/* End PBXFileReference section */ |
| 137 | 231 |
|
| 232 |
+/* Begin PBXFileSystemSynchronizedRootGroup section */ |
|
| 233 |
+ 43BE08E12F78F49500250EEC /* SidebarList */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SidebarList; sourceTree = "<group>"; };
|
|
| 234 |
+/* End PBXFileSystemSynchronizedRootGroup section */ |
|
| 235 |
+ |
|
| 138 | 236 |
/* Begin PBXFrameworksBuildPhase section */ |
| 139 | 237 |
43CBF659240BF3EB00255B8B /* Frameworks */ = {
|
| 140 | 238 |
isa = PBXFrameworksBuildPhase; |
@@ -241,21 +339,21 @@ |
||
| 241 | 339 |
path = "Vendor Contacts"; |
| 242 | 340 |
sourceTree = "<group>"; |
| 243 | 341 |
}; |
| 244 |
- 4308CF89241777130002E80B /* Data Groups */ = {
|
|
| 342 |
+ 4308CF89241777130002E80B /* DataGroups */ = {
|
|
| 245 | 343 |
isa = PBXGroup; |
| 246 | 344 |
children = ( |
| 247 |
- 4308CF872417770D0002E80B /* DataGroupsView.swift */, |
|
| 248 |
- 4308CF8524176CAB0002E80B /* DataGroupRowView.swift */, |
|
| 345 |
+ 4308CF872417770D0002E80B /* DataGroupsSheetView.swift */, |
|
| 346 |
+ D28F11263C8E4A7A00A10036 /* Subviews */, |
|
| 249 | 347 |
); |
| 250 |
- path = "Data Groups"; |
|
| 348 |
+ path = DataGroups; |
|
| 251 | 349 |
sourceTree = "<group>"; |
| 252 | 350 |
}; |
| 253 |
- 432F6ED8246684060043912E /* Chart */ = {
|
|
| 351 |
+ 432F6ED8246684060043912E /* Subviews */ = {
|
|
| 254 | 352 |
isa = PBXGroup; |
| 255 | 353 |
children = ( |
| 256 |
- 437F0AB62463108F005DEBEC /* MeasurementChartView.swift */, |
|
| 354 |
+ 43554B31244449B5004E66F5 /* MeasurementSeriesSampleView.swift */, |
|
| 257 | 355 |
); |
| 258 |
- path = Chart; |
|
| 356 |
+ path = Subviews; |
|
| 259 | 357 |
sourceTree = "<group>"; |
| 260 | 358 |
}; |
| 261 | 359 |
4347F01B28D717C1007EE7B1 /* Frameworks */ = {
|
@@ -265,37 +363,39 @@ |
||
| 265 | 363 |
name = Frameworks; |
| 266 | 364 |
sourceTree = "<group>"; |
| 267 | 365 |
}; |
| 268 |
- 43554B3024444983004E66F5 /* Measurements */ = {
|
|
| 366 |
+ 43554B3024444983004E66F5 /* MeasurementSeries */ = {
|
|
| 269 | 367 |
isa = PBXGroup; |
| 270 | 368 |
children = ( |
| 271 |
- 43554B2E24443939004E66F5 /* MeasurementsView.swift */, |
|
| 272 |
- 43554B31244449B5004E66F5 /* MeasurementPointView.swift */, |
|
| 273 |
- 432F6ED8246684060043912E /* Chart */, |
|
| 369 |
+ 43554B2E24443939004E66F5 /* MeasurementSeriesSheetView.swift */, |
|
| 370 |
+ 432F6ED8246684060043912E /* Subviews */, |
|
| 274 | 371 |
); |
| 275 |
- path = Measurements; |
|
| 372 |
+ path = MeasurementSeries; |
|
| 276 | 373 |
sourceTree = "<group>"; |
| 277 | 374 |
}; |
| 278 | 375 |
437D47CF2415F8CF00B7768E /* Meter */ = {
|
| 279 | 376 |
isa = PBXGroup; |
| 280 | 377 |
children = ( |
| 281 | 378 |
4383B469240FE4A600DAAEBF /* MeterView.swift */, |
| 282 |
- 4360A34E241D5CF100B464F9 /* MeterSettingsView.swift */, |
|
| 283 |
- 437D47D02415F91B00B7768E /* LiveView.swift */, |
|
| 284 |
- 437D47D42415FD8C00B7768E /* RecordingView.swift */, |
|
| 285 |
- 437D47D62415FDF300B7768E /* ControlView.swift */, |
|
| 286 |
- 4308CF89241777130002E80B /* Data Groups */, |
|
| 287 |
- 4360A34C241CBB3800B464F9 /* RSSIView.swift */, |
|
| 288 |
- 430CB4FB245E07EB006525C2 /* ChevronView.swift */, |
|
| 289 |
- 43554B3024444983004E66F5 /* Measurements */, |
|
| 379 |
+ D28F113F3C8E4A7A00A1004F /* Components */, |
|
| 380 |
+ D28F11093C8E4A7A00A10019 /* Tabs */, |
|
| 381 |
+ D28F10013C8E4A7A00A10001 /* Sheets */, |
|
| 290 | 382 |
); |
| 291 | 383 |
path = Meter; |
| 292 | 384 |
sourceTree = "<group>"; |
| 293 | 385 |
}; |
| 386 |
+ 437F0AB32463108F005DEBEC /* Charts */ = {
|
|
| 387 |
+ isa = PBXGroup; |
|
| 388 |
+ children = ( |
|
| 389 |
+ 437F0AB82463108F005DEBEC /* TimeSeriesChart.swift */, |
|
| 390 |
+ ); |
|
| 391 |
+ path = Charts; |
|
| 392 |
+ sourceTree = "<group>"; |
|
| 393 |
+ }; |
|
| 294 | 394 |
4383B463240EB66400DAAEBF /* Templates */ = {
|
| 295 | 395 |
isa = PBXGroup; |
| 296 | 396 |
children = ( |
| 297 | 397 |
4383B464240EB6B200DAAEBF /* UserDefault.swift */, |
| 298 |
- 43567FE82443AD7C00000282 /* ICloudDefault.swift */, |
|
| 398 |
+ C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */, |
|
| 299 | 399 |
); |
| 300 | 400 |
path = Templates; |
| 301 | 401 |
sourceTree = "<group>"; |
@@ -357,6 +457,12 @@ |
||
| 357 | 457 |
isa = PBXGroup; |
| 358 | 458 |
children = ( |
| 359 | 459 |
4383B461240EB5E400DAAEBF /* AppData.swift */, |
| 460 |
+ C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */, |
|
| 461 |
+ C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */, |
|
| 462 |
+ F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */, |
|
| 463 |
+ B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */, |
|
| 464 |
+ C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */, |
|
| 465 |
+ 7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */, |
|
| 360 | 466 |
43CBF676240C043E00255B8B /* BluetoothManager.swift */, |
| 361 | 467 |
4383B45F240EB2D000DAAEBF /* Meter.swift */, |
| 362 | 468 |
43ED78AD2420A0BE00974487 /* BluetoothSerial.swift */, |
@@ -364,7 +470,6 @@ |
||
| 364 | 470 |
4386958E2F6A4E3E008855A9 /* MeterCapabilities.swift */, |
| 365 | 471 |
4386958A2F6A1001008855A9 /* UMProtocol.swift */, |
| 366 | 472 |
4386958C2F6A1002008855A9 /* TC66Protocol.swift */, |
| 367 |
- 43CBF663240BF3EB00255B8B /* CKModel.xcdatamodeld */, |
|
| 368 | 473 |
438695882463F062008855A9 /* Measurements.swift */, |
| 369 | 474 |
432EA6432445A559006FC905 /* ChartContext.swift */, |
| 370 | 475 |
); |
@@ -375,10 +480,12 @@ |
||
| 375 | 480 |
isa = PBXGroup; |
| 376 | 481 |
children = ( |
| 377 | 482 |
43CBF666240BF3EB00255B8B /* ContentView.swift */, |
| 378 |
- 4327461A24619CED0009BE4B /* MeterRowView.swift */, |
|
| 483 |
+ AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */, |
|
| 484 |
+ C10000203C8E4A7A00A10020 /* ChargedDevices */, |
|
| 485 |
+ 56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */, |
|
| 379 | 486 |
437D47CF2415F8CF00B7768E /* Meter */, |
| 487 |
+ D28F10023C8E4A7A00A10002 /* Components */, |
|
| 380 | 488 |
4311E639241384960080EA59 /* DeviceHelpView.swift */, |
| 381 |
- 43DFBE3F2441A37B004A47EA /* BorderView.swift */, |
|
| 382 | 489 |
); |
| 383 | 490 |
path = Views; |
| 384 | 491 |
sourceTree = "<group>"; |
@@ -399,6 +506,264 @@ |
||
| 399 | 506 |
path = Extensions; |
| 400 | 507 |
sourceTree = "<group>"; |
| 401 | 508 |
}; |
| 509 |
+ AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */ = {
|
|
| 510 |
+ isa = PBXGroup; |
|
| 511 |
+ children = ( |
|
| 512 |
+ AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */, |
|
| 513 |
+ 43BE08E12F78F49500250EEC /* SidebarList */, |
|
| 514 |
+ ); |
|
| 515 |
+ path = Sidebar; |
|
| 516 |
+ sourceTree = "<group>"; |
|
| 517 |
+ }; |
|
| 518 |
+ C10000203C8E4A7A00A10020 /* ChargedDevices */ = {
|
|
| 519 |
+ isa = PBXGroup; |
|
| 520 |
+ children = ( |
|
| 521 |
+ CD0000103FA0000000000010 /* Components */, |
|
| 522 |
+ CD0000113FA0000000000011 /* Details */, |
|
| 523 |
+ CD0000123FA0000000000012 /* Sessions */, |
|
| 524 |
+ CD0000133FA0000000000013 /* Sheets */, |
|
| 525 |
+ CD0000173FA0000000000017 /* Sidebar */, |
|
| 526 |
+ ); |
|
| 527 |
+ path = ChargedDevices; |
|
| 528 |
+ sourceTree = "<group>"; |
|
| 529 |
+ }; |
|
| 530 |
+ CD0000103FA0000000000010 /* Components */ = {
|
|
| 531 |
+ isa = PBXGroup; |
|
| 532 |
+ children = ( |
|
| 533 |
+ CD0001063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift */, |
|
| 534 |
+ CD0001043FA0000000000004 /* ChargedDeviceEditorScaffoldView.swift */, |
|
| 535 |
+ CD0001013FA0000000000001 /* ChargedDeviceIdentityViews.swift */, |
|
| 536 |
+ CD0001023FA0000000000002 /* ChargedDeviceLibraryRowView.swift */, |
|
| 537 |
+ C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */, |
|
| 538 |
+ CD0001033FA0000000000003 /* ChargedDeviceSidebarCardView.swift */, |
|
| 539 |
+ ); |
|
| 540 |
+ path = Components; |
|
| 541 |
+ sourceTree = "<group>"; |
|
| 542 |
+ }; |
|
| 543 |
+ CD0000113FA0000000000011 /* Details */ = {
|
|
| 544 |
+ isa = PBXGroup; |
|
| 545 |
+ children = ( |
|
| 546 |
+ C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */, |
|
| 547 |
+ ); |
|
| 548 |
+ path = Details; |
|
| 549 |
+ sourceTree = "<group>"; |
|
| 550 |
+ }; |
|
| 551 |
+ CD0000123FA0000000000012 /* Sessions */ = {
|
|
| 552 |
+ isa = PBXGroup; |
|
| 553 |
+ children = ( |
|
| 554 |
+ C100001B3C8E4A7A00A1002B /* ChargeSessionDetailView.swift */, |
|
| 555 |
+ C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */, |
|
| 556 |
+ ); |
|
| 557 |
+ path = Sessions; |
|
| 558 |
+ sourceTree = "<group>"; |
|
| 559 |
+ }; |
|
| 560 |
+ CD0000133FA0000000000013 /* Sheets */ = {
|
|
| 561 |
+ isa = PBXGroup; |
|
| 562 |
+ children = ( |
|
| 563 |
+ CD0000163FA0000000000016 /* ChargeSession */, |
|
| 564 |
+ CD0000143FA0000000000014 /* Editors */, |
|
| 565 |
+ CD0000153FA0000000000015 /* Library */, |
|
| 566 |
+ ); |
|
| 567 |
+ path = Sheets; |
|
| 568 |
+ sourceTree = "<group>"; |
|
| 569 |
+ }; |
|
| 570 |
+ CD0000143FA0000000000014 /* Editors */ = {
|
|
| 571 |
+ isa = PBXGroup; |
|
| 572 |
+ children = ( |
|
| 573 |
+ C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */, |
|
| 574 |
+ CE00CE023C8E4A7A00CE00CE /* ChargerEditorSheetView.swift */, |
|
| 575 |
+ ); |
|
| 576 |
+ path = Editors; |
|
| 577 |
+ sourceTree = "<group>"; |
|
| 578 |
+ }; |
|
| 579 |
+ CD0000153FA0000000000015 /* Library */ = {
|
|
| 580 |
+ isa = PBXGroup; |
|
| 581 |
+ children = ( |
|
| 582 |
+ C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */, |
|
| 583 |
+ ); |
|
| 584 |
+ path = Library; |
|
| 585 |
+ sourceTree = "<group>"; |
|
| 586 |
+ }; |
|
| 587 |
+ CD0000163FA0000000000016 /* ChargeSession */ = {
|
|
| 588 |
+ isa = PBXGroup; |
|
| 589 |
+ children = ( |
|
| 590 |
+ C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */, |
|
| 591 |
+ C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */, |
|
| 592 |
+ ); |
|
| 593 |
+ path = ChargeSession; |
|
| 594 |
+ sourceTree = "<group>"; |
|
| 595 |
+ }; |
|
| 596 |
+ CD0000173FA0000000000017 /* Sidebar */ = {
|
|
| 597 |
+ isa = PBXGroup; |
|
| 598 |
+ children = ( |
|
| 599 |
+ CD0001053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift */, |
|
| 600 |
+ C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */, |
|
| 601 |
+ ); |
|
| 602 |
+ path = Sidebar; |
|
| 603 |
+ sourceTree = "<group>"; |
|
| 604 |
+ }; |
|
| 605 |
+ D28F10013C8E4A7A00A10001 /* Sheets */ = {
|
|
| 606 |
+ isa = PBXGroup; |
|
| 607 |
+ children = ( |
|
| 608 |
+ 4308CF89241777130002E80B /* DataGroups */, |
|
| 609 |
+ 43554B3024444983004E66F5 /* MeasurementSeries */, |
|
| 610 |
+ D28F11273C8E4A7A00A10037 /* ChargeRecord */, |
|
| 611 |
+ ); |
|
| 612 |
+ path = Sheets; |
|
| 613 |
+ sourceTree = "<group>"; |
|
| 614 |
+ }; |
|
| 615 |
+ D28F10023C8E4A7A00A10002 /* Components */ = {
|
|
| 616 |
+ isa = PBXGroup; |
|
| 617 |
+ children = ( |
|
| 618 |
+ 437F0AB32463108F005DEBEC /* Charts */, |
|
| 619 |
+ D28F10033C8E4A7A00A10003 /* Generic */, |
|
| 620 |
+ ); |
|
| 621 |
+ path = Components; |
|
| 622 |
+ sourceTree = "<group>"; |
|
| 623 |
+ }; |
|
| 624 |
+ D28F10033C8E4A7A00A10003 /* Generic */ = {
|
|
| 625 |
+ isa = PBXGroup; |
|
| 626 |
+ children = ( |
|
| 627 |
+ 430CB4FE245E07EB006525C4 /* AdaptiveTabBarPresentation.swift */, |
|
| 628 |
+ 4360A34C241CBB3800B464F9 /* RSSIView.swift */, |
|
| 629 |
+ 430CB4FB245E07EB006525C2 /* ChevronView.swift */, |
|
| 630 |
+ ); |
|
| 631 |
+ path = Generic; |
|
| 632 |
+ sourceTree = "<group>"; |
|
| 633 |
+ }; |
|
| 634 |
+ D28F11093C8E4A7A00A10019 /* Tabs */ = {
|
|
| 635 |
+ isa = PBXGroup; |
|
| 636 |
+ children = ( |
|
| 637 |
+ D28F110A3C8E4A7A00A1001A /* Home */, |
|
| 638 |
+ D28F110B3C8E4A7A00A1001B /* Live */, |
|
| 639 |
+ D28F110C3C8E4A7A00A1001C /* Chart */, |
|
| 640 |
+ D28F11453C8E4A7A00A10055 /* ChargeRecord */, |
|
| 641 |
+ D28F11463C8E4A7A00A10056 /* DataGroups */, |
|
| 642 |
+ D28F110D3C8E4A7A00A1001D /* Settings */, |
|
| 643 |
+ ); |
|
| 644 |
+ path = Tabs; |
|
| 645 |
+ sourceTree = "<group>"; |
|
| 646 |
+ }; |
|
| 647 |
+ D28F110A3C8E4A7A00A1001A /* Home */ = {
|
|
| 648 |
+ isa = PBXGroup; |
|
| 649 |
+ children = ( |
|
| 650 |
+ D28F11023C8E4A7A00A10012 /* MeterHomeTabView.swift */, |
|
| 651 |
+ D28F111B3C8E4A7A00A1002B /* Subviews */, |
|
| 652 |
+ ); |
|
| 653 |
+ path = Home; |
|
| 654 |
+ sourceTree = "<group>"; |
|
| 655 |
+ }; |
|
| 656 |
+ D28F110B3C8E4A7A00A1001B /* Live */ = {
|
|
| 657 |
+ isa = PBXGroup; |
|
| 658 |
+ children = ( |
|
| 659 |
+ D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */, |
|
| 660 |
+ B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */, |
|
| 661 |
+ D28F11253C8E4A7A00A10035 /* Subviews */, |
|
| 662 |
+ ); |
|
| 663 |
+ path = Live; |
|
| 664 |
+ sourceTree = "<group>"; |
|
| 665 |
+ }; |
|
| 666 |
+ D28F110C3C8E4A7A00A1001C /* Chart */ = {
|
|
| 667 |
+ isa = PBXGroup; |
|
| 668 |
+ children = ( |
|
| 669 |
+ D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */, |
|
| 670 |
+ ); |
|
| 671 |
+ path = Chart; |
|
| 672 |
+ sourceTree = "<group>"; |
|
| 673 |
+ }; |
|
| 674 |
+ D28F110D3C8E4A7A00A1001D /* Settings */ = {
|
|
| 675 |
+ isa = PBXGroup; |
|
| 676 |
+ children = ( |
|
| 677 |
+ D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */, |
|
| 678 |
+ D28F111C3C8E4A7A00A1002C /* Subviews */, |
|
| 679 |
+ ); |
|
| 680 |
+ path = Settings; |
|
| 681 |
+ sourceTree = "<group>"; |
|
| 682 |
+ }; |
|
| 683 |
+ D28F111B3C8E4A7A00A1002B /* Subviews */ = {
|
|
| 684 |
+ isa = PBXGroup; |
|
| 685 |
+ children = ( |
|
| 686 |
+ D28F113A3C8E4A7A00A1004A /* MeterConnectionStatusBadgeView.swift */, |
|
| 687 |
+ D28F113C3C8E4A7A00A1004C /* MeterConnectionActionView.swift */, |
|
| 688 |
+ D28F113E3C8E4A7A00A1004E /* MeterOverviewSectionView.swift */, |
|
| 689 |
+ ); |
|
| 690 |
+ path = Subviews; |
|
| 691 |
+ sourceTree = "<group>"; |
|
| 692 |
+ }; |
|
| 693 |
+ D28F111C3C8E4A7A00A1002C /* Subviews */ = {
|
|
| 694 |
+ isa = PBXGroup; |
|
| 695 |
+ children = ( |
|
| 696 |
+ D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */, |
|
| 697 |
+ D28F11183C8E4A7A00A10028 /* ScreenTimeoutEditorView.swift */, |
|
| 698 |
+ D28F111A3C8E4A7A00A1002A /* ScreenBrightnessEditorView.swift */, |
|
| 699 |
+ 437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */, |
|
| 700 |
+ D28F11323C8E4A7A00A10042 /* MeterScreenControlButtonView.swift */, |
|
| 701 |
+ D28F11343C8E4A7A00A10044 /* MeterCurrentScreenSummaryView.swift */, |
|
| 702 |
+ ); |
|
| 703 |
+ path = Subviews; |
|
| 704 |
+ sourceTree = "<group>"; |
|
| 705 |
+ }; |
|
| 706 |
+ D28F11253C8E4A7A00A10035 /* Subviews */ = {
|
|
| 707 |
+ isa = PBXGroup; |
|
| 708 |
+ children = ( |
|
| 709 |
+ 437D47D02415F91B00B7768E /* MeterLiveContentView.swift */, |
|
| 710 |
+ D28F11223C8E4A7A00A10032 /* MeterLiveMetricRange.swift */, |
|
| 711 |
+ D28F11243C8E4A7A00A10034 /* LoadResistanceIconView.swift */, |
|
| 712 |
+ ); |
|
| 713 |
+ path = Subviews; |
|
| 714 |
+ sourceTree = "<group>"; |
|
| 715 |
+ }; |
|
| 716 |
+ D28F11263C8E4A7A00A10036 /* Subviews */ = {
|
|
| 717 |
+ isa = PBXGroup; |
|
| 718 |
+ children = ( |
|
| 719 |
+ 4308CF8524176CAB0002E80B /* DataGroupRowView.swift */, |
|
| 720 |
+ ); |
|
| 721 |
+ path = Subviews; |
|
| 722 |
+ sourceTree = "<group>"; |
|
| 723 |
+ }; |
|
| 724 |
+ D28F11273C8E4A7A00A10037 /* ChargeRecord */ = {
|
|
| 725 |
+ isa = PBXGroup; |
|
| 726 |
+ children = ( |
|
| 727 |
+ 437D47D42415FD8C00B7768E /* ChargeRecordSheetView.swift */, |
|
| 728 |
+ D28F11383C8E4A7A00A10048 /* Subviews */, |
|
| 729 |
+ ); |
|
| 730 |
+ path = ChargeRecord; |
|
| 731 |
+ sourceTree = "<group>"; |
|
| 732 |
+ }; |
|
| 733 |
+ D28F11383C8E4A7A00A10048 /* Subviews */ = {
|
|
| 734 |
+ isa = PBXGroup; |
|
| 735 |
+ children = ( |
|
| 736 |
+ D28F11363C8E4A7A00A10046 /* ChargeRecordMetricsTableView.swift */, |
|
| 737 |
+ ); |
|
| 738 |
+ path = Subviews; |
|
| 739 |
+ sourceTree = "<group>"; |
|
| 740 |
+ }; |
|
| 741 |
+ D28F113F3C8E4A7A00A1004F /* Components */ = {
|
|
| 742 |
+ isa = PBXGroup; |
|
| 743 |
+ children = ( |
|
| 744 |
+ D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */, |
|
| 745 |
+ D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */, |
|
| 746 |
+ 437F0AB62463108F005DEBEC /* MeasurementChartView.swift */, |
|
| 747 |
+ ); |
|
| 748 |
+ path = Components; |
|
| 749 |
+ sourceTree = "<group>"; |
|
| 750 |
+ }; |
|
| 751 |
+ D28F11453C8E4A7A00A10055 /* ChargeRecord */ = {
|
|
| 752 |
+ isa = PBXGroup; |
|
| 753 |
+ children = ( |
|
| 754 |
+ D28F11423C8E4A7A00A10052 /* MeterChargeRecordTabView.swift */, |
|
| 755 |
+ ); |
|
| 756 |
+ path = ChargeRecord; |
|
| 757 |
+ sourceTree = "<group>"; |
|
| 758 |
+ }; |
|
| 759 |
+ D28F11463C8E4A7A00A10056 /* DataGroups */ = {
|
|
| 760 |
+ isa = PBXGroup; |
|
| 761 |
+ children = ( |
|
| 762 |
+ D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */, |
|
| 763 |
+ ); |
|
| 764 |
+ path = DataGroups; |
|
| 765 |
+ sourceTree = "<group>"; |
|
| 766 |
+ }; |
|
| 402 | 767 |
/* End PBXGroup section */ |
| 403 | 768 |
|
| 404 | 769 |
/* Begin PBXNativeTarget section */ |
@@ -414,6 +779,9 @@ |
||
| 414 | 779 |
); |
| 415 | 780 |
dependencies = ( |
| 416 | 781 |
); |
| 782 |
+ fileSystemSynchronizedGroups = ( |
|
| 783 |
+ 43BE08E12F78F49500250EEC /* SidebarList */, |
|
| 784 |
+ ); |
|
| 417 | 785 |
name = "USB Meter"; |
| 418 | 786 |
packageProductDependencies = ( |
| 419 | 787 |
4347F01C28D717C1007EE7B1 /* CryptoSwift */, |
@@ -430,11 +798,19 @@ |
||
| 430 | 798 |
attributes = {
|
| 431 | 799 |
BuildIndependentTargetsInParallel = YES; |
| 432 | 800 |
LastSwiftUpdateCheck = 1130; |
| 433 |
- LastUpgradeCheck = 2630; |
|
| 801 |
+ LastUpgradeCheck = 2640; |
|
| 434 | 802 |
ORGANIZATIONNAME = "Bogdan Timofte"; |
| 435 | 803 |
TargetAttributes = {
|
| 436 | 804 |
43CBF65B240BF3EB00255B8B = {
|
| 437 | 805 |
CreatedOnToolsVersion = 11.3.1; |
| 806 |
+ SystemCapabilities = {
|
|
| 807 |
+ com.apple.BackgroundModes = {
|
|
| 808 |
+ enabled = 1; |
|
| 809 |
+ }; |
|
| 810 |
+ com.apple.iCloud = {
|
|
| 811 |
+ enabled = 1; |
|
| 812 |
+ }; |
|
| 813 |
+ }; |
|
| 438 | 814 |
}; |
| 439 | 815 |
}; |
| 440 | 816 |
}; |
@@ -467,6 +843,7 @@ |
||
| 467 | 843 |
43CBF66F240BF3ED00255B8B /* LaunchScreen.storyboard in Resources */, |
| 468 | 844 |
43CBF66C240BF3ED00255B8B /* Preview Assets.xcassets in Resources */, |
| 469 | 845 |
43CBF669240BF3ED00255B8B /* Assets.xcassets in Resources */, |
| 846 |
+ C1F000013C90000100A10001 /* ChargedDeviceTemplates.json in Resources */, |
|
| 470 | 847 |
); |
| 471 | 848 |
runOnlyForDeploymentPostprocessing = 0; |
| 472 | 849 |
}; |
@@ -478,42 +855,83 @@ |
||
| 478 | 855 |
buildActionMask = 2147483647; |
| 479 | 856 |
files = ( |
| 480 | 857 |
43874C852415611200525397 /* Double.swift in Sources */, |
| 481 |
- 437D47D72415FDF300B7768E /* ControlView.swift in Sources */, |
|
| 482 |
- 4308CF882417770D0002E80B /* DataGroupsView.swift in Sources */, |
|
| 483 |
- 43567FE92443AD7C00000282 /* ICloudDefault.swift in Sources */, |
|
| 858 |
+ 437D47D72415FDF300B7768E /* MeterScreenControlsView.swift in Sources */, |
|
| 859 |
+ 4308CF882417770D0002E80B /* DataGroupsSheetView.swift in Sources */, |
|
| 484 | 860 |
4383B468240F845500DAAEBF /* MacAdress.swift in Sources */, |
| 485 | 861 |
43CBF681240D153000255B8B /* CBManagerState.swift in Sources */, |
| 486 | 862 |
4383B46A240FE4A600DAAEBF /* MeterView.swift in Sources */, |
| 863 |
+ D28F11013C8E4A7A00A10011 /* MeterHomeTabView.swift in Sources */, |
|
| 864 |
+ D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */, |
|
| 865 |
+ D28F11053C8E4A7A00A10015 /* MeterChartTabView.swift in Sources */, |
|
| 866 |
+ D28F11073C8E4A7A00A10017 /* MeterSettingsTabView.swift in Sources */, |
|
| 867 |
+ D28F11413C8E4A7A00A10051 /* MeterChargeRecordTabView.swift in Sources */, |
|
| 868 |
+ D28F11433C8E4A7A00A10053 /* MeterDataGroupsTabView.swift in Sources */, |
|
| 869 |
+ D28F11113C8E4A7A00A10021 /* MeterInfoCardView.swift in Sources */, |
|
| 870 |
+ D28F11133C8E4A7A00A10023 /* MeterInfoRowView.swift in Sources */, |
|
| 871 |
+ D28F11153C8E4A7A00A10025 /* MeterNameEditorView.swift in Sources */, |
|
| 872 |
+ D28F11173C8E4A7A00A10027 /* ScreenTimeoutEditorView.swift in Sources */, |
|
| 873 |
+ D28F11193C8E4A7A00A10029 /* ScreenBrightnessEditorView.swift in Sources */, |
|
| 874 |
+ D28F11213C8E4A7A00A10031 /* MeterLiveMetricRange.swift in Sources */, |
|
| 875 |
+ D28F11233C8E4A7A00A10033 /* LoadResistanceIconView.swift in Sources */, |
|
| 876 |
+ D28F11313C8E4A7A00A10041 /* MeterScreenControlButtonView.swift in Sources */, |
|
| 877 |
+ D28F11333C8E4A7A00A10043 /* MeterCurrentScreenSummaryView.swift in Sources */, |
|
| 878 |
+ D28F11353C8E4A7A00A10045 /* ChargeRecordMetricsTableView.swift in Sources */, |
|
| 879 |
+ D28F11393C8E4A7A00A10049 /* MeterConnectionStatusBadgeView.swift in Sources */, |
|
| 880 |
+ D28F113B3C8E4A7A00A1004B /* MeterConnectionActionView.swift in Sources */, |
|
| 881 |
+ D28F113D3C8E4A7A00A1004D /* MeterOverviewSectionView.swift in Sources */, |
|
| 882 |
+ C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */, |
|
| 883 |
+ C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */, |
|
| 884 |
+ F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */, |
|
| 885 |
+ C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */, |
|
| 886 |
+ C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */, |
|
| 887 |
+ C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */, |
|
| 888 |
+ C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */, |
|
| 889 |
+ C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */, |
|
| 890 |
+ C100000B3C8E4A7A00A1000B /* ChargeSessionDetailView.swift in Sources */, |
|
| 891 |
+ C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */, |
|
| 892 |
+ C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */, |
|
| 893 |
+ C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */, |
|
| 894 |
+ CE00CE013C8E4A7A00CE00CE /* ChargerEditorSheetView.swift in Sources */, |
|
| 895 |
+ CD0002013FA0000000000001 /* ChargedDeviceIdentityViews.swift in Sources */, |
|
| 896 |
+ CD0002023FA0000000000002 /* ChargedDeviceLibraryRowView.swift in Sources */, |
|
| 897 |
+ CD0002033FA0000000000003 /* ChargedDeviceSidebarCardView.swift in Sources */, |
|
| 898 |
+ CD0002043FA0000000000004 /* ChargedDeviceEditorScaffoldView.swift in Sources */, |
|
| 899 |
+ CD0002053FA0000000000005 /* SidebarChargedDeviceLibraryView.swift in Sources */, |
|
| 900 |
+ CD0002063FA0000000000006 /* ChargedDeviceDetailTabBarView.swift in Sources */, |
|
| 901 |
+ C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */, |
|
| 902 |
+ B0A000113C8F000100A10011 /* ChargerStandbyPowerStore.swift in Sources */, |
|
| 903 |
+ B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */, |
|
| 487 | 904 |
4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */, |
| 488 |
- 437D47D12415F91B00B7768E /* LiveView.swift in Sources */, |
|
| 489 |
- 43CBF665240BF3EB00255B8B /* CKModel.xcdatamodeld in Sources */, |
|
| 490 |
- 4360A34F241D5CF100B464F9 /* MeterSettingsView.swift in Sources */, |
|
| 905 |
+ 430CB4FD245E07EB006525C3 /* AdaptiveTabBarPresentation.swift in Sources */, |
|
| 906 |
+ 437D47D12415F91B00B7768E /* MeterLiveContentView.swift in Sources */, |
|
| 491 | 907 |
4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */, |
| 908 |
+ 3407A133FADB8858DC2A1FED /* MeterNameStore.swift in Sources */, |
|
| 492 | 909 |
43CBF677240C043E00255B8B /* BluetoothManager.swift in Sources */, |
| 493 | 910 |
43CBF660240BF3EB00255B8B /* AppDelegate.swift in Sources */, |
| 494 | 911 |
438B9555246D2D7500E61AE7 /* Path.swift in Sources */, |
| 495 | 912 |
4383B460240EB2D000DAAEBF /* Meter.swift in Sources */, |
| 913 |
+ AAD5F9B12B1CAC7A00F8E4F9 /* SidebarView.swift in Sources */, |
|
| 496 | 914 |
43CBF667240BF3EB00255B8B /* ContentView.swift in Sources */, |
| 497 |
- 43DFBE402441A37B004A47EA /* BorderView.swift in Sources */, |
|
| 498 | 915 |
437F0AB72463108F005DEBEC /* MeasurementChartView.swift in Sources */, |
| 916 |
+ 437F0AB92463108F005DEBEC /* TimeSeriesChart.swift in Sources */, |
|
| 499 | 917 |
437D47D32415FB7E00B7768E /* Decimal.swift in Sources */, |
| 500 | 918 |
43874C7F2414F3F400525397 /* Float.swift in Sources */, |
| 501 | 919 |
4383B462240EB5E400DAAEBF /* AppData.swift in Sources */, |
| 502 | 920 |
4386958D2F6A1002008855A9 /* TC66Protocol.swift in Sources */, |
| 503 |
- 437D47D52415FD8C00B7768E /* RecordingView.swift in Sources */, |
|
| 921 |
+ 437D47D52415FD8C00B7768E /* ChargeRecordSheetView.swift in Sources */, |
|
| 504 | 922 |
432EA6442445A559006FC905 /* ChartContext.swift in Sources */, |
| 505 | 923 |
4308CF8624176CAB0002E80B /* DataGroupRowView.swift in Sources */, |
| 506 | 924 |
4386958F2F6A4E3E008855A9 /* MeterCapabilities.swift in Sources */, |
| 507 |
- 43554B32244449B5004E66F5 /* MeasurementPointView.swift in Sources */, |
|
| 925 |
+ 43554B32244449B5004E66F5 /* MeasurementSeriesSampleView.swift in Sources */, |
|
| 508 | 926 |
43F7792B2465AE1600745DF4 /* UIView.swift in Sources */, |
| 509 | 927 |
43ED78AE2420A0BE00974487 /* BluetoothSerial.swift in Sources */, |
| 510 | 928 |
43CBF662240BF3EB00255B8B /* SceneDelegate.swift in Sources */, |
| 511 | 929 |
4351E7BB24685ACD00E798A3 /* CGPoint.swift in Sources */, |
| 512 |
- 4327461B24619CED0009BE4B /* MeterRowView.swift in Sources */, |
|
| 513 |
- 43554B2F24443939004E66F5 /* MeasurementsView.swift in Sources */, |
|
| 930 |
+ 43554B2F24443939004E66F5 /* MeasurementSeriesSheetView.swift in Sources */, |
|
| 514 | 931 |
430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */, |
| 515 | 932 |
43554B3424444B0E004E66F5 /* Date.swift in Sources */, |
| 516 | 933 |
4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */, |
| 934 |
+ E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */, |
|
| 517 | 935 |
439D996524234B98008DE3AA /* BluetoothRadio.swift in Sources */, |
| 518 | 936 |
438695892463F062008855A9 /* Measurements.swift in Sources */, |
| 519 | 937 |
4386958B2F6A1001008855A9 /* UMProtocol.swift in Sources */, |
@@ -539,6 +957,7 @@ |
||
| 539 | 957 |
isa = XCBuildConfiguration; |
| 540 | 958 |
buildSettings = {
|
| 541 | 959 |
ALWAYS_SEARCH_USER_PATHS = NO; |
| 960 |
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; |
|
| 542 | 961 |
CLANG_ANALYZER_NONNULL = YES; |
| 543 | 962 |
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; |
| 544 | 963 |
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; |
@@ -603,6 +1022,7 @@ |
||
| 603 | 1022 |
isa = XCBuildConfiguration; |
| 604 | 1023 |
buildSettings = {
|
| 605 | 1024 |
ALWAYS_SEARCH_USER_PATHS = NO; |
| 1025 |
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; |
|
| 606 | 1026 |
CLANG_ANALYZER_NONNULL = YES; |
| 607 | 1027 |
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; |
| 608 | 1028 |
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; |
@@ -668,6 +1088,7 @@ |
||
| 668 | 1088 |
ENABLE_PREVIEWS = YES; |
| 669 | 1089 |
ENABLE_RESOURCE_ACCESS_BLUETOOTH = YES; |
| 670 | 1090 |
INFOPLIST_FILE = "USB Meter/Info.plist"; |
| 1091 |
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; |
|
| 671 | 1092 |
LD_RUNPATH_SEARCH_PATHS = ( |
| 672 | 1093 |
"$(inherited)", |
| 673 | 1094 |
"@executable_path/Frameworks", |
@@ -675,7 +1096,10 @@ |
||
| 675 | 1096 |
PRODUCT_BUNDLE_IDENTIFIER = "ro.xdev.USB-Meter"; |
| 676 | 1097 |
PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 1098 |
STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| 1099 |
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
|
| 678 | 1100 |
SUPPORTS_MACCATALYST = YES; |
| 1101 |
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; |
|
| 1102 |
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; |
|
| 679 | 1103 |
SWIFT_VERSION = 5.0; |
| 680 | 1104 |
TARGETED_DEVICE_FAMILY = "1,2"; |
| 681 | 1105 |
}; |
@@ -692,6 +1116,7 @@ |
||
| 692 | 1116 |
ENABLE_PREVIEWS = YES; |
| 693 | 1117 |
ENABLE_RESOURCE_ACCESS_BLUETOOTH = YES; |
| 694 | 1118 |
INFOPLIST_FILE = "USB Meter/Info.plist"; |
| 1119 |
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; |
|
| 695 | 1120 |
LD_RUNPATH_SEARCH_PATHS = ( |
| 696 | 1121 |
"$(inherited)", |
| 697 | 1122 |
"@executable_path/Frameworks", |
@@ -699,7 +1124,10 @@ |
||
| 699 | 1124 |
PRODUCT_BUNDLE_IDENTIFIER = "ro.xdev.USB-Meter"; |
| 700 | 1125 |
PRODUCT_NAME = "$(TARGET_NAME)"; |
| 701 | 1126 |
STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| 1127 |
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
|
| 702 | 1128 |
SUPPORTS_MACCATALYST = YES; |
| 1129 |
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; |
|
| 1130 |
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; |
|
| 703 | 1131 |
SWIFT_VERSION = 5.0; |
| 704 | 1132 |
TARGETED_DEVICE_FAMILY = "1,2"; |
| 705 | 1133 |
}; |
@@ -748,12 +1176,22 @@ |
||
| 748 | 1176 |
/* End XCSwiftPackageProductDependency section */ |
| 749 | 1177 |
|
| 750 | 1178 |
/* Begin XCVersionGroup section */ |
| 751 |
- 43CBF663240BF3EB00255B8B /* CKModel.xcdatamodeld */ = {
|
|
| 1179 |
+ C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */ = {
|
|
| 752 | 1180 |
isa = XCVersionGroup; |
| 753 | 1181 |
children = ( |
| 754 |
- 43CBF664240BF3EB00255B8B /* USB_Meter.xcdatamodel */, |
|
| 1182 |
+ C10000193C8E4A7A00A10019 /* USB_Meter 4.xcdatamodel */, |
|
| 1183 |
+ C100001A3C8E4A7A00A1001A /* USB_Meter 5.xcdatamodel */, |
|
| 1184 |
+ C100001B3C8E4A7A00A1001B /* USB_Meter 6.xcdatamodel */, |
|
| 1185 |
+ C100001C3C8E4A7A00A1001C /* USB_Meter 7.xcdatamodel */, |
|
| 1186 |
+ C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */, |
|
| 1187 |
+ C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */, |
|
| 1188 |
+ C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */, |
|
| 1189 |
+ C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */, |
|
| 1190 |
+ E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */, |
|
| 1191 |
+ F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */, |
|
| 1192 |
+ F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */, |
|
| 755 | 1193 |
); |
| 756 |
- currentVersion = 43CBF664240BF3EB00255B8B /* USB_Meter.xcdatamodel */; |
|
| 1194 |
+ currentVersion = F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */; |
|
| 757 | 1195 |
path = CKModel.xcdatamodeld; |
| 758 | 1196 |
sourceTree = "<group>"; |
| 759 | 1197 |
versionGroupType = wrapper.xcdatamodel; |
@@ -6,11 +6,15 @@ |
||
| 6 | 6 |
// Copyright © 2020 Bogdan Timofte. All rights reserved. |
| 7 | 7 |
// |
| 8 | 8 |
|
| 9 |
-import UIKit |
|
| 9 |
+import CloudKit |
|
| 10 | 10 |
import CoreData |
| 11 |
+import OSLog |
|
| 12 |
+import UIKit |
|
| 13 |
+import UserNotifications |
|
| 11 | 14 |
|
| 12 | 15 |
//let btSerial = BluetoothSerial(delegate: BSD()) |
| 13 | 16 |
let appData = AppData() |
| 17 |
+private let restoreLogger = Logger(subsystem: "ro.xdev.USB-Meter", category: "Restore") |
|
| 14 | 18 |
enum Constants {
|
| 15 | 19 |
static let chartUnderscan: CGFloat = 0.5 |
| 16 | 20 |
static let chartOverscan: CGFloat = 1 - chartUnderscan |
@@ -19,6 +23,9 @@ enum Constants {
|
||
| 19 | 23 |
|
| 20 | 24 |
// MARK: Debug |
| 21 | 25 |
public func track(_ message: String = "", file: String = #file, function: String = #function, line: Int = #line ) {
|
| 26 |
+ guard shouldEmitTrackMessage(message, file: file, function: function) else {
|
|
| 27 |
+ return |
|
| 28 |
+ } |
|
| 22 | 29 |
let date = Date() |
| 23 | 30 |
let calendar = Calendar.current |
| 24 | 31 |
let hour = calendar.component(.hour, from: date) |
@@ -27,15 +34,126 @@ public func track(_ message: String = "", file: String = #file, function: String |
||
| 27 | 34 |
print("\(hour):\(minutes):\(seconds) - \(file):\(line) - \(function) \(message)")
|
| 28 | 35 |
} |
| 29 | 36 |
|
| 37 |
+public func restoreTrace(_ message: String) {
|
|
| 38 |
+ restoreLogger.debug("\(message, privacy: .public)")
|
|
| 39 |
+} |
|
| 40 |
+ |
|
| 41 |
+private func shouldEmitTrackMessage(_ message: String, file: String, function: String) -> Bool {
|
|
| 42 |
+ #if DEBUG |
|
| 43 |
+ if ProcessInfo.processInfo.environment["USB_METER_VERBOSE_LOGS"] == "1" {
|
|
| 44 |
+ return true |
|
| 45 |
+ } |
|
| 46 |
+ |
|
| 47 |
+ #if targetEnvironment(macCatalyst) |
|
| 48 |
+ let importantMarkers = [ |
|
| 49 |
+ "Error", |
|
| 50 |
+ "error", |
|
| 51 |
+ "Failed", |
|
| 52 |
+ "failed", |
|
| 53 |
+ "timeout", |
|
| 54 |
+ "Timeout", |
|
| 55 |
+ "Missing", |
|
| 56 |
+ "missing", |
|
| 57 |
+ "overflow", |
|
| 58 |
+ "Disconnect", |
|
| 59 |
+ "disconnect", |
|
| 60 |
+ "Disconnected", |
|
| 61 |
+ "unauthorized", |
|
| 62 |
+ "not authorized", |
|
| 63 |
+ "not supported", |
|
| 64 |
+ "Unexpected", |
|
| 65 |
+ "Invalid Context", |
|
| 66 |
+ "ignored", |
|
| 67 |
+ "Guard:", |
|
| 68 |
+ "Skip data request", |
|
| 69 |
+ "Dropping unsolicited data", |
|
| 70 |
+ "This is not possible!", |
|
| 71 |
+ "Inferred", |
|
| 72 |
+ "Clearing", |
|
| 73 |
+ "Reconnecting" |
|
| 74 |
+ ] |
|
| 75 |
+ |
|
| 76 |
+ if importantMarkers.contains(where: { message.contains($0) }) {
|
|
| 77 |
+ return true |
|
| 78 |
+ } |
|
| 79 |
+ |
|
| 80 |
+ let noisyFunctions: Set<String> = [ |
|
| 81 |
+ "logRuntimeICloudDiagnostics()", |
|
| 82 |
+ "refreshCloudAvailability(reason:)", |
|
| 83 |
+ "start()", |
|
| 84 |
+ "centralManagerDidUpdateState(_:)", |
|
| 85 |
+ "discoveredMeter(peripheral:advertising:rssi:)", |
|
| 86 |
+ "connect()", |
|
| 87 |
+ "connectionEstablished()", |
|
| 88 |
+ "peripheral(_:didDiscoverServices:)", |
|
| 89 |
+ "peripheral(_:didDiscoverCharacteristicsFor:error:)", |
|
| 90 |
+ "refreshOperationalStateIfReady()", |
|
| 91 |
+ "peripheral(_:didUpdateNotificationStateFor:error:)", |
|
| 92 |
+ "scheduleDataDumpRequest(after:reason:)" |
|
| 93 |
+ ] |
|
| 94 |
+ |
|
| 95 |
+ if noisyFunctions.contains(function) {
|
|
| 96 |
+ return false |
|
| 97 |
+ } |
|
| 98 |
+ |
|
| 99 |
+ let noisyMarkers = [ |
|
| 100 |
+ "Runtime iCloud diagnostics", |
|
| 101 |
+ "iCloud availability", |
|
| 102 |
+ "Starting Bluetooth manager", |
|
| 103 |
+ "Bluetooth is On... Start scanning...", |
|
| 104 |
+ "adding new USB Meter", |
|
| 105 |
+ "Connect called for", |
|
| 106 |
+ "Connection established for", |
|
| 107 |
+ "Optional([<CBService:", |
|
| 108 |
+ "Optional([<CBCharacteristic:", |
|
| 109 |
+ "Waiting for notifications on", |
|
| 110 |
+ "Notification state updated for", |
|
| 111 |
+ "Peripheral ready with notify", |
|
| 112 |
+ "Schedule data request in", |
|
| 113 |
+ "Operational state changed" |
|
| 114 |
+ ] |
|
| 115 |
+ |
|
| 116 |
+ if noisyMarkers.contains(where: { message.contains($0) }) {
|
|
| 117 |
+ return false |
|
| 118 |
+ } |
|
| 119 |
+ #endif |
|
| 120 |
+ |
|
| 121 |
+ return true |
|
| 122 |
+ #else |
|
| 123 |
+ _ = file |
|
| 124 |
+ _ = function |
|
| 125 |
+ return false |
|
| 126 |
+ #endif |
|
| 127 |
+} |
|
| 128 |
+ |
|
| 30 | 129 |
@UIApplicationMain |
| 31 |
-class AppDelegate: UIResponder, UIApplicationDelegate {
|
|
| 130 |
+class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
|
| 131 |
+ private let cloudKitContainerIdentifier = "iCloud.ro.xdev.USB-Meter" |
|
| 32 | 132 |
|
| 33 | 133 |
|
| 34 | 134 |
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
| 35 |
- // Override point for customization after application launch. |
|
| 135 |
+ logRuntimeICloudDiagnostics() |
|
| 136 |
+ UNUserNotificationCenter.current().delegate = self |
|
| 137 |
+ application.registerForRemoteNotifications() |
|
| 138 |
+ appData.activateChargeInsights(context: persistentContainer.viewContext) |
|
| 36 | 139 |
return true |
| 37 | 140 |
} |
| 38 | 141 |
|
| 142 |
+ private func logRuntimeICloudDiagnostics() {
|
|
| 143 |
+ #if DEBUG |
|
| 144 |
+ let hasUbiquityIdentityToken = FileManager.default.ubiquityIdentityToken != nil |
|
| 145 |
+ track("Runtime iCloud diagnostics: ubiquityIdentityTokenAvailable=\(hasUbiquityIdentityToken)")
|
|
| 146 |
+ CKContainer(identifier: cloudKitContainerIdentifier).accountStatus { status, error in
|
|
| 147 |
+ if let error {
|
|
| 148 |
+ track("CloudKit account status error: \(error.localizedDescription)")
|
|
| 149 |
+ return |
|
| 150 |
+ } |
|
| 151 |
+ |
|
| 152 |
+ track("CloudKit account status: \(status.rawValue)")
|
|
| 153 |
+ } |
|
| 154 |
+ #endif |
|
| 155 |
+ } |
|
| 156 |
+ |
|
| 39 | 157 |
// MARK: UISceneSession Lifecycle |
| 40 | 158 |
|
| 41 | 159 |
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
@@ -50,50 +168,86 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||
| 50 | 168 |
// Use this method to release any resources that were specific to the discarded scenes, as they will not return. |
| 51 | 169 |
} |
| 52 | 170 |
|
| 53 |
- // MARK: - Core Data stack |
|
| 171 |
+ func applicationWillTerminate(_ application: UIApplication) {
|
|
| 172 |
+ _ = appData.flushChargeInsights() |
|
| 173 |
+ saveContext() |
|
| 174 |
+ } |
|
| 175 |
+ |
|
| 176 |
+ func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
| 177 |
+ #if DEBUG |
|
| 178 |
+ track("Registered for remote notifications with device token length \(deviceToken.count)")
|
|
| 179 |
+ #endif |
|
| 180 |
+ } |
|
| 181 |
+ |
|
| 182 |
+ func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
| 183 |
+ track("Remote notification registration failed: \(error.localizedDescription)")
|
|
| 184 |
+ } |
|
| 185 |
+ |
|
| 186 |
+ func application( |
|
| 187 |
+ _ application: UIApplication, |
|
| 188 |
+ didReceiveRemoteNotification userInfo: [AnyHashable : Any], |
|
| 189 |
+ fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void |
|
| 190 |
+ ) {
|
|
| 191 |
+ #if DEBUG |
|
| 192 |
+ track("Received remote notification with keys: \(userInfo.keys.map(String.init(describing:)).joined(separator: ", "))")
|
|
| 193 |
+ #endif |
|
| 194 |
+ completionHandler(.newData) |
|
| 195 |
+ } |
|
| 54 | 196 |
|
| 55 | 197 |
lazy var persistentContainer: NSPersistentCloudKitContainer = {
|
| 56 |
- /* |
|
| 57 |
- The persistent container for the application. This implementation |
|
| 58 |
- creates and returns a container, having loaded the store for the |
|
| 59 |
- application to it. This property is optional since there are legitimate |
|
| 60 |
- error conditions that could cause the creation of the store to fail. |
|
| 61 |
- */ |
|
| 62 | 198 |
let container = NSPersistentCloudKitContainer(name: "CKModel") |
| 63 |
- container.loadPersistentStores(completionHandler: { (storeDescription, error) in
|
|
| 199 |
+ |
|
| 200 |
+ if let description = container.persistentStoreDescriptions.first {
|
|
| 201 |
+ description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions( |
|
| 202 |
+ containerIdentifier: cloudKitContainerIdentifier |
|
| 203 |
+ ) |
|
| 204 |
+ description.shouldMigrateStoreAutomatically = true |
|
| 205 |
+ description.shouldInferMappingModelAutomatically = true |
|
| 206 |
+ description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) |
|
| 207 |
+ description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) |
|
| 208 |
+ } |
|
| 209 |
+ |
|
| 210 |
+ container.loadPersistentStores { storeDescription, error in
|
|
| 64 | 211 |
if let error = error as NSError? {
|
| 65 |
- // Replace this implementation with code to handle the error appropriately. |
|
| 66 |
- // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. |
|
| 67 |
- |
|
| 68 |
- /* |
|
| 69 |
- Typical reasons for an error here include: |
|
| 70 |
- * The parent directory does not exist, cannot be created, or disallows writing. |
|
| 71 |
- * The persistent store is not accessible, due to permissions or data protection when the device is locked. |
|
| 72 |
- * The device is out of space. |
|
| 73 |
- * The store could not be migrated to the current model version. |
|
| 74 |
- Check the error message to determine what the actual problem was. |
|
| 75 |
- */ |
|
| 76 |
- fatalError("Unresolved error \(error), \(error.userInfo)")
|
|
| 212 |
+ // Log the error but do NOT destroy the store — wiping local data and |
|
| 213 |
+ // waiting for a full CloudKit re-sync is far worse than a degraded launch. |
|
| 214 |
+ NSLog( |
|
| 215 |
+ "Core Data store load failed (url=%@): %@ — %@", |
|
| 216 |
+ storeDescription.url?.path ?? "unknown", |
|
| 217 |
+ error.localizedDescription, |
|
| 218 |
+ error.userInfo |
|
| 219 |
+ ) |
|
| 220 |
+ #if DEBUG |
|
| 221 |
+ fatalError("Unresolved Core Data error \(error), \(error.userInfo)")
|
|
| 222 |
+ #endif |
|
| 77 | 223 |
} |
| 78 |
- }) |
|
| 224 |
+ } |
|
| 225 |
+ |
|
| 226 |
+ container.viewContext.automaticallyMergesChangesFromParent = true |
|
| 227 |
+ container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy |
|
| 79 | 228 |
return container |
| 80 | 229 |
}() |
| 81 | 230 |
|
| 82 |
- // MARK: - Core Data Saving support |
|
| 83 |
- |
|
| 84 |
- func saveContext () {
|
|
| 231 |
+ func saveContext() {
|
|
| 85 | 232 |
let context = persistentContainer.viewContext |
| 86 |
- if context.hasChanges {
|
|
| 87 |
- do {
|
|
| 88 |
- try context.save() |
|
| 89 |
- } catch {
|
|
| 90 |
- // Replace this implementation with code to handle the error appropriately. |
|
| 91 |
- // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. |
|
| 92 |
- let nserror = error as NSError |
|
| 93 |
- fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
|
|
| 94 |
- } |
|
| 233 |
+ guard context.hasChanges else { return }
|
|
| 234 |
+ |
|
| 235 |
+ do {
|
|
| 236 |
+ try context.save() |
|
| 237 |
+ } catch {
|
|
| 238 |
+ let nsError = error as NSError |
|
| 239 |
+ NSLog("Core Data save failed: %@", nsError.localizedDescription)
|
|
| 240 |
+ #if DEBUG |
|
| 241 |
+ fatalError("Unresolved Core Data save error \(nsError), \(nsError.userInfo)")
|
|
| 242 |
+ #endif |
|
| 95 | 243 |
} |
| 96 | 244 |
} |
| 97 | 245 |
|
| 246 |
+ func userNotificationCenter( |
|
| 247 |
+ _ center: UNUserNotificationCenter, |
|
| 248 |
+ willPresent notification: UNNotification, |
|
| 249 |
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void |
|
| 250 |
+ ) {
|
|
| 251 |
+ completionHandler([.banner, .sound, .list]) |
|
| 252 |
+ } |
|
| 98 | 253 |
} |
| 99 |
- |
|
@@ -58,6 +58,10 @@ |
||
| 58 | 58 |
</array> |
| 59 | 59 |
</dict> |
| 60 | 60 |
</dict> |
| 61 |
+ <key>UIBackgroundModes</key> |
|
| 62 |
+ <array> |
|
| 63 |
+ <string>remote-notification</string> |
|
| 64 |
+ </array> |
|
| 61 | 65 |
<key>UILaunchStoryboardName</key> |
| 62 | 66 |
<string>LaunchScreen</string> |
| 63 | 67 |
<key>UIRequiredDeviceCapabilities</key> |
@@ -9,63 +9,1445 @@ |
||
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
import Combine |
| 11 | 11 |
import CoreBluetooth |
| 12 |
+import CoreData |
|
| 13 |
+import UserNotifications |
|
| 14 |
+ |
|
| 15 |
+struct BatteryCheckpointPlausibilityWarning: Identifiable, Hashable {
|
|
| 16 |
+ let title: String |
|
| 17 |
+ let message: String |
|
| 18 |
+ |
|
| 19 |
+ var id: String {
|
|
| 20 |
+ "\(title)\n\(message)" |
|
| 21 |
+ } |
|
| 22 |
+} |
|
| 12 | 23 |
|
| 13 | 24 |
final class AppData : ObservableObject {
|
| 14 |
- private var icloudGefaultsNotification: AnyCancellable? |
|
| 25 |
+ struct MeterSummary: Identifiable {
|
|
| 26 |
+ let macAddress: String |
|
| 27 |
+ let displayName: String |
|
| 28 |
+ let modelSummary: String |
|
| 29 |
+ let advertisedName: String? |
|
| 30 |
+ let lastSeen: Date? |
|
| 31 |
+ let lastConnected: Date? |
|
| 32 |
+ let meter: Meter? |
|
| 33 |
+ |
|
| 34 |
+ var id: String {
|
|
| 35 |
+ macAddress |
|
| 36 |
+ } |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 15 | 39 |
private var bluetoothManagerNotification: AnyCancellable? |
| 40 |
+ private var meterStoreObserver: AnyCancellable? |
|
| 41 |
+ private var meterStoreCloudObserver: AnyCancellable? |
|
| 42 |
+ private var chargeInsightsStoreObserver: AnyCancellable? |
|
| 43 |
+ private var chargeInsightsRemoteObserver: AnyCancellable? |
|
| 44 |
+ private var chargerStandbyPowerStoreObserver: AnyCancellable? |
|
| 45 |
+ private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem? |
|
| 46 |
+ private var chargeInsightsReadStore: ChargeInsightsStore? |
|
| 47 |
+ private var pendingChargeObservationSnapshots: [String: ChargingMonitorSnapshot] = [:] |
|
| 48 |
+ private var pendingChargeObservationWorkItems: [String: DispatchWorkItem] = [:] |
|
| 49 |
+ private let chargedDevicesReloadQueue = DispatchQueue( |
|
| 50 |
+ label: "ro.xdev.usb-meter.charged-devices-reload", |
|
| 51 |
+ qos: .userInitiated |
|
| 52 |
+ ) |
|
| 53 |
+ private var chargedDevicesReloadInFlight = false |
|
| 54 |
+ private var chargedDevicesReloadPending = false |
|
| 55 |
+ private let chargeObservationPersistInterval: TimeInterval = 30 |
|
| 56 |
+ private let meterPresencePersistInterval: TimeInterval = 15 |
|
| 57 |
+ private let meterStore = MeterNameStore.shared |
|
| 58 |
+ private var chargeInsightsStore: ChargeInsightsStore? |
|
| 59 |
+ private let chargerStandbyPowerStore = ChargerStandbyPowerStore() |
|
| 60 |
+ private let chargeNotificationCoordinator = ChargeNotificationCoordinator() |
|
| 61 |
+ private var meterSummariesCache: (version: Int, summaries: [MeterSummary])? |
|
| 62 |
+ private var meterSummariesVersion: Int = 0 |
|
| 16 | 63 |
|
| 17 | 64 |
init() {
|
| 18 |
- icloudGefaultsNotification = NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification).sink(receiveValue: test) |
|
| 19 | 65 |
bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
|
| 20 | 66 |
self?.scheduleObjectWillChange() |
| 21 | 67 |
} |
| 22 |
- //NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification).sink(receiveValue: { notification in
|
|
| 23 |
- |
|
| 68 |
+ meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange) |
|
| 69 |
+ .receive(on: DispatchQueue.main) |
|
| 70 |
+ .sink { [weak self] _ in
|
|
| 71 |
+ self?.invalidateMeterSummaries() |
|
| 72 |
+ self?.refreshMeterMetadata() |
|
| 73 |
+ self?.scheduleObjectWillChange() |
|
| 74 |
+ } |
|
| 75 |
+ meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange) |
|
| 76 |
+ .receive(on: DispatchQueue.main) |
|
| 77 |
+ .sink { [weak self] _ in
|
|
| 78 |
+ self?.scheduleObjectWillChange() |
|
| 79 |
+ } |
|
| 80 |
+ chargerStandbyPowerStoreObserver = NotificationCenter.default.publisher(for: .chargerStandbyPowerStoreDidChange) |
|
| 81 |
+ .receive(on: DispatchQueue.main) |
|
| 82 |
+ .sink { [weak self] _ in
|
|
| 83 |
+ self?.reloadChargedDevices() |
|
| 84 |
+ } |
|
| 24 | 85 |
} |
| 25 |
- |
|
| 86 |
+ |
|
| 26 | 87 |
let bluetoothManager = BluetoothManager() |
| 27 |
- |
|
| 88 |
+ |
|
| 28 | 89 |
@Published var enableRecordFeature: Bool = true |
| 29 |
- |
|
| 30 |
- @Published var meters: [UUID:Meter] = [UUID:Meter]() |
|
| 31 |
- |
|
| 32 |
- @ICloudDefault(key: "MeterNames", defaultValue: [:]) var meterNames: [String:String] |
|
| 33 |
- @ICloudDefault(key: "TC66TemperatureUnits", defaultValue: [:]) var tc66TemperatureUnits: [String:String] |
|
| 34 |
- func test(notification: NotificationCenter.Publisher.Output) -> Void {
|
|
| 35 |
- if let changedKeys = notification.userInfo?["NSUbiquitousKeyValueStoreChangedKeysKey"] as? [String] {
|
|
| 36 |
- var somethingChanged = false |
|
| 37 |
- for changedKey in changedKeys {
|
|
| 38 |
- switch changedKey {
|
|
| 39 |
- case "MeterNames": |
|
| 40 |
- for meter in self.meters.values {
|
|
| 41 |
- if let newName = self.meterNames[meter.btSerial.macAddress.description] {
|
|
| 42 |
- if meter.name != newName {
|
|
| 43 |
- meter.name = newName |
|
| 44 |
- somethingChanged = true |
|
| 45 |
- } |
|
| 46 |
- } |
|
| 47 |
- } |
|
| 48 |
- case "TC66TemperatureUnits": |
|
| 49 |
- for meter in self.meters.values where meter.supportsManualTemperatureUnitSelection {
|
|
| 50 |
- meter.reloadTemperatureUnitPreference() |
|
| 51 |
- somethingChanged = true |
|
| 90 |
+ |
|
| 91 |
+ @Published var meters: [UUID:Meter] = [UUID:Meter]() {
|
|
| 92 |
+ didSet {
|
|
| 93 |
+ invalidateMeterSummaries() |
|
| 94 |
+ } |
|
| 95 |
+ } |
|
| 96 |
+ @Published private(set) var chargedDevices: [ChargedDeviceSummary] = [] |
|
| 97 |
+ @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:] |
|
| 98 |
+ |
|
| 99 |
+ var deviceSummaries: [ChargedDeviceSummary] {
|
|
| 100 |
+ chargedDevices.filter { !$0.isCharger }
|
|
| 101 |
+ } |
|
| 102 |
+ |
|
| 103 |
+ var chargerSummaries: [ChargedDeviceSummary] {
|
|
| 104 |
+ chargedDevices.filter { $0.isCharger }
|
|
| 105 |
+ } |
|
| 106 |
+ |
|
| 107 |
+ var cloudAvailability: MeterNameStore.CloudAvailability {
|
|
| 108 |
+ meterStore.currentCloudAvailability |
|
| 109 |
+ } |
|
| 110 |
+ |
|
| 111 |
+ func activateChargeInsights(context: NSManagedObjectContext) {
|
|
| 112 |
+ guard chargeInsightsStore == nil else {
|
|
| 113 |
+ return |
|
| 114 |
+ } |
|
| 115 |
+ |
|
| 116 |
+ context.automaticallyMergesChangesFromParent = true |
|
| 117 |
+ context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy |
|
| 118 |
+ if let coordinator = context.persistentStoreCoordinator {
|
|
| 119 |
+ let writeContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) |
|
| 120 |
+ writeContext.persistentStoreCoordinator = coordinator |
|
| 121 |
+ writeContext.automaticallyMergesChangesFromParent = false |
|
| 122 |
+ writeContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy |
|
| 123 |
+ chargeInsightsStore = ChargeInsightsStore(context: writeContext) |
|
| 124 |
+ |
|
| 125 |
+ let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) |
|
| 126 |
+ readContext.persistentStoreCoordinator = coordinator |
|
| 127 |
+ readContext.automaticallyMergesChangesFromParent = true |
|
| 128 |
+ readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy |
|
| 129 |
+ chargeInsightsReadStore = ChargeInsightsStore(context: readContext) |
|
| 130 |
+ |
|
| 131 |
+ chargeInsightsStoreObserver = NotificationCenter.default.publisher( |
|
| 132 |
+ for: .NSManagedObjectContextDidSave, |
|
| 133 |
+ object: writeContext |
|
| 134 |
+ ) |
|
| 135 |
+ .sink { [weak self, weak context] notification in
|
|
| 136 |
+ guard let self, let context else { return }
|
|
| 137 |
+ context.perform {
|
|
| 138 |
+ context.mergeChanges(fromContextDidSave: notification) |
|
| 139 |
+ DispatchQueue.main.async {
|
|
| 140 |
+ self.scheduleChargedDevicesReload() |
|
| 52 | 141 |
} |
| 53 |
- default: |
|
| 54 |
- track("Unknown key: '\(changedKey)' changed in iCloud)")
|
|
| 55 |
- } |
|
| 56 |
- if changedKey == "MeterNames" {
|
|
| 57 |
- |
|
| 58 | 142 |
} |
| 59 | 143 |
} |
| 60 |
- if somethingChanged {
|
|
| 61 |
- scheduleObjectWillChange() |
|
| 144 |
+ } else {
|
|
| 145 |
+ chargeInsightsStore = ChargeInsightsStore(context: context) |
|
| 146 |
+ chargeInsightsReadStore = ChargeInsightsStore(context: context) |
|
| 147 |
+ |
|
| 148 |
+ chargeInsightsStoreObserver = NotificationCenter.default.publisher( |
|
| 149 |
+ for: .NSManagedObjectContextDidSave, |
|
| 150 |
+ object: context |
|
| 151 |
+ ) |
|
| 152 |
+ .receive(on: DispatchQueue.main) |
|
| 153 |
+ .sink { [weak self] _ in
|
|
| 154 |
+ self?.scheduleChargedDevicesReload() |
|
| 155 |
+ } |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ chargeInsightsRemoteObserver = NotificationCenter.default.publisher( |
|
| 159 |
+ for: .NSPersistentStoreRemoteChange, |
|
| 160 |
+ object: nil |
|
| 161 |
+ ) |
|
| 162 |
+ .receive(on: DispatchQueue.main) |
|
| 163 |
+ .sink { [weak self] _ in
|
|
| 164 |
+ self?.scheduleChargedDevicesReload() |
|
| 165 |
+ } |
|
| 166 |
+ |
|
| 167 |
+ chargeNotificationCoordinator.ensureAuthorizationIfNeeded() |
|
| 168 |
+ reloadChargedDevices() |
|
| 169 |
+ } |
|
| 170 |
+ |
|
| 171 |
+ func meterName(for macAddress: String) -> String? {
|
|
| 172 |
+ meterStore.name(for: macAddress) |
|
| 173 |
+ } |
|
| 174 |
+ |
|
| 175 |
+ func setMeterName(_ name: String, for macAddress: String) {
|
|
| 176 |
+ meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil) |
|
| 177 |
+ } |
|
| 178 |
+ |
|
| 179 |
+ func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
|
|
| 180 |
+ let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue |
|
| 181 |
+ return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius |
|
| 182 |
+ } |
|
| 183 |
+ |
|
| 184 |
+ func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
|
|
| 185 |
+ meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue) |
|
| 186 |
+ } |
|
| 187 |
+ |
|
| 188 |
+ func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
|
|
| 189 |
+ meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName) |
|
| 190 |
+ } |
|
| 191 |
+ |
|
| 192 |
+ func noteMeterSeen(at date: Date, macAddress: String) {
|
|
| 193 |
+ if let persistedLastSeen = meterStore.lastSeen(for: macAddress), |
|
| 194 |
+ date.timeIntervalSince(persistedLastSeen) < meterPresencePersistInterval {
|
|
| 195 |
+ return |
|
| 196 |
+ } |
|
| 197 |
+ meterStore.noteLastSeen(date, for: macAddress) |
|
| 198 |
+ } |
|
| 199 |
+ |
|
| 200 |
+ func noteMeterConnected(at date: Date, macAddress: String) {
|
|
| 201 |
+ meterStore.noteLastConnected(date, for: macAddress) |
|
| 202 |
+ } |
|
| 203 |
+ |
|
| 204 |
+ func lastSeen(for macAddress: String) -> Date? {
|
|
| 205 |
+ meterStore.lastSeen(for: macAddress) |
|
| 206 |
+ } |
|
| 207 |
+ |
|
| 208 |
+ func lastConnected(for macAddress: String) -> Date? {
|
|
| 209 |
+ meterStore.lastConnected(for: macAddress) |
|
| 210 |
+ } |
|
| 211 |
+ |
|
| 212 |
+ func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
|
|
| 213 |
+ chargedDevices.first(where: { $0.id == id })
|
|
| 214 |
+ } |
|
| 215 |
+ |
|
| 216 |
+ func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
|
|
| 217 |
+ for chargedDevice in chargedDevices {
|
|
| 218 |
+ if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
|
|
| 219 |
+ return session |
|
| 220 |
+ } |
|
| 221 |
+ } |
|
| 222 |
+ return nil |
|
| 223 |
+ } |
|
| 224 |
+ |
|
| 225 |
+ func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
|
|
| 226 |
+ let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() |
|
| 227 |
+ return chargedDevices.filter { chargedDevice in
|
|
| 228 |
+ guard chargedDevice.isCharger == false else {
|
|
| 229 |
+ return false |
|
| 62 | 230 |
} |
| 231 |
+ return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
|
|
| 63 | 232 |
} |
| 64 | 233 |
} |
| 65 | 234 |
|
| 235 |
+ func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
|
|
| 236 |
+ let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() |
|
| 237 |
+ return chargedDevices.filter { chargedDevice in
|
|
| 238 |
+ guard chargedDevice.isCharger else {
|
|
| 239 |
+ return false |
|
| 240 |
+ } |
|
| 241 |
+ return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
|
|
| 242 |
+ } |
|
| 243 |
+ } |
|
| 244 |
+ |
|
| 245 |
+ func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
|
|
| 246 |
+ let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() |
|
| 247 |
+ |
|
| 248 |
+ if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC), |
|
| 249 |
+ let liveDevice = chargedDevices.first(where: {
|
|
| 250 |
+ $0.id == activeSession.chargedDeviceID && $0.isCharger == false |
|
| 251 |
+ }) {
|
|
| 252 |
+ return liveDevice |
|
| 253 |
+ } |
|
| 254 |
+ |
|
| 255 |
+ return chargedDevices.first(where: {
|
|
| 256 |
+ $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC |
|
| 257 |
+ }) |
|
| 258 |
+ } |
|
| 259 |
+ |
|
| 260 |
+ func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
|
|
| 261 |
+ let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() |
|
| 262 |
+ |
|
| 263 |
+ if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC), |
|
| 264 |
+ let chargerID = activeSession.chargerID, |
|
| 265 |
+ let liveCharger = chargedDevices.first(where: {
|
|
| 266 |
+ $0.id == chargerID && $0.isCharger |
|
| 267 |
+ }) {
|
|
| 268 |
+ return liveCharger |
|
| 269 |
+ } |
|
| 270 |
+ |
|
| 271 |
+ return chargedDevices.first(where: {
|
|
| 272 |
+ $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC |
|
| 273 |
+ }) |
|
| 274 |
+ } |
|
| 275 |
+ |
|
| 276 |
+ func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
|
|
| 277 |
+ let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) |
|
| 278 |
+ |
|
| 279 |
+ if expireOverlongChargeSessionsIfNeeded() {
|
|
| 280 |
+ reloadChargedDevices() |
|
| 281 |
+ return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC) |
|
| 282 |
+ } |
|
| 283 |
+ |
|
| 284 |
+ if let cachedSummary = cachedActiveChargeSessionSummary(for: normalizedMAC) {
|
|
| 285 |
+ if let persistedSummary = chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC), |
|
| 286 |
+ persistedSummary.aggregatedSamples.count > cachedSummary.aggregatedSamples.count {
|
|
| 287 |
+ return persistedSummary |
|
| 288 |
+ } |
|
| 289 |
+ return cachedSummary |
|
| 290 |
+ } |
|
| 291 |
+ return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC) |
|
| 292 |
+ } |
|
| 293 |
+ |
|
| 294 |
+ func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
|
|
| 295 |
+ activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)] |
|
| 296 |
+ } |
|
| 297 |
+ |
|
| 298 |
+ @discardableResult |
|
| 299 |
+ func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
|
|
| 300 |
+ guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
|
|
| 301 |
+ return false |
|
| 302 |
+ } |
|
| 303 |
+ |
|
| 304 |
+ let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description) |
|
| 305 |
+ if let existingSession = activeChargerStandbySessions[normalizedMAC] {
|
|
| 306 |
+ return existingSession.chargerID == chargerID |
|
| 307 |
+ } |
|
| 308 |
+ |
|
| 309 |
+ let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter) |
|
| 310 |
+ session.onChange = { [weak self] in
|
|
| 311 |
+ self?.scheduleObjectWillChange() |
|
| 312 |
+ } |
|
| 313 |
+ session.onStabilized = { [weak self, weak session] in
|
|
| 314 |
+ guard let self, let session else { return }
|
|
| 315 |
+ self.notifyChargerStandbyMeasurementReady(for: session) |
|
| 316 |
+ } |
|
| 317 |
+ |
|
| 318 |
+ activeChargerStandbySessions[normalizedMAC] = session |
|
| 319 |
+ session.start() |
|
| 320 |
+ |
|
| 321 |
+ // Starting a standby run on an available meter should also initiate the BLE link. |
|
| 322 |
+ if meter.operationalState == .peripheralNotConnected {
|
|
| 323 |
+ meter.connect() |
|
| 324 |
+ } |
|
| 325 |
+ |
|
| 326 |
+ scheduleObjectWillChange() |
|
| 327 |
+ return true |
|
| 328 |
+ } |
|
| 329 |
+ |
|
| 330 |
+ @discardableResult |
|
| 331 |
+ func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
|
|
| 332 |
+ let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) |
|
| 333 |
+ guard let session = activeChargerStandbySessions[normalizedMAC] else {
|
|
| 334 |
+ return false |
|
| 335 |
+ } |
|
| 336 |
+ |
|
| 337 |
+ session.stop() |
|
| 338 |
+ |
|
| 339 |
+ guard save else {
|
|
| 340 |
+ activeChargerStandbySessions[normalizedMAC] = nil |
|
| 341 |
+ scheduleObjectWillChange() |
|
| 342 |
+ return true |
|
| 343 |
+ } |
|
| 344 |
+ |
|
| 345 |
+ guard let summary = session.makeSummary() else {
|
|
| 346 |
+ scheduleObjectWillChange() |
|
| 347 |
+ return false |
|
| 348 |
+ } |
|
| 349 |
+ |
|
| 350 |
+ let didSave = chargerStandbyPowerStore.save(summary) |
|
| 351 |
+ if didSave {
|
|
| 352 |
+ activeChargerStandbySessions[normalizedMAC] = nil |
|
| 353 |
+ reloadChargedDevices() |
|
| 354 |
+ } else {
|
|
| 355 |
+ scheduleObjectWillChange() |
|
| 356 |
+ } |
|
| 357 |
+ |
|
| 358 |
+ return didSave |
|
| 359 |
+ } |
|
| 360 |
+ |
|
| 361 |
+ @discardableResult |
|
| 362 |
+ func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
|
|
| 363 |
+ let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID) |
|
| 364 |
+ if didDelete {
|
|
| 365 |
+ reloadChargedDevices() |
|
| 366 |
+ } else {
|
|
| 367 |
+ scheduleObjectWillChange() |
|
| 368 |
+ } |
|
| 369 |
+ return didDelete |
|
| 370 |
+ } |
|
| 371 |
+ |
|
| 372 |
+ @discardableResult |
|
| 373 |
+ func createDevice( |
|
| 374 |
+ name: String, |
|
| 375 |
+ deviceClass: ChargedDeviceClass, |
|
| 376 |
+ templateID: String?, |
|
| 377 |
+ chargingStateAvailability: ChargingStateAvailability, |
|
| 378 |
+ supportsWiredCharging: Bool, |
|
| 379 |
+ supportsWirelessCharging: Bool, |
|
| 380 |
+ wirelessChargingProfile: WirelessChargingProfile, |
|
| 381 |
+ configuredCompletionCurrents: [ChargeSessionKind: Double], |
|
| 382 |
+ notes: String?, |
|
| 383 |
+ meterMACAddress: String? |
|
| 384 |
+ ) -> Bool {
|
|
| 385 |
+ let didSave = chargeInsightsStore?.createDevice( |
|
| 386 |
+ name: name, |
|
| 387 |
+ deviceClass: deviceClass, |
|
| 388 |
+ templateID: templateID, |
|
| 389 |
+ chargingStateAvailability: chargingStateAvailability, |
|
| 390 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 391 |
+ supportsWirelessCharging: supportsWirelessCharging, |
|
| 392 |
+ wirelessChargingProfile: wirelessChargingProfile, |
|
| 393 |
+ configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 394 |
+ notes: notes, |
|
| 395 |
+ assignTo: meterMACAddress |
|
| 396 |
+ ) ?? false |
|
| 397 |
+ |
|
| 398 |
+ if didSave {
|
|
| 399 |
+ reloadChargedDevices() |
|
| 400 |
+ } |
|
| 401 |
+ |
|
| 402 |
+ return didSave |
|
| 403 |
+ } |
|
| 404 |
+ |
|
| 405 |
+ @discardableResult |
|
| 406 |
+ func createCharger( |
|
| 407 |
+ name: String, |
|
| 408 |
+ chargerType: ChargerType, |
|
| 409 |
+ notes: String?, |
|
| 410 |
+ meterMACAddress: String? |
|
| 411 |
+ ) -> Bool {
|
|
| 412 |
+ let didSave = chargeInsightsStore?.createCharger( |
|
| 413 |
+ name: name, |
|
| 414 |
+ chargerType: chargerType, |
|
| 415 |
+ notes: notes, |
|
| 416 |
+ assignTo: meterMACAddress |
|
| 417 |
+ ) ?? false |
|
| 418 |
+ |
|
| 419 |
+ if didSave {
|
|
| 420 |
+ reloadChargedDevices() |
|
| 421 |
+ } |
|
| 422 |
+ |
|
| 423 |
+ return didSave |
|
| 424 |
+ } |
|
| 425 |
+ |
|
| 426 |
+ @discardableResult |
|
| 427 |
+ func updateDevice( |
|
| 428 |
+ id: UUID, |
|
| 429 |
+ name: String, |
|
| 430 |
+ deviceClass: ChargedDeviceClass, |
|
| 431 |
+ templateID: String?, |
|
| 432 |
+ chargingStateAvailability: ChargingStateAvailability, |
|
| 433 |
+ supportsWiredCharging: Bool, |
|
| 434 |
+ supportsWirelessCharging: Bool, |
|
| 435 |
+ wirelessChargingProfile: WirelessChargingProfile, |
|
| 436 |
+ configuredCompletionCurrents: [ChargeSessionKind: Double], |
|
| 437 |
+ notes: String? |
|
| 438 |
+ ) -> Bool {
|
|
| 439 |
+ let didSave = chargeInsightsStore?.updateDevice( |
|
| 440 |
+ id: id, |
|
| 441 |
+ name: name, |
|
| 442 |
+ deviceClass: deviceClass, |
|
| 443 |
+ templateID: templateID, |
|
| 444 |
+ chargingStateAvailability: chargingStateAvailability, |
|
| 445 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 446 |
+ supportsWirelessCharging: supportsWirelessCharging, |
|
| 447 |
+ wirelessChargingProfile: wirelessChargingProfile, |
|
| 448 |
+ configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 449 |
+ notes: notes |
|
| 450 |
+ ) ?? false |
|
| 451 |
+ |
|
| 452 |
+ if didSave {
|
|
| 453 |
+ reloadChargedDevices() |
|
| 454 |
+ } |
|
| 455 |
+ |
|
| 456 |
+ return didSave |
|
| 457 |
+ } |
|
| 458 |
+ |
|
| 459 |
+ @discardableResult |
|
| 460 |
+ func updateCharger( |
|
| 461 |
+ id: UUID, |
|
| 462 |
+ name: String, |
|
| 463 |
+ chargerType: ChargerType, |
|
| 464 |
+ notes: String? |
|
| 465 |
+ ) -> Bool {
|
|
| 466 |
+ let didSave = chargeInsightsStore?.updateCharger( |
|
| 467 |
+ id: id, |
|
| 468 |
+ name: name, |
|
| 469 |
+ chargerType: chargerType, |
|
| 470 |
+ notes: notes |
|
| 471 |
+ ) ?? false |
|
| 472 |
+ |
|
| 473 |
+ if didSave {
|
|
| 474 |
+ reloadChargedDevices() |
|
| 475 |
+ } |
|
| 476 |
+ |
|
| 477 |
+ return didSave |
|
| 478 |
+ } |
|
| 479 |
+ |
|
| 480 |
+ @discardableResult |
|
| 481 |
+ func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
|
|
| 482 |
+ let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false |
|
| 483 |
+ if didSave {
|
|
| 484 |
+ reloadChargedDevices() |
|
| 485 |
+ } |
|
| 486 |
+ return didSave |
|
| 487 |
+ } |
|
| 488 |
+ |
|
| 489 |
+ @discardableResult |
|
| 490 |
+ func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
|
|
| 491 |
+ let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false |
|
| 492 |
+ if didSave {
|
|
| 493 |
+ reloadChargedDevices() |
|
| 494 |
+ } |
|
| 495 |
+ return didSave |
|
| 496 |
+ } |
|
| 497 |
+ |
|
| 498 |
+ func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
|
|
| 499 |
+ guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
|
|
| 500 |
+ return |
|
| 501 |
+ } |
|
| 502 |
+ guard activeSession.status.isOpen else {
|
|
| 503 |
+ return |
|
| 504 |
+ } |
|
| 505 |
+ meter.restoreChargeMonitoringIfNeeded(from: activeSession) |
|
| 506 |
+ } |
|
| 507 |
+ |
|
| 508 |
+ @discardableResult |
|
| 509 |
+ func startChargeSession( |
|
| 510 |
+ for meter: Meter, |
|
| 511 |
+ chargedDeviceID: UUID, |
|
| 512 |
+ chargerID: UUID?, |
|
| 513 |
+ chargingTransportMode: ChargingTransportMode, |
|
| 514 |
+ chargingStateMode: ChargingStateMode, |
|
| 515 |
+ autoStopEnabled: Bool, |
|
| 516 |
+ initialBatteryPercent: Double?, |
|
| 517 |
+ startsFromFlatBattery: Bool |
|
| 518 |
+ ) -> Bool {
|
|
| 519 |
+ meter.resetMeterCountersForNewSession() |
|
| 520 |
+ |
|
| 521 |
+ guard let snapshot = meter.chargingMonitorSnapshot else {
|
|
| 522 |
+ return false |
|
| 523 |
+ } |
|
| 524 |
+ |
|
| 525 |
+ let didSave = chargeInsightsStore?.startSession( |
|
| 526 |
+ for: snapshot, |
|
| 527 |
+ chargedDeviceID: chargedDeviceID, |
|
| 528 |
+ chargerID: chargerID, |
|
| 529 |
+ chargingTransportMode: chargingTransportMode, |
|
| 530 |
+ chargingStateMode: chargingStateMode, |
|
| 531 |
+ autoStopEnabled: autoStopEnabled, |
|
| 532 |
+ initialBatteryPercent: initialBatteryPercent, |
|
| 533 |
+ startsFromFlatBattery: startsFromFlatBattery |
|
| 534 |
+ ) ?? false |
|
| 535 |
+ if didSave {
|
|
| 536 |
+ meter.resetChargeRecordGraph() |
|
| 537 |
+ let activeSession = chargeInsightsStore?.activeChargeSessionSummary( |
|
| 538 |
+ forMeterMACAddress: meter.btSerial.macAddress.description |
|
| 539 |
+ ) |
|
| 540 |
+ if let activeSession, |
|
| 541 |
+ meter.supportsRecordingThreshold, |
|
| 542 |
+ activeSession.stopThresholdAmps > 0 {
|
|
| 543 |
+ meter.recordingTreshold = activeSession.stopThresholdAmps |
|
| 544 |
+ } |
|
| 545 |
+ if let activeSession {
|
|
| 546 |
+ meter.restoreChargeMonitoringIfNeeded(from: activeSession) |
|
| 547 |
+ } |
|
| 548 |
+ reloadChargedDevices() |
|
| 549 |
+ } |
|
| 550 |
+ return didSave |
|
| 551 |
+ } |
|
| 552 |
+ |
|
| 553 |
+ @discardableResult |
|
| 554 |
+ func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
|
|
| 555 |
+ let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date() |
|
| 556 |
+ |
|
| 557 |
+ if let meter {
|
|
| 558 |
+ _ = persistChargeSnapshot(from: meter, observedAt: observedAt) |
|
| 559 |
+ } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
|
|
| 560 |
+ _ = flushPendingChargeObservation(for: meterMACAddress) |
|
| 561 |
+ } |
|
| 562 |
+ |
|
| 563 |
+ let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false |
|
| 564 |
+ if didSave {
|
|
| 565 |
+ reloadChargedDevices() |
|
| 566 |
+ } |
|
| 567 |
+ return didSave |
|
| 568 |
+ } |
|
| 569 |
+ |
|
| 570 |
+ @discardableResult |
|
| 571 |
+ func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
|
|
| 572 |
+ let snapshot = meter?.chargingMonitorSnapshot |
|
| 573 |
+ let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false |
|
| 574 |
+ if didSave {
|
|
| 575 |
+ reloadChargedDevices() |
|
| 576 |
+ } |
|
| 577 |
+ return didSave |
|
| 578 |
+ } |
|
| 579 |
+ |
|
| 580 |
+ @discardableResult |
|
| 581 |
+ func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil, from meter: Meter? = nil) -> Bool {
|
|
| 582 |
+ if let meter {
|
|
| 583 |
+ _ = persistChargeSnapshot(from: meter) |
|
| 584 |
+ } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
|
|
| 585 |
+ _ = flushPendingChargeObservation(for: meterMACAddress) |
|
| 586 |
+ } |
|
| 587 |
+ |
|
| 588 |
+ let didSave = chargeInsightsStore?.stopSession( |
|
| 589 |
+ id: sessionID, |
|
| 590 |
+ finalBatteryPercent: finalBatteryPercent |
|
| 591 |
+ ) ?? false |
|
| 592 |
+ reloadChargedDevices() |
|
| 593 |
+ return didSave |
|
| 594 |
+ } |
|
| 595 |
+ |
|
| 596 |
+ func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
|
|
| 597 |
+ guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
|
|
| 598 |
+ return |
|
| 599 |
+ } |
|
| 600 |
+ |
|
| 601 |
+ stageChargeObservation(snapshot) |
|
| 602 |
+ } |
|
| 603 |
+ |
|
| 604 |
+ @discardableResult |
|
| 605 |
+ func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
|
|
| 606 |
+ _ = persistChargeSnapshot(from: meter) |
|
| 607 |
+ |
|
| 608 |
+ let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) |
|
| 609 |
+ let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
|
|
| 610 |
+ let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
|
|
| 611 |
+ |
|
| 612 |
+ let didSave = chargeInsightsStore?.addBatteryCheckpoint( |
|
| 613 |
+ percent: percent, |
|
| 614 |
+ for: meter.btSerial.macAddress.description, |
|
| 615 |
+ measuredEnergyWh: checkpointEnergyWh, |
|
| 616 |
+ measuredChargeAh: checkpointChargeAh |
|
| 617 |
+ ) ?? false |
|
| 618 |
+ |
|
| 619 |
+ if didSave {
|
|
| 620 |
+ reloadChargedDevices() |
|
| 621 |
+ } |
|
| 622 |
+ |
|
| 623 |
+ return didSave |
|
| 624 |
+ } |
|
| 625 |
+ |
|
| 626 |
+ @discardableResult |
|
| 627 |
+ func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
|
|
| 628 |
+ guard canAddBatteryCheckpoint(to: sessionID) else {
|
|
| 629 |
+ return false |
|
| 630 |
+ } |
|
| 631 |
+ |
|
| 632 |
+ let didSave = chargeInsightsStore?.addBatteryCheckpoint( |
|
| 633 |
+ percent: percent, |
|
| 634 |
+ for: sessionID |
|
| 635 |
+ ) ?? false |
|
| 636 |
+ |
|
| 637 |
+ if didSave {
|
|
| 638 |
+ reloadChargedDevices() |
|
| 639 |
+ } |
|
| 640 |
+ |
|
| 641 |
+ return didSave |
|
| 642 |
+ } |
|
| 643 |
+ |
|
| 644 |
+ @discardableResult |
|
| 645 |
+ func addBatteryCheckpoint( |
|
| 646 |
+ percent: Double, |
|
| 647 |
+ for sessionID: UUID, |
|
| 648 |
+ measuredEnergyWh: Double?, |
|
| 649 |
+ measuredChargeAh: Double? |
|
| 650 |
+ ) -> Bool {
|
|
| 651 |
+ guard canAddBatteryCheckpoint(to: sessionID) else {
|
|
| 652 |
+ return false |
|
| 653 |
+ } |
|
| 654 |
+ |
|
| 655 |
+ let didSave = chargeInsightsStore?.addBatteryCheckpoint( |
|
| 656 |
+ percent: percent, |
|
| 657 |
+ for: sessionID, |
|
| 658 |
+ measuredEnergyWh: measuredEnergyWh, |
|
| 659 |
+ measuredChargeAh: measuredChargeAh |
|
| 660 |
+ ) ?? false |
|
| 661 |
+ |
|
| 662 |
+ if didSave {
|
|
| 663 |
+ reloadChargedDevices() |
|
| 664 |
+ } |
|
| 665 |
+ |
|
| 666 |
+ return didSave |
|
| 667 |
+ } |
|
| 668 |
+ |
|
| 669 |
+ func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
|
|
| 670 |
+ guard let session = chargeSessionSummary(id: sessionID), |
|
| 671 |
+ session.status.isOpen, |
|
| 672 |
+ let meterMACAddress = session.meterMACAddress else {
|
|
| 673 |
+ return false |
|
| 674 |
+ } |
|
| 675 |
+ |
|
| 676 |
+ return meter(for: meterMACAddress) != nil |
|
| 677 |
+ } |
|
| 678 |
+ |
|
| 679 |
+ func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
|
|
| 680 |
+ guard let session = chargeSessionSummary(id: sessionID) else {
|
|
| 681 |
+ return "Battery checkpoints are available only while the charge session is still active." |
|
| 682 |
+ } |
|
| 683 |
+ |
|
| 684 |
+ guard session.status.isOpen else {
|
|
| 685 |
+ return "Battery checkpoints are available only while the charge session is still active." |
|
| 686 |
+ } |
|
| 687 |
+ |
|
| 688 |
+ guard let meterMACAddress = session.meterMACAddress, |
|
| 689 |
+ meter(for: meterMACAddress) != nil else {
|
|
| 690 |
+ return "Add battery checkpoints only on the device that is actively monitoring this charging session. Devices following the session through iCloud may not have data that is fresh or precise enough." |
|
| 691 |
+ } |
|
| 692 |
+ |
|
| 693 |
+ return nil |
|
| 694 |
+ } |
|
| 695 |
+ |
|
| 696 |
+ func batteryCheckpointPlausibilityWarning( |
|
| 697 |
+ percent: Double, |
|
| 698 |
+ for sessionID: UUID, |
|
| 699 |
+ effectiveEnergyWhOverride: Double? = nil |
|
| 700 |
+ ) -> BatteryCheckpointPlausibilityWarning? {
|
|
| 701 |
+ guard let session = chargeSessionSummary(id: sessionID) else {
|
|
| 702 |
+ return nil |
|
| 703 |
+ } |
|
| 704 |
+ return batteryCheckpointPlausibilityWarning( |
|
| 705 |
+ percent: percent, |
|
| 706 |
+ for: session, |
|
| 707 |
+ effectiveEnergyWhOverride: effectiveEnergyWhOverride |
|
| 708 |
+ ) |
|
| 709 |
+ } |
|
| 710 |
+ |
|
| 711 |
+ @discardableResult |
|
| 712 |
+ func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
|
|
| 713 |
+ let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint( |
|
| 714 |
+ id: checkpointID, |
|
| 715 |
+ from: sessionID |
|
| 716 |
+ ) ?? false |
|
| 717 |
+ |
|
| 718 |
+ if didDelete {
|
|
| 719 |
+ reloadChargedDevices() |
|
| 720 |
+ } |
|
| 721 |
+ |
|
| 722 |
+ return didDelete |
|
| 723 |
+ } |
|
| 724 |
+ |
|
| 725 |
+ @discardableResult |
|
| 726 |
+ func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
|
|
| 727 |
+ let didSave = chargeInsightsStore?.setSessionTrim( |
|
| 728 |
+ sessionID: sessionID, |
|
| 729 |
+ start: start, |
|
| 730 |
+ end: end |
|
| 731 |
+ ) ?? false |
|
| 732 |
+ if didSave {
|
|
| 733 |
+ reloadChargedDevices() |
|
| 734 |
+ } |
|
| 735 |
+ return didSave |
|
| 736 |
+ } |
|
| 737 |
+ |
|
| 738 |
+ @discardableResult |
|
| 739 |
+ func flushChargeInsights() -> Bool {
|
|
| 740 |
+ let didFlushObservations = flushAllPendingChargeObservations() |
|
| 741 |
+ let didSave = chargeInsightsStore?.flushPendingChanges() ?? false |
|
| 742 |
+ if didFlushObservations || didSave {
|
|
| 743 |
+ reloadChargedDevices() |
|
| 744 |
+ } |
|
| 745 |
+ return didFlushObservations || didSave |
|
| 746 |
+ } |
|
| 747 |
+ |
|
| 748 |
+ @discardableResult |
|
| 749 |
+ func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
|
|
| 750 |
+ guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
|
|
| 751 |
+ return false |
|
| 752 |
+ } |
|
| 753 |
+ return setTargetBatteryPercent(percent, for: activeSession.id) |
|
| 754 |
+ } |
|
| 755 |
+ |
|
| 756 |
+ @discardableResult |
|
| 757 |
+ func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
|
|
| 758 |
+ if percent != nil {
|
|
| 759 |
+ chargeNotificationCoordinator.ensureAuthorizationIfNeeded() |
|
| 760 |
+ } |
|
| 761 |
+ |
|
| 762 |
+ let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false |
|
| 763 |
+ if didSave {
|
|
| 764 |
+ reloadChargedDevices() |
|
| 765 |
+ } |
|
| 766 |
+ return didSave |
|
| 767 |
+ } |
|
| 768 |
+ |
|
| 769 |
+ @discardableResult |
|
| 770 |
+ func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
|
|
| 771 |
+ let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false |
|
| 772 |
+ if didSave {
|
|
| 773 |
+ reloadChargedDevices() |
|
| 774 |
+ } |
|
| 775 |
+ return didSave |
|
| 776 |
+ } |
|
| 777 |
+ |
|
| 778 |
+ @discardableResult |
|
| 779 |
+ func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
|
|
| 780 |
+ let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false |
|
| 781 |
+ if didSave {
|
|
| 782 |
+ reloadChargedDevices() |
|
| 783 |
+ } |
|
| 784 |
+ return didSave |
|
| 785 |
+ } |
|
| 786 |
+ |
|
| 787 |
+ @discardableResult |
|
| 788 |
+ func deleteChargeSession(sessionID: UUID) -> Bool {
|
|
| 789 |
+ let deletedSession = chargedDevices |
|
| 790 |
+ .flatMap(\.sessions) |
|
| 791 |
+ .first(where: { $0.id == sessionID })
|
|
| 792 |
+ |
|
| 793 |
+ let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false |
|
| 794 |
+ guard didDelete else {
|
|
| 795 |
+ return false |
|
| 796 |
+ } |
|
| 797 |
+ |
|
| 798 |
+ if deletedSession?.status.isOpen == true, |
|
| 799 |
+ let meterMACAddress = deletedSession?.meterMACAddress, |
|
| 800 |
+ let liveMeter = meter(for: meterMACAddress) {
|
|
| 801 |
+ liveMeter.resetChargeRecord() |
|
| 802 |
+ } |
|
| 803 |
+ |
|
| 804 |
+ reloadChargedDevices() |
|
| 805 |
+ return true |
|
| 806 |
+ } |
|
| 807 |
+ |
|
| 808 |
+ @discardableResult |
|
| 809 |
+ func deleteChargedDevice(id: UUID) -> Bool {
|
|
| 810 |
+ let deletedDevice = chargedDeviceSummary(id: id) |
|
| 811 |
+ let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false |
|
| 812 |
+ guard didDelete else {
|
|
| 813 |
+ return false |
|
| 814 |
+ } |
|
| 815 |
+ |
|
| 816 |
+ if deletedDevice?.isCharger == true {
|
|
| 817 |
+ _ = chargerStandbyPowerStore.removeMeasurements(for: id) |
|
| 818 |
+ for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
|
|
| 819 |
+ session.stop() |
|
| 820 |
+ activeChargerStandbySessions[meterMACAddress] = nil |
|
| 821 |
+ } |
|
| 822 |
+ } |
|
| 823 |
+ |
|
| 824 |
+ if deletedDevice?.isCharger == false, |
|
| 825 |
+ deletedDevice?.activeSession?.status.isOpen == true, |
|
| 826 |
+ let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress, |
|
| 827 |
+ let liveMeter = meter(for: meterMACAddress) {
|
|
| 828 |
+ liveMeter.resetChargeRecord() |
|
| 829 |
+ } |
|
| 830 |
+ |
|
| 831 |
+ reloadChargedDevices() |
|
| 832 |
+ return true |
|
| 833 |
+ } |
|
| 834 |
+ |
|
| 835 |
+ @discardableResult |
|
| 836 |
+ func createKnownMeter( |
|
| 837 |
+ macAddress: String, |
|
| 838 |
+ customName: String?, |
|
| 839 |
+ modelName: String, |
|
| 840 |
+ advertisedName: String? |
|
| 841 |
+ ) -> Bool {
|
|
| 842 |
+ let normalizedMAC = Self.normalizedMACAddress(macAddress) |
|
| 843 |
+ guard Self.isValidMACAddress(normalizedMAC) else {
|
|
| 844 |
+ return false |
|
| 845 |
+ } |
|
| 846 |
+ |
|
| 847 |
+ registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName) |
|
| 848 |
+ if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
|
|
| 849 |
+ setMeterName(customName, for: normalizedMAC) |
|
| 850 |
+ } |
|
| 851 |
+ noteMeterSeen(at: Date(), macAddress: normalizedMAC) |
|
| 852 |
+ return true |
|
| 853 |
+ } |
|
| 854 |
+ |
|
| 855 |
+ @discardableResult |
|
| 856 |
+ func deleteMeter(macAddress: String) -> Bool {
|
|
| 857 |
+ let normalizedMAC = Self.normalizedMACAddress(macAddress) |
|
| 858 |
+ guard Self.isValidMACAddress(normalizedMAC) else {
|
|
| 859 |
+ return false |
|
| 860 |
+ } |
|
| 861 |
+ |
|
| 862 |
+ for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
|
|
| 863 |
+ meter.disconnect() |
|
| 864 |
+ } |
|
| 865 |
+ meters = meters.filter { element in
|
|
| 866 |
+ element.value.btSerial.macAddress.description != normalizedMAC |
|
| 867 |
+ } |
|
| 868 |
+ |
|
| 869 |
+ let didDelete = meterStore.remove(macAddress: normalizedMAC) |
|
| 870 |
+ if didDelete {
|
|
| 871 |
+ scheduleObjectWillChange() |
|
| 872 |
+ } |
|
| 873 |
+ return didDelete |
|
| 874 |
+ } |
|
| 875 |
+ |
|
| 876 |
+ var meterSummaries: [MeterSummary] {
|
|
| 877 |
+ if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
|
|
| 878 |
+ return meterSummariesCache.summaries |
|
| 879 |
+ } |
|
| 880 |
+ |
|
| 881 |
+ let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
|
|
| 882 |
+ let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
|
|
| 883 |
+ let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys) |
|
| 884 |
+ |
|
| 885 |
+ let summaries = macAddresses.map { macAddress in
|
|
| 886 |
+ let liveMeter = liveMetersByMAC[macAddress] |
|
| 887 |
+ let record = recordsByMAC[macAddress] |
|
| 888 |
+ |
|
| 889 |
+ return MeterSummary( |
|
| 890 |
+ macAddress: macAddress, |
|
| 891 |
+ displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record), |
|
| 892 |
+ modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter", |
|
| 893 |
+ advertisedName: liveMeter?.modelString ?? record?.advertisedName, |
|
| 894 |
+ lastSeen: liveMeter?.lastSeen ?? record?.lastSeen, |
|
| 895 |
+ lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected, |
|
| 896 |
+ meter: liveMeter |
|
| 897 |
+ ) |
|
| 898 |
+ } |
|
| 899 |
+ .sorted { lhs, rhs in
|
|
| 900 |
+ if lhs.meter != nil && rhs.meter == nil {
|
|
| 901 |
+ return true |
|
| 902 |
+ } |
|
| 903 |
+ if lhs.meter == nil && rhs.meter != nil {
|
|
| 904 |
+ return false |
|
| 905 |
+ } |
|
| 906 |
+ let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) |
|
| 907 |
+ if byName != .orderedSame {
|
|
| 908 |
+ return byName == .orderedAscending |
|
| 909 |
+ } |
|
| 910 |
+ return lhs.macAddress < rhs.macAddress |
|
| 911 |
+ } |
|
| 912 |
+ |
|
| 913 |
+ meterSummariesCache = (version: meterSummariesVersion, summaries: summaries) |
|
| 914 |
+ return summaries |
|
| 915 |
+ } |
|
| 916 |
+ |
|
| 66 | 917 |
private func scheduleObjectWillChange() {
|
| 67 | 918 |
DispatchQueue.main.async { [weak self] in
|
| 68 | 919 |
self?.objectWillChange.send() |
| 69 | 920 |
} |
| 70 | 921 |
} |
| 922 |
+ |
|
| 923 |
+ private func invalidateMeterSummaries() {
|
|
| 924 |
+ meterSummariesVersion += 1 |
|
| 925 |
+ meterSummariesCache = nil |
|
| 926 |
+ } |
|
| 927 |
+ |
|
| 928 |
+ private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
|
|
| 929 |
+ pendingChargedDevicesReloadWorkItem?.cancel() |
|
| 930 |
+ |
|
| 931 |
+ let workItem = DispatchWorkItem { [weak self] in
|
|
| 932 |
+ self?.reloadChargedDevices() |
|
| 933 |
+ } |
|
| 934 |
+ pendingChargedDevicesReloadWorkItem = workItem |
|
| 935 |
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) |
|
| 936 |
+ } |
|
| 937 |
+ |
|
| 938 |
+ private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) {
|
|
| 939 |
+ let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress) |
|
| 940 |
+ guard !normalizedMAC.isEmpty else {
|
|
| 941 |
+ return |
|
| 942 |
+ } |
|
| 943 |
+ |
|
| 944 |
+ pendingChargeObservationSnapshots[normalizedMAC] = snapshot |
|
| 945 |
+ |
|
| 946 |
+ guard scheduleFlush else {
|
|
| 947 |
+ return |
|
| 948 |
+ } |
|
| 949 |
+ |
|
| 950 |
+ guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
|
|
| 951 |
+ return |
|
| 952 |
+ } |
|
| 953 |
+ |
|
| 954 |
+ let workItem = DispatchWorkItem { [weak self] in
|
|
| 955 |
+ guard let self else { return }
|
|
| 956 |
+ self.pendingChargeObservationWorkItems[normalizedMAC] = nil |
|
| 957 |
+ guard let snapshot = self.pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
|
|
| 958 |
+ return |
|
| 959 |
+ } |
|
| 960 |
+ // CoreData write on background — DidSave observer handles the reload |
|
| 961 |
+ let store = self.chargeInsightsStore |
|
| 962 |
+ DispatchQueue.global(qos: .utility).async {
|
|
| 963 |
+ store?.observe(snapshot: snapshot) |
|
| 964 |
+ } |
|
| 965 |
+ } |
|
| 966 |
+ pendingChargeObservationWorkItems[normalizedMAC] = workItem |
|
| 967 |
+ DispatchQueue.main.asyncAfter(deadline: .now() + chargeObservationPersistInterval, execute: workItem) |
|
| 968 |
+ } |
|
| 969 |
+ |
|
| 970 |
+ @discardableResult |
|
| 971 |
+ private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
|
|
| 972 |
+ guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
|
|
| 973 |
+ return false |
|
| 974 |
+ } |
|
| 975 |
+ |
|
| 976 |
+ stageChargeObservation(snapshot, scheduleFlush: false) |
|
| 977 |
+ return flushPendingChargeObservation(for: snapshot.meterMACAddress) |
|
| 978 |
+ } |
|
| 979 |
+ |
|
| 980 |
+ @discardableResult |
|
| 981 |
+ private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
|
|
| 982 |
+ let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) |
|
| 983 |
+ guard !normalizedMAC.isEmpty else {
|
|
| 984 |
+ return false |
|
| 985 |
+ } |
|
| 986 |
+ |
|
| 987 |
+ pendingChargeObservationWorkItems[normalizedMAC]?.cancel() |
|
| 988 |
+ pendingChargeObservationWorkItems[normalizedMAC] = nil |
|
| 989 |
+ |
|
| 990 |
+ guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
|
|
| 991 |
+ return false |
|
| 992 |
+ } |
|
| 993 |
+ |
|
| 994 |
+ let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false |
|
| 995 |
+ return didSave |
|
| 996 |
+ } |
|
| 997 |
+ |
|
| 998 |
+ @discardableResult |
|
| 999 |
+ private func flushAllPendingChargeObservations() -> Bool {
|
|
| 1000 |
+ let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys) |
|
| 1001 |
+ var didSave = false |
|
| 1002 |
+ |
|
| 1003 |
+ for meterMACAddress in pendingMeterMACAddresses {
|
|
| 1004 |
+ if flushPendingChargeObservation(for: meterMACAddress) {
|
|
| 1005 |
+ didSave = true |
|
| 1006 |
+ } |
|
| 1007 |
+ } |
|
| 1008 |
+ |
|
| 1009 |
+ return didSave |
|
| 1010 |
+ } |
|
| 1011 |
+ |
|
| 1012 |
+ private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
|
|
| 1013 |
+ let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) |
|
| 1014 |
+ guard !normalizedMAC.isEmpty else {
|
|
| 1015 |
+ return nil |
|
| 1016 |
+ } |
|
| 1017 |
+ |
|
| 1018 |
+ return chargedDevices |
|
| 1019 |
+ .lazy |
|
| 1020 |
+ .compactMap(\.activeSession) |
|
| 1021 |
+ .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
|
|
| 1022 |
+ } |
|
| 1023 |
+ |
|
| 1024 |
+ @discardableResult |
|
| 1025 |
+ private func healDuplicateOpenSessions() -> Bool {
|
|
| 1026 |
+ chargeInsightsStore?.healDuplicateOpenSessions() ?? false |
|
| 1027 |
+ } |
|
| 1028 |
+ |
|
| 1029 |
+ @discardableResult |
|
| 1030 |
+ private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
|
|
| 1031 |
+ chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false |
|
| 1032 |
+ } |
|
| 1033 |
+ |
|
| 1034 |
+ private func reloadChargedDevices() {
|
|
| 1035 |
+ if Thread.isMainThread == false {
|
|
| 1036 |
+ DispatchQueue.main.async { [weak self] in
|
|
| 1037 |
+ self?.reloadChargedDevices() |
|
| 1038 |
+ } |
|
| 1039 |
+ return |
|
| 1040 |
+ } |
|
| 1041 |
+ |
|
| 1042 |
+ pendingChargedDevicesReloadWorkItem?.cancel() |
|
| 1043 |
+ pendingChargedDevicesReloadWorkItem = nil |
|
| 1044 |
+ |
|
| 1045 |
+ _ = healDuplicateOpenSessions() |
|
| 1046 |
+ _ = expireOverlongChargeSessionsIfNeeded() |
|
| 1047 |
+ |
|
| 1048 |
+ guard chargedDevicesReloadInFlight == false else {
|
|
| 1049 |
+ chargedDevicesReloadPending = true |
|
| 1050 |
+ return |
|
| 1051 |
+ } |
|
| 1052 |
+ |
|
| 1053 |
+ let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID() |
|
| 1054 |
+ let readStore = chargeInsightsReadStore ?? chargeInsightsStore |
|
| 1055 |
+ chargedDevicesReloadInFlight = true |
|
| 1056 |
+ chargedDevicesReloadPending = false |
|
| 1057 |
+ |
|
| 1058 |
+ chargedDevicesReloadQueue.async { [weak self] in
|
|
| 1059 |
+ guard let self else { return }
|
|
| 1060 |
+ |
|
| 1061 |
+ readStore?.resetContext() |
|
| 1062 |
+ let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
|
|
| 1063 |
+ chargedDevice.withStandbyPowerMeasurements( |
|
| 1064 |
+ standbyMeasurementsByChargerID[chargedDevice.id] ?? [] |
|
| 1065 |
+ ) |
|
| 1066 |
+ } |
|
| 1067 |
+ |
|
| 1068 |
+ DispatchQueue.main.async { [weak self] in
|
|
| 1069 |
+ guard let self else { return }
|
|
| 1070 |
+ |
|
| 1071 |
+ self.chargedDevices = summaries |
|
| 1072 |
+ self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries) |
|
| 1073 |
+ for meter in self.meters.values {
|
|
| 1074 |
+ self.restoreChargeMonitoringStateIfNeeded(for: meter) |
|
| 1075 |
+ } |
|
| 1076 |
+ |
|
| 1077 |
+ self.chargedDevicesReloadInFlight = false |
|
| 1078 |
+ if self.chargedDevicesReloadPending {
|
|
| 1079 |
+ self.reloadChargedDevices() |
|
| 1080 |
+ } |
|
| 1081 |
+ } |
|
| 1082 |
+ } |
|
| 1083 |
+ } |
|
| 1084 |
+ |
|
| 1085 |
+ private func meter(for meterMACAddress: String) -> Meter? {
|
|
| 1086 |
+ meters.values.first { meter in
|
|
| 1087 |
+ meter.btSerial.macAddress.description == meterMACAddress |
|
| 1088 |
+ } |
|
| 1089 |
+ } |
|
| 1090 |
+ |
|
| 1091 |
+ private func refreshMeterMetadata() {
|
|
| 1092 |
+ DispatchQueue.main.async { [weak self] in
|
|
| 1093 |
+ guard let self else { return }
|
|
| 1094 |
+ var didUpdateAnyMeter = false |
|
| 1095 |
+ for meter in self.meters.values {
|
|
| 1096 |
+ let mac = meter.btSerial.macAddress.description |
|
| 1097 |
+ let displayName = self.meterName(for: mac) ?? mac |
|
| 1098 |
+ if meter.name != displayName {
|
|
| 1099 |
+ meter.updateNameFromStore(displayName) |
|
| 1100 |
+ didUpdateAnyMeter = true |
|
| 1101 |
+ } |
|
| 1102 |
+ |
|
| 1103 |
+ let previousTemperaturePreference = meter.tc66TemperatureUnitPreference |
|
| 1104 |
+ meter.reloadTemperatureUnitPreference() |
|
| 1105 |
+ if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
|
|
| 1106 |
+ didUpdateAnyMeter = true |
|
| 1107 |
+ } |
|
| 1108 |
+ } |
|
| 1109 |
+ |
|
| 1110 |
+ if didUpdateAnyMeter {
|
|
| 1111 |
+ self.scheduleObjectWillChange() |
|
| 1112 |
+ } |
|
| 1113 |
+ } |
|
| 1114 |
+ } |
|
| 1115 |
+ |
|
| 1116 |
+ private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
|
|
| 1117 |
+ guard let charger = chargedDeviceSummary(id: session.chargerID), |
|
| 1118 |
+ let statistics = session.statistics else {
|
|
| 1119 |
+ return |
|
| 1120 |
+ } |
|
| 1121 |
+ |
|
| 1122 |
+ let content = UNMutableNotificationContent() |
|
| 1123 |
+ content.title = "Standby baseline stabilised" |
|
| 1124 |
+ content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready." |
|
| 1125 |
+ content.sound = .default |
|
| 1126 |
+ content.threadIdentifier = "charger-standby-\(charger.id.uuidString)" |
|
| 1127 |
+ |
|
| 1128 |
+ let request = UNNotificationRequest( |
|
| 1129 |
+ identifier: "charger-standby-\(session.id.uuidString)", |
|
| 1130 |
+ content: content, |
|
| 1131 |
+ trigger: nil |
|
| 1132 |
+ ) |
|
| 1133 |
+ UNUserNotificationCenter.current().add(request) |
|
| 1134 |
+ scheduleObjectWillChange() |
|
| 1135 |
+ } |
|
| 1136 |
+ |
|
| 1137 |
+ private func batteryCheckpointPlausibilityWarning( |
|
| 1138 |
+ percent: Double, |
|
| 1139 |
+ for session: ChargeSessionSummary, |
|
| 1140 |
+ effectiveEnergyWhOverride: Double? = nil |
|
| 1141 |
+ ) -> BatteryCheckpointPlausibilityWarning? {
|
|
| 1142 |
+ guard percent.isFinite, percent >= 0, percent <= 100 else {
|
|
| 1143 |
+ return nil |
|
| 1144 |
+ } |
|
| 1145 |
+ |
|
| 1146 |
+ let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
|
|
| 1147 |
+ if lhs.timestamp != rhs.timestamp {
|
|
| 1148 |
+ return lhs.timestamp < rhs.timestamp |
|
| 1149 |
+ } |
|
| 1150 |
+ if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
|
|
| 1151 |
+ return lhs.measuredEnergyWh < rhs.measuredEnergyWh |
|
| 1152 |
+ } |
|
| 1153 |
+ return lhs.id.uuidString < rhs.id.uuidString |
|
| 1154 |
+ } |
|
| 1155 |
+ |
|
| 1156 |
+ if let lastCheckpoint = sortedCheckpoints.last, |
|
| 1157 |
+ percent < lastCheckpoint.batteryPercent - 1.5 {
|
|
| 1158 |
+ return BatteryCheckpointPlausibilityWarning( |
|
| 1159 |
+ title: "Checkpoint Goes Backwards", |
|
| 1160 |
+ message: "The latest checkpoint is \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.timestamp.format()). A new value of \(percent.format(decimalDigits: 0))% is unexpectedly lower while the session is still charging." |
|
| 1161 |
+ ) |
|
| 1162 |
+ } |
|
| 1163 |
+ |
|
| 1164 |
+ let effectiveEnergyWh = effectiveEnergyWhOverride |
|
| 1165 |
+ ?? session.effectiveBatteryEnergyWh |
|
| 1166 |
+ ?? session.measuredEnergyWh |
|
| 1167 |
+ |
|
| 1168 |
+ if let lastCheckpoint = sortedCheckpoints.last, |
|
| 1169 |
+ let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
|
|
| 1170 |
+ let estimatedCapacityWh = session.capacityEstimateWh |
|
| 1171 |
+ ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode) |
|
| 1172 |
+ ?? chargedDevice.estimatedBatteryCapacityWh |
|
| 1173 |
+ |
|
| 1174 |
+ if let estimatedCapacityWh, estimatedCapacityWh > 0 {
|
|
| 1175 |
+ let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0) |
|
| 1176 |
+ let expectedPercent = min( |
|
| 1177 |
+ 100, |
|
| 1178 |
+ max( |
|
| 1179 |
+ lastCheckpoint.batteryPercent, |
|
| 1180 |
+ lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100 |
|
| 1181 |
+ ) |
|
| 1182 |
+ ) |
|
| 1183 |
+ let predictionGap = percent - expectedPercent |
|
| 1184 |
+ guard abs(predictionGap) >= 4 else {
|
|
| 1185 |
+ return nil |
|
| 1186 |
+ } |
|
| 1187 |
+ |
|
| 1188 |
+ let direction = predictionGap > 0 ? "above" : "below" |
|
| 1189 |
+ let gapText = abs(predictionGap).format(decimalDigits: 0) |
|
| 1190 |
+ let expectedText = expectedPercent.format(decimalDigits: 0) |
|
| 1191 |
+ |
|
| 1192 |
+ return BatteryCheckpointPlausibilityWarning( |
|
| 1193 |
+ title: "Checkpoint Looks Implausible", |
|
| 1194 |
+ message: "The last checkpoint stored \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh. The current counted energy is \(effectiveEnergyWh.format(decimalDigits: 2)) Wh, which supports about \(expectedText)% based on \(estimatedCapacityWh.format(decimalDigits: 2)) Wh capacity. The entered value is about \(gapText) percentage points \(direction) that." |
|
| 1195 |
+ ) |
|
| 1196 |
+ } |
|
| 1197 |
+ } |
|
| 1198 |
+ |
|
| 1199 |
+ guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID), |
|
| 1200 |
+ let prediction = chargedDevice.batteryLevelPrediction( |
|
| 1201 |
+ for: session, |
|
| 1202 |
+ effectiveEnergyWhOverride: effectiveEnergyWh |
|
| 1203 |
+ ) |
|
| 1204 |
+ else {
|
|
| 1205 |
+ return nil |
|
| 1206 |
+ } |
|
| 1207 |
+ |
|
| 1208 |
+ let predictionGap = percent - prediction.predictedPercent |
|
| 1209 |
+ guard abs(predictionGap) >= 4 else {
|
|
| 1210 |
+ return nil |
|
| 1211 |
+ } |
|
| 1212 |
+ |
|
| 1213 |
+ let direction = predictionGap > 0 ? "above" : "below" |
|
| 1214 |
+ let gapText = abs(predictionGap).format(decimalDigits: 0) |
|
| 1215 |
+ let predictedText = prediction.predictedPercent.format(decimalDigits: 0) |
|
| 1216 |
+ |
|
| 1217 |
+ if let lastCheckpoint = sortedCheckpoints.last {
|
|
| 1218 |
+ let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0) |
|
| 1219 |
+ return BatteryCheckpointPlausibilityWarning( |
|
| 1220 |
+ title: "Checkpoint Looks Implausible", |
|
| 1221 |
+ message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Since the last checkpoint at \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))%, only \(energyDeltaWh.format(decimalDigits: 2)) Wh were added." |
|
| 1222 |
+ ) |
|
| 1223 |
+ } |
|
| 1224 |
+ |
|
| 1225 |
+ return BatteryCheckpointPlausibilityWarning( |
|
| 1226 |
+ title: "Checkpoint Looks Implausible", |
|
| 1227 |
+ message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Save it only if the device gauge really jumped that much." |
|
| 1228 |
+ ) |
|
| 1229 |
+ } |
|
| 1230 |
+ |
|
| 1231 |
+ private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
|
|
| 1232 |
+ let storedEnergyWh = session.effectiveOrMeasuredEnergyWh |
|
| 1233 |
+ guard session.isTrimmed == false else {
|
|
| 1234 |
+ return storedEnergyWh |
|
| 1235 |
+ } |
|
| 1236 |
+ guard session.status.isOpen else {
|
|
| 1237 |
+ return storedEnergyWh |
|
| 1238 |
+ } |
|
| 1239 |
+ |
|
| 1240 |
+ guard session.meterMACAddress == meter.btSerial.macAddress.description else {
|
|
| 1241 |
+ return storedEnergyWh |
|
| 1242 |
+ } |
|
| 1243 |
+ |
|
| 1244 |
+ if let baselineEnergyWh = session.meterEnergyBaselineWh {
|
|
| 1245 |
+ return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0)) |
|
| 1246 |
+ } |
|
| 1247 |
+ |
|
| 1248 |
+ return storedEnergyWh |
|
| 1249 |
+ } |
|
| 1250 |
+ |
|
| 1251 |
+ private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
|
|
| 1252 |
+ let storedChargeAh = session.measuredChargeAh |
|
| 1253 |
+ guard session.isTrimmed == false else {
|
|
| 1254 |
+ return storedChargeAh |
|
| 1255 |
+ } |
|
| 1256 |
+ guard session.status.isOpen else {
|
|
| 1257 |
+ return storedChargeAh |
|
| 1258 |
+ } |
|
| 1259 |
+ |
|
| 1260 |
+ guard session.meterMACAddress == meter.btSerial.macAddress.description else {
|
|
| 1261 |
+ return storedChargeAh |
|
| 1262 |
+ } |
|
| 1263 |
+ |
|
| 1264 |
+ if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
| 1265 |
+ return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0)) |
|
| 1266 |
+ } |
|
| 1267 |
+ |
|
| 1268 |
+ return storedChargeAh |
|
| 1269 |
+ } |
|
| 1270 |
+} |
|
| 1271 |
+ |
|
| 1272 |
+extension AppData.MeterSummary {
|
|
| 1273 |
+ var tint: Color {
|
|
| 1274 |
+ switch modelSummary {
|
|
| 1275 |
+ case "UM25C": |
|
| 1276 |
+ return .blue |
|
| 1277 |
+ case "UM34C": |
|
| 1278 |
+ return .yellow |
|
| 1279 |
+ case "TC66C": |
|
| 1280 |
+ return Model.TC66C.color |
|
| 1281 |
+ default: |
|
| 1282 |
+ return .secondary |
|
| 1283 |
+ } |
|
| 1284 |
+ } |
|
| 1285 |
+} |
|
| 1286 |
+ |
|
| 1287 |
+extension AppData {
|
|
| 1288 |
+ static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
|
|
| 1289 |
+ if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
|
|
| 1290 |
+ return liveName |
|
| 1291 |
+ } |
|
| 1292 |
+ if let customName = record?.customName {
|
|
| 1293 |
+ return customName |
|
| 1294 |
+ } |
|
| 1295 |
+ if let advertisedName = record?.advertisedName {
|
|
| 1296 |
+ return advertisedName |
|
| 1297 |
+ } |
|
| 1298 |
+ if let recordModel = record?.modelName {
|
|
| 1299 |
+ return recordModel |
|
| 1300 |
+ } |
|
| 1301 |
+ if let liveModel = liveMeter?.deviceModelSummary {
|
|
| 1302 |
+ return liveModel |
|
| 1303 |
+ } |
|
| 1304 |
+ return "Meter" |
|
| 1305 |
+ } |
|
| 1306 |
+ |
|
| 1307 |
+ static func normalizedMACAddress(_ macAddress: String) -> String {
|
|
| 1308 |
+ macAddress |
|
| 1309 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 1310 |
+ .uppercased() |
|
| 1311 |
+ } |
|
| 1312 |
+ |
|
| 1313 |
+ static func isValidMACAddress(_ macAddress: String) -> Bool {
|
|
| 1314 |
+ macAddress.range( |
|
| 1315 |
+ of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
|
|
| 1316 |
+ options: .regularExpression |
|
| 1317 |
+ ) != nil |
|
| 1318 |
+ } |
|
| 1319 |
+} |
|
| 1320 |
+ |
|
| 1321 |
+private final class ChargeNotificationCoordinator {
|
|
| 1322 |
+ private struct Payload {
|
|
| 1323 |
+ let id: String |
|
| 1324 |
+ let title: String |
|
| 1325 |
+ let body: String |
|
| 1326 |
+ let threadIdentifier: String |
|
| 1327 |
+ } |
|
| 1328 |
+ |
|
| 1329 |
+ private let notificationCenter = UNUserNotificationCenter.current() |
|
| 1330 |
+ private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs" |
|
| 1331 |
+ private let eventRecencyWindow: TimeInterval = 24 * 60 * 60 |
|
| 1332 |
+ private var inFlightEventIDs: Set<String> = [] |
|
| 1333 |
+ |
|
| 1334 |
+ func ensureAuthorizationIfNeeded() {
|
|
| 1335 |
+ notificationCenter.getNotificationSettings { [weak self] settings in
|
|
| 1336 |
+ guard settings.authorizationStatus == .notDetermined else {
|
|
| 1337 |
+ return |
|
| 1338 |
+ } |
|
| 1339 |
+ |
|
| 1340 |
+ self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
|
|
| 1341 |
+ if let error {
|
|
| 1342 |
+ track("Notification authorization request failed: \(error.localizedDescription)")
|
|
| 1343 |
+ } |
|
| 1344 |
+ } |
|
| 1345 |
+ } |
|
| 1346 |
+ } |
|
| 1347 |
+ |
|
| 1348 |
+ func process(chargedDevices: [ChargedDeviceSummary]) {
|
|
| 1349 |
+ let now = Date() |
|
| 1350 |
+ let pendingPayloads = chargedDevices.flatMap { chargedDevice in
|
|
| 1351 |
+ payloads(for: chargedDevice, now: now) |
|
| 1352 |
+ } |
|
| 1353 |
+ |
|
| 1354 |
+ for payload in pendingPayloads {
|
|
| 1355 |
+ scheduleIfNeeded(payload) |
|
| 1356 |
+ } |
|
| 1357 |
+ } |
|
| 1358 |
+ |
|
| 1359 |
+ private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
|
|
| 1360 |
+ chargedDevice.sessions.compactMap { session in
|
|
| 1361 |
+ if let triggeredAt = session.targetBatteryAlertTriggeredAt, |
|
| 1362 |
+ now.timeIntervalSince(triggeredAt) <= eventRecencyWindow, |
|
| 1363 |
+ let targetBatteryPercent = session.targetBatteryPercent {
|
|
| 1364 |
+ let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent |
|
| 1365 |
+ ?? session.endBatteryPercent |
|
| 1366 |
+ ?? targetBatteryPercent |
|
| 1367 |
+ |
|
| 1368 |
+ return Payload( |
|
| 1369 |
+ id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))", |
|
| 1370 |
+ title: "Battery target reached", |
|
| 1371 |
+ body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).", |
|
| 1372 |
+ threadIdentifier: session.id.uuidString |
|
| 1373 |
+ ) |
|
| 1374 |
+ } |
|
| 1375 |
+ |
|
| 1376 |
+ if session.requiresCompletionConfirmation, |
|
| 1377 |
+ let requestedAt = session.completionConfirmationRequestedAt, |
|
| 1378 |
+ now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
|
|
| 1379 |
+ let estimatedPercent = session.completionContradictionPercent |
|
| 1380 |
+ ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent |
|
| 1381 |
+ let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")." |
|
| 1382 |
+ let detail = estimatedPercent.map {
|
|
| 1383 |
+ " Estimated battery is only \($0.format(decimalDigits: 0))%." |
|
| 1384 |
+ } ?? "" |
|
| 1385 |
+ |
|
| 1386 |
+ return Payload( |
|
| 1387 |
+ id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))", |
|
| 1388 |
+ title: "Confirm charge completion", |
|
| 1389 |
+ body: bodyPrefix + detail, |
|
| 1390 |
+ threadIdentifier: session.id.uuidString |
|
| 1391 |
+ ) |
|
| 1392 |
+ } |
|
| 1393 |
+ |
|
| 1394 |
+ return nil |
|
| 1395 |
+ } |
|
| 1396 |
+ } |
|
| 1397 |
+ |
|
| 1398 |
+ private func scheduleIfNeeded(_ payload: Payload) {
|
|
| 1399 |
+ guard deliveredEventIDs().contains(payload.id) == false else {
|
|
| 1400 |
+ return |
|
| 1401 |
+ } |
|
| 1402 |
+ |
|
| 1403 |
+ guard inFlightEventIDs.contains(payload.id) == false else {
|
|
| 1404 |
+ return |
|
| 1405 |
+ } |
|
| 1406 |
+ |
|
| 1407 |
+ inFlightEventIDs.insert(payload.id) |
|
| 1408 |
+ |
|
| 1409 |
+ notificationCenter.getNotificationSettings { [weak self] settings in
|
|
| 1410 |
+ guard let self else { return }
|
|
| 1411 |
+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
|
|
| 1412 |
+ DispatchQueue.main.async {
|
|
| 1413 |
+ self.inFlightEventIDs.remove(payload.id) |
|
| 1414 |
+ } |
|
| 1415 |
+ return |
|
| 1416 |
+ } |
|
| 1417 |
+ |
|
| 1418 |
+ let content = UNMutableNotificationContent() |
|
| 1419 |
+ content.title = payload.title |
|
| 1420 |
+ content.body = payload.body |
|
| 1421 |
+ content.sound = .default |
|
| 1422 |
+ content.threadIdentifier = payload.threadIdentifier |
|
| 1423 |
+ |
|
| 1424 |
+ let request = UNNotificationRequest( |
|
| 1425 |
+ identifier: payload.id, |
|
| 1426 |
+ content: content, |
|
| 1427 |
+ trigger: nil |
|
| 1428 |
+ ) |
|
| 1429 |
+ |
|
| 1430 |
+ self.notificationCenter.add(request) { error in
|
|
| 1431 |
+ DispatchQueue.main.async {
|
|
| 1432 |
+ self.inFlightEventIDs.remove(payload.id) |
|
| 1433 |
+ if let error {
|
|
| 1434 |
+ track("Failed scheduling local notification: \(error.localizedDescription)")
|
|
| 1435 |
+ return |
|
| 1436 |
+ } |
|
| 1437 |
+ self.storeDeliveredEventID(payload.id) |
|
| 1438 |
+ } |
|
| 1439 |
+ } |
|
| 1440 |
+ } |
|
| 1441 |
+ } |
|
| 1442 |
+ |
|
| 1443 |
+ private func deliveredEventIDs() -> Set<String> {
|
|
| 1444 |
+ let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? [] |
|
| 1445 |
+ return Set(values) |
|
| 1446 |
+ } |
|
| 1447 |
+ |
|
| 1448 |
+ private func storeDeliveredEventID(_ id: String) {
|
|
| 1449 |
+ var values = deliveredEventIDs() |
|
| 1450 |
+ values.insert(id) |
|
| 1451 |
+ UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey) |
|
| 1452 |
+ } |
|
| 71 | 1453 |
} |
@@ -61,16 +61,25 @@ class BluetoothManager : NSObject, ObservableObject {
|
||
| 61 | 61 |
} |
| 62 | 62 |
|
| 63 | 63 |
let macAddress = MACAddress(from: manufacturerData.suffix(from: 2)) |
| 64 |
+ let macAddressString = macAddress.description |
|
| 65 |
+ appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: peripheralName) |
|
| 66 |
+ appData.noteMeterSeen(at: Date(), macAddress: macAddressString) |
|
| 64 | 67 |
|
| 65 | 68 |
if appData.meters[peripheral.identifier] == nil {
|
| 66 | 69 |
track("adding new USB Meter named '\(peripheralName)' with MAC Address: '\(macAddress)'")
|
| 67 | 70 |
let btSerial = BluetoothSerial(peripheral: peripheral, radio: model.radio, with: macAddress, managedBy: manager!, RSSI: RSSI.intValue) |
| 68 | 71 |
var m = appData.meters |
| 69 |
- m[peripheral.identifier] = Meter(model: model, with: btSerial) |
|
| 72 |
+ let meter = Meter(model: model, with: btSerial) |
|
| 73 |
+ m[peripheral.identifier] = meter |
|
| 70 | 74 |
appData.meters = m |
| 75 |
+ appData.restoreChargeMonitoringStateIfNeeded(for: meter) |
|
| 71 | 76 |
} else if let meter = appData.meters[peripheral.identifier] {
|
| 72 | 77 |
meter.lastSeen = Date() |
| 73 | 78 |
meter.btSerial.updateRSSI(RSSI.intValue) |
| 79 |
+ let macAddress = meter.btSerial.macAddress.description |
|
| 80 |
+ if meter.name == macAddress, let syncedName = appData.meterName(for: macAddress), syncedName != macAddress {
|
|
| 81 |
+ meter.updateNameFromStore(syncedName) |
|
| 82 |
+ } |
|
| 74 | 83 |
if peripheral.delegate == nil {
|
| 75 | 84 |
peripheral.delegate = meter.btSerial |
| 76 | 85 |
} |
@@ -108,10 +117,14 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 108 | 117 |
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
| 109 | 118 |
managerState = central.state; |
| 110 | 119 |
track("\(central.state)")
|
| 120 |
+ for meter in appData.meters.values {
|
|
| 121 |
+ meter.btSerial.centralStateChanged(to: central.state) |
|
| 122 |
+ } |
|
| 111 | 123 |
|
| 112 | 124 |
switch central.state {
|
| 113 | 125 |
case .poweredOff: |
| 114 | 126 |
scanStartedAt = nil |
| 127 |
+ advertisementDataCache.clear() |
|
| 115 | 128 |
track("Bluetooth is Off. How should I behave?")
|
| 116 | 129 |
case .poweredOn: |
| 117 | 130 |
scanStartedAt = Date() |
@@ -122,18 +135,23 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 122 | 135 |
scanForMeters() |
| 123 | 136 |
case .resetting: |
| 124 | 137 |
scanStartedAt = nil |
| 138 |
+ advertisementDataCache.clear() |
|
| 125 | 139 |
track("Bluetooth is reseting... . Whatever that means.")
|
| 126 | 140 |
case .unauthorized: |
| 127 | 141 |
scanStartedAt = nil |
| 142 |
+ advertisementDataCache.clear() |
|
| 128 | 143 |
track("Bluetooth is not authorized.")
|
| 129 | 144 |
case .unknown: |
| 130 | 145 |
scanStartedAt = nil |
| 146 |
+ advertisementDataCache.clear() |
|
| 131 | 147 |
track("Bluetooth is in an unknown state.")
|
| 132 | 148 |
case .unsupported: |
| 133 | 149 |
scanStartedAt = nil |
| 150 |
+ advertisementDataCache.clear() |
|
| 134 | 151 |
track("Bluetooth not supported by device")
|
| 135 | 152 |
default: |
| 136 | 153 |
scanStartedAt = nil |
| 154 |
+ advertisementDataCache.clear() |
|
| 137 | 155 |
track("Bluetooth is in a state never seen before!")
|
| 138 | 156 |
} |
| 139 | 157 |
} |
@@ -152,7 +170,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 152 | 170 |
usbMeter.btSerial.connectionEstablished() |
| 153 | 171 |
} |
| 154 | 172 |
else {
|
| 155 |
- track("Connected to unknown meter with UUID: '\(peripheral.identifier)'")
|
|
| 173 |
+ track("Connected to meter with UUID: '\(peripheral.identifier)'")
|
|
| 156 | 174 |
} |
| 157 | 175 |
} |
| 158 | 176 |
|
@@ -163,7 +181,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 163 | 181 |
usbMeter.btSerial.connectionClosed() |
| 164 | 182 |
} |
| 165 | 183 |
else {
|
| 166 |
- track("Disconnected from unknown meter with UUID: '\(peripheral.identifier)'")
|
|
| 184 |
+ track("Disconnected from meter with UUID: '\(peripheral.identifier)'")
|
|
| 167 | 185 |
} |
| 168 | 186 |
} |
| 169 | 187 |
|
@@ -172,7 +190,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 172 | 190 |
if let usbMeter = appData.meters[peripheral.identifier] {
|
| 173 | 191 |
usbMeter.btSerial.connectionClosed() |
| 174 | 192 |
} else {
|
| 175 |
- track("Failed to connect to unknown meter with UUID: '\(peripheral.identifier)'")
|
|
| 193 |
+ track("Failed to connect to meter with UUID: '\(peripheral.identifier)'")
|
|
| 176 | 194 |
} |
| 177 | 195 |
} |
| 178 | 196 |
} |
@@ -108,9 +108,22 @@ final class BluetoothSerial : NSObject, ObservableObject {
|
||
| 108 | 108 |
notifyCharacteristic = nil |
| 109 | 109 |
} |
| 110 | 110 |
} |
| 111 |
+ |
|
| 112 |
+ private func forceNotConnected(reason: String, clearCharacteristics: Bool = true) {
|
|
| 113 |
+ resetCommunicationState(reason: reason, clearCharacteristics: clearCharacteristics) |
|
| 114 |
+ guard operationalState != .peripheralNotConnected else {
|
|
| 115 |
+ return |
|
| 116 |
+ } |
|
| 117 |
+ operationalState = .peripheralNotConnected |
|
| 118 |
+ } |
|
| 111 | 119 |
|
| 112 | 120 |
func connect() {
|
| 113 | 121 |
administrativeState = .up |
| 122 |
+ guard manager.state == .poweredOn else {
|
|
| 123 |
+ track("Connect requested for '\(peripheral.identifier)' but central state is \(manager.state)")
|
|
| 124 |
+ forceNotConnected(reason: "connect() while central is \(manager.state)") |
|
| 125 |
+ return |
|
| 126 |
+ } |
|
| 114 | 127 |
if operationalState < .peripheralConnected {
|
| 115 | 128 |
resetCommunicationState(reason: "connect()", clearCharacteristics: true) |
| 116 | 129 |
operationalState = .peripheralConnectionPending |
@@ -126,6 +139,11 @@ final class BluetoothSerial : NSObject, ObservableObject {
|
||
| 126 | 139 |
resetCommunicationState(reason: "disconnect()", clearCharacteristics: true) |
| 127 | 140 |
if peripheral.state != .disconnected || operationalState != .peripheralNotConnected {
|
| 128 | 141 |
track("Disconnect requested for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)")
|
| 142 |
+ guard manager.state == .poweredOn else {
|
|
| 143 |
+ track("Skipping central cancel for '\(peripheral.identifier)' because central state is \(manager.state)")
|
|
| 144 |
+ forceNotConnected(reason: "disconnect() while central is \(manager.state)", clearCharacteristics: false) |
|
| 145 |
+ return |
|
| 146 |
+ } |
|
| 129 | 147 |
manager.cancelPeripheralConnection(peripheral) |
| 130 | 148 |
} |
| 131 | 149 |
} |
@@ -186,6 +204,26 @@ final class BluetoothSerial : NSObject, ObservableObject {
|
||
| 186 | 204 |
operationalState = .peripheralNotConnected |
| 187 | 205 |
} |
| 188 | 206 |
|
| 207 |
+ func centralStateChanged(to newState: CBManagerState) {
|
|
| 208 |
+ switch newState {
|
|
| 209 |
+ case .poweredOn: |
|
| 210 |
+ if administrativeState == .up, |
|
| 211 |
+ operationalState == .peripheralNotConnected, |
|
| 212 |
+ peripheral.state == .disconnected {
|
|
| 213 |
+ track("Central returned to poweredOn. Restoring connection to '\(peripheral.identifier)'")
|
|
| 214 |
+ connect() |
|
| 215 |
+ } |
|
| 216 |
+ case .poweredOff, .resetting, .unauthorized, .unknown, .unsupported: |
|
| 217 |
+ if operationalState != .peripheralNotConnected || expectedResponseLength != 0 || !buffer.isEmpty {
|
|
| 218 |
+ track("Central changed to \(newState). Forcing '\(peripheral.identifier)' to not connected.")
|
|
| 219 |
+ } |
|
| 220 |
+ forceNotConnected(reason: "centralStateChanged(\(newState))") |
|
| 221 |
+ @unknown default: |
|
| 222 |
+ track("Central changed to an unknown state. Forcing '\(peripheral.identifier)' to not connected.")
|
|
| 223 |
+ forceNotConnected(reason: "centralStateChanged(@unknown default)") |
|
| 224 |
+ } |
|
| 225 |
+ } |
|
| 226 |
+ |
|
| 189 | 227 |
func setWDT() {
|
| 190 | 228 |
wdTimer?.invalidate() |
| 191 | 229 |
wdTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false, block: {_ in
|
@@ -3,6 +3,6 @@ |
||
| 3 | 3 |
<plist version="1.0"> |
| 4 | 4 |
<dict> |
| 5 | 5 |
<key>_XCCurrentVersionName</key> |
| 6 |
- <string>USB_Meter.xcdatamodel</string> |
|
| 6 |
+ <string>USB_Meter 16.xcdatamodel</string> |
|
| 7 | 7 |
</dict> |
| 8 | 8 |
</plist> |
@@ -0,0 +1,121 @@ |
||
| 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="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 8 |
+ <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/> |
|
| 9 |
+ <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 10 |
+ <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 11 |
+ <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 12 |
+ <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/> |
|
| 13 |
+ <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/> |
|
| 14 |
+ <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/> |
|
| 15 |
+ <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 16 |
+ <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 17 |
+ <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 18 |
+ <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 19 |
+ <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 20 |
+ <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 21 |
+ <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 22 |
+ <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 23 |
+ <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 24 |
+ <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/> |
|
| 25 |
+ <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 26 |
+ <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 27 |
+ <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 28 |
+ <attribute name="qrIdentifier" optional="YES" attributeType="String"/> |
|
| 29 |
+ <attribute name="lastAssociatedMeterMAC" 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="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 54 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 55 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 56 |
+ <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 57 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 58 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 59 |
+ <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 60 |
+ <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 61 |
+ <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 62 |
+ <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 63 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 64 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 65 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 66 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 67 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 68 |
+ <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 69 |
+ <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 70 |
+ <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 71 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 72 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 73 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 74 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 75 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 76 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 77 |
+ <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 78 |
+ <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 79 |
+ <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 80 |
+ <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 81 |
+ <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 82 |
+ <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 83 |
+ <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 84 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 85 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 86 |
+ </entity> |
|
| 87 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 88 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 89 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 90 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 91 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 92 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 93 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 94 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 95 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 96 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 97 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 98 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 99 |
+ </entity> |
|
| 100 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class"> |
|
| 101 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 102 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 103 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 104 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 105 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 106 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 107 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 108 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 109 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 110 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 111 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 112 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 113 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 114 |
+ </entity> |
|
| 115 |
+ <elements> |
|
| 116 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="418"/> |
|
| 117 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="838"/> |
|
| 118 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 119 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 120 |
+ </elements> |
|
| 121 |
+</model> |
|
@@ -0,0 +1,123 @@ |
||
| 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="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 8 |
+ <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/> |
|
| 9 |
+ <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 10 |
+ <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 11 |
+ <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 12 |
+ <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/> |
|
| 13 |
+ <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/> |
|
| 14 |
+ <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/> |
|
| 15 |
+ <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 16 |
+ <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 17 |
+ <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 18 |
+ <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 19 |
+ <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 20 |
+ <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 21 |
+ <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 22 |
+ <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 23 |
+ <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 24 |
+ <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/> |
|
| 25 |
+ <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 26 |
+ <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 27 |
+ <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 28 |
+ <attribute name="qrIdentifier" optional="YES" attributeType="String"/> |
|
| 29 |
+ <attribute name="lastAssociatedMeterMAC" 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="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 87 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 88 |
+ </entity> |
|
| 89 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 90 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 91 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 92 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 93 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 94 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 95 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 96 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 97 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 98 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 99 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 100 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 101 |
+ </entity> |
|
| 102 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class"> |
|
| 103 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 104 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 105 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 106 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 107 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 108 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 109 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 110 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 111 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 112 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 113 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 114 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 115 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 116 |
+ </entity> |
|
| 117 |
+ <elements> |
|
| 118 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="418"/> |
|
| 119 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="838"/> |
|
| 120 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 121 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 122 |
+ </elements> |
|
| 123 |
+</model> |
|
@@ -0,0 +1,124 @@ |
||
| 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="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 88 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 89 |
+ </entity> |
|
| 90 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 91 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 92 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 93 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 94 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 95 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 96 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 97 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 98 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 99 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 100 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 101 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 102 |
+ </entity> |
|
| 103 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class"> |
|
| 104 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 105 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 106 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 107 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 108 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 109 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 110 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 111 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 112 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 113 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 114 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 115 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 116 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 117 |
+ </entity> |
|
| 118 |
+ <elements> |
|
| 119 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/> |
|
| 120 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="838"/> |
|
| 121 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 122 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 123 |
+ </elements> |
|
| 124 |
+</model> |
|
@@ -0,0 +1,125 @@ |
||
| 1 |
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
|
| 2 |
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES"> |
|
| 3 |
+ <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class"> |
|
| 4 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 5 |
+ <attribute name="name" optional="YES" attributeType="String"/> |
|
| 6 |
+ <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/> |
|
| 7 |
+ <attribute name="deviceTemplateID" optional="YES" attributeType="String"/> |
|
| 8 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 9 |
+ <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/> |
|
| 10 |
+ <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 11 |
+ <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 12 |
+ <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 13 |
+ <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/> |
|
| 14 |
+ <attribute name="chargerTypeRawValue" optional="YES" attributeType="String"/> |
|
| 15 |
+ <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/> |
|
| 16 |
+ <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/> |
|
| 17 |
+ <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 18 |
+ <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 19 |
+ <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 20 |
+ <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 21 |
+ <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 22 |
+ <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 23 |
+ <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 24 |
+ <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 25 |
+ <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 26 |
+ <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/> |
|
| 27 |
+ <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 28 |
+ <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 29 |
+ <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 30 |
+ <attribute name="qrIdentifier" optional="YES" attributeType="String"/> |
|
| 31 |
+ <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/> |
|
| 32 |
+ <attribute name="notes" optional="YES" attributeType="String"/> |
|
| 33 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 34 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 35 |
+ </entity> |
|
| 36 |
+ <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class"> |
|
| 37 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 38 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 39 |
+ <attribute name="chargerID" optional="YES" attributeType="String"/> |
|
| 40 |
+ <attribute name="meterMACAddress" optional="YES" attributeType="String"/> |
|
| 41 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 42 |
+ <attribute name="meterModel" optional="YES" attributeType="String"/> |
|
| 43 |
+ <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 44 |
+ <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 45 |
+ <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 46 |
+ <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 47 |
+ <attribute name="statusRawValue" optional="YES" attributeType="String"/> |
|
| 48 |
+ <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/> |
|
| 49 |
+ <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 50 |
+ <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/> |
|
| 51 |
+ <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 52 |
+ <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 53 |
+ <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 54 |
+ <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 55 |
+ <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 56 |
+ <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 57 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 58 |
+ <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 59 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 60 |
+ <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 61 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 62 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 63 |
+ <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 64 |
+ <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 65 |
+ <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 66 |
+ <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 67 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 68 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 69 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 70 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 71 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 72 |
+ <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 73 |
+ <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 74 |
+ <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 75 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 76 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 77 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 78 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 79 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 80 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 81 |
+ <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 82 |
+ <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 83 |
+ <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 84 |
+ <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 85 |
+ <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 86 |
+ <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 87 |
+ <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 88 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 89 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 90 |
+ </entity> |
|
| 91 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 92 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 93 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 94 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 95 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 96 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 97 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 98 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 99 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 100 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 101 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 102 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 103 |
+ </entity> |
|
| 104 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class"> |
|
| 105 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 106 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 107 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 108 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 109 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 110 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 111 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 112 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 113 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 114 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 115 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 116 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 117 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 118 |
+ </entity> |
|
| 119 |
+ <elements> |
|
| 120 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="448"/> |
|
| 121 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="838"/> |
|
| 122 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 123 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 124 |
+ </elements> |
|
| 125 |
+</model> |
|
@@ -0,0 +1,124 @@ |
||
| 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="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 88 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 89 |
+ </entity> |
|
| 90 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 91 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 92 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 93 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 94 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 95 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 96 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 97 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 98 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 99 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 100 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 101 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 102 |
+ </entity> |
|
| 103 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class"> |
|
| 104 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 105 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 106 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 107 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 108 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 109 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 110 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 111 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 112 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 113 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 114 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 115 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 116 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 117 |
+ </entity> |
|
| 118 |
+ <elements> |
|
| 119 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/> |
|
| 120 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="838"/> |
|
| 121 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 122 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 123 |
+ </elements> |
|
| 124 |
+</model> |
|
@@ -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> |
|
@@ -0,0 +1,127 @@ |
||
| 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="wasConflictHealed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> |
|
| 90 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 91 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 92 |
+ </entity> |
|
| 93 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 94 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 95 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 96 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 97 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 98 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 99 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 100 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 101 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 102 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 103 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 104 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 105 |
+ </entity> |
|
| 106 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class"> |
|
| 107 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 108 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 109 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 110 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 111 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 112 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 113 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 114 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 115 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 116 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 117 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 118 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 119 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 120 |
+ </entity> |
|
| 121 |
+ <elements> |
|
| 122 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/> |
|
| 123 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="883"/> |
|
| 124 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 125 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 126 |
+ </elements> |
|
| 127 |
+</model> |
|
@@ -0,0 +1,67 @@ |
||
| 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="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 8 |
+ <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 9 |
+ <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 10 |
+ <attribute name="qrIdentifier" optional="YES" attributeType="String"/> |
|
| 11 |
+ <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/> |
|
| 12 |
+ <attribute name="notes" optional="YES" attributeType="String"/> |
|
| 13 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 14 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 15 |
+ </entity> |
|
| 16 |
+ <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class"> |
|
| 17 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 18 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 19 |
+ <attribute name="meterMACAddress" optional="YES" attributeType="String"/> |
|
| 20 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 21 |
+ <attribute name="meterModel" optional="YES" attributeType="String"/> |
|
| 22 |
+ <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 23 |
+ <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 24 |
+ <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 25 |
+ <attribute name="statusRawValue" optional="YES" attributeType="String"/> |
|
| 26 |
+ <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/> |
|
| 27 |
+ <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 28 |
+ <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 29 |
+ <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 30 |
+ <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 31 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 32 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 33 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 34 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 35 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 36 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 37 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 38 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 39 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 40 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 41 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 42 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 43 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 44 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 45 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 46 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 47 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 48 |
+ </entity> |
|
| 49 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 50 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 51 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 52 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 53 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 54 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 55 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 56 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 57 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 58 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 59 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 60 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 61 |
+ </entity> |
|
| 62 |
+ <elements> |
|
| 63 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="193"/> |
|
| 64 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="478"/> |
|
| 65 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 66 |
+ </elements> |
|
| 67 |
+</model> |
|
@@ -0,0 +1,75 @@ |
||
| 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="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 8 |
+ <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 9 |
+ <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 10 |
+ <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 11 |
+ <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 12 |
+ <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 13 |
+ <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 14 |
+ <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 15 |
+ <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 16 |
+ <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 17 |
+ <attribute name="qrIdentifier" optional="YES" attributeType="String"/> |
|
| 18 |
+ <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/> |
|
| 19 |
+ <attribute name="notes" optional="YES" attributeType="String"/> |
|
| 20 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 21 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 22 |
+ </entity> |
|
| 23 |
+ <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class"> |
|
| 24 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 25 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 26 |
+ <attribute name="meterMACAddress" optional="YES" attributeType="String"/> |
|
| 27 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 28 |
+ <attribute name="meterModel" optional="YES" attributeType="String"/> |
|
| 29 |
+ <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 30 |
+ <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 31 |
+ <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 32 |
+ <attribute name="statusRawValue" optional="YES" attributeType="String"/> |
|
| 33 |
+ <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/> |
|
| 34 |
+ <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 35 |
+ <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 36 |
+ <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 37 |
+ <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 38 |
+ <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 39 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 40 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 41 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 42 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 43 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 44 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 45 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 46 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 47 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 48 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 49 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 50 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 51 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 52 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 53 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 54 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 55 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 56 |
+ </entity> |
|
| 57 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 58 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 59 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 60 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 61 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 62 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 63 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 64 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 65 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 66 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 67 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 68 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 69 |
+ </entity> |
|
| 70 |
+ <elements> |
|
| 71 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="268"/> |
|
| 72 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="493"/> |
|
| 73 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 74 |
+ </elements> |
|
| 75 |
+</model> |
|
@@ -0,0 +1,91 @@ |
||
| 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="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 8 |
+ <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 9 |
+ <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 10 |
+ <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 11 |
+ <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 12 |
+ <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 13 |
+ <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 14 |
+ <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 15 |
+ <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 16 |
+ <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 17 |
+ <attribute name="qrIdentifier" optional="YES" attributeType="String"/> |
|
| 18 |
+ <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/> |
|
| 19 |
+ <attribute name="notes" optional="YES" attributeType="String"/> |
|
| 20 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 21 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 22 |
+ </entity> |
|
| 23 |
+ <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class"> |
|
| 24 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 25 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 26 |
+ <attribute name="meterMACAddress" optional="YES" attributeType="String"/> |
|
| 27 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 28 |
+ <attribute name="meterModel" optional="YES" attributeType="String"/> |
|
| 29 |
+ <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 30 |
+ <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 31 |
+ <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 32 |
+ <attribute name="statusRawValue" optional="YES" attributeType="String"/> |
|
| 33 |
+ <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/> |
|
| 34 |
+ <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 35 |
+ <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 36 |
+ <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 37 |
+ <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 38 |
+ <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 39 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 40 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 41 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 42 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 43 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 44 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 45 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 46 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 47 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 48 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 49 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 50 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 51 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 52 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 53 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 54 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 55 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 56 |
+ </entity> |
|
| 57 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 58 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 59 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 60 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 61 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 62 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 63 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 64 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 65 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 66 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 67 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 68 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 69 |
+ </entity> |
|
| 70 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class"> |
|
| 71 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 72 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 73 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 74 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 75 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 76 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 77 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 78 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 79 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 80 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 81 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 82 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 83 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 84 |
+ </entity> |
|
| 85 |
+ <elements> |
|
| 86 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="268"/> |
|
| 87 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="493"/> |
|
| 88 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 89 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 90 |
+ </elements> |
|
| 91 |
+</model> |
|
@@ -0,0 +1,97 @@ |
||
| 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="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 8 |
+ <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 9 |
+ <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 10 |
+ <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 11 |
+ <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 12 |
+ <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 13 |
+ <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 14 |
+ <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 15 |
+ <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 16 |
+ <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 17 |
+ <attribute name="qrIdentifier" optional="YES" attributeType="String"/> |
|
| 18 |
+ <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/> |
|
| 19 |
+ <attribute name="notes" optional="YES" attributeType="String"/> |
|
| 20 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 21 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 22 |
+ </entity> |
|
| 23 |
+ <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class"> |
|
| 24 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 25 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 26 |
+ <attribute name="meterMACAddress" optional="YES" attributeType="String"/> |
|
| 27 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 28 |
+ <attribute name="meterModel" optional="YES" attributeType="String"/> |
|
| 29 |
+ <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 30 |
+ <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 31 |
+ <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 32 |
+ <attribute name="statusRawValue" optional="YES" attributeType="String"/> |
|
| 33 |
+ <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/> |
|
| 34 |
+ <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 35 |
+ <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 36 |
+ <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 37 |
+ <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 38 |
+ <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 39 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 40 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 41 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 42 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 43 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 44 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 45 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 46 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 47 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 48 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 49 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 50 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 51 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 52 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 53 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 54 |
+ <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 55 |
+ <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 56 |
+ <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 57 |
+ <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 58 |
+ <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 59 |
+ <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 60 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 61 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 62 |
+ </entity> |
|
| 63 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 64 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 65 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 66 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 67 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 68 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 69 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 70 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 71 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 72 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 73 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 74 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 75 |
+ </entity> |
|
| 76 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class"> |
|
| 77 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 78 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 79 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 80 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 81 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 82 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 83 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 84 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 85 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 86 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 87 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 88 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 89 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 90 |
+ </entity> |
|
| 91 |
+ <elements> |
|
| 92 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="268"/> |
|
| 93 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="583"/> |
|
| 94 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 95 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 96 |
+ </elements> |
|
| 97 |
+</model> |
|
@@ -0,0 +1,106 @@ |
||
| 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="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 8 |
+ <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 9 |
+ <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 10 |
+ <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 11 |
+ <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/> |
|
| 12 |
+ <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 13 |
+ <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 14 |
+ <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 15 |
+ <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 16 |
+ <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 17 |
+ <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 18 |
+ <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 19 |
+ <attribute name="qrIdentifier" optional="YES" attributeType="String"/> |
|
| 20 |
+ <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/> |
|
| 21 |
+ <attribute name="notes" optional="YES" attributeType="String"/> |
|
| 22 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 23 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 24 |
+ </entity> |
|
| 25 |
+ <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class"> |
|
| 26 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 27 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 28 |
+ <attribute name="meterMACAddress" optional="YES" attributeType="String"/> |
|
| 29 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 30 |
+ <attribute name="meterModel" optional="YES" attributeType="String"/> |
|
| 31 |
+ <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 32 |
+ <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 33 |
+ <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 34 |
+ <attribute name="statusRawValue" optional="YES" attributeType="String"/> |
|
| 35 |
+ <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/> |
|
| 36 |
+ <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 37 |
+ <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 38 |
+ <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 39 |
+ <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 40 |
+ <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 41 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 42 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 43 |
+ <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 44 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 45 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 46 |
+ <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 47 |
+ <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 48 |
+ <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 49 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 50 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 51 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 52 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 53 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 54 |
+ <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 55 |
+ <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 56 |
+ <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 57 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 58 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 59 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 60 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 61 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 62 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 63 |
+ <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 64 |
+ <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 65 |
+ <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 66 |
+ <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 67 |
+ <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 68 |
+ <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 69 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 70 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 71 |
+ </entity> |
|
| 72 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 73 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 74 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 75 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 76 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 77 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 78 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 79 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 80 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 81 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 82 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 83 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 84 |
+ </entity> |
|
| 85 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class"> |
|
| 86 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 87 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 88 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 89 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 90 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 91 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 92 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 93 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 94 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 95 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 96 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 97 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 98 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 99 |
+ </entity> |
|
| 100 |
+ <elements> |
|
| 101 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="298"/> |
|
| 102 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="718"/> |
|
| 103 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 104 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 105 |
+ </elements> |
|
| 106 |
+</model> |
|
@@ -0,0 +1,114 @@ |
||
| 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="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 8 |
+ <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 9 |
+ <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 10 |
+ <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 11 |
+ <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/> |
|
| 12 |
+ <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 13 |
+ <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 14 |
+ <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 15 |
+ <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 16 |
+ <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 17 |
+ <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 18 |
+ <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 19 |
+ <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 20 |
+ <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 21 |
+ <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/> |
|
| 22 |
+ <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 23 |
+ <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 24 |
+ <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 25 |
+ <attribute name="qrIdentifier" optional="YES" attributeType="String"/> |
|
| 26 |
+ <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/> |
|
| 27 |
+ <attribute name="notes" optional="YES" attributeType="String"/> |
|
| 28 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 29 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 30 |
+ </entity> |
|
| 31 |
+ <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class"> |
|
| 32 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 33 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 34 |
+ <attribute name="chargerID" optional="YES" attributeType="String"/> |
|
| 35 |
+ <attribute name="meterMACAddress" optional="YES" attributeType="String"/> |
|
| 36 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 37 |
+ <attribute name="meterModel" optional="YES" attributeType="String"/> |
|
| 38 |
+ <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 39 |
+ <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 40 |
+ <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 41 |
+ <attribute name="statusRawValue" optional="YES" attributeType="String"/> |
|
| 42 |
+ <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/> |
|
| 43 |
+ <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 44 |
+ <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 45 |
+ <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 46 |
+ <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 47 |
+ <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 48 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 49 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 50 |
+ <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 51 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 52 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 53 |
+ <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 54 |
+ <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 55 |
+ <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 56 |
+ <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 57 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 58 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 59 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 60 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 61 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 62 |
+ <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 63 |
+ <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 64 |
+ <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 65 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 66 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 67 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 68 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 69 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 70 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 71 |
+ <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 72 |
+ <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 73 |
+ <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 74 |
+ <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 75 |
+ <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 76 |
+ <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 77 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 78 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 79 |
+ </entity> |
|
| 80 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 81 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 82 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 83 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 84 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 85 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 86 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 87 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 88 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 89 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 90 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 91 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 92 |
+ </entity> |
|
| 93 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class"> |
|
| 94 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 95 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 96 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 97 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 98 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 99 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 100 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 101 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 102 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 103 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 104 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 105 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 106 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 107 |
+ </entity> |
|
| 108 |
+ <elements> |
|
| 109 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="373"/> |
|
| 110 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="748"/> |
|
| 111 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 112 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 113 |
+ </elements> |
|
| 114 |
+</model> |
|
@@ -1,7 +0,0 @@ |
||
| 1 |
-<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
|
| 2 |
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="16119" systemVersion="19E287" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier=""> |
|
| 3 |
- <entity name="Entity" representedClassName="Entity" syncable="YES" codeGenerationType="class"/> |
|
| 4 |
- <elements> |
|
| 5 |
- <element name="Entity" positionX="-63" positionY="-18" width="128" height="43"/> |
|
| 6 |
- </elements> |
|
| 7 |
-</model> |
|
@@ -0,0 +1,1595 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargeInsightsModel.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 10/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import Foundation |
|
| 9 |
+ |
|
| 10 |
+enum ChargedDeviceKind: String, Identifiable, Codable {
|
|
| 11 |
+ case device |
|
| 12 |
+ case charger |
|
| 13 |
+ |
|
| 14 |
+ var id: String { rawValue }
|
|
| 15 |
+ |
|
| 16 |
+ var title: String {
|
|
| 17 |
+ switch self {
|
|
| 18 |
+ case .device: |
|
| 19 |
+ return "Device" |
|
| 20 |
+ case .charger: |
|
| 21 |
+ return "Charger" |
|
| 22 |
+ } |
|
| 23 |
+ } |
|
| 24 |
+ |
|
| 25 |
+ var pluralTitle: String {
|
|
| 26 |
+ switch self {
|
|
| 27 |
+ case .device: |
|
| 28 |
+ return "Devices" |
|
| 29 |
+ case .charger: |
|
| 30 |
+ return "Chargers" |
|
| 31 |
+ } |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 34 |
+ var symbolName: String {
|
|
| 35 |
+ switch self {
|
|
| 36 |
+ case .device: |
|
| 37 |
+ return "iphone" |
|
| 38 |
+ case .charger: |
|
| 39 |
+ return "bolt.horizontal.circle" |
|
| 40 |
+ } |
|
| 41 |
+ } |
|
| 42 |
+} |
|
| 43 |
+ |
|
| 44 |
+enum ChargedDeviceClass: String, CaseIterable, Identifiable, Codable {
|
|
| 45 |
+ case iphone |
|
| 46 |
+ case watch |
|
| 47 |
+ case powerbank |
|
| 48 |
+ case charger |
|
| 49 |
+ case other |
|
| 50 |
+ |
|
| 51 |
+ var id: String { rawValue }
|
|
| 52 |
+ |
|
| 53 |
+ static var deviceCases: [ChargedDeviceClass] {
|
|
| 54 |
+ allCases.filter { $0 != .charger }
|
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 57 |
+ var kind: ChargedDeviceKind {
|
|
| 58 |
+ self == .charger ? .charger : .device |
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 61 |
+ var title: String {
|
|
| 62 |
+ switch self {
|
|
| 63 |
+ case .iphone: |
|
| 64 |
+ return "iPhone" |
|
| 65 |
+ case .watch: |
|
| 66 |
+ return "Watch" |
|
| 67 |
+ case .powerbank: |
|
| 68 |
+ return "Powerbank" |
|
| 69 |
+ case .charger: |
|
| 70 |
+ return "Charger" |
|
| 71 |
+ case .other: |
|
| 72 |
+ return "Other" |
|
| 73 |
+ } |
|
| 74 |
+ } |
|
| 75 |
+ |
|
| 76 |
+ var symbolName: String {
|
|
| 77 |
+ switch self {
|
|
| 78 |
+ case .iphone: |
|
| 79 |
+ return "iphone" |
|
| 80 |
+ case .watch: |
|
| 81 |
+ return "applewatch" |
|
| 82 |
+ case .powerbank: |
|
| 83 |
+ return "battery.100.bolt" |
|
| 84 |
+ case .charger: |
|
| 85 |
+ return "bolt.badge.clock" |
|
| 86 |
+ case .other: |
|
| 87 |
+ return "shippingbox" |
|
| 88 |
+ } |
|
| 89 |
+ } |
|
| 90 |
+ |
|
| 91 |
+ var enforcedChargingSupport: (wired: Bool, wireless: Bool)? {
|
|
| 92 |
+ switch self {
|
|
| 93 |
+ case .watch: |
|
| 94 |
+ return (wired: false, wireless: true) |
|
| 95 |
+ case .powerbank: |
|
| 96 |
+ return (wired: true, wireless: false) |
|
| 97 |
+ case .charger: |
|
| 98 |
+ return (wired: false, wireless: true) |
|
| 99 |
+ case .iphone, .other: |
|
| 100 |
+ return nil |
|
| 101 |
+ } |
|
| 102 |
+ } |
|
| 103 |
+ |
|
| 104 |
+ var enforcedChargingStateAvailability: ChargingStateAvailability? {
|
|
| 105 |
+ switch self {
|
|
| 106 |
+ case .watch: |
|
| 107 |
+ return .onOnly |
|
| 108 |
+ case .powerbank: |
|
| 109 |
+ return .offOnly |
|
| 110 |
+ case .charger: |
|
| 111 |
+ return .onOnly |
|
| 112 |
+ case .iphone, .other: |
|
| 113 |
+ return nil |
|
| 114 |
+ } |
|
| 115 |
+ } |
|
| 116 |
+ |
|
| 117 |
+ var defaultChargingSupport: (wired: Bool, wireless: Bool) {
|
|
| 118 |
+ if let enforcedChargingSupport {
|
|
| 119 |
+ return enforcedChargingSupport |
|
| 120 |
+ } |
|
| 121 |
+ |
|
| 122 |
+ switch self {
|
|
| 123 |
+ case .iphone: |
|
| 124 |
+ return (wired: true, wireless: true) |
|
| 125 |
+ case .watch: |
|
| 126 |
+ return (wired: false, wireless: true) |
|
| 127 |
+ case .powerbank: |
|
| 128 |
+ return (wired: true, wireless: false) |
|
| 129 |
+ case .charger: |
|
| 130 |
+ return (wired: false, wireless: true) |
|
| 131 |
+ case .other: |
|
| 132 |
+ return (wired: true, wireless: false) |
|
| 133 |
+ } |
|
| 134 |
+ } |
|
| 135 |
+ |
|
| 136 |
+ var defaultChargingStateAvailability: ChargingStateAvailability {
|
|
| 137 |
+ enforcedChargingStateAvailability ?? {
|
|
| 138 |
+ switch self {
|
|
| 139 |
+ case .iphone: |
|
| 140 |
+ return .onOrOff |
|
| 141 |
+ case .watch: |
|
| 142 |
+ return .onOnly |
|
| 143 |
+ case .powerbank: |
|
| 144 |
+ return .offOnly |
|
| 145 |
+ case .charger, .other: |
|
| 146 |
+ return .onOrOff |
|
| 147 |
+ } |
|
| 148 |
+ }() |
|
| 149 |
+ } |
|
| 150 |
+ |
|
| 151 |
+ func normalizedChargingSupport( |
|
| 152 |
+ supportsWiredCharging: Bool, |
|
| 153 |
+ supportsWirelessCharging: Bool |
|
| 154 |
+ ) -> (wired: Bool, wireless: Bool) {
|
|
| 155 |
+ enforcedChargingSupport ?? (wired: supportsWiredCharging, wireless: supportsWirelessCharging) |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ func normalizedChargingStateAvailability( |
|
| 159 |
+ _ chargingStateAvailability: ChargingStateAvailability |
|
| 160 |
+ ) -> ChargingStateAvailability {
|
|
| 161 |
+ enforcedChargingStateAvailability ?? chargingStateAvailability |
|
| 162 |
+ } |
|
| 163 |
+} |
|
| 164 |
+ |
|
| 165 |
+enum ChargeSessionStatus: String {
|
|
| 166 |
+ case active |
|
| 167 |
+ case paused |
|
| 168 |
+ case completed |
|
| 169 |
+ case abandoned |
|
| 170 |
+ |
|
| 171 |
+ var title: String {
|
|
| 172 |
+ switch self {
|
|
| 173 |
+ case .active: |
|
| 174 |
+ return "Active" |
|
| 175 |
+ case .paused: |
|
| 176 |
+ return "Paused" |
|
| 177 |
+ case .completed: |
|
| 178 |
+ return "Completed" |
|
| 179 |
+ case .abandoned: |
|
| 180 |
+ return "Abandoned" |
|
| 181 |
+ } |
|
| 182 |
+ } |
|
| 183 |
+ |
|
| 184 |
+ var isOpen: Bool {
|
|
| 185 |
+ switch self {
|
|
| 186 |
+ case .active, .paused: |
|
| 187 |
+ return true |
|
| 188 |
+ case .completed, .abandoned: |
|
| 189 |
+ return false |
|
| 190 |
+ } |
|
| 191 |
+ } |
|
| 192 |
+} |
|
| 193 |
+ |
|
| 194 |
+enum ChargeSessionSourceMode: String {
|
|
| 195 |
+ case live |
|
| 196 |
+ case offline |
|
| 197 |
+ case blended |
|
| 198 |
+ |
|
| 199 |
+ var title: String {
|
|
| 200 |
+ switch self {
|
|
| 201 |
+ case .live: |
|
| 202 |
+ return "Live" |
|
| 203 |
+ case .offline: |
|
| 204 |
+ return "Offline Counters" |
|
| 205 |
+ case .blended: |
|
| 206 |
+ return "Blended" |
|
| 207 |
+ } |
|
| 208 |
+ } |
|
| 209 |
+} |
|
| 210 |
+ |
|
| 211 |
+enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
|
|
| 212 |
+ case wired |
|
| 213 |
+ case wireless |
|
| 214 |
+ |
|
| 215 |
+ var id: String { rawValue }
|
|
| 216 |
+ |
|
| 217 |
+ var title: String {
|
|
| 218 |
+ switch self {
|
|
| 219 |
+ case .wired: |
|
| 220 |
+ return "Wired" |
|
| 221 |
+ case .wireless: |
|
| 222 |
+ return "Wireless" |
|
| 223 |
+ } |
|
| 224 |
+ } |
|
| 225 |
+ |
|
| 226 |
+ var symbolName: String {
|
|
| 227 |
+ switch self {
|
|
| 228 |
+ case .wired: |
|
| 229 |
+ return "cable.connector" |
|
| 230 |
+ case .wireless: |
|
| 231 |
+ return "dot.radiowaves.left.and.right" |
|
| 232 |
+ } |
|
| 233 |
+ } |
|
| 234 |
+} |
|
| 235 |
+ |
|
| 236 |
+enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
|
|
| 237 |
+ case on |
|
| 238 |
+ case off |
|
| 239 |
+ |
|
| 240 |
+ var id: String { rawValue }
|
|
| 241 |
+ |
|
| 242 |
+ var title: String {
|
|
| 243 |
+ switch self {
|
|
| 244 |
+ case .on: |
|
| 245 |
+ return "On" |
|
| 246 |
+ case .off: |
|
| 247 |
+ return "Off" |
|
| 248 |
+ } |
|
| 249 |
+ } |
|
| 250 |
+ |
|
| 251 |
+ var description: String {
|
|
| 252 |
+ switch self {
|
|
| 253 |
+ case .on: |
|
| 254 |
+ return "Device stays powered on while charging." |
|
| 255 |
+ case .off: |
|
| 256 |
+ return "Device is powered off while charging." |
|
| 257 |
+ } |
|
| 258 |
+ } |
|
| 259 |
+} |
|
| 260 |
+ |
|
| 261 |
+enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
|
|
| 262 |
+ case onOnly |
|
| 263 |
+ case onOrOff |
|
| 264 |
+ case offOnly |
|
| 265 |
+ |
|
| 266 |
+ var id: String { rawValue }
|
|
| 267 |
+ |
|
| 268 |
+ var title: String {
|
|
| 269 |
+ switch self {
|
|
| 270 |
+ case .onOnly: |
|
| 271 |
+ return "On Only" |
|
| 272 |
+ case .onOrOff: |
|
| 273 |
+ return "On or Off" |
|
| 274 |
+ case .offOnly: |
|
| 275 |
+ return "Off Only" |
|
| 276 |
+ } |
|
| 277 |
+ } |
|
| 278 |
+ |
|
| 279 |
+ var description: String {
|
|
| 280 |
+ switch self {
|
|
| 281 |
+ case .onOnly: |
|
| 282 |
+ return "The device can be recorded only while it is powered on." |
|
| 283 |
+ case .onOrOff: |
|
| 284 |
+ return "The session must specify whether the device is on or off." |
|
| 285 |
+ case .offOnly: |
|
| 286 |
+ return "The device can be recorded only while it is powered off." |
|
| 287 |
+ } |
|
| 288 |
+ } |
|
| 289 |
+ |
|
| 290 |
+ var supportedModes: [ChargingStateMode] {
|
|
| 291 |
+ switch self {
|
|
| 292 |
+ case .onOnly: |
|
| 293 |
+ return [.on] |
|
| 294 |
+ case .onOrOff: |
|
| 295 |
+ return [.on, .off] |
|
| 296 |
+ case .offOnly: |
|
| 297 |
+ return [.off] |
|
| 298 |
+ } |
|
| 299 |
+ } |
|
| 300 |
+ |
|
| 301 |
+ var supportsMultipleModes: Bool {
|
|
| 302 |
+ supportedModes.count > 1 |
|
| 303 |
+ } |
|
| 304 |
+ |
|
| 305 |
+ var supportsChargingWhileOff: Bool {
|
|
| 306 |
+ self != .onOnly |
|
| 307 |
+ } |
|
| 308 |
+ |
|
| 309 |
+ static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
|
|
| 310 |
+ supportsChargingWhileOff ? .onOrOff : .onOnly |
|
| 311 |
+ } |
|
| 312 |
+} |
|
| 313 |
+ |
|
| 314 |
+enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
|
|
| 315 |
+ case wiredOn |
|
| 316 |
+ case wiredOff |
|
| 317 |
+ case wirelessOn |
|
| 318 |
+ case wirelessOff |
|
| 319 |
+ |
|
| 320 |
+ var id: String { rawValue }
|
|
| 321 |
+ |
|
| 322 |
+ init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
|
|
| 323 |
+ switch (chargingTransportMode, chargingStateMode) {
|
|
| 324 |
+ case (.wired, .on): |
|
| 325 |
+ self = .wiredOn |
|
| 326 |
+ case (.wired, .off): |
|
| 327 |
+ self = .wiredOff |
|
| 328 |
+ case (.wireless, .on): |
|
| 329 |
+ self = .wirelessOn |
|
| 330 |
+ case (.wireless, .off): |
|
| 331 |
+ self = .wirelessOff |
|
| 332 |
+ } |
|
| 333 |
+ } |
|
| 334 |
+ |
|
| 335 |
+ var chargingTransportMode: ChargingTransportMode {
|
|
| 336 |
+ switch self {
|
|
| 337 |
+ case .wiredOn, .wiredOff: |
|
| 338 |
+ return .wired |
|
| 339 |
+ case .wirelessOn, .wirelessOff: |
|
| 340 |
+ return .wireless |
|
| 341 |
+ } |
|
| 342 |
+ } |
|
| 343 |
+ |
|
| 344 |
+ var chargingStateMode: ChargingStateMode {
|
|
| 345 |
+ switch self {
|
|
| 346 |
+ case .wiredOn, .wirelessOn: |
|
| 347 |
+ return .on |
|
| 348 |
+ case .wiredOff, .wirelessOff: |
|
| 349 |
+ return .off |
|
| 350 |
+ } |
|
| 351 |
+ } |
|
| 352 |
+ |
|
| 353 |
+ var title: String {
|
|
| 354 |
+ "\(chargingTransportMode.title) • \(chargingStateMode.title)" |
|
| 355 |
+ } |
|
| 356 |
+ |
|
| 357 |
+ var shortTitle: String {
|
|
| 358 |
+ "\(chargingTransportMode.title) \(chargingStateMode.title)" |
|
| 359 |
+ } |
|
| 360 |
+} |
|
| 361 |
+ |
|
| 362 |
+enum WirelessChargingProfile: String, CaseIterable, Identifiable, Codable {
|
|
| 363 |
+ case magsafe |
|
| 364 |
+ case genericQi |
|
| 365 |
+ |
|
| 366 |
+ var id: String { rawValue }
|
|
| 367 |
+ |
|
| 368 |
+ var title: String {
|
|
| 369 |
+ switch self {
|
|
| 370 |
+ case .magsafe: |
|
| 371 |
+ return "MagSafe" |
|
| 372 |
+ case .genericQi: |
|
| 373 |
+ return "Generic Qi" |
|
| 374 |
+ } |
|
| 375 |
+ } |
|
| 376 |
+ |
|
| 377 |
+ var description: String {
|
|
| 378 |
+ switch self {
|
|
| 379 |
+ case .magsafe: |
|
| 380 |
+ return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity." |
|
| 381 |
+ case .genericQi: |
|
| 382 |
+ return "Use only automatic efficiency estimates and show a low-efficiency warning when needed." |
|
| 383 |
+ } |
|
| 384 |
+ } |
|
| 385 |
+} |
|
| 386 |
+ |
|
| 387 |
+enum ChargerType: String, CaseIterable, Identifiable, Codable {
|
|
| 388 |
+ case appleMagSafe |
|
| 389 |
+ case appleWatch |
|
| 390 |
+ case genericMagSafe |
|
| 391 |
+ case genericQi |
|
| 392 |
+ |
|
| 393 |
+ var id: String { rawValue }
|
|
| 394 |
+ |
|
| 395 |
+ var title: String {
|
|
| 396 |
+ switch self {
|
|
| 397 |
+ case .appleMagSafe: return "Apple MagSafe Charger" |
|
| 398 |
+ case .appleWatch: return "Apple Watch Charger" |
|
| 399 |
+ case .genericMagSafe: return "Generic MagSafe" |
|
| 400 |
+ case .genericQi: return "Generic Qi" |
|
| 401 |
+ } |
|
| 402 |
+ } |
|
| 403 |
+ |
|
| 404 |
+ var symbolName: String {
|
|
| 405 |
+ switch self {
|
|
| 406 |
+ case .appleMagSafe: return "magsafe.batterypack" |
|
| 407 |
+ case .appleWatch: return "applewatch.radiowaves.left.and.right" |
|
| 408 |
+ case .genericMagSafe: return "bolt.circle" |
|
| 409 |
+ case .genericQi: return "bolt.horizontal.circle" |
|
| 410 |
+ } |
|
| 411 |
+ } |
|
| 412 |
+ |
|
| 413 |
+ /// Whether this charger type uses magnetic alignment, enabling more accurate efficiency calibration. |
|
| 414 |
+ var supportsAlignment: Bool {
|
|
| 415 |
+ switch self {
|
|
| 416 |
+ case .appleMagSafe, .appleWatch, .genericMagSafe: return true |
|
| 417 |
+ case .genericQi: return false |
|
| 418 |
+ } |
|
| 419 |
+ } |
|
| 420 |
+ |
|
| 421 |
+ var wirelessChargingProfile: WirelessChargingProfile {
|
|
| 422 |
+ supportsAlignment ? .magsafe : .genericQi |
|
| 423 |
+ } |
|
| 424 |
+} |
|
| 425 |
+ |
|
| 426 |
+enum ChargedDeviceTemplateIconSource: String, Codable {
|
|
| 427 |
+ case systemSymbol |
|
| 428 |
+ case asset |
|
| 429 |
+} |
|
| 430 |
+ |
|
| 431 |
+struct ChargedDeviceTemplateIcon: Hashable, Codable {
|
|
| 432 |
+ let type: ChargedDeviceTemplateIconSource |
|
| 433 |
+ let name: String |
|
| 434 |
+ let fallbackSystemName: String? |
|
| 435 |
+ |
|
| 436 |
+ static func systemSymbol( |
|
| 437 |
+ _ name: String, |
|
| 438 |
+ fallbackSystemName: String? = nil |
|
| 439 |
+ ) -> ChargedDeviceTemplateIcon {
|
|
| 440 |
+ ChargedDeviceTemplateIcon( |
|
| 441 |
+ type: .systemSymbol, |
|
| 442 |
+ name: name, |
|
| 443 |
+ fallbackSystemName: fallbackSystemName |
|
| 444 |
+ ) |
|
| 445 |
+ } |
|
| 446 |
+ |
|
| 447 |
+ func resolvedSystemSymbolName(fallbackSystemName: String) -> String {
|
|
| 448 |
+ switch type {
|
|
| 449 |
+ case .systemSymbol: |
|
| 450 |
+ return name |
|
| 451 |
+ case .asset: |
|
| 452 |
+ return self.fallbackSystemName ?? fallbackSystemName |
|
| 453 |
+ } |
|
| 454 |
+ } |
|
| 455 |
+} |
|
| 456 |
+ |
|
| 457 |
+struct ChargedDeviceTemplateDefinition: Identifiable, Hashable, Codable {
|
|
| 458 |
+ let id: String |
|
| 459 |
+ let name: String |
|
| 460 |
+ let group: String |
|
| 461 |
+ let kind: ChargedDeviceKind |
|
| 462 |
+ let deviceClass: ChargedDeviceClass |
|
| 463 |
+ let icon: ChargedDeviceTemplateIcon |
|
| 464 |
+ let chargingStateAvailability: ChargingStateAvailability |
|
| 465 |
+ let supportsWiredCharging: Bool |
|
| 466 |
+ let supportsWirelessCharging: Bool |
|
| 467 |
+ let wirelessChargingProfile: WirelessChargingProfile |
|
| 468 |
+ let sortOrder: Int |
|
| 469 |
+ |
|
| 470 |
+ var chargingSupportSummary: String {
|
|
| 471 |
+ switch (supportsWiredCharging, supportsWirelessCharging) {
|
|
| 472 |
+ case (true, true): |
|
| 473 |
+ return "Wired + Wireless" |
|
| 474 |
+ case (true, false): |
|
| 475 |
+ return "Wired only" |
|
| 476 |
+ case (false, true): |
|
| 477 |
+ return "Wireless only" |
|
| 478 |
+ case (false, false): |
|
| 479 |
+ return "No charging transport" |
|
| 480 |
+ } |
|
| 481 |
+ } |
|
| 482 |
+ |
|
| 483 |
+ var capabilitySummary: String {
|
|
| 484 |
+ if kind == .charger {
|
|
| 485 |
+ return wirelessChargingProfile.title |
|
| 486 |
+ } |
|
| 487 |
+ var components = [chargingStateAvailability.title, chargingSupportSummary] |
|
| 488 |
+ if supportsWirelessCharging {
|
|
| 489 |
+ components.append(wirelessChargingProfile.title) |
|
| 490 |
+ } |
|
| 491 |
+ return components.joined(separator: " • ") |
|
| 492 |
+ } |
|
| 493 |
+} |
|
| 494 |
+ |
|
| 495 |
+private struct ChargedDeviceTemplateDocument: Codable {
|
|
| 496 |
+ let templates: [ChargedDeviceTemplateDefinition] |
|
| 497 |
+} |
|
| 498 |
+ |
|
| 499 |
+struct ChargedDeviceTemplateCatalog {
|
|
| 500 |
+ static let shared = ChargedDeviceTemplateCatalog() |
|
| 501 |
+ |
|
| 502 |
+ let templates: [ChargedDeviceTemplateDefinition] |
|
| 503 |
+ private let templatesByID: [String: ChargedDeviceTemplateDefinition] |
|
| 504 |
+ |
|
| 505 |
+ private init(bundle: Bundle = .main) {
|
|
| 506 |
+ let loadedTemplates: [ChargedDeviceTemplateDefinition] |
|
| 507 |
+ |
|
| 508 |
+ if let resourceURL = bundle.url(forResource: "ChargedDeviceTemplates", withExtension: "json"), |
|
| 509 |
+ let data = try? Data(contentsOf: resourceURL), |
|
| 510 |
+ let document = try? JSONDecoder().decode(ChargedDeviceTemplateDocument.self, from: data) {
|
|
| 511 |
+ loadedTemplates = document.templates |
|
| 512 |
+ } else {
|
|
| 513 |
+ loadedTemplates = [] |
|
| 514 |
+ } |
|
| 515 |
+ |
|
| 516 |
+ self.templates = loadedTemplates.sorted { lhs, rhs in
|
|
| 517 |
+ if lhs.group != rhs.group {
|
|
| 518 |
+ return lhs.group < rhs.group |
|
| 519 |
+ } |
|
| 520 |
+ if lhs.sortOrder != rhs.sortOrder {
|
|
| 521 |
+ return lhs.sortOrder < rhs.sortOrder |
|
| 522 |
+ } |
|
| 523 |
+ return lhs.name < rhs.name |
|
| 524 |
+ } |
|
| 525 |
+ self.templatesByID = Dictionary(uniqueKeysWithValues: self.templates.map { ($0.id, $0) })
|
|
| 526 |
+ } |
|
| 527 |
+ |
|
| 528 |
+ func template(id: String?) -> ChargedDeviceTemplateDefinition? {
|
|
| 529 |
+ guard let id else {
|
|
| 530 |
+ return nil |
|
| 531 |
+ } |
|
| 532 |
+ return templatesByID[id] |
|
| 533 |
+ } |
|
| 534 |
+ |
|
| 535 |
+ func templates(for kind: ChargedDeviceKind) -> [ChargedDeviceTemplateDefinition] {
|
|
| 536 |
+ templates.filter { $0.kind == kind }
|
|
| 537 |
+ } |
|
| 538 |
+} |
|
| 539 |
+ |
|
| 540 |
+struct ChargeCheckpointSummary: Identifiable, Hashable {
|
|
| 541 |
+ let id: UUID |
|
| 542 |
+ let sessionID: UUID |
|
| 543 |
+ let chargedDeviceID: UUID |
|
| 544 |
+ let timestamp: Date |
|
| 545 |
+ let batteryPercent: Double |
|
| 546 |
+ let measuredEnergyWh: Double |
|
| 547 |
+ let measuredChargeAh: Double |
|
| 548 |
+ let currentAmps: Double |
|
| 549 |
+ let voltageVolts: Double? |
|
| 550 |
+ let label: String? |
|
| 551 |
+ |
|
| 552 |
+ var flag: ChargeCheckpointFlag {
|
|
| 553 |
+ ChargeCheckpointFlag.fromStoredLabel(label) |
|
| 554 |
+ } |
|
| 555 |
+} |
|
| 556 |
+ |
|
| 557 |
+enum ChargeCheckpointFlag: String, CaseIterable {
|
|
| 558 |
+ case initial |
|
| 559 |
+ case intermediate |
|
| 560 |
+ case final |
|
| 561 |
+ |
|
| 562 |
+ var title: String {
|
|
| 563 |
+ switch self {
|
|
| 564 |
+ case .initial: |
|
| 565 |
+ return "Initial" |
|
| 566 |
+ case .intermediate: |
|
| 567 |
+ return "Intermediate" |
|
| 568 |
+ case .final: |
|
| 569 |
+ return "Final" |
|
| 570 |
+ } |
|
| 571 |
+ } |
|
| 572 |
+ |
|
| 573 |
+ var anchorDescription: String {
|
|
| 574 |
+ switch self {
|
|
| 575 |
+ case .initial: |
|
| 576 |
+ return "initial checkpoint" |
|
| 577 |
+ case .intermediate: |
|
| 578 |
+ return "intermediate checkpoint" |
|
| 579 |
+ case .final: |
|
| 580 |
+ return "final checkpoint" |
|
| 581 |
+ } |
|
| 582 |
+ } |
|
| 583 |
+ |
|
| 584 |
+ static func fromStoredLabel(_ label: String?) -> ChargeCheckpointFlag {
|
|
| 585 |
+ let normalized = label? |
|
| 586 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 587 |
+ .lowercased() |
|
| 588 |
+ |
|
| 589 |
+ switch normalized {
|
|
| 590 |
+ case "initial", "start": |
|
| 591 |
+ return .initial |
|
| 592 |
+ case "final", "end": |
|
| 593 |
+ return .final |
|
| 594 |
+ case "intermediate", nil, "": |
|
| 595 |
+ return .intermediate |
|
| 596 |
+ default: |
|
| 597 |
+ return .intermediate |
|
| 598 |
+ } |
|
| 599 |
+ } |
|
| 600 |
+} |
|
| 601 |
+ |
|
| 602 |
+struct ChargeSessionSampleSummary: Identifiable, Hashable {
|
|
| 603 |
+ let sessionID: UUID |
|
| 604 |
+ let chargedDeviceID: UUID |
|
| 605 |
+ let bucketIndex: Int |
|
| 606 |
+ let timestamp: Date |
|
| 607 |
+ let averageCurrentAmps: Double |
|
| 608 |
+ let averageVoltageVolts: Double? |
|
| 609 |
+ let averagePowerWatts: Double |
|
| 610 |
+ let measuredEnergyWh: Double |
|
| 611 |
+ let measuredChargeAh: Double |
|
| 612 |
+ let sampleCount: Int |
|
| 613 |
+ |
|
| 614 |
+ var id: String {
|
|
| 615 |
+ "\(sessionID.uuidString)-\(bucketIndex)" |
|
| 616 |
+ } |
|
| 617 |
+} |
|
| 618 |
+ |
|
| 619 |
+struct ChargeSessionSummary: Identifiable, Hashable {
|
|
| 620 |
+ let id: UUID |
|
| 621 |
+ let chargedDeviceID: UUID |
|
| 622 |
+ let chargerID: UUID? |
|
| 623 |
+ let meterMACAddress: String? |
|
| 624 |
+ let meterName: String? |
|
| 625 |
+ let meterModel: String? |
|
| 626 |
+ let startedAt: Date |
|
| 627 |
+ let endedAt: Date? |
|
| 628 |
+ let lastObservedAt: Date |
|
| 629 |
+ let pausedAt: Date? |
|
| 630 |
+ let status: ChargeSessionStatus |
|
| 631 |
+ let sourceMode: ChargeSessionSourceMode |
|
| 632 |
+ let chargingTransportMode: ChargingTransportMode |
|
| 633 |
+ let chargingStateMode: ChargingStateMode |
|
| 634 |
+ let autoStopEnabled: Bool |
|
| 635 |
+ let measuredEnergyWh: Double |
|
| 636 |
+ let effectiveBatteryEnergyWh: Double? |
|
| 637 |
+ let measuredChargeAh: Double |
|
| 638 |
+ let meterEnergyBaselineWh: Double? |
|
| 639 |
+ let meterChargeBaselineAh: Double? |
|
| 640 |
+ let meterDurationBaselineSeconds: Double? |
|
| 641 |
+ let meterLastDurationSeconds: Double? |
|
| 642 |
+ let minimumObservedCurrentAmps: Double? |
|
| 643 |
+ let maximumObservedCurrentAmps: Double? |
|
| 644 |
+ let maximumObservedPowerWatts: Double? |
|
| 645 |
+ let maximumObservedVoltageVolts: Double? |
|
| 646 |
+ let hasObservedChargeFlow: Bool |
|
| 647 |
+ let selectedSourceVoltageVolts: Double? |
|
| 648 |
+ let completionCurrentAmps: Double? |
|
| 649 |
+ let stopThresholdAmps: Double |
|
| 650 |
+ let startBatteryPercent: Double? |
|
| 651 |
+ let endBatteryPercent: Double? |
|
| 652 |
+ let capacityEstimateWh: Double? |
|
| 653 |
+ let wirelessEfficiencyFactor: Double? |
|
| 654 |
+ let usesEstimatedWirelessEfficiency: Bool |
|
| 655 |
+ let shouldWarnAboutLowWirelessEfficiency: Bool |
|
| 656 |
+ let supportsChargingWhileOff: Bool |
|
| 657 |
+ let usedOfflineMeterCounters: Bool |
|
| 658 |
+ let targetBatteryPercent: Double? |
|
| 659 |
+ let targetBatteryAlertTriggeredAt: Date? |
|
| 660 |
+ let requiresCompletionConfirmation: Bool |
|
| 661 |
+ let completionConfirmationRequestedAt: Date? |
|
| 662 |
+ let completionContradictionPercent: Double? |
|
| 663 |
+ let selectedDataGroup: UInt8? |
|
| 664 |
+ let trimStart: Date? |
|
| 665 |
+ let trimEnd: Date? |
|
| 666 |
+ let wasConflictHealed: Bool |
|
| 667 |
+ let checkpoints: [ChargeCheckpointSummary] |
|
| 668 |
+ let aggregatedSamples: [ChargeSessionSampleSummary] |
|
| 669 |
+ |
|
| 670 |
+ var effectiveTrimStart: Date { trimStart ?? startedAt }
|
|
| 671 |
+ var effectiveTrimEnd: Date { trimEnd ?? (endedAt ?? lastObservedAt) }
|
|
| 672 |
+ var isTrimmed: Bool { trimStart != nil || trimEnd != nil }
|
|
| 673 |
+ var effectiveTimeRange: ClosedRange<Date> {
|
|
| 674 |
+ let start = effectiveTrimStart |
|
| 675 |
+ let end = max(effectiveTrimEnd, start) |
|
| 676 |
+ return start...end |
|
| 677 |
+ } |
|
| 678 |
+ var displayedAggregatedSamples: [ChargeSessionSampleSummary] {
|
|
| 679 |
+ guard isTrimmed else { return aggregatedSamples }
|
|
| 680 |
+ let range = effectiveTimeRange |
|
| 681 |
+ return aggregatedSamples.filter { range.contains($0.timestamp) }
|
|
| 682 |
+ } |
|
| 683 |
+ |
|
| 684 |
+ var sessionKind: ChargeSessionKind {
|
|
| 685 |
+ ChargeSessionKind( |
|
| 686 |
+ chargingTransportMode: chargingTransportMode, |
|
| 687 |
+ chargingStateMode: chargingStateMode |
|
| 688 |
+ ) |
|
| 689 |
+ } |
|
| 690 |
+ |
|
| 691 |
+ var duration: TimeInterval {
|
|
| 692 |
+ (endedAt ?? lastObservedAt).timeIntervalSince(startedAt) |
|
| 693 |
+ } |
|
| 694 |
+ |
|
| 695 |
+ var meterObservedDuration: TimeInterval? {
|
|
| 696 |
+ guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
|
|
| 697 |
+ return nil |
|
| 698 |
+ } |
|
| 699 |
+ guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
|
|
| 700 |
+ return nil |
|
| 701 |
+ } |
|
| 702 |
+ return meterLastDurationSeconds - meterDurationBaselineSeconds |
|
| 703 |
+ } |
|
| 704 |
+ |
|
| 705 |
+ var effectiveDuration: TimeInterval {
|
|
| 706 |
+ if isTrimmed {
|
|
| 707 |
+ return max(effectiveTrimEnd.timeIntervalSince(effectiveTrimStart), 0) |
|
| 708 |
+ } |
|
| 709 |
+ |
|
| 710 |
+ // Use timestamp-based duration as primary source; only use meter counter if it's consistent |
|
| 711 |
+ let timestampDuration = duration |
|
| 712 |
+ |
|
| 713 |
+ if let meterDuration = meterObservedDuration {
|
|
| 714 |
+ // Allow 5% tolerance for meter counter vs timestamp calculation |
|
| 715 |
+ let tolerance = timestampDuration * 0.05 |
|
| 716 |
+ let lower = timestampDuration - tolerance |
|
| 717 |
+ let upper = timestampDuration + tolerance |
|
| 718 |
+ |
|
| 719 |
+ // If meter duration is within tolerance range, use it (more precise) |
|
| 720 |
+ // Otherwise fall back to timestamp-based duration |
|
| 721 |
+ if meterDuration >= lower && meterDuration <= upper {
|
|
| 722 |
+ return meterDuration |
|
| 723 |
+ } |
|
| 724 |
+ } |
|
| 725 |
+ |
|
| 726 |
+ return timestampDuration |
|
| 727 |
+ } |
|
| 728 |
+ |
|
| 729 |
+ var effectiveOrMeasuredEnergyWh: Double {
|
|
| 730 |
+ effectiveBatteryEnergyWh ?? measuredEnergyWh |
|
| 731 |
+ } |
|
| 732 |
+ |
|
| 733 |
+ var hasSavableChargeData: Bool {
|
|
| 734 |
+ hasObservedChargeFlow |
|
| 735 |
+ || measuredEnergyWh > 0 |
|
| 736 |
+ || measuredChargeAh > 0 |
|
| 737 |
+ || (maximumObservedCurrentAmps ?? 0) > 0 |
|
| 738 |
+ || (maximumObservedPowerWatts ?? 0) > 0 |
|
| 739 |
+ || !aggregatedSamples.isEmpty |
|
| 740 |
+ } |
|
| 741 |
+ |
|
| 742 |
+ var batteryDeltaPercent: Double? {
|
|
| 743 |
+ guard let startBatteryPercent, let endBatteryPercent, |
|
| 744 |
+ startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
|
|
| 745 |
+ return endBatteryPercent - startBatteryPercent |
|
| 746 |
+ } |
|
| 747 |
+ |
|
| 748 |
+ var canAutoStop: Bool {
|
|
| 749 |
+ autoStopEnabled && stopThresholdAmps > 0 |
|
| 750 |
+ } |
|
| 751 |
+ |
|
| 752 |
+ var isPaused: Bool {
|
|
| 753 |
+ status == .paused |
|
| 754 |
+ } |
|
| 755 |
+ |
|
| 756 |
+ var isOpen: Bool {
|
|
| 757 |
+ status.isOpen |
|
| 758 |
+ } |
|
| 759 |
+} |
|
| 760 |
+ |
|
| 761 |
+struct BatteryLevelPrediction: Hashable {
|
|
| 762 |
+ let predictedPercent: Double |
|
| 763 |
+ let estimatedCapacityWh: Double |
|
| 764 |
+ let anchorPercent: Double |
|
| 765 |
+ let anchorEnergyWh: Double |
|
| 766 |
+ let anchorDescription: String |
|
| 767 |
+} |
|
| 768 |
+ |
|
| 769 |
+enum BatteryLevelPredictionTuning {
|
|
| 770 |
+ static let checkpointSettleDuration: TimeInterval = 10 * 60 |
|
| 771 |
+ |
|
| 772 |
+ static func predictedPercent( |
|
| 773 |
+ anchorPercent: Double, |
|
| 774 |
+ anchorEnergyWh: Double, |
|
| 775 |
+ anchorTimestamp: Date, |
|
| 776 |
+ anchorIsCheckpoint: Bool, |
|
| 777 |
+ effectiveEnergyWh: Double, |
|
| 778 |
+ referenceTimestamp: Date, |
|
| 779 |
+ estimatedCapacityWh: Double |
|
| 780 |
+ ) -> Double {
|
|
| 781 |
+ let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0) |
|
| 782 |
+ let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100 |
|
| 783 |
+ let stabilizedGainPercent: Double |
|
| 784 |
+ |
|
| 785 |
+ if anchorIsCheckpoint {
|
|
| 786 |
+ let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0) |
|
| 787 |
+ let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1) |
|
| 788 |
+ stabilizedGainPercent = rawGainPercent * settleProgress |
|
| 789 |
+ } else {
|
|
| 790 |
+ stabilizedGainPercent = rawGainPercent |
|
| 791 |
+ } |
|
| 792 |
+ |
|
| 793 |
+ return min( |
|
| 794 |
+ 100, |
|
| 795 |
+ max( |
|
| 796 |
+ 0, |
|
| 797 |
+ anchorPercent + stabilizedGainPercent |
|
| 798 |
+ ) |
|
| 799 |
+ ) |
|
| 800 |
+ } |
|
| 801 |
+} |
|
| 802 |
+ |
|
| 803 |
+struct CapacityTrendPoint: Identifiable, Hashable {
|
|
| 804 |
+ let sessionID: UUID |
|
| 805 |
+ let timestamp: Date |
|
| 806 |
+ let capacityWh: Double |
|
| 807 |
+ let chargingTransportMode: ChargingTransportMode |
|
| 808 |
+ |
|
| 809 |
+ var id: UUID { sessionID }
|
|
| 810 |
+} |
|
| 811 |
+ |
|
| 812 |
+struct TypicalChargeCurvePoint: Identifiable, Hashable {
|
|
| 813 |
+ let percentBin: Int |
|
| 814 |
+ let averageEnergyWh: Double |
|
| 815 |
+ let averageChargeAh: Double |
|
| 816 |
+ let sampleCount: Int |
|
| 817 |
+ |
|
| 818 |
+ var id: Int { percentBin }
|
|
| 819 |
+} |
|
| 820 |
+ |
|
| 821 |
+struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
|
|
| 822 |
+ let timestamp: Date |
|
| 823 |
+ let powerWatts: Double |
|
| 824 |
+ let currentAmps: Double |
|
| 825 |
+ let voltageVolts: Double |
|
| 826 |
+ |
|
| 827 |
+ var id: TimeInterval {
|
|
| 828 |
+ timestamp.timeIntervalSince1970 |
|
| 829 |
+ } |
|
| 830 |
+} |
|
| 831 |
+ |
|
| 832 |
+struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
|
|
| 833 |
+ let index: Int |
|
| 834 |
+ let lowerBoundWatts: Double |
|
| 835 |
+ let upperBoundWatts: Double |
|
| 836 |
+ let count: Int |
|
| 837 |
+ let relativeFrequency: Double |
|
| 838 |
+ |
|
| 839 |
+ var id: Int { index }
|
|
| 840 |
+} |
|
| 841 |
+ |
|
| 842 |
+enum HistogramResolution: Int, CaseIterable, Identifiable {
|
|
| 843 |
+ case x1 = 1 |
|
| 844 |
+ case x2 = 2 |
|
| 845 |
+ case x4 = 4 |
|
| 846 |
+ |
|
| 847 |
+ var id: Int { rawValue }
|
|
| 848 |
+ |
|
| 849 |
+ var label: String {
|
|
| 850 |
+ switch self {
|
|
| 851 |
+ case .x1: return "1×" |
|
| 852 |
+ case .x2: return "2×" |
|
| 853 |
+ case .x4: return "4×" |
|
| 854 |
+ } |
|
| 855 |
+ } |
|
| 856 |
+} |
|
| 857 |
+ |
|
| 858 |
+struct ChargerStandbyPowerMeasurementStatistics: Hashable {
|
|
| 859 |
+ let sampleCount: Int |
|
| 860 |
+ let observedDuration: TimeInterval |
|
| 861 |
+ let averagePowerWatts: Double |
|
| 862 |
+ let recentAveragePowerWatts: Double |
|
| 863 |
+ let medianPowerWatts: Double |
|
| 864 |
+ let minimumPowerWatts: Double |
|
| 865 |
+ let maximumPowerWatts: Double |
|
| 866 |
+ let standardDeviationPowerWatts: Double |
|
| 867 |
+ let coefficientOfVariation: Double |
|
| 868 |
+ let averageCurrentAmps: Double |
|
| 869 |
+ let averageVoltageVolts: Double |
|
| 870 |
+ let stabilityDeltaWatts: Double |
|
| 871 |
+ let stabilityToleranceWatts: Double |
|
| 872 |
+ let histogram: [ChargerStandbyPowerDistributionBin] |
|
| 873 |
+ |
|
| 874 |
+ var projectedDailyEnergyWh: Double {
|
|
| 875 |
+ averagePowerWatts * 24 |
|
| 876 |
+ } |
|
| 877 |
+ |
|
| 878 |
+ var projectedWeeklyEnergyWh: Double {
|
|
| 879 |
+ averagePowerWatts * 24 * 7 |
|
| 880 |
+ } |
|
| 881 |
+ |
|
| 882 |
+ var projectedMonthlyEnergyWh: Double {
|
|
| 883 |
+ averagePowerWatts * 24 * 30 |
|
| 884 |
+ } |
|
| 885 |
+ |
|
| 886 |
+ var projectedYearlyEnergyWh: Double {
|
|
| 887 |
+ averagePowerWatts * 24 * 365 |
|
| 888 |
+ } |
|
| 889 |
+ |
|
| 890 |
+ var stabilityDeltaMilliwatts: Double {
|
|
| 891 |
+ stabilityDeltaWatts * 1000 |
|
| 892 |
+ } |
|
| 893 |
+ |
|
| 894 |
+ var isStable: Bool {
|
|
| 895 |
+ sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount |
|
| 896 |
+ && stabilityDeltaWatts <= stabilityToleranceWatts |
|
| 897 |
+ } |
|
| 898 |
+} |
|
| 899 |
+ |
|
| 900 |
+struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
|
|
| 901 |
+ let id: UUID |
|
| 902 |
+ let chargerID: UUID |
|
| 903 |
+ let meterMACAddress: String |
|
| 904 |
+ let meterName: String? |
|
| 905 |
+ let meterModel: String? |
|
| 906 |
+ let startedAt: Date |
|
| 907 |
+ let endedAt: Date |
|
| 908 |
+ let sampleCount: Int |
|
| 909 |
+ let stabilizedAt: Date? |
|
| 910 |
+ let averagePowerWatts: Double |
|
| 911 |
+ let recentAveragePowerWatts: Double |
|
| 912 |
+ let medianPowerWatts: Double |
|
| 913 |
+ let minimumPowerWatts: Double |
|
| 914 |
+ let maximumPowerWatts: Double |
|
| 915 |
+ let standardDeviationPowerWatts: Double |
|
| 916 |
+ let coefficientOfVariation: Double |
|
| 917 |
+ let averageCurrentAmps: Double |
|
| 918 |
+ let averageVoltageVolts: Double |
|
| 919 |
+ let stabilityDeltaWatts: Double |
|
| 920 |
+ let stabilityToleranceWatts: Double |
|
| 921 |
+ /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display. |
|
| 922 |
+ let storedHistogram: [ChargerStandbyPowerDistributionBin] |
|
| 923 |
+ |
|
| 924 |
+ // MARK: - Codable (with migration from legacy powerSamplesWatts) |
|
| 925 |
+ |
|
| 926 |
+ private enum CodingKeys: String, CodingKey {
|
|
| 927 |
+ case id, chargerID, meterMACAddress, meterName, meterModel |
|
| 928 |
+ case startedAt, endedAt, sampleCount, stabilizedAt |
|
| 929 |
+ case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts |
|
| 930 |
+ case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts |
|
| 931 |
+ case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts |
|
| 932 |
+ case stabilityDeltaWatts, stabilityToleranceWatts |
|
| 933 |
+ case storedHistogram |
|
| 934 |
+ case powerSamplesWatts // legacy – decode only |
|
| 935 |
+ } |
|
| 936 |
+ |
|
| 937 |
+ init( |
|
| 938 |
+ id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?, |
|
| 939 |
+ startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?, |
|
| 940 |
+ averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double, |
|
| 941 |
+ minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double, |
|
| 942 |
+ coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double, |
|
| 943 |
+ stabilityDeltaWatts: Double, stabilityToleranceWatts: Double, |
|
| 944 |
+ storedHistogram: [ChargerStandbyPowerDistributionBin] |
|
| 945 |
+ ) {
|
|
| 946 |
+ self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress |
|
| 947 |
+ self.meterName = meterName; self.meterModel = meterModel |
|
| 948 |
+ self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount |
|
| 949 |
+ self.stabilizedAt = stabilizedAt |
|
| 950 |
+ self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts |
|
| 951 |
+ self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts |
|
| 952 |
+ self.maximumPowerWatts = maximumPowerWatts |
|
| 953 |
+ self.standardDeviationPowerWatts = standardDeviationPowerWatts |
|
| 954 |
+ self.coefficientOfVariation = coefficientOfVariation |
|
| 955 |
+ self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts |
|
| 956 |
+ self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts |
|
| 957 |
+ self.storedHistogram = storedHistogram |
|
| 958 |
+ } |
|
| 959 |
+ |
|
| 960 |
+ init(from decoder: Decoder) throws {
|
|
| 961 |
+ let c = try decoder.container(keyedBy: CodingKeys.self) |
|
| 962 |
+ id = try c.decode(UUID.self, forKey: .id) |
|
| 963 |
+ chargerID = try c.decode(UUID.self, forKey: .chargerID) |
|
| 964 |
+ meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress) |
|
| 965 |
+ meterName = try c.decodeIfPresent(String.self, forKey: .meterName) |
|
| 966 |
+ meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel) |
|
| 967 |
+ startedAt = try c.decode(Date.self, forKey: .startedAt) |
|
| 968 |
+ endedAt = try c.decode(Date.self, forKey: .endedAt) |
|
| 969 |
+ sampleCount = try c.decode(Int.self, forKey: .sampleCount) |
|
| 970 |
+ stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt) |
|
| 971 |
+ averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts) |
|
| 972 |
+ recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts) |
|
| 973 |
+ medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts) |
|
| 974 |
+ minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts) |
|
| 975 |
+ maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts) |
|
| 976 |
+ standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts) |
|
| 977 |
+ coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation) |
|
| 978 |
+ averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps) |
|
| 979 |
+ averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts) |
|
| 980 |
+ stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts) |
|
| 981 |
+ stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts) |
|
| 982 |
+ |
|
| 983 |
+ let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram) |
|
| 984 |
+ if let decodedBins, !decodedBins.isEmpty {
|
|
| 985 |
+ storedHistogram = decodedBins |
|
| 986 |
+ } else {
|
|
| 987 |
+ // Migrate from legacy raw samples format |
|
| 988 |
+ let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? [] |
|
| 989 |
+ let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded()))) |
|
| 990 |
+ storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram( |
|
| 991 |
+ for: samples, |
|
| 992 |
+ preferredBinCount: base * HistogramResolution.x4.rawValue |
|
| 993 |
+ ) |
|
| 994 |
+ } |
|
| 995 |
+ } |
|
| 996 |
+ |
|
| 997 |
+ func encode(to encoder: Encoder) throws {
|
|
| 998 |
+ var c = encoder.container(keyedBy: CodingKeys.self) |
|
| 999 |
+ try c.encode(id, forKey: .id) |
|
| 1000 |
+ try c.encode(chargerID, forKey: .chargerID) |
|
| 1001 |
+ try c.encode(meterMACAddress, forKey: .meterMACAddress) |
|
| 1002 |
+ try c.encodeIfPresent(meterName, forKey: .meterName) |
|
| 1003 |
+ try c.encodeIfPresent(meterModel, forKey: .meterModel) |
|
| 1004 |
+ try c.encode(startedAt, forKey: .startedAt) |
|
| 1005 |
+ try c.encode(endedAt, forKey: .endedAt) |
|
| 1006 |
+ try c.encode(sampleCount, forKey: .sampleCount) |
|
| 1007 |
+ try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt) |
|
| 1008 |
+ try c.encode(averagePowerWatts, forKey: .averagePowerWatts) |
|
| 1009 |
+ try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts) |
|
| 1010 |
+ try c.encode(medianPowerWatts, forKey: .medianPowerWatts) |
|
| 1011 |
+ try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts) |
|
| 1012 |
+ try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts) |
|
| 1013 |
+ try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts) |
|
| 1014 |
+ try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation) |
|
| 1015 |
+ try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps) |
|
| 1016 |
+ try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts) |
|
| 1017 |
+ try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts) |
|
| 1018 |
+ try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts) |
|
| 1019 |
+ try c.encode(storedHistogram, forKey: .storedHistogram) |
|
| 1020 |
+ } |
|
| 1021 |
+ |
|
| 1022 |
+ // MARK: - Computed |
|
| 1023 |
+ |
|
| 1024 |
+ var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
|
|
| 1025 |
+ var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
|
|
| 1026 |
+ var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
|
|
| 1027 |
+ var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
|
|
| 1028 |
+ var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
|
|
| 1029 |
+ var isStable: Bool { stabilizedAt != nil }
|
|
| 1030 |
+ |
|
| 1031 |
+ /// Returns the histogram downsampled to the requested resolution. |
|
| 1032 |
+ /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue. |
|
| 1033 |
+ func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
|
|
| 1034 |
+ let factor = HistogramResolution.x4.rawValue / resolution.rawValue |
|
| 1035 |
+ return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor) |
|
| 1036 |
+ } |
|
| 1037 |
+} |
|
| 1038 |
+ |
|
| 1039 |
+enum ChargerStandbyPowerMeasurementAnalyzer {
|
|
| 1040 |
+ static let minimumStableSampleCount = 45 |
|
| 1041 |
+ static let recentSampleWindow = 40 |
|
| 1042 |
+ static let minimumStabilityToleranceWatts = 0.010 |
|
| 1043 |
+ static let relativeStabilityTolerance = 0.05 |
|
| 1044 |
+ |
|
| 1045 |
+ static func statistics( |
|
| 1046 |
+ from samples: [ChargerStandbyPowerSample], |
|
| 1047 |
+ startedAt: Date, |
|
| 1048 |
+ referenceDate: Date = Date() |
|
| 1049 |
+ ) -> ChargerStandbyPowerMeasurementStatistics? {
|
|
| 1050 |
+ guard !samples.isEmpty else {
|
|
| 1051 |
+ return nil |
|
| 1052 |
+ } |
|
| 1053 |
+ |
|
| 1054 |
+ let powerValues = samples.map(\.powerWatts).filter(\.isFinite) |
|
| 1055 |
+ let currentValues = samples.map(\.currentAmps).filter(\.isFinite) |
|
| 1056 |
+ let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite) |
|
| 1057 |
+ |
|
| 1058 |
+ guard powerValues.isEmpty == false else {
|
|
| 1059 |
+ return nil |
|
| 1060 |
+ } |
|
| 1061 |
+ |
|
| 1062 |
+ let averagePower = mean(powerValues) |
|
| 1063 |
+ let recentWindow = min(recentSampleWindow, max(1, powerValues.count)) |
|
| 1064 |
+ let recentAveragePower = mean(Array(powerValues.suffix(recentWindow))) |
|
| 1065 |
+ let stabilityDelta = abs(averagePower - recentAveragePower) |
|
| 1066 |
+ let stabilityTolerance = max( |
|
| 1067 |
+ minimumStabilityToleranceWatts, |
|
| 1068 |
+ abs(averagePower) * relativeStabilityTolerance |
|
| 1069 |
+ ) |
|
| 1070 |
+ |
|
| 1071 |
+ let baseBinCount = min(18, max(8, Int(Double(powerValues.count).squareRoot().rounded()))) |
|
| 1072 |
+ let liveHistogram = histogram(for: powerValues, preferredBinCount: baseBinCount * HistogramResolution.x4.rawValue) |
|
| 1073 |
+ |
|
| 1074 |
+ return ChargerStandbyPowerMeasurementStatistics( |
|
| 1075 |
+ sampleCount: powerValues.count, |
|
| 1076 |
+ observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0), |
|
| 1077 |
+ averagePowerWatts: averagePower, |
|
| 1078 |
+ recentAveragePowerWatts: recentAveragePower, |
|
| 1079 |
+ medianPowerWatts: median(powerValues), |
|
| 1080 |
+ minimumPowerWatts: powerValues.min() ?? 0, |
|
| 1081 |
+ maximumPowerWatts: powerValues.max() ?? 0, |
|
| 1082 |
+ standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower), |
|
| 1083 |
+ coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower), |
|
| 1084 |
+ averageCurrentAmps: mean(currentValues), |
|
| 1085 |
+ averageVoltageVolts: mean(voltageValues), |
|
| 1086 |
+ stabilityDeltaWatts: stabilityDelta, |
|
| 1087 |
+ stabilityToleranceWatts: stabilityTolerance, |
|
| 1088 |
+ histogram: liveHistogram |
|
| 1089 |
+ ) |
|
| 1090 |
+ } |
|
| 1091 |
+ |
|
| 1092 |
+ static func measurementSummary( |
|
| 1093 |
+ chargerID: UUID, |
|
| 1094 |
+ meterMACAddress: String, |
|
| 1095 |
+ meterName: String?, |
|
| 1096 |
+ meterModel: String?, |
|
| 1097 |
+ startedAt: Date, |
|
| 1098 |
+ endedAt: Date, |
|
| 1099 |
+ samples: [ChargerStandbyPowerSample], |
|
| 1100 |
+ stabilizedAt: Date? |
|
| 1101 |
+ ) -> ChargerStandbyPowerMeasurementSummary? {
|
|
| 1102 |
+ guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
|
|
| 1103 |
+ return nil |
|
| 1104 |
+ } |
|
| 1105 |
+ |
|
| 1106 |
+ return ChargerStandbyPowerMeasurementSummary( |
|
| 1107 |
+ id: UUID(), |
|
| 1108 |
+ chargerID: chargerID, |
|
| 1109 |
+ meterMACAddress: meterMACAddress, |
|
| 1110 |
+ meterName: meterName, |
|
| 1111 |
+ meterModel: meterModel, |
|
| 1112 |
+ startedAt: startedAt, |
|
| 1113 |
+ endedAt: endedAt, |
|
| 1114 |
+ sampleCount: statistics.sampleCount, |
|
| 1115 |
+ stabilizedAt: stabilizedAt, |
|
| 1116 |
+ averagePowerWatts: statistics.averagePowerWatts, |
|
| 1117 |
+ recentAveragePowerWatts: statistics.recentAveragePowerWatts, |
|
| 1118 |
+ medianPowerWatts: statistics.medianPowerWatts, |
|
| 1119 |
+ minimumPowerWatts: statistics.minimumPowerWatts, |
|
| 1120 |
+ maximumPowerWatts: statistics.maximumPowerWatts, |
|
| 1121 |
+ standardDeviationPowerWatts: statistics.standardDeviationPowerWatts, |
|
| 1122 |
+ coefficientOfVariation: statistics.coefficientOfVariation, |
|
| 1123 |
+ averageCurrentAmps: statistics.averageCurrentAmps, |
|
| 1124 |
+ averageVoltageVolts: statistics.averageVoltageVolts, |
|
| 1125 |
+ stabilityDeltaWatts: statistics.stabilityDeltaWatts, |
|
| 1126 |
+ stabilityToleranceWatts: statistics.stabilityToleranceWatts, |
|
| 1127 |
+ storedHistogram: statistics.histogram |
|
| 1128 |
+ ) |
|
| 1129 |
+ } |
|
| 1130 |
+ |
|
| 1131 |
+ /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1. |
|
| 1132 |
+ static func downsample( |
|
| 1133 |
+ _ bins: [ChargerStandbyPowerDistributionBin], |
|
| 1134 |
+ factor: Int |
|
| 1135 |
+ ) -> [ChargerStandbyPowerDistributionBin] {
|
|
| 1136 |
+ guard factor > 1, !bins.isEmpty else { return bins }
|
|
| 1137 |
+ let totalCount = bins.reduce(0) { $0 + $1.count }
|
|
| 1138 |
+ var result: [ChargerStandbyPowerDistributionBin] = [] |
|
| 1139 |
+ var inputIndex = 0 |
|
| 1140 |
+ var outputIndex = 0 |
|
| 1141 |
+ while inputIndex < bins.count {
|
|
| 1142 |
+ let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)]) |
|
| 1143 |
+ let mergedCount = group.reduce(0) { $0 + $1.count }
|
|
| 1144 |
+ let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0 |
|
| 1145 |
+ result.append(ChargerStandbyPowerDistributionBin( |
|
| 1146 |
+ index: outputIndex, |
|
| 1147 |
+ lowerBoundWatts: group.first!.lowerBoundWatts, |
|
| 1148 |
+ upperBoundWatts: group.last!.upperBoundWatts, |
|
| 1149 |
+ count: mergedCount, |
|
| 1150 |
+ relativeFrequency: relFreq |
|
| 1151 |
+ )) |
|
| 1152 |
+ inputIndex += factor |
|
| 1153 |
+ outputIndex += 1 |
|
| 1154 |
+ } |
|
| 1155 |
+ return result |
|
| 1156 |
+ } |
|
| 1157 |
+ |
|
| 1158 |
+ static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
|
|
| 1159 |
+ let finiteValues = values.filter(\.isFinite) |
|
| 1160 |
+ guard finiteValues.isEmpty == false else {
|
|
| 1161 |
+ return [] |
|
| 1162 |
+ } |
|
| 1163 |
+ |
|
| 1164 |
+ let minimum = finiteValues.min() ?? 0 |
|
| 1165 |
+ let maximum = finiteValues.max() ?? 0 |
|
| 1166 |
+ let spread = maximum - minimum |
|
| 1167 |
+ let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded()))) |
|
| 1168 |
+ |
|
| 1169 |
+ guard spread > 0 else {
|
|
| 1170 |
+ return [ |
|
| 1171 |
+ ChargerStandbyPowerDistributionBin( |
|
| 1172 |
+ index: 0, |
|
| 1173 |
+ lowerBoundWatts: minimum, |
|
| 1174 |
+ upperBoundWatts: maximum, |
|
| 1175 |
+ count: finiteValues.count, |
|
| 1176 |
+ relativeFrequency: 1 |
|
| 1177 |
+ ) |
|
| 1178 |
+ ] |
|
| 1179 |
+ } |
|
| 1180 |
+ |
|
| 1181 |
+ let safeBinCount = max(1, binCount) |
|
| 1182 |
+ let binWidth = spread / Double(safeBinCount) |
|
| 1183 |
+ var counts = Array(repeating: 0, count: safeBinCount) |
|
| 1184 |
+ |
|
| 1185 |
+ for value in finiteValues {
|
|
| 1186 |
+ let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down)) |
|
| 1187 |
+ let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1) |
|
| 1188 |
+ counts[safeIndex] += 1 |
|
| 1189 |
+ } |
|
| 1190 |
+ |
|
| 1191 |
+ return counts.enumerated().map { index, count in
|
|
| 1192 |
+ let lowerBound = minimum + (Double(index) * binWidth) |
|
| 1193 |
+ let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth |
|
| 1194 |
+ |
|
| 1195 |
+ return ChargerStandbyPowerDistributionBin( |
|
| 1196 |
+ index: index, |
|
| 1197 |
+ lowerBoundWatts: lowerBound, |
|
| 1198 |
+ upperBoundWatts: upperBound, |
|
| 1199 |
+ count: count, |
|
| 1200 |
+ relativeFrequency: Double(count) / Double(finiteValues.count) |
|
| 1201 |
+ ) |
|
| 1202 |
+ } |
|
| 1203 |
+ } |
|
| 1204 |
+ |
|
| 1205 |
+ private static func mean(_ values: [Double]) -> Double {
|
|
| 1206 |
+ guard values.isEmpty == false else {
|
|
| 1207 |
+ return 0 |
|
| 1208 |
+ } |
|
| 1209 |
+ return values.reduce(0, +) / Double(values.count) |
|
| 1210 |
+ } |
|
| 1211 |
+ |
|
| 1212 |
+ private static func median(_ values: [Double]) -> Double {
|
|
| 1213 |
+ guard values.isEmpty == false else {
|
|
| 1214 |
+ return 0 |
|
| 1215 |
+ } |
|
| 1216 |
+ |
|
| 1217 |
+ let sorted = values.sorted() |
|
| 1218 |
+ let middleIndex = sorted.count / 2 |
|
| 1219 |
+ |
|
| 1220 |
+ if sorted.count.isMultiple(of: 2) {
|
|
| 1221 |
+ return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2 |
|
| 1222 |
+ } |
|
| 1223 |
+ |
|
| 1224 |
+ return sorted[middleIndex] |
|
| 1225 |
+ } |
|
| 1226 |
+ |
|
| 1227 |
+ private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
|
|
| 1228 |
+ guard values.count > 1 else {
|
|
| 1229 |
+ return 0 |
|
| 1230 |
+ } |
|
| 1231 |
+ |
|
| 1232 |
+ let variance = values.reduce(0) { partialResult, value in
|
|
| 1233 |
+ let delta = value - mean |
|
| 1234 |
+ return partialResult + (delta * delta) |
|
| 1235 |
+ } / Double(values.count) |
|
| 1236 |
+ |
|
| 1237 |
+ return variance.squareRoot() |
|
| 1238 |
+ } |
|
| 1239 |
+ |
|
| 1240 |
+ private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
|
|
| 1241 |
+ guard abs(mean) > 0.000_001 else {
|
|
| 1242 |
+ return 0 |
|
| 1243 |
+ } |
|
| 1244 |
+ |
|
| 1245 |
+ return standardDeviation(values, mean: mean) / abs(mean) |
|
| 1246 |
+ } |
|
| 1247 |
+} |
|
| 1248 |
+ |
|
| 1249 |
+struct ChargedDeviceSummary: Identifiable, Hashable {
|
|
| 1250 |
+ let id: UUID |
|
| 1251 |
+ let qrIdentifier: String |
|
| 1252 |
+ let name: String |
|
| 1253 |
+ let deviceClass: ChargedDeviceClass |
|
| 1254 |
+ let deviceTemplateID: String? |
|
| 1255 |
+ let templateDefinition: ChargedDeviceTemplateDefinition? |
|
| 1256 |
+ let supportsChargingWhileOff: Bool |
|
| 1257 |
+ let chargingStateAvailability: ChargingStateAvailability |
|
| 1258 |
+ let supportsWiredCharging: Bool |
|
| 1259 |
+ let supportsWirelessCharging: Bool |
|
| 1260 |
+ let chargerType: ChargerType? |
|
| 1261 |
+ let wirelessChargingProfile: WirelessChargingProfile |
|
| 1262 |
+ let configuredCompletionCurrents: [ChargeSessionKind: Double] |
|
| 1263 |
+ let learnedCompletionCurrents: [ChargeSessionKind: Double] |
|
| 1264 |
+ let wirelessChargerEfficiencyFactor: Double? |
|
| 1265 |
+ let wiredChargeCompletionCurrentAmps: Double? |
|
| 1266 |
+ let wirelessChargeCompletionCurrentAmps: Double? |
|
| 1267 |
+ let chargerObservedVoltageSelections: [Double] |
|
| 1268 |
+ let chargerIdleCurrentAmps: Double? |
|
| 1269 |
+ let chargerEfficiencyFactor: Double? |
|
| 1270 |
+ let chargerMaximumPowerWatts: Double? |
|
| 1271 |
+ let notes: String? |
|
| 1272 |
+ let minimumCurrentAmps: Double? |
|
| 1273 |
+ let estimatedBatteryCapacityWh: Double? |
|
| 1274 |
+ let wiredMinimumCurrentAmps: Double? |
|
| 1275 |
+ let wirelessMinimumCurrentAmps: Double? |
|
| 1276 |
+ let wiredEstimatedBatteryCapacityWh: Double? |
|
| 1277 |
+ let wirelessEstimatedBatteryCapacityWh: Double? |
|
| 1278 |
+ let lastAssociatedMeterMAC: String? |
|
| 1279 |
+ let createdAt: Date |
|
| 1280 |
+ let updatedAt: Date |
|
| 1281 |
+ let sessions: [ChargeSessionSummary] |
|
| 1282 |
+ let capacityHistory: [CapacityTrendPoint] |
|
| 1283 |
+ let typicalCurve: [TypicalChargeCurvePoint] |
|
| 1284 |
+ let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary] |
|
| 1285 |
+ |
|
| 1286 |
+ var isCharger: Bool {
|
|
| 1287 |
+ deviceClass == .charger |
|
| 1288 |
+ } |
|
| 1289 |
+ |
|
| 1290 |
+ var kind: ChargedDeviceKind {
|
|
| 1291 |
+ deviceClass.kind |
|
| 1292 |
+ } |
|
| 1293 |
+ |
|
| 1294 |
+ var identityTitle: String {
|
|
| 1295 |
+ templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title) |
|
| 1296 |
+ } |
|
| 1297 |
+ |
|
| 1298 |
+ var fallbackIdentitySymbolName: String {
|
|
| 1299 |
+ isCharger ? kind.symbolName : deviceClass.symbolName |
|
| 1300 |
+ } |
|
| 1301 |
+ |
|
| 1302 |
+ var identityIcon: ChargedDeviceTemplateIcon {
|
|
| 1303 |
+ templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName) |
|
| 1304 |
+ } |
|
| 1305 |
+ |
|
| 1306 |
+ var identitySymbolName: String {
|
|
| 1307 |
+ identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName) |
|
| 1308 |
+ } |
|
| 1309 |
+ |
|
| 1310 |
+ var activeSession: ChargeSessionSummary? {
|
|
| 1311 |
+ sessions.first(where: \.isOpen) |
|
| 1312 |
+ } |
|
| 1313 |
+ |
|
| 1314 |
+ var recentCompletedSessions: [ChargeSessionSummary] {
|
|
| 1315 |
+ sessions.filter { $0.status == .completed }
|
|
| 1316 |
+ } |
|
| 1317 |
+ |
|
| 1318 |
+ var sessionCount: Int {
|
|
| 1319 |
+ sessions.count |
|
| 1320 |
+ } |
|
| 1321 |
+ |
|
| 1322 |
+ var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
|
|
| 1323 |
+ standbyPowerMeasurements.first |
|
| 1324 |
+ } |
|
| 1325 |
+ |
|
| 1326 |
+ var supportedChargingModes: [ChargingTransportMode] {
|
|
| 1327 |
+ var modes: [ChargingTransportMode] = [] |
|
| 1328 |
+ if supportsWiredCharging {
|
|
| 1329 |
+ modes.append(.wired) |
|
| 1330 |
+ } |
|
| 1331 |
+ if supportsWirelessCharging {
|
|
| 1332 |
+ modes.append(.wireless) |
|
| 1333 |
+ } |
|
| 1334 |
+ return modes |
|
| 1335 |
+ } |
|
| 1336 |
+ |
|
| 1337 |
+ var supportedChargingStateModes: [ChargingStateMode] {
|
|
| 1338 |
+ chargingStateAvailability.supportedModes |
|
| 1339 |
+ } |
|
| 1340 |
+ |
|
| 1341 |
+ var hasMultipleChargingTransports: Bool {
|
|
| 1342 |
+ supportedChargingModes.count > 1 |
|
| 1343 |
+ } |
|
| 1344 |
+ |
|
| 1345 |
+ var hasMultipleChargingStateModes: Bool {
|
|
| 1346 |
+ supportedChargingStateModes.count > 1 |
|
| 1347 |
+ } |
|
| 1348 |
+ |
|
| 1349 |
+ var showsWirelessProfileDetails: Bool {
|
|
| 1350 |
+ supportsWirelessCharging |
|
| 1351 |
+ && hasMultipleChargingTransports |
|
| 1352 |
+ && deviceClass != .watch |
|
| 1353 |
+ } |
|
| 1354 |
+ |
|
| 1355 |
+ var chargingSupportSummary: String {
|
|
| 1356 |
+ switch (supportsWiredCharging, supportsWirelessCharging) {
|
|
| 1357 |
+ case (true, true): |
|
| 1358 |
+ return "Supports wired and wireless charging." |
|
| 1359 |
+ case (true, false): |
|
| 1360 |
+ return "Supports wired charging only." |
|
| 1361 |
+ case (false, true): |
|
| 1362 |
+ return "Supports wireless charging only." |
|
| 1363 |
+ case (false, false): |
|
| 1364 |
+ return "No charging method configured." |
|
| 1365 |
+ } |
|
| 1366 |
+ } |
|
| 1367 |
+ |
|
| 1368 |
+ func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
|
|
| 1369 |
+ if let matchingSession = sessions.first(where: {
|
|
| 1370 |
+ $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode |
|
| 1371 |
+ }) {
|
|
| 1372 |
+ return matchingSession.chargingStateMode |
|
| 1373 |
+ } |
|
| 1374 |
+ return chargingStateAvailability.supportedModes.first ?? .on |
|
| 1375 |
+ } |
|
| 1376 |
+ |
|
| 1377 |
+ func sessionKind( |
|
| 1378 |
+ for chargingTransportMode: ChargingTransportMode, |
|
| 1379 |
+ chargingStateMode: ChargingStateMode? = nil |
|
| 1380 |
+ ) -> ChargeSessionKind {
|
|
| 1381 |
+ ChargeSessionKind( |
|
| 1382 |
+ chargingTransportMode: chargingTransportMode, |
|
| 1383 |
+ chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode) |
|
| 1384 |
+ ) |
|
| 1385 |
+ } |
|
| 1386 |
+ |
|
| 1387 |
+ func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
|
|
| 1388 |
+ switch chargingTransportMode {
|
|
| 1389 |
+ case .wired: |
|
| 1390 |
+ return wiredEstimatedBatteryCapacityWh |
|
| 1391 |
+ case .wireless: |
|
| 1392 |
+ return wirelessEstimatedBatteryCapacityWh |
|
| 1393 |
+ } |
|
| 1394 |
+ } |
|
| 1395 |
+ |
|
| 1396 |
+ func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
|
|
| 1397 |
+ switch chargingTransportMode {
|
|
| 1398 |
+ case .wired: |
|
| 1399 |
+ return wiredMinimumCurrentAmps |
|
| 1400 |
+ case .wireless: |
|
| 1401 |
+ return wirelessMinimumCurrentAmps |
|
| 1402 |
+ } |
|
| 1403 |
+ } |
|
| 1404 |
+ |
|
| 1405 |
+ func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
|
|
| 1406 |
+ hasMultipleChargingTransports |
|
| 1407 |
+ || supportedChargingModes.contains(chargingTransportMode) == false |
|
| 1408 |
+ } |
|
| 1409 |
+ |
|
| 1410 |
+ func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
|
|
| 1411 |
+ hasMultipleChargingStateModes |
|
| 1412 |
+ || supportedChargingStateModes.contains(chargingStateMode) == false |
|
| 1413 |
+ } |
|
| 1414 |
+ |
|
| 1415 |
+ func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
|
|
| 1416 |
+ if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
|
|
| 1417 |
+ return explicitCurrent |
|
| 1418 |
+ } |
|
| 1419 |
+ |
|
| 1420 |
+ switch sessionKind.chargingTransportMode {
|
|
| 1421 |
+ case .wired: |
|
| 1422 |
+ return wiredChargeCompletionCurrentAmps |
|
| 1423 |
+ case .wireless: |
|
| 1424 |
+ return wirelessChargeCompletionCurrentAmps |
|
| 1425 |
+ } |
|
| 1426 |
+ } |
|
| 1427 |
+ |
|
| 1428 |
+ func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
|
|
| 1429 |
+ if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
|
|
| 1430 |
+ return learnedCurrent |
|
| 1431 |
+ } |
|
| 1432 |
+ |
|
| 1433 |
+ switch sessionKind.chargingTransportMode {
|
|
| 1434 |
+ case .wired: |
|
| 1435 |
+ return wiredMinimumCurrentAmps ?? minimumCurrentAmps |
|
| 1436 |
+ case .wireless: |
|
| 1437 |
+ return wirelessMinimumCurrentAmps ?? minimumCurrentAmps |
|
| 1438 |
+ } |
|
| 1439 |
+ } |
|
| 1440 |
+ |
|
| 1441 |
+ func resolvedCompletionCurrentAmps( |
|
| 1442 |
+ for chargingTransportMode: ChargingTransportMode, |
|
| 1443 |
+ chargingStateMode: ChargingStateMode? = nil |
|
| 1444 |
+ ) -> Double? {
|
|
| 1445 |
+ let sessionKind = sessionKind( |
|
| 1446 |
+ for: chargingTransportMode, |
|
| 1447 |
+ chargingStateMode: chargingStateMode |
|
| 1448 |
+ ) |
|
| 1449 |
+ |
|
| 1450 |
+ return configuredCompletionCurrentAmps(for: sessionKind) |
|
| 1451 |
+ ?? learnedCompletionCurrentAmps(for: sessionKind) |
|
| 1452 |
+ ?? minimumCurrentAmps(for: chargingTransportMode) |
|
| 1453 |
+ ?? minimumCurrentAmps |
|
| 1454 |
+ } |
|
| 1455 |
+ |
|
| 1456 |
+ func batteryLevelPrediction( |
|
| 1457 |
+ for session: ChargeSessionSummary, |
|
| 1458 |
+ effectiveEnergyWhOverride: Double? = nil |
|
| 1459 |
+ ) -> BatteryLevelPrediction? {
|
|
| 1460 |
+ let estimatedCapacityWh = session.capacityEstimateWh |
|
| 1461 |
+ ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode) |
|
| 1462 |
+ ?? estimatedBatteryCapacityWh |
|
| 1463 |
+ |
|
| 1464 |
+ guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
|
|
| 1465 |
+ return nil |
|
| 1466 |
+ } |
|
| 1467 |
+ |
|
| 1468 |
+ let effectiveEnergyWh = effectiveEnergyWhOverride |
|
| 1469 |
+ ?? session.effectiveBatteryEnergyWh |
|
| 1470 |
+ ?? session.measuredEnergyWh |
|
| 1471 |
+ |
|
| 1472 |
+ struct Anchor {
|
|
| 1473 |
+ let percent: Double |
|
| 1474 |
+ let energyWh: Double |
|
| 1475 |
+ let timestamp: Date |
|
| 1476 |
+ let description: String |
|
| 1477 |
+ let isCheckpoint: Bool |
|
| 1478 |
+ } |
|
| 1479 |
+ |
|
| 1480 |
+ var anchors: [Anchor] = [] |
|
| 1481 |
+ |
|
| 1482 |
+ if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
|
|
| 1483 |
+ anchors.append( |
|
| 1484 |
+ Anchor( |
|
| 1485 |
+ percent: startBatteryPercent, |
|
| 1486 |
+ energyWh: 0, |
|
| 1487 |
+ timestamp: session.effectiveTrimStart, |
|
| 1488 |
+ description: "session start", |
|
| 1489 |
+ isCheckpoint: false |
|
| 1490 |
+ ) |
|
| 1491 |
+ ) |
|
| 1492 |
+ } |
|
| 1493 |
+ |
|
| 1494 |
+ anchors.append( |
|
| 1495 |
+ contentsOf: session.checkpoints |
|
| 1496 |
+ .sorted { lhs, rhs in
|
|
| 1497 |
+ if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
|
|
| 1498 |
+ return lhs.measuredEnergyWh < rhs.measuredEnergyWh |
|
| 1499 |
+ } |
|
| 1500 |
+ return lhs.timestamp < rhs.timestamp |
|
| 1501 |
+ } |
|
| 1502 |
+ .filter { checkpoint in
|
|
| 1503 |
+ checkpoint.batteryPercent >= 0 |
|
| 1504 |
+ } |
|
| 1505 |
+ .map { checkpoint in
|
|
| 1506 |
+ return Anchor( |
|
| 1507 |
+ percent: checkpoint.batteryPercent, |
|
| 1508 |
+ energyWh: checkpoint.measuredEnergyWh, |
|
| 1509 |
+ timestamp: checkpoint.timestamp, |
|
| 1510 |
+ description: checkpoint.flag.anchorDescription, |
|
| 1511 |
+ isCheckpoint: true |
|
| 1512 |
+ ) |
|
| 1513 |
+ } |
|
| 1514 |
+ ) |
|
| 1515 |
+ |
|
| 1516 |
+ guard !anchors.isEmpty else {
|
|
| 1517 |
+ return nil |
|
| 1518 |
+ } |
|
| 1519 |
+ |
|
| 1520 |
+ let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
|
|
| 1521 |
+ let anchor = eligibleAnchors.last ?? anchors.first! |
|
| 1522 |
+ let predictedPercent = BatteryLevelPredictionTuning.predictedPercent( |
|
| 1523 |
+ anchorPercent: anchor.percent, |
|
| 1524 |
+ anchorEnergyWh: anchor.energyWh, |
|
| 1525 |
+ anchorTimestamp: anchor.timestamp, |
|
| 1526 |
+ anchorIsCheckpoint: anchor.isCheckpoint, |
|
| 1527 |
+ effectiveEnergyWh: effectiveEnergyWh, |
|
| 1528 |
+ referenceTimestamp: session.lastObservedAt, |
|
| 1529 |
+ estimatedCapacityWh: estimatedCapacityWh |
|
| 1530 |
+ ) |
|
| 1531 |
+ |
|
| 1532 |
+ return BatteryLevelPrediction( |
|
| 1533 |
+ predictedPercent: predictedPercent, |
|
| 1534 |
+ estimatedCapacityWh: estimatedCapacityWh, |
|
| 1535 |
+ anchorPercent: anchor.percent, |
|
| 1536 |
+ anchorEnergyWh: anchor.energyWh, |
|
| 1537 |
+ anchorDescription: anchor.description |
|
| 1538 |
+ ) |
|
| 1539 |
+ } |
|
| 1540 |
+ |
|
| 1541 |
+ func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
|
|
| 1542 |
+ ChargedDeviceSummary( |
|
| 1543 |
+ id: id, |
|
| 1544 |
+ qrIdentifier: qrIdentifier, |
|
| 1545 |
+ name: name, |
|
| 1546 |
+ deviceClass: deviceClass, |
|
| 1547 |
+ deviceTemplateID: deviceTemplateID, |
|
| 1548 |
+ templateDefinition: templateDefinition, |
|
| 1549 |
+ supportsChargingWhileOff: supportsChargingWhileOff, |
|
| 1550 |
+ chargingStateAvailability: chargingStateAvailability, |
|
| 1551 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 1552 |
+ supportsWirelessCharging: supportsWirelessCharging, |
|
| 1553 |
+ chargerType: chargerType, |
|
| 1554 |
+ wirelessChargingProfile: wirelessChargingProfile, |
|
| 1555 |
+ configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 1556 |
+ learnedCompletionCurrents: learnedCompletionCurrents, |
|
| 1557 |
+ wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor, |
|
| 1558 |
+ wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps, |
|
| 1559 |
+ wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps, |
|
| 1560 |
+ chargerObservedVoltageSelections: chargerObservedVoltageSelections, |
|
| 1561 |
+ chargerIdleCurrentAmps: chargerIdleCurrentAmps, |
|
| 1562 |
+ chargerEfficiencyFactor: chargerEfficiencyFactor, |
|
| 1563 |
+ chargerMaximumPowerWatts: chargerMaximumPowerWatts, |
|
| 1564 |
+ notes: notes, |
|
| 1565 |
+ minimumCurrentAmps: minimumCurrentAmps, |
|
| 1566 |
+ estimatedBatteryCapacityWh: estimatedBatteryCapacityWh, |
|
| 1567 |
+ wiredMinimumCurrentAmps: wiredMinimumCurrentAmps, |
|
| 1568 |
+ wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps, |
|
| 1569 |
+ wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh, |
|
| 1570 |
+ wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh, |
|
| 1571 |
+ lastAssociatedMeterMAC: lastAssociatedMeterMAC, |
|
| 1572 |
+ createdAt: createdAt, |
|
| 1573 |
+ updatedAt: updatedAt, |
|
| 1574 |
+ sessions: sessions, |
|
| 1575 |
+ capacityHistory: capacityHistory, |
|
| 1576 |
+ typicalCurve: typicalCurve, |
|
| 1577 |
+ standbyPowerMeasurements: measurements |
|
| 1578 |
+ ) |
|
| 1579 |
+ } |
|
| 1580 |
+} |
|
| 1581 |
+ |
|
| 1582 |
+struct ChargingMonitorSnapshot {
|
|
| 1583 |
+ let meterMACAddress: String |
|
| 1584 |
+ let meterName: String |
|
| 1585 |
+ let meterModel: String |
|
| 1586 |
+ let observedAt: Date |
|
| 1587 |
+ let voltageVolts: Double |
|
| 1588 |
+ let currentAmps: Double |
|
| 1589 |
+ let powerWatts: Double |
|
| 1590 |
+ let selectedDataGroup: UInt8? |
|
| 1591 |
+ let meterChargeCounterAh: Double? |
|
| 1592 |
+ let meterEnergyCounterWh: Double? |
|
| 1593 |
+ let meterRecordingDurationSeconds: TimeInterval? |
|
| 1594 |
+ let fallbackStopThresholdAmps: Double |
|
| 1595 |
+} |
|
@@ -0,0 +1,3548 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargeInsightsStore.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 10/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import CoreData |
|
| 9 |
+import Foundation |
|
| 10 |
+ |
|
| 11 |
+final class ChargeInsightsStore {
|
|
| 12 |
+ private enum EntityName {
|
|
| 13 |
+ static let chargedDevice = "ChargedDevice" |
|
| 14 |
+ static let chargeSession = "ChargeSession" |
|
| 15 |
+ static let chargeCheckpoint = "ChargeCheckpoint" |
|
| 16 |
+ static let chargeSessionSample = "ChargeSessionSample" |
|
| 17 |
+ } |
|
| 18 |
+ |
|
| 19 |
+ private enum MeterAssignmentKind {
|
|
| 20 |
+ case chargedDevice |
|
| 21 |
+ case charger |
|
| 22 |
+ |
|
| 23 |
+ var expectsChargerClass: Bool {
|
|
| 24 |
+ switch self {
|
|
| 25 |
+ case .chargedDevice: |
|
| 26 |
+ return false |
|
| 27 |
+ case .charger: |
|
| 28 |
+ return true |
|
| 29 |
+ } |
|
| 30 |
+ } |
|
| 31 |
+ } |
|
| 32 |
+ |
|
| 33 |
+ private static let maximumChargeSessionDuration: TimeInterval = 12 * 60 * 60 |
|
| 34 |
+ private static let persistedSamplesPerHour = 360 |
|
| 35 |
+ private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour) |
|
| 36 |
+ |
|
| 37 |
+ private let context: NSManagedObjectContext |
|
| 38 |
+ private let stopDetectionHoldDuration: TimeInterval = 20 |
|
| 39 |
+ private let maximumLiveIntegrationGap: TimeInterval = 90 |
|
| 40 |
+ private let activeSessionSaveInterval: TimeInterval = 60 |
|
| 41 |
+ private let aggregatedSampleSaveInterval: TimeInterval = 30 |
|
| 42 |
+ private let counterDecreaseTolerance = 0.002 |
|
| 43 |
+ private let completionConfirmationCooldown: TimeInterval = 15 * 60 |
|
| 44 |
+ private let pausedSessionTimeout: TimeInterval = 10 * 60 |
|
| 45 |
+ private let defaultCompletionPercentThreshold = 95.0 |
|
| 46 |
+ private let completionContradictionTolerancePercent = 2.0 |
|
| 47 |
+ private let minimumWirelessEfficiencyFactor = 0.35 |
|
| 48 |
+ private let maximumWirelessEfficiencyFactor = 0.95 |
|
| 49 |
+ private let lowWirelessEfficiencyThreshold = 0.72 |
|
| 50 |
+ private let unresolvedFlatBatteryPercent = -1.0 |
|
| 51 |
+ |
|
| 52 |
+ init(context: NSManagedObjectContext) {
|
|
| 53 |
+ self.context = context |
|
| 54 |
+ } |
|
| 55 |
+ |
|
| 56 |
+ func refreshContext() {
|
|
| 57 |
+ context.performAndWait {
|
|
| 58 |
+ context.processPendingChanges() |
|
| 59 |
+ } |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 62 |
+ func resetContext() {
|
|
| 63 |
+ context.performAndWait {
|
|
| 64 |
+ context.reset() |
|
| 65 |
+ } |
|
| 66 |
+ } |
|
| 67 |
+ |
|
| 68 |
+ @discardableResult |
|
| 69 |
+ func flushPendingChanges() -> Bool {
|
|
| 70 |
+ var didSave = false |
|
| 71 |
+ context.performAndWait {
|
|
| 72 |
+ context.processPendingChanges() |
|
| 73 |
+ didSave = saveContext() |
|
| 74 |
+ } |
|
| 75 |
+ return didSave |
|
| 76 |
+ } |
|
| 77 |
+ |
|
| 78 |
+ @discardableResult |
|
| 79 |
+ func completeExpiredOpenSessions(referenceDate: Date = Date()) -> Bool {
|
|
| 80 |
+ var didSave = false |
|
| 81 |
+ |
|
| 82 |
+ context.performAndWait {
|
|
| 83 |
+ let expiredSessions = fetchOpenSessionObjects().compactMap { session -> NSManagedObject? in
|
|
| 84 |
+ guard automaticCompletionDate(for: session, referenceDate: referenceDate) != nil else {
|
|
| 85 |
+ return nil |
|
| 86 |
+ } |
|
| 87 |
+ return session |
|
| 88 |
+ } |
|
| 89 |
+ guard expiredSessions.isEmpty == false else {
|
|
| 90 |
+ return |
|
| 91 |
+ } |
|
| 92 |
+ |
|
| 93 |
+ var chargedDeviceIDsToRefresh = Set<String>() |
|
| 94 |
+ for session in expiredSessions {
|
|
| 95 |
+ guard let completionDate = automaticCompletionDate(for: session, referenceDate: referenceDate) else {
|
|
| 96 |
+ continue |
|
| 97 |
+ } |
|
| 98 |
+ finishSession( |
|
| 99 |
+ session, |
|
| 100 |
+ observedAt: completionDate, |
|
| 101 |
+ finalBatteryPercent: nil, |
|
| 102 |
+ status: .completed |
|
| 103 |
+ ) |
|
| 104 |
+ if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
|
|
| 105 |
+ chargedDeviceIDsToRefresh.insert(chargedDeviceID) |
|
| 106 |
+ } |
|
| 107 |
+ } |
|
| 108 |
+ |
|
| 109 |
+ guard saveContext() else {
|
|
| 110 |
+ return |
|
| 111 |
+ } |
|
| 112 |
+ |
|
| 113 |
+ for chargedDeviceID in chargedDeviceIDsToRefresh {
|
|
| 114 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 115 |
+ } |
|
| 116 |
+ didSave = saveContext() |
|
| 117 |
+ } |
|
| 118 |
+ |
|
| 119 |
+ return didSave |
|
| 120 |
+ } |
|
| 121 |
+ |
|
| 122 |
+ // Heals the invariant "at most one open session per meter MAC". |
|
| 123 |
+ // Called after every remote CloudKit sync import to resolve sessions that were started |
|
| 124 |
+ // independently on different devices while offline. |
|
| 125 |
+ // |
|
| 126 |
+ // Scenario: session A started on Device 1 and forgotten; user starts session B on Device 2 |
|
| 127 |
+ // while offline. After sync both appear open for the same meter. |
|
| 128 |
+ // |
|
| 129 |
+ // Winner = session with the latest startedAt (represents the user's intentional new session). |
|
| 130 |
+ // Loser endedAt is set to winner's startedAt so there is no time overlap. |
|
| 131 |
+ @discardableResult |
|
| 132 |
+ func healDuplicateOpenSessions() -> Bool {
|
|
| 133 |
+ var didSave = false |
|
| 134 |
+ |
|
| 135 |
+ context.performAndWait {
|
|
| 136 |
+ let openSessions = fetchOpenSessionObjects() |
|
| 137 |
+ |
|
| 138 |
+ var sessionsByMAC: [String: [NSManagedObject]] = [:] |
|
| 139 |
+ for session in openSessions {
|
|
| 140 |
+ guard let mac = stringValue(session, key: "meterMACAddress") else { continue }
|
|
| 141 |
+ sessionsByMAC[mac, default: []].append(session) |
|
| 142 |
+ } |
|
| 143 |
+ |
|
| 144 |
+ let duplicatedMACs = sessionsByMAC.filter { $0.value.count > 1 }
|
|
| 145 |
+ guard !duplicatedMACs.isEmpty else { return }
|
|
| 146 |
+ |
|
| 147 |
+ var chargedDeviceIDsToRefresh = Set<String>() |
|
| 148 |
+ |
|
| 149 |
+ for (_, sessions) in duplicatedMACs {
|
|
| 150 |
+ // Winner = most recently started (explicit user intent); tie-break by measuredEnergyWh |
|
| 151 |
+ let winner = sessions.max { a, b in
|
|
| 152 |
+ let aDate = (a.value(forKey: "startedAt") as? Date) ?? .distantPast |
|
| 153 |
+ let bDate = (b.value(forKey: "startedAt") as? Date) ?? .distantPast |
|
| 154 |
+ if aDate != bDate { return aDate < bDate }
|
|
| 155 |
+ let aEnergy = (a.value(forKey: "measuredEnergyWh") as? Double) ?? 0 |
|
| 156 |
+ let bEnergy = (b.value(forKey: "measuredEnergyWh") as? Double) ?? 0 |
|
| 157 |
+ return aEnergy < bEnergy |
|
| 158 |
+ } |
|
| 159 |
+ let winnerStartedAt = (winner?.value(forKey: "startedAt") as? Date) ?? Date() |
|
| 160 |
+ |
|
| 161 |
+ for loser in sessions where loser !== winner {
|
|
| 162 |
+ // End the loser exactly when the winner began — no overlap. |
|
| 163 |
+ finishSession(loser, observedAt: winnerStartedAt, finalBatteryPercent: nil, status: .abandoned) |
|
| 164 |
+ loser.setValue(true, forKey: "wasConflictHealed") |
|
| 165 |
+ if let chargedDeviceID = stringValue(loser, key: "chargedDeviceID") {
|
|
| 166 |
+ chargedDeviceIDsToRefresh.insert(chargedDeviceID) |
|
| 167 |
+ } |
|
| 168 |
+ track("ChargeInsightsStore: healed duplicate open session \(stringValue(loser, key: "id") ?? "?") for meter \(stringValue(loser, key: "meterMACAddress") ?? "?")")
|
|
| 169 |
+ } |
|
| 170 |
+ } |
|
| 171 |
+ |
|
| 172 |
+ guard saveContext() else { return }
|
|
| 173 |
+ |
|
| 174 |
+ for chargedDeviceID in chargedDeviceIDsToRefresh {
|
|
| 175 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 176 |
+ } |
|
| 177 |
+ didSave = saveContext() |
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ return didSave |
|
| 181 |
+ } |
|
| 182 |
+ |
|
| 183 |
+ @discardableResult |
|
| 184 |
+ func createDevice( |
|
| 185 |
+ name: String, |
|
| 186 |
+ deviceClass: ChargedDeviceClass, |
|
| 187 |
+ templateID: String?, |
|
| 188 |
+ chargingStateAvailability: ChargingStateAvailability, |
|
| 189 |
+ supportsWiredCharging: Bool, |
|
| 190 |
+ supportsWirelessCharging: Bool, |
|
| 191 |
+ wirelessChargingProfile: WirelessChargingProfile, |
|
| 192 |
+ configuredCompletionCurrents: [ChargeSessionKind: Double], |
|
| 193 |
+ notes: String?, |
|
| 194 |
+ assignTo meterMACAddress: String? |
|
| 195 |
+ ) -> Bool {
|
|
| 196 |
+ guard deviceClass.kind == .device else { return false }
|
|
| 197 |
+ let normalizedName = normalizedText(name) |
|
| 198 |
+ let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability) |
|
| 199 |
+ let normalizedChargingSupport = deviceClass.normalizedChargingSupport( |
|
| 200 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 201 |
+ supportsWirelessCharging: supportsWirelessCharging |
|
| 202 |
+ ) |
|
| 203 |
+ let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device) |
|
| 204 |
+ guard !normalizedName.isEmpty else { return false }
|
|
| 205 |
+ guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
|
|
| 206 |
+ |
|
| 207 |
+ var didSave = false |
|
| 208 |
+ context.performAndWait {
|
|
| 209 |
+ guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
|
|
| 210 |
+ return |
|
| 211 |
+ } |
|
| 212 |
+ |
|
| 213 |
+ let object = NSManagedObject(entity: entity, insertInto: context) |
|
| 214 |
+ let now = Date() |
|
| 215 |
+ object.setValue(UUID().uuidString, forKey: "id") |
|
| 216 |
+ object.setValue(normalizedName, forKey: "name") |
|
| 217 |
+ object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue") |
|
| 218 |
+ object.setValue(normalizedTemplateID, forKey: "deviceTemplateID") |
|
| 219 |
+ object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue") |
|
| 220 |
+ object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff") |
|
| 221 |
+ object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging") |
|
| 222 |
+ object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging") |
|
| 223 |
+ object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue") |
|
| 224 |
+ object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue") |
|
| 225 |
+ object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps") |
|
| 226 |
+ object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps") |
|
| 227 |
+ object.setValue(normalizedOptionalText(notes), forKey: "notes") |
|
| 228 |
+ object.setValue(generateQRIdentifier(), forKey: "qrIdentifier") |
|
| 229 |
+ object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC") |
|
| 230 |
+ object.setValue(now, forKey: "createdAt") |
|
| 231 |
+ object.setValue(now, forKey: "updatedAt") |
|
| 232 |
+ didSave = saveContext() |
|
| 233 |
+ } |
|
| 234 |
+ return didSave |
|
| 235 |
+ } |
|
| 236 |
+ |
|
| 237 |
+ @discardableResult |
|
| 238 |
+ func createCharger( |
|
| 239 |
+ name: String, |
|
| 240 |
+ chargerType: ChargerType, |
|
| 241 |
+ notes: String?, |
|
| 242 |
+ assignTo meterMACAddress: String? |
|
| 243 |
+ ) -> Bool {
|
|
| 244 |
+ let normalizedName = normalizedText(name) |
|
| 245 |
+ guard !normalizedName.isEmpty else { return false }
|
|
| 246 |
+ |
|
| 247 |
+ var didSave = false |
|
| 248 |
+ context.performAndWait {
|
|
| 249 |
+ guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
|
|
| 250 |
+ return |
|
| 251 |
+ } |
|
| 252 |
+ |
|
| 253 |
+ let object = NSManagedObject(entity: entity, insertInto: context) |
|
| 254 |
+ let now = Date() |
|
| 255 |
+ object.setValue(UUID().uuidString, forKey: "id") |
|
| 256 |
+ object.setValue(normalizedName, forKey: "name") |
|
| 257 |
+ object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue") |
|
| 258 |
+ object.setValue(nil, forKey: "deviceTemplateID") |
|
| 259 |
+ object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue") |
|
| 260 |
+ object.setValue(false, forKey: "supportsChargingWhileOff") |
|
| 261 |
+ object.setValue(false, forKey: "supportsWiredCharging") |
|
| 262 |
+ object.setValue(true, forKey: "supportsWirelessCharging") |
|
| 263 |
+ if object.entity.attributesByName["chargerTypeRawValue"] != nil {
|
|
| 264 |
+ object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue") |
|
| 265 |
+ } |
|
| 266 |
+ object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue") |
|
| 267 |
+ object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue") |
|
| 268 |
+ object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps") |
|
| 269 |
+ object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps") |
|
| 270 |
+ object.setValue(normalizedOptionalText(notes), forKey: "notes") |
|
| 271 |
+ object.setValue(generateQRIdentifier(), forKey: "qrIdentifier") |
|
| 272 |
+ object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC") |
|
| 273 |
+ object.setValue(now, forKey: "createdAt") |
|
| 274 |
+ object.setValue(now, forKey: "updatedAt") |
|
| 275 |
+ didSave = saveContext() |
|
| 276 |
+ } |
|
| 277 |
+ return didSave |
|
| 278 |
+ } |
|
| 279 |
+ |
|
| 280 |
+ @discardableResult |
|
| 281 |
+ func updateDevice( |
|
| 282 |
+ id: UUID, |
|
| 283 |
+ name: String, |
|
| 284 |
+ deviceClass: ChargedDeviceClass, |
|
| 285 |
+ templateID: String?, |
|
| 286 |
+ chargingStateAvailability: ChargingStateAvailability, |
|
| 287 |
+ supportsWiredCharging: Bool, |
|
| 288 |
+ supportsWirelessCharging: Bool, |
|
| 289 |
+ wirelessChargingProfile: WirelessChargingProfile, |
|
| 290 |
+ configuredCompletionCurrents: [ChargeSessionKind: Double], |
|
| 291 |
+ notes: String? |
|
| 292 |
+ ) -> Bool {
|
|
| 293 |
+ guard deviceClass.kind == .device else { return false }
|
|
| 294 |
+ let normalizedName = normalizedText(name) |
|
| 295 |
+ let normalizedChargingStateAvailability = deviceClass.normalizedChargingStateAvailability(chargingStateAvailability) |
|
| 296 |
+ let normalizedChargingSupport = deviceClass.normalizedChargingSupport( |
|
| 297 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 298 |
+ supportsWirelessCharging: supportsWirelessCharging |
|
| 299 |
+ ) |
|
| 300 |
+ let normalizedTemplateID = normalizedTemplateID(templateID, kind: .device) |
|
| 301 |
+ guard !normalizedName.isEmpty else { return false }
|
|
| 302 |
+ guard normalizedChargingSupport.wired || normalizedChargingSupport.wireless else { return false }
|
|
| 303 |
+ |
|
| 304 |
+ var didSave = false |
|
| 305 |
+ context.performAndWait {
|
|
| 306 |
+ guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
|
|
| 307 |
+ return |
|
| 308 |
+ } |
|
| 309 |
+ guard isChargerObject(object) == false else {
|
|
| 310 |
+ return |
|
| 311 |
+ } |
|
| 312 |
+ |
|
| 313 |
+ let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff") |
|
| 314 |
+ let previousChargingStateAvailability = self.chargingStateAvailability(for: object) |
|
| 315 |
+ let previousSupportsWiredCharging = self.supportsWiredCharging(for: object) |
|
| 316 |
+ let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object) |
|
| 317 |
+ let now = Date() |
|
| 318 |
+ |
|
| 319 |
+ object.setValue(normalizedName, forKey: "name") |
|
| 320 |
+ object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue") |
|
| 321 |
+ object.setValue(normalizedTemplateID, forKey: "deviceTemplateID") |
|
| 322 |
+ object.setValue(normalizedChargingStateAvailability.rawValue, forKey: "chargingStateAvailabilityRawValue") |
|
| 323 |
+ object.setValue(normalizedChargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff") |
|
| 324 |
+ object.setValue(normalizedChargingSupport.wired, forKey: "supportsWiredCharging") |
|
| 325 |
+ object.setValue(normalizedChargingSupport.wireless, forKey: "supportsWirelessCharging") |
|
| 326 |
+ object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue") |
|
| 327 |
+ object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue") |
|
| 328 |
+ object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps") |
|
| 329 |
+ object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wireless), forKey: "wirelessChargeCompletionCurrentAmps") |
|
| 330 |
+ object.setValue(normalizedOptionalText(notes), forKey: "notes") |
|
| 331 |
+ object.setValue(now, forKey: "updatedAt") |
|
| 332 |
+ |
|
| 333 |
+ let supportsChargingWhileOff = normalizedChargingStateAvailability.supportsChargingWhileOff |
|
| 334 |
+ let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff |
|
| 335 |
+ let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity |
|
| 336 |
+ || previousChargingStateAvailability != normalizedChargingStateAvailability |
|
| 337 |
+ || previousSupportsWiredCharging != normalizedChargingSupport.wired |
|
| 338 |
+ || previousSupportsWirelessCharging != normalizedChargingSupport.wireless |
|
| 339 |
+ |
|
| 340 |
+ if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
|
|
| 341 |
+ let sessions = fetchSessions(forChargedDeviceID: id.uuidString) |
|
| 342 |
+ for session in sessions {
|
|
| 343 |
+ let isOpen = statusValue(session, key: "statusRawValue")?.isOpen == true |
|
| 344 |
+ |
|
| 345 |
+ if shouldRecalculateSessionCapacity {
|
|
| 346 |
+ session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff") |
|
| 347 |
+ updateCapacityEstimate(for: session) |
|
| 348 |
+ session.setValue(now, forKey: "updatedAt") |
|
| 349 |
+ } |
|
| 350 |
+ |
|
| 351 |
+ guard isOpen, shouldRefreshActiveSessions else {
|
|
| 352 |
+ continue |
|
| 353 |
+ } |
|
| 354 |
+ |
|
| 355 |
+ let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode( |
|
| 356 |
+ chargingTransportMode(for: session), |
|
| 357 |
+ supportsWiredCharging: normalizedChargingSupport.wired, |
|
| 358 |
+ supportsWirelessCharging: normalizedChargingSupport.wireless |
|
| 359 |
+ ) |
|
| 360 |
+ let resolvedSessionChargingStateMode = resolvedChargingStateMode( |
|
| 361 |
+ chargingStateMode(for: session), |
|
| 362 |
+ availability: normalizedChargingStateAvailability |
|
| 363 |
+ ) |
|
| 364 |
+ let charger = stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:)) |
|
| 365 |
+ |
|
| 366 |
+ session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff") |
|
| 367 |
+ session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue") |
|
| 368 |
+ session.setValue(resolvedSessionChargingStateMode.rawValue, forKey: "chargingStateRawValue") |
|
| 369 |
+ session.setValue( |
|
| 370 |
+ resolvedStopThreshold( |
|
| 371 |
+ for: object, |
|
| 372 |
+ chargingTransportMode: resolvedSessionChargingTransportMode, |
|
| 373 |
+ chargingStateMode: resolvedSessionChargingStateMode, |
|
| 374 |
+ charger: charger, |
|
| 375 |
+ fallback: optionalDoubleValue(session, key: "stopThresholdAmps") |
|
| 376 |
+ ) ?? 0, |
|
| 377 |
+ forKey: "stopThresholdAmps" |
|
| 378 |
+ ) |
|
| 379 |
+ session.setValue(now, forKey: "updatedAt") |
|
| 380 |
+ updateCapacityEstimate(for: session) |
|
| 381 |
+ } |
|
| 382 |
+ } |
|
| 383 |
+ |
|
| 384 |
+ refreshDerivedMetrics(forChargedDeviceID: id.uuidString) |
|
| 385 |
+ didSave = saveContext() |
|
| 386 |
+ } |
|
| 387 |
+ return didSave |
|
| 388 |
+ } |
|
| 389 |
+ |
|
| 390 |
+ @discardableResult |
|
| 391 |
+ func updateCharger( |
|
| 392 |
+ id: UUID, |
|
| 393 |
+ name: String, |
|
| 394 |
+ chargerType: ChargerType, |
|
| 395 |
+ notes: String? |
|
| 396 |
+ ) -> Bool {
|
|
| 397 |
+ let normalizedName = normalizedText(name) |
|
| 398 |
+ guard !normalizedName.isEmpty else { return false }
|
|
| 399 |
+ |
|
| 400 |
+ var didSave = false |
|
| 401 |
+ context.performAndWait {
|
|
| 402 |
+ guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
|
|
| 403 |
+ return |
|
| 404 |
+ } |
|
| 405 |
+ guard isChargerObject(object) else {
|
|
| 406 |
+ return |
|
| 407 |
+ } |
|
| 408 |
+ |
|
| 409 |
+ object.setValue(normalizedName, forKey: "name") |
|
| 410 |
+ object.setValue(nil, forKey: "deviceTemplateID") |
|
| 411 |
+ object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue") |
|
| 412 |
+ object.setValue(false, forKey: "supportsChargingWhileOff") |
|
| 413 |
+ object.setValue(false, forKey: "supportsWiredCharging") |
|
| 414 |
+ object.setValue(true, forKey: "supportsWirelessCharging") |
|
| 415 |
+ if object.entity.attributesByName["chargerTypeRawValue"] != nil {
|
|
| 416 |
+ object.setValue(chargerType.rawValue, forKey: "chargerTypeRawValue") |
|
| 417 |
+ } |
|
| 418 |
+ object.setValue(chargerType.wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue") |
|
| 419 |
+ object.setValue(normalizedOptionalText(notes), forKey: "notes") |
|
| 420 |
+ object.setValue(Date(), forKey: "updatedAt") |
|
| 421 |
+ refreshDerivedMetrics(forChargedDeviceID: id.uuidString) |
|
| 422 |
+ didSave = saveContext() |
|
| 423 |
+ } |
|
| 424 |
+ |
|
| 425 |
+ return didSave |
|
| 426 |
+ } |
|
| 427 |
+ |
|
| 428 |
+ @discardableResult |
|
| 429 |
+ func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
|
|
| 430 |
+ assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice) |
|
| 431 |
+ } |
|
| 432 |
+ |
|
| 433 |
+ @discardableResult |
|
| 434 |
+ func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
|
|
| 435 |
+ assign(itemWithID: id, to: meterMACAddress, kind: .charger) |
|
| 436 |
+ } |
|
| 437 |
+ |
|
| 438 |
+ @discardableResult |
|
| 439 |
+ private func assign( |
|
| 440 |
+ itemWithID id: UUID, |
|
| 441 |
+ to meterMACAddress: String, |
|
| 442 |
+ kind: MeterAssignmentKind |
|
| 443 |
+ ) -> Bool {
|
|
| 444 |
+ let normalizedMAC = normalizedMACAddress(meterMACAddress) |
|
| 445 |
+ guard !normalizedMAC.isEmpty else { return false }
|
|
| 446 |
+ |
|
| 447 |
+ var didSave = false |
|
| 448 |
+ context.performAndWait {
|
|
| 449 |
+ guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
|
|
| 450 |
+ return |
|
| 451 |
+ } |
|
| 452 |
+ |
|
| 453 |
+ let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger |
|
| 454 |
+ guard isCharger == kind.expectsChargerClass else {
|
|
| 455 |
+ return |
|
| 456 |
+ } |
|
| 457 |
+ |
|
| 458 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice) |
|
| 459 |
+ request.predicate = NSPredicate( |
|
| 460 |
+ format: "lastAssociatedMeterMAC == %@ AND id != %@", |
|
| 461 |
+ normalizedMAC, |
|
| 462 |
+ id.uuidString |
|
| 463 |
+ ) |
|
| 464 |
+ let previouslyAssignedDevices = (try? context.fetch(request)) ?? [] |
|
| 465 |
+ for previousDevice in previouslyAssignedDevices {
|
|
| 466 |
+ let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger |
|
| 467 |
+ guard previousIsCharger == kind.expectsChargerClass else {
|
|
| 468 |
+ continue |
|
| 469 |
+ } |
|
| 470 |
+ previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC") |
|
| 471 |
+ previousDevice.setValue(Date(), forKey: "updatedAt") |
|
| 472 |
+ } |
|
| 473 |
+ |
|
| 474 |
+ object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC") |
|
| 475 |
+ object.setValue(Date(), forKey: "updatedAt") |
|
| 476 |
+ |
|
| 477 |
+ if kind == .charger, |
|
| 478 |
+ let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC), |
|
| 479 |
+ chargingTransportMode(for: openSession) == .wireless {
|
|
| 480 |
+ openSession.setValue(id.uuidString, forKey: "chargerID") |
|
| 481 |
+ openSession.setValue(Date(), forKey: "updatedAt") |
|
| 482 |
+ } |
|
| 483 |
+ |
|
| 484 |
+ didSave = saveContext() |
|
| 485 |
+ } |
|
| 486 |
+ return didSave |
|
| 487 |
+ } |
|
| 488 |
+ |
|
| 489 |
+ @discardableResult |
|
| 490 |
+ func startSession( |
|
| 491 |
+ for snapshot: ChargingMonitorSnapshot, |
|
| 492 |
+ chargedDeviceID: UUID, |
|
| 493 |
+ chargerID: UUID?, |
|
| 494 |
+ chargingTransportMode: ChargingTransportMode, |
|
| 495 |
+ chargingStateMode: ChargingStateMode, |
|
| 496 |
+ autoStopEnabled: Bool, |
|
| 497 |
+ initialBatteryPercent: Double?, |
|
| 498 |
+ startsFromFlatBattery: Bool |
|
| 499 |
+ ) -> Bool {
|
|
| 500 |
+ if let initialBatteryPercent, |
|
| 501 |
+ (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
|
|
| 502 |
+ return false |
|
| 503 |
+ } |
|
| 504 |
+ |
|
| 505 |
+ var didSave = false |
|
| 506 |
+ context.performAndWait {
|
|
| 507 |
+ guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
|
|
| 508 |
+ return |
|
| 509 |
+ } |
|
| 510 |
+ guard isChargerObject(chargedDevice) == false else {
|
|
| 511 |
+ return |
|
| 512 |
+ } |
|
| 513 |
+ |
|
| 514 |
+ guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
|
|
| 515 |
+ return |
|
| 516 |
+ } |
|
| 517 |
+ |
|
| 518 |
+ let resolvedChargingTransportMode = resolvedPreferredChargingTransportMode( |
|
| 519 |
+ chargingTransportMode, |
|
| 520 |
+ supportsWiredCharging: supportsWiredCharging(for: chargedDevice), |
|
| 521 |
+ supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice) |
|
| 522 |
+ ) |
|
| 523 |
+ let resolvedChargingStateMode = resolvedChargingStateMode( |
|
| 524 |
+ chargingStateMode, |
|
| 525 |
+ availability: chargingStateAvailability(for: chargedDevice) |
|
| 526 |
+ ) |
|
| 527 |
+ let charger = resolvedChargingTransportMode == .wireless |
|
| 528 |
+ ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
|
|
| 529 |
+ : nil |
|
| 530 |
+ if let charger, isChargerObject(charger) == false {
|
|
| 531 |
+ return |
|
| 532 |
+ } |
|
| 533 |
+ guard resolvedChargingTransportMode == .wired || charger != nil else {
|
|
| 534 |
+ return |
|
| 535 |
+ } |
|
| 536 |
+ let stopThreshold = resolvedStopThreshold( |
|
| 537 |
+ for: chargedDevice, |
|
| 538 |
+ chargingTransportMode: resolvedChargingTransportMode, |
|
| 539 |
+ chargingStateMode: resolvedChargingStateMode, |
|
| 540 |
+ charger: charger, |
|
| 541 |
+ fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil |
|
| 542 |
+ ) |
|
| 543 |
+ guard let session = createSessionObject( |
|
| 544 |
+ for: chargedDevice, |
|
| 545 |
+ charger: charger, |
|
| 546 |
+ snapshot: snapshot, |
|
| 547 |
+ stopThreshold: stopThreshold, |
|
| 548 |
+ chargingTransportMode: resolvedChargingTransportMode, |
|
| 549 |
+ chargingStateMode: resolvedChargingStateMode, |
|
| 550 |
+ autoStopEnabled: autoStopEnabled |
|
| 551 |
+ ) else {
|
|
| 552 |
+ return |
|
| 553 |
+ } |
|
| 554 |
+ |
|
| 555 |
+ if startsFromFlatBattery {
|
|
| 556 |
+ session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent") |
|
| 557 |
+ session.setValue(nil, forKey: "endBatteryPercent") |
|
| 558 |
+ } else if let initialBatteryPercent {
|
|
| 559 |
+ guard insertBatteryCheckpoint( |
|
| 560 |
+ percent: initialBatteryPercent, |
|
| 561 |
+ flag: .initial, |
|
| 562 |
+ timestamp: snapshot.observedAt, |
|
| 563 |
+ to: session |
|
| 564 |
+ ) != nil else {
|
|
| 565 |
+ return |
|
| 566 |
+ } |
|
| 567 |
+ } |
|
| 568 |
+ didSave = saveContext() |
|
| 569 |
+ } |
|
| 570 |
+ return didSave |
|
| 571 |
+ } |
|
| 572 |
+ |
|
| 573 |
+ @discardableResult |
|
| 574 |
+ func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
|
|
| 575 |
+ var didSave = false |
|
| 576 |
+ context.performAndWait {
|
|
| 577 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
|
| 578 |
+ return |
|
| 579 |
+ } |
|
| 580 |
+ |
|
| 581 |
+ guard statusValue(session, key: "statusRawValue") == .active else {
|
|
| 582 |
+ return |
|
| 583 |
+ } |
|
| 584 |
+ |
|
| 585 |
+ session.setValue(ChargeSessionStatus.paused.rawValue, forKey: "statusRawValue") |
|
| 586 |
+ session.setValue(observedAt, forKey: "pausedAt") |
|
| 587 |
+ session.setValue(nil, forKey: "belowThresholdSince") |
|
| 588 |
+ clearCompletionConfirmationState(for: session) |
|
| 589 |
+ session.setValue(observedAt, forKey: "updatedAt") |
|
| 590 |
+ didSave = saveContext() |
|
| 591 |
+ } |
|
| 592 |
+ return didSave |
|
| 593 |
+ } |
|
| 594 |
+ |
|
| 595 |
+ @discardableResult |
|
| 596 |
+ func resumeSession(id sessionID: UUID, snapshot: ChargingMonitorSnapshot?) -> Bool {
|
|
| 597 |
+ var didSave = false |
|
| 598 |
+ context.performAndWait {
|
|
| 599 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
|
| 600 |
+ return |
|
| 601 |
+ } |
|
| 602 |
+ |
|
| 603 |
+ guard statusValue(session, key: "statusRawValue") == .paused else {
|
|
| 604 |
+ return |
|
| 605 |
+ } |
|
| 606 |
+ |
|
| 607 |
+ let resumedAt = snapshot?.observedAt ?? Date() |
|
| 608 |
+ if let completionDate = automaticCompletionDate(for: session, referenceDate: resumedAt) {
|
|
| 609 |
+ finishSession( |
|
| 610 |
+ session, |
|
| 611 |
+ observedAt: completionDate, |
|
| 612 |
+ finalBatteryPercent: nil, |
|
| 613 |
+ status: .completed |
|
| 614 |
+ ) |
|
| 615 |
+ guard saveContext() else {
|
|
| 616 |
+ return |
|
| 617 |
+ } |
|
| 618 |
+ if let deviceID = stringValue(session, key: "chargedDeviceID") {
|
|
| 619 |
+ refreshDerivedMetrics(forChargedDeviceID: deviceID) |
|
| 620 |
+ didSave = saveContext() |
|
| 621 |
+ } else {
|
|
| 622 |
+ didSave = true |
|
| 623 |
+ } |
|
| 624 |
+ return |
|
| 625 |
+ } |
|
| 626 |
+ |
|
| 627 |
+ session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue") |
|
| 628 |
+ session.setValue(nil, forKey: "pausedAt") |
|
| 629 |
+ session.setValue(nil, forKey: "belowThresholdSince") |
|
| 630 |
+ clearCompletionConfirmationState(for: session) |
|
| 631 |
+ session.setValue(resumedAt, forKey: "lastObservedAt") |
|
| 632 |
+ if let snapshot {
|
|
| 633 |
+ session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps") |
|
| 634 |
+ session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts") |
|
| 635 |
+ session.setValue( |
|
| 636 |
+ chargingTransportMode(for: session) == .wired ? snapshot.voltageVolts : nil, |
|
| 637 |
+ forKey: "lastObservedVoltageVolts" |
|
| 638 |
+ ) |
|
| 639 |
+ } else {
|
|
| 640 |
+ session.setValue(0, forKey: "lastObservedCurrentAmps") |
|
| 641 |
+ session.setValue(0, forKey: "lastObservedPowerWatts") |
|
| 642 |
+ session.setValue(nil, forKey: "lastObservedVoltageVolts") |
|
| 643 |
+ } |
|
| 644 |
+ session.setValue(resumedAt, forKey: "updatedAt") |
|
| 645 |
+ didSave = saveContext() |
|
| 646 |
+ } |
|
| 647 |
+ return didSave |
|
| 648 |
+ } |
|
| 649 |
+ |
|
| 650 |
+ @discardableResult |
|
| 651 |
+ func stopSession( |
|
| 652 |
+ id sessionID: UUID, |
|
| 653 |
+ finalBatteryPercent: Double? = nil |
|
| 654 |
+ ) -> Bool {
|
|
| 655 |
+ if let finalBatteryPercent {
|
|
| 656 |
+ guard finalBatteryPercent.isFinite, finalBatteryPercent >= 0, finalBatteryPercent <= 100 else {
|
|
| 657 |
+ return false |
|
| 658 |
+ } |
|
| 659 |
+ } |
|
| 660 |
+ |
|
| 661 |
+ var didSave = false |
|
| 662 |
+ var deviceIDToRefresh: String? |
|
| 663 |
+ |
|
| 664 |
+ context.performAndWait {
|
|
| 665 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
|
| 666 |
+ return |
|
| 667 |
+ } |
|
| 668 |
+ |
|
| 669 |
+ guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
|
|
| 670 |
+ return |
|
| 671 |
+ } |
|
| 672 |
+ |
|
| 673 |
+ restoreMeasuredTotalsFromLatestSampleIfNeeded(session) |
|
| 674 |
+ |
|
| 675 |
+ guard hasSavableChargeData(session) else {
|
|
| 676 |
+ return |
|
| 677 |
+ } |
|
| 678 |
+ |
|
| 679 |
+ let observedAt = snapshotDateForManualStop(session) |
|
| 680 |
+ finishSession( |
|
| 681 |
+ session, |
|
| 682 |
+ observedAt: observedAt, |
|
| 683 |
+ finalBatteryPercent: finalBatteryPercent, |
|
| 684 |
+ status: .completed |
|
| 685 |
+ ) |
|
| 686 |
+ |
|
| 687 |
+ guard saveContext() else {
|
|
| 688 |
+ return |
|
| 689 |
+ } |
|
| 690 |
+ |
|
| 691 |
+ didSave = true |
|
| 692 |
+ deviceIDToRefresh = stringValue(session, key: "chargedDeviceID") |
|
| 693 |
+ } |
|
| 694 |
+ |
|
| 695 |
+ if let deviceID = deviceIDToRefresh {
|
|
| 696 |
+ context.perform { [weak self] in
|
|
| 697 |
+ guard let self else { return }
|
|
| 698 |
+ self.refreshDerivedMetrics(forChargedDeviceID: deviceID) |
|
| 699 |
+ self.saveContext() |
|
| 700 |
+ } |
|
| 701 |
+ } |
|
| 702 |
+ |
|
| 703 |
+ return didSave |
|
| 704 |
+ } |
|
| 705 |
+ |
|
| 706 |
+ @discardableResult |
|
| 707 |
+ func addBatteryCheckpoint( |
|
| 708 |
+ percent: Double, |
|
| 709 |
+ for meterMACAddress: String, |
|
| 710 |
+ measuredEnergyWh: Double? = nil, |
|
| 711 |
+ measuredChargeAh: Double? = nil |
|
| 712 |
+ ) -> Bool {
|
|
| 713 |
+ guard percent.isFinite, percent >= 0, percent <= 100 else {
|
|
| 714 |
+ return false |
|
| 715 |
+ } |
|
| 716 |
+ |
|
| 717 |
+ var didSave = false |
|
| 718 |
+ context.performAndWait {
|
|
| 719 |
+ guard let session = fetchOpenSessionObject(forMeterMACAddress: meterMACAddress) else {
|
|
| 720 |
+ return |
|
| 721 |
+ } |
|
| 722 |
+ |
|
| 723 |
+ didSave = addBatteryCheckpoint( |
|
| 724 |
+ percent: percent, |
|
| 725 |
+ measuredEnergyWh: measuredEnergyWh, |
|
| 726 |
+ measuredChargeAh: measuredChargeAh, |
|
| 727 |
+ flag: .intermediate, |
|
| 728 |
+ to: session |
|
| 729 |
+ ) |
|
| 730 |
+ } |
|
| 731 |
+ return didSave |
|
| 732 |
+ } |
|
| 733 |
+ |
|
| 734 |
+ @discardableResult |
|
| 735 |
+ func addBatteryCheckpoint( |
|
| 736 |
+ percent: Double, |
|
| 737 |
+ for sessionID: UUID, |
|
| 738 |
+ measuredEnergyWh: Double? = nil, |
|
| 739 |
+ measuredChargeAh: Double? = nil |
|
| 740 |
+ ) -> Bool {
|
|
| 741 |
+ guard percent.isFinite, percent >= 0, percent <= 100 else {
|
|
| 742 |
+ return false |
|
| 743 |
+ } |
|
| 744 |
+ |
|
| 745 |
+ var didSave = false |
|
| 746 |
+ context.performAndWait {
|
|
| 747 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
|
| 748 |
+ return |
|
| 749 |
+ } |
|
| 750 |
+ |
|
| 751 |
+ didSave = addBatteryCheckpoint( |
|
| 752 |
+ percent: percent, |
|
| 753 |
+ measuredEnergyWh: measuredEnergyWh, |
|
| 754 |
+ measuredChargeAh: measuredChargeAh, |
|
| 755 |
+ flag: .intermediate, |
|
| 756 |
+ to: session |
|
| 757 |
+ ) |
|
| 758 |
+ } |
|
| 759 |
+ return didSave |
|
| 760 |
+ } |
|
| 761 |
+ |
|
| 762 |
+ @discardableResult |
|
| 763 |
+ func deleteBatteryCheckpoint( |
|
| 764 |
+ id checkpointID: UUID, |
|
| 765 |
+ from sessionID: UUID |
|
| 766 |
+ ) -> Bool {
|
|
| 767 |
+ var didSave = false |
|
| 768 |
+ context.performAndWait {
|
|
| 769 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString), |
|
| 770 |
+ let checkpoint = fetchCheckpointObject( |
|
| 771 |
+ id: checkpointID.uuidString, |
|
| 772 |
+ sessionID: sessionID.uuidString |
|
| 773 |
+ ) else {
|
|
| 774 |
+ return |
|
| 775 |
+ } |
|
| 776 |
+ |
|
| 777 |
+ let chargedDeviceID = stringValue(session, key: "chargedDeviceID") |
|
| 778 |
+ context.delete(checkpoint) |
|
| 779 |
+ refreshCheckpointDerivedValues(for: session) |
|
| 780 |
+ |
|
| 781 |
+ if let chargedDeviceID {
|
|
| 782 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 783 |
+ } |
|
| 784 |
+ |
|
| 785 |
+ didSave = saveContext() |
|
| 786 |
+ } |
|
| 787 |
+ return didSave |
|
| 788 |
+ } |
|
| 789 |
+ |
|
| 790 |
+ @discardableResult |
|
| 791 |
+ func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
|
|
| 792 |
+ if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
|
|
| 793 |
+ return false |
|
| 794 |
+ } |
|
| 795 |
+ |
|
| 796 |
+ var didSave = false |
|
| 797 |
+ context.performAndWait {
|
|
| 798 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
|
| 799 |
+ return |
|
| 800 |
+ } |
|
| 801 |
+ |
|
| 802 |
+ session.setValue(percent, forKey: "targetBatteryPercent") |
|
| 803 |
+ session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt") |
|
| 804 |
+ session.setValue(Date(), forKey: "updatedAt") |
|
| 805 |
+ didSave = saveContext() |
|
| 806 |
+ } |
|
| 807 |
+ return didSave |
|
| 808 |
+ } |
|
| 809 |
+ |
|
| 810 |
+ @discardableResult |
|
| 811 |
+ func confirmCompletion(for sessionID: UUID) -> Bool {
|
|
| 812 |
+ var didSave = false |
|
| 813 |
+ context.performAndWait {
|
|
| 814 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
|
| 815 |
+ return |
|
| 816 |
+ } |
|
| 817 |
+ |
|
| 818 |
+ guard statusValue(session, key: "statusRawValue") == .active else {
|
|
| 819 |
+ return |
|
| 820 |
+ } |
|
| 821 |
+ |
|
| 822 |
+ finishSession( |
|
| 823 |
+ session, |
|
| 824 |
+ observedAt: dateValue(session, key: "lastObservedAt") ?? Date(), |
|
| 825 |
+ finalBatteryPercent: nil, |
|
| 826 |
+ status: .completed |
|
| 827 |
+ ) |
|
| 828 |
+ |
|
| 829 |
+ if saveContext() {
|
|
| 830 |
+ if let deviceID = stringValue(session, key: "chargedDeviceID") {
|
|
| 831 |
+ refreshDerivedMetrics(forChargedDeviceID: deviceID) |
|
| 832 |
+ didSave = saveContext() |
|
| 833 |
+ } else {
|
|
| 834 |
+ didSave = true |
|
| 835 |
+ } |
|
| 836 |
+ } |
|
| 837 |
+ } |
|
| 838 |
+ return didSave |
|
| 839 |
+ } |
|
| 840 |
+ |
|
| 841 |
+ @discardableResult |
|
| 842 |
+ func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
|
|
| 843 |
+ var didSave = false |
|
| 844 |
+ context.performAndWait {
|
|
| 845 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
|
| 846 |
+ return |
|
| 847 |
+ } |
|
| 848 |
+ |
|
| 849 |
+ guard statusValue(session, key: "statusRawValue") == .active else {
|
|
| 850 |
+ return |
|
| 851 |
+ } |
|
| 852 |
+ |
|
| 853 |
+ clearCompletionConfirmationState(for: session) |
|
| 854 |
+ session.setValue(nil, forKey: "belowThresholdSince") |
|
| 855 |
+ session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil") |
|
| 856 |
+ session.setValue(Date(), forKey: "updatedAt") |
|
| 857 |
+ didSave = saveContext() |
|
| 858 |
+ } |
|
| 859 |
+ return didSave |
|
| 860 |
+ } |
|
| 861 |
+ |
|
| 862 |
+ @discardableResult |
|
| 863 |
+ func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
|
|
| 864 |
+ var didSave = false |
|
| 865 |
+ context.performAndWait {
|
|
| 866 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
|
| 867 |
+ return |
|
| 868 |
+ } |
|
| 869 |
+ |
|
| 870 |
+ let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast |
|
| 871 |
+ let sessionEnd = dateValue(session, key: "endedAt") |
|
| 872 |
+ ?? dateValue(session, key: "lastObservedAt") |
|
| 873 |
+ ?? Date.distantFuture |
|
| 874 |
+ |
|
| 875 |
+ let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd) |
|
| 876 |
+ let effectiveEnd = max(min(end ?? sessionEnd, sessionEnd), effectiveStart) |
|
| 877 |
+ let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart |
|
| 878 |
+ let persistedEnd = effectiveEnd == sessionEnd ? nil : effectiveEnd |
|
| 879 |
+ |
|
| 880 |
+ let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString) |
|
| 881 |
+ .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
|
|
| 882 |
+ guard let ts = dateValue(obj, key: "timestamp") else { return nil }
|
|
| 883 |
+ return ( |
|
| 884 |
+ timestamp: ts, |
|
| 885 |
+ energy: doubleValue(obj, key: "measuredEnergyWh"), |
|
| 886 |
+ charge: doubleValue(obj, key: "measuredChargeAh") |
|
| 887 |
+ ) |
|
| 888 |
+ } |
|
| 889 |
+ .sorted { $0.timestamp < $1.timestamp }
|
|
| 890 |
+ |
|
| 891 |
+ // Each sample stores cumulative energy since session start. |
|
| 892 |
+ // Trimmed energy = value at trimEnd - value just before trimStart. |
|
| 893 |
+ let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
|
|
| 894 |
+ let endSample = allSamples.last { $0.timestamp <= effectiveEnd }
|
|
| 895 |
+ let baselineEnergy = baselineSample?.energy ?? 0 |
|
| 896 |
+ let baselineCharge = baselineSample?.charge ?? 0 |
|
| 897 |
+ |
|
| 898 |
+ if let endSample {
|
|
| 899 |
+ let trimmedEnergy = max(endSample.energy - baselineEnergy, 0) |
|
| 900 |
+ let trimmedCharge = max(endSample.charge - baselineCharge, 0) |
|
| 901 |
+ session.setValue(trimmedEnergy, forKey: "measuredEnergyWh") |
|
| 902 |
+ session.setValue(trimmedCharge, forKey: "measuredChargeAh") |
|
| 903 |
+ } else {
|
|
| 904 |
+ session.setValue(0, forKey: "measuredEnergyWh") |
|
| 905 |
+ session.setValue(0, forKey: "measuredChargeAh") |
|
| 906 |
+ } |
|
| 907 |
+ |
|
| 908 |
+ session.setValue(persistedStart, forKey: "trimStart") |
|
| 909 |
+ session.setValue(persistedEnd, forKey: "trimEnd") |
|
| 910 |
+ session.setValue(Date(), forKey: "updatedAt") |
|
| 911 |
+ |
|
| 912 |
+ let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString) |
|
| 913 |
+ for checkpoint in checkpoints {
|
|
| 914 |
+ guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
|
|
| 915 |
+ |
|
| 916 |
+ if timestamp < effectiveStart || timestamp > effectiveEnd {
|
|
| 917 |
+ context.delete(checkpoint) |
|
| 918 |
+ continue |
|
| 919 |
+ } |
|
| 920 |
+ |
|
| 921 |
+ let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
|
|
| 922 |
+ let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0) |
|
| 923 |
+ let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0) |
|
| 924 |
+ checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh") |
|
| 925 |
+ checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh") |
|
| 926 |
+ } |
|
| 927 |
+ |
|
| 928 |
+ let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString) |
|
| 929 |
+ .sorted {
|
|
| 930 |
+ (dateValue($0, key: "timestamp") ?? .distantPast) |
|
| 931 |
+ < (dateValue($1, key: "timestamp") ?? .distantPast) |
|
| 932 |
+ } |
|
| 933 |
+ let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
|
|
| 934 |
+ let label = stringValue(checkpoint, key: "label") |
|
| 935 |
+ let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture |
|
| 936 |
+ return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart |
|
| 937 |
+ } |
|
| 938 |
+ |
|
| 939 |
+ if persistedStart == nil {
|
|
| 940 |
+ if let restoredInitialCheckpoint, |
|
| 941 |
+ let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"), |
|
| 942 |
+ percent >= 0 {
|
|
| 943 |
+ session.setValue(percent, forKey: "startBatteryPercent") |
|
| 944 |
+ } |
|
| 945 |
+ } else {
|
|
| 946 |
+ session.setValue(nil, forKey: "startBatteryPercent") |
|
| 947 |
+ } |
|
| 948 |
+ |
|
| 949 |
+ refreshCheckpointDerivedValues(for: session) |
|
| 950 |
+ |
|
| 951 |
+ if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
|
|
| 952 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 953 |
+ } |
|
| 954 |
+ |
|
| 955 |
+ didSave = saveContext() |
|
| 956 |
+ } |
|
| 957 |
+ return didSave |
|
| 958 |
+ } |
|
| 959 |
+ |
|
| 960 |
+ @discardableResult |
|
| 961 |
+ func deleteChargeSession(id sessionID: UUID) -> Bool {
|
|
| 962 |
+ var didSave = false |
|
| 963 |
+ context.performAndWait {
|
|
| 964 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
|
| 965 |
+ return |
|
| 966 |
+ } |
|
| 967 |
+ |
|
| 968 |
+ let chargedDeviceID = stringValue(session, key: "chargedDeviceID") |
|
| 969 |
+ |
|
| 970 |
+ fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete) |
|
| 971 |
+ fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete) |
|
| 972 |
+ context.delete(session) |
|
| 973 |
+ |
|
| 974 |
+ guard saveContext() else {
|
|
| 975 |
+ return |
|
| 976 |
+ } |
|
| 977 |
+ |
|
| 978 |
+ if let chargedDeviceID {
|
|
| 979 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 980 |
+ didSave = saveContext() |
|
| 981 |
+ } else {
|
|
| 982 |
+ didSave = true |
|
| 983 |
+ } |
|
| 984 |
+ } |
|
| 985 |
+ return didSave |
|
| 986 |
+ } |
|
| 987 |
+ |
|
| 988 |
+ @discardableResult |
|
| 989 |
+ func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
|
|
| 990 |
+ var didSave = false |
|
| 991 |
+ |
|
| 992 |
+ context.performAndWait {
|
|
| 993 |
+ guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
|
|
| 994 |
+ return |
|
| 995 |
+ } |
|
| 996 |
+ |
|
| 997 |
+ let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") |
|
| 998 |
+ let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString) |
|
| 999 |
+ let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString) |
|
| 1000 |
+ |
|
| 1001 |
+ var impactedChargedDeviceIDs = Set<String>() |
|
| 1002 |
+ |
|
| 1003 |
+ for session in deviceSessions {
|
|
| 1004 |
+ if let impactedID = stringValue(session, key: "chargedDeviceID") {
|
|
| 1005 |
+ impactedChargedDeviceIDs.insert(impactedID) |
|
| 1006 |
+ } |
|
| 1007 |
+ if let impactedChargerID = stringValue(session, key: "chargerID") {
|
|
| 1008 |
+ impactedChargedDeviceIDs.insert(impactedChargerID) |
|
| 1009 |
+ } |
|
| 1010 |
+ if let sessionID = stringValue(session, key: "id") {
|
|
| 1011 |
+ fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete) |
|
| 1012 |
+ fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete) |
|
| 1013 |
+ } |
|
| 1014 |
+ context.delete(session) |
|
| 1015 |
+ } |
|
| 1016 |
+ |
|
| 1017 |
+ if deviceClass == .charger {
|
|
| 1018 |
+ for session in linkedWirelessSessions {
|
|
| 1019 |
+ guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
|
|
| 1020 |
+ continue |
|
| 1021 |
+ } |
|
| 1022 |
+ if let impactedID = stringValue(session, key: "chargedDeviceID") {
|
|
| 1023 |
+ impactedChargedDeviceIDs.insert(impactedID) |
|
| 1024 |
+ } |
|
| 1025 |
+ session.setValue(nil, forKey: "chargerID") |
|
| 1026 |
+ session.setValue(Date(), forKey: "updatedAt") |
|
| 1027 |
+ } |
|
| 1028 |
+ } |
|
| 1029 |
+ |
|
| 1030 |
+ context.delete(chargedDevice) |
|
| 1031 |
+ |
|
| 1032 |
+ guard saveContext() else {
|
|
| 1033 |
+ return |
|
| 1034 |
+ } |
|
| 1035 |
+ |
|
| 1036 |
+ impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString) |
|
| 1037 |
+ for impactedID in impactedChargedDeviceIDs {
|
|
| 1038 |
+ refreshDerivedMetrics(forChargedDeviceID: impactedID) |
|
| 1039 |
+ } |
|
| 1040 |
+ didSave = saveContext() |
|
| 1041 |
+ } |
|
| 1042 |
+ |
|
| 1043 |
+ return didSave |
|
| 1044 |
+ } |
|
| 1045 |
+ |
|
| 1046 |
+ @discardableResult |
|
| 1047 |
+ func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
|
|
| 1048 |
+ var didSave = false |
|
| 1049 |
+ |
|
| 1050 |
+ context.performAndWait {
|
|
| 1051 |
+ guard let session = fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress), |
|
| 1052 |
+ let resolvedDevice = stringValue(session, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:)) else {
|
|
| 1053 |
+ return |
|
| 1054 |
+ } |
|
| 1055 |
+ |
|
| 1056 |
+ if statusValue(session, key: "statusRawValue") == .paused {
|
|
| 1057 |
+ if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
|
|
| 1058 |
+ didSave = true |
|
| 1059 |
+ } |
|
| 1060 |
+ return |
|
| 1061 |
+ } |
|
| 1062 |
+ |
|
| 1063 |
+ let chargingTransportMode = self.chargingTransportMode(for: session) |
|
| 1064 |
+ let chargingStateMode = self.chargingStateMode(for: session) |
|
| 1065 |
+ let charger = chargingTransportMode == .wireless |
|
| 1066 |
+ ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:)) |
|
| 1067 |
+ : nil |
|
| 1068 |
+ guard chargingTransportMode == .wired || charger != nil else {
|
|
| 1069 |
+ return |
|
| 1070 |
+ } |
|
| 1071 |
+ let stopThreshold = resolvedStopThreshold( |
|
| 1072 |
+ for: resolvedDevice, |
|
| 1073 |
+ chargingTransportMode: chargingTransportMode, |
|
| 1074 |
+ chargingStateMode: chargingStateMode, |
|
| 1075 |
+ charger: charger, |
|
| 1076 |
+ fallback: optionalDoubleValue(session, key: "stopThresholdAmps") |
|
| 1077 |
+ ) |
|
| 1078 |
+ |
|
| 1079 |
+ let sessionSnapshot = snapshotClampedToMaximumDuration(snapshot, for: session) |
|
| 1080 |
+ update(session: session, with: sessionSnapshot, stopThreshold: stopThreshold, charger: charger) |
|
| 1081 |
+ let aggregatedSample = updateAggregatedSample(session: session, with: sessionSnapshot) |
|
| 1082 |
+ if let completionDate = automaticCompletionDate(for: session, referenceDate: snapshot.observedAt), |
|
| 1083 |
+ statusValue(session, key: "statusRawValue")?.isOpen == true {
|
|
| 1084 |
+ finishSession( |
|
| 1085 |
+ session, |
|
| 1086 |
+ observedAt: completionDate, |
|
| 1087 |
+ finalBatteryPercent: nil, |
|
| 1088 |
+ status: .completed |
|
| 1089 |
+ ) |
|
| 1090 |
+ } |
|
| 1091 |
+ |
|
| 1092 |
+ let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt) |
|
| 1093 |
+ let shouldPersistAggregatedCurve = aggregatedSample.map {
|
|
| 1094 |
+ shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt) |
|
| 1095 |
+ } ?? false |
|
| 1096 |
+ |
|
| 1097 |
+ guard saveReason != .none || shouldPersistAggregatedCurve else {
|
|
| 1098 |
+ return |
|
| 1099 |
+ } |
|
| 1100 |
+ |
|
| 1101 |
+ session.setValue(sessionSnapshot.observedAt, forKey: "updatedAt") |
|
| 1102 |
+ |
|
| 1103 |
+ if saveContext() {
|
|
| 1104 |
+ if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
|
|
| 1105 |
+ refreshDerivedMetrics(forChargedDeviceID: deviceID) |
|
| 1106 |
+ didSave = saveContext() |
|
| 1107 |
+ } else {
|
|
| 1108 |
+ didSave = true |
|
| 1109 |
+ } |
|
| 1110 |
+ } |
|
| 1111 |
+ } |
|
| 1112 |
+ |
|
| 1113 |
+ return didSave |
|
| 1114 |
+ } |
|
| 1115 |
+ |
|
| 1116 |
+ func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
|
|
| 1117 |
+ var summaries: [ChargedDeviceSummary] = [] |
|
| 1118 |
+ |
|
| 1119 |
+ context.performAndWait {
|
|
| 1120 |
+ let devices = fetchObjects(entityName: EntityName.chargedDevice) |
|
| 1121 |
+ let sessions = fetchObjects(entityName: EntityName.chargeSession) |
|
| 1122 |
+ let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint) |
|
| 1123 |
+ |
|
| 1124 |
+ let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
|
|
| 1125 |
+ let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
|
|
| 1126 |
+ let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
|
|
| 1127 |
+ let sampleBackedSessionIDs = sampleBackedSessionIDs( |
|
| 1128 |
+ devices: devices, |
|
| 1129 |
+ sessionsByDeviceID: sessionsByDeviceID, |
|
| 1130 |
+ sessionsByChargerID: sessionsByChargerID |
|
| 1131 |
+ ) |
|
| 1132 |
+ let samplesBySessionID = Dictionary( |
|
| 1133 |
+ grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs)) |
|
| 1134 |
+ ) { stringValue($0, key: "sessionID") ?? "" }
|
|
| 1135 |
+ |
|
| 1136 |
+ summaries = devices.compactMap { device in
|
|
| 1137 |
+ guard |
|
| 1138 |
+ let id = uuidValue(device, key: "id"), |
|
| 1139 |
+ let name = stringValue(device, key: "name"), |
|
| 1140 |
+ let qrIdentifier = stringValue(device, key: "qrIdentifier"), |
|
| 1141 |
+ let rawClass = stringValue(device, key: "deviceClassRawValue"), |
|
| 1142 |
+ let deviceClass = ChargedDeviceClass(rawValue: rawClass) |
|
| 1143 |
+ else {
|
|
| 1144 |
+ return nil |
|
| 1145 |
+ } |
|
| 1146 |
+ |
|
| 1147 |
+ let chargingStateAvailability = chargingStateAvailability(for: device) |
|
| 1148 |
+ let supportsWiredCharging = supportsWiredCharging(for: device) |
|
| 1149 |
+ let supportsWirelessCharging = supportsWirelessCharging(for: device) |
|
| 1150 |
+ let templateDefinition = templateDefinition(for: device) |
|
| 1151 |
+ |
|
| 1152 |
+ let sessionObjects = relevantSessionObjects( |
|
| 1153 |
+ for: id.uuidString, |
|
| 1154 |
+ deviceClass: deviceClass, |
|
| 1155 |
+ sessionsByDeviceID: sessionsByDeviceID, |
|
| 1156 |
+ sessionsByChargerID: sessionsByChargerID |
|
| 1157 |
+ ) |
|
| 1158 |
+ let sessionSummaries = sessionObjects |
|
| 1159 |
+ .compactMap { session in
|
|
| 1160 |
+ makeSessionSummary( |
|
| 1161 |
+ from: session, |
|
| 1162 |
+ checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [], |
|
| 1163 |
+ samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? [] |
|
| 1164 |
+ ) |
|
| 1165 |
+ } |
|
| 1166 |
+ .sorted { lhs, rhs in
|
|
| 1167 |
+ if lhs.status.isOpen && !rhs.status.isOpen {
|
|
| 1168 |
+ return true |
|
| 1169 |
+ } |
|
| 1170 |
+ if !lhs.status.isOpen && rhs.status.isOpen {
|
|
| 1171 |
+ return false |
|
| 1172 |
+ } |
|
| 1173 |
+ if lhs.status == .active && rhs.status == .paused {
|
|
| 1174 |
+ return true |
|
| 1175 |
+ } |
|
| 1176 |
+ if lhs.status == .paused && rhs.status == .active {
|
|
| 1177 |
+ return false |
|
| 1178 |
+ } |
|
| 1179 |
+ return lhs.startedAt > rhs.startedAt |
|
| 1180 |
+ } |
|
| 1181 |
+ |
|
| 1182 |
+ return ChargedDeviceSummary( |
|
| 1183 |
+ id: id, |
|
| 1184 |
+ qrIdentifier: qrIdentifier, |
|
| 1185 |
+ name: name, |
|
| 1186 |
+ deviceClass: deviceClass, |
|
| 1187 |
+ deviceTemplateID: stringValue(device, key: "deviceTemplateID"), |
|
| 1188 |
+ templateDefinition: templateDefinition, |
|
| 1189 |
+ supportsChargingWhileOff: chargingStateAvailability.supportsChargingWhileOff, |
|
| 1190 |
+ chargingStateAvailability: chargingStateAvailability, |
|
| 1191 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 1192 |
+ supportsWirelessCharging: supportsWirelessCharging, |
|
| 1193 |
+ chargerType: chargerType(for: device), |
|
| 1194 |
+ wirelessChargingProfile: wirelessChargingProfile(for: device), |
|
| 1195 |
+ configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"), |
|
| 1196 |
+ learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"), |
|
| 1197 |
+ wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"), |
|
| 1198 |
+ wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"), |
|
| 1199 |
+ wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"), |
|
| 1200 |
+ chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device), |
|
| 1201 |
+ chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"), |
|
| 1202 |
+ chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"), |
|
| 1203 |
+ chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"), |
|
| 1204 |
+ notes: stringValue(device, key: "notes"), |
|
| 1205 |
+ minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"), |
|
| 1206 |
+ estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"), |
|
| 1207 |
+ wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"), |
|
| 1208 |
+ wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"), |
|
| 1209 |
+ wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"), |
|
| 1210 |
+ wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"), |
|
| 1211 |
+ lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"), |
|
| 1212 |
+ createdAt: dateValue(device, key: "createdAt") ?? .distantPast, |
|
| 1213 |
+ updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast, |
|
| 1214 |
+ sessions: sessionSummaries, |
|
| 1215 |
+ capacityHistory: buildCapacityHistory(from: sessionSummaries), |
|
| 1216 |
+ typicalCurve: buildTypicalCurve(from: sessionSummaries), |
|
| 1217 |
+ standbyPowerMeasurements: [] |
|
| 1218 |
+ ) |
|
| 1219 |
+ } |
|
| 1220 |
+ .sorted { lhs, rhs in
|
|
| 1221 |
+ if lhs.activeSession != nil && rhs.activeSession == nil {
|
|
| 1222 |
+ return true |
|
| 1223 |
+ } |
|
| 1224 |
+ if lhs.activeSession == nil && rhs.activeSession != nil {
|
|
| 1225 |
+ return false |
|
| 1226 |
+ } |
|
| 1227 |
+ if lhs.updatedAt != rhs.updatedAt {
|
|
| 1228 |
+ return lhs.updatedAt > rhs.updatedAt |
|
| 1229 |
+ } |
|
| 1230 |
+ return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending |
|
| 1231 |
+ } |
|
| 1232 |
+ } |
|
| 1233 |
+ |
|
| 1234 |
+ return summaries |
|
| 1235 |
+ } |
|
| 1236 |
+ |
|
| 1237 |
+ func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
|
|
| 1238 |
+ let normalizedMAC = normalizedMACAddress(meterMACAddress) |
|
| 1239 |
+ guard !normalizedMAC.isEmpty else { return nil }
|
|
| 1240 |
+ |
|
| 1241 |
+ let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
|
|
| 1242 |
+ |
|
| 1243 |
+ if let activeMatch = summaries.first(where: { summary in
|
|
| 1244 |
+ summary.activeSession?.meterMACAddress == normalizedMAC |
|
| 1245 |
+ }) {
|
|
| 1246 |
+ return activeMatch |
|
| 1247 |
+ } |
|
| 1248 |
+ |
|
| 1249 |
+ return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
|
|
| 1250 |
+ } |
|
| 1251 |
+ |
|
| 1252 |
+ func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
|
|
| 1253 |
+ let normalizedMAC = normalizedMACAddress(meterMACAddress) |
|
| 1254 |
+ guard !normalizedMAC.isEmpty else { return nil }
|
|
| 1255 |
+ |
|
| 1256 |
+ var summary: ChargeSessionSummary? |
|
| 1257 |
+ |
|
| 1258 |
+ context.performAndWait {
|
|
| 1259 |
+ guard let session = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC), |
|
| 1260 |
+ let sessionID = stringValue(session, key: "id") else {
|
|
| 1261 |
+ return |
|
| 1262 |
+ } |
|
| 1263 |
+ |
|
| 1264 |
+ summary = makeSessionSummary( |
|
| 1265 |
+ from: session, |
|
| 1266 |
+ checkpoints: fetchCheckpointObjects(forSessionID: sessionID), |
|
| 1267 |
+ samples: fetchSessionSampleObjects(forSessionID: sessionID) |
|
| 1268 |
+ ) |
|
| 1269 |
+ } |
|
| 1270 |
+ |
|
| 1271 |
+ return summary |
|
| 1272 |
+ } |
|
| 1273 |
+ |
|
| 1274 |
+ private func createSessionObject( |
|
| 1275 |
+ for chargedDevice: NSManagedObject, |
|
| 1276 |
+ charger: NSManagedObject?, |
|
| 1277 |
+ snapshot: ChargingMonitorSnapshot, |
|
| 1278 |
+ stopThreshold: Double?, |
|
| 1279 |
+ chargingTransportMode: ChargingTransportMode, |
|
| 1280 |
+ chargingStateMode: ChargingStateMode, |
|
| 1281 |
+ autoStopEnabled: Bool |
|
| 1282 |
+ ) -> NSManagedObject? {
|
|
| 1283 |
+ guard |
|
| 1284 |
+ let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context), |
|
| 1285 |
+ let chargedDeviceID = stringValue(chargedDevice, key: "id") |
|
| 1286 |
+ else {
|
|
| 1287 |
+ return nil |
|
| 1288 |
+ } |
|
| 1289 |
+ |
|
| 1290 |
+ let session = NSManagedObject(entity: entity, insertInto: context) |
|
| 1291 |
+ let now = snapshot.observedAt |
|
| 1292 |
+ session.setValue(UUID().uuidString, forKey: "id") |
|
| 1293 |
+ session.setValue(chargedDeviceID, forKey: "chargedDeviceID") |
|
| 1294 |
+ session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
|
|
| 1295 |
+ session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress") |
|
| 1296 |
+ session.setValue(snapshot.meterName, forKey: "meterName") |
|
| 1297 |
+ session.setValue(snapshot.meterModel, forKey: "meterModel") |
|
| 1298 |
+ session.setValue(now, forKey: "startedAt") |
|
| 1299 |
+ session.setValue(now, forKey: "lastObservedAt") |
|
| 1300 |
+ session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue") |
|
| 1301 |
+ let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil |
|
| 1302 |
+ session.setValue( |
|
| 1303 |
+ (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue, |
|
| 1304 |
+ forKey: "sourceModeRawValue" |
|
| 1305 |
+ ) |
|
| 1306 |
+ session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue") |
|
| 1307 |
+ session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue") |
|
| 1308 |
+ session.setValue(autoStopEnabled, forKey: "autoStopEnabled") |
|
| 1309 |
+ session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps") |
|
| 1310 |
+ session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts") |
|
| 1311 |
+ session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps") |
|
| 1312 |
+ session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts") |
|
| 1313 |
+ session.setValue( |
|
| 1314 |
+ chargingTransportMode == .wired ? snapshot.voltageVolts : nil, |
|
| 1315 |
+ forKey: "lastObservedVoltageVolts" |
|
| 1316 |
+ ) |
|
| 1317 |
+ session.setValue( |
|
| 1318 |
+ hasObservedChargeFlow( |
|
| 1319 |
+ currentAmps: snapshot.currentAmps, |
|
| 1320 |
+ chargingTransportMode: chargingTransportMode, |
|
| 1321 |
+ charger: charger, |
|
| 1322 |
+ stopThreshold: stopThreshold |
|
| 1323 |
+ ), |
|
| 1324 |
+ forKey: "hasObservedChargeFlow" |
|
| 1325 |
+ ) |
|
| 1326 |
+ session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps") |
|
| 1327 |
+ session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps") |
|
| 1328 |
+ session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts") |
|
| 1329 |
+ session.setValue( |
|
| 1330 |
+ chargingTransportMode == .wired ? snapshot.voltageVolts : nil, |
|
| 1331 |
+ forKey: "maximumObservedVoltageVolts" |
|
| 1332 |
+ ) |
|
| 1333 |
+ session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff") |
|
| 1334 |
+ if let selectedDataGroup = snapshot.selectedDataGroup {
|
|
| 1335 |
+ session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup") |
|
| 1336 |
+ } |
|
| 1337 |
+ if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
|
|
| 1338 |
+ session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh") |
|
| 1339 |
+ session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh") |
|
| 1340 |
+ } |
|
| 1341 |
+ if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
|
|
| 1342 |
+ session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh") |
|
| 1343 |
+ session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh") |
|
| 1344 |
+ } |
|
| 1345 |
+ if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
|
|
| 1346 |
+ setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds") |
|
| 1347 |
+ setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds") |
|
| 1348 |
+ } |
|
| 1349 |
+ session.setValue(now, forKey: "createdAt") |
|
| 1350 |
+ session.setValue(now, forKey: "updatedAt") |
|
| 1351 |
+ |
|
| 1352 |
+ chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC") |
|
| 1353 |
+ chargedDevice.setValue(now, forKey: "updatedAt") |
|
| 1354 |
+ return session |
|
| 1355 |
+ } |
|
| 1356 |
+ |
|
| 1357 |
+ private func update( |
|
| 1358 |
+ session: NSManagedObject, |
|
| 1359 |
+ with snapshot: ChargingMonitorSnapshot, |
|
| 1360 |
+ stopThreshold: Double?, |
|
| 1361 |
+ charger: NSManagedObject? |
|
| 1362 |
+ ) {
|
|
| 1363 |
+ let sessionChargingTransportMode = chargingTransportMode(for: session) |
|
| 1364 |
+ let lastObservedAt = dateValue(session, key: "lastObservedAt") |
|
| 1365 |
+ let previousPower = doubleValue(session, key: "lastObservedPowerWatts") |
|
| 1366 |
+ let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps") |
|
| 1367 |
+ var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh") |
|
| 1368 |
+ var measuredChargeAh = doubleValue(session, key: "measuredChargeAh") |
|
| 1369 |
+ var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live |
|
| 1370 |
+ var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters") |
|
| 1371 |
+ |
|
| 1372 |
+ if let lastObservedAt {
|
|
| 1373 |
+ let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0) |
|
| 1374 |
+ if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
|
|
| 1375 |
+ measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600 |
|
| 1376 |
+ measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600 |
|
| 1377 |
+ if sourceMode == .offline {
|
|
| 1378 |
+ sourceMode = .blended |
|
| 1379 |
+ } |
|
| 1380 |
+ } |
|
| 1381 |
+ } |
|
| 1382 |
+ |
|
| 1383 |
+ if let counterGroup = snapshot.selectedDataGroup, |
|
| 1384 |
+ let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"), |
|
| 1385 |
+ UInt8(storedGroup) != counterGroup {
|
|
| 1386 |
+ session.setValue(Int16(counterGroup), forKey: "selectedDataGroup") |
|
| 1387 |
+ session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh") |
|
| 1388 |
+ session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh") |
|
| 1389 |
+ } |
|
| 1390 |
+ |
|
| 1391 |
+ if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
|
|
| 1392 |
+ let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh |
|
| 1393 |
+ let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh") |
|
| 1394 |
+ if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
|
|
| 1395 |
+ session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh") |
|
| 1396 |
+ } |
|
| 1397 |
+ |
|
| 1398 |
+ if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
|
|
| 1399 |
+ let offlineEnergy = meterEnergyCounterWh - baselineEnergy |
|
| 1400 |
+ measuredEnergyWh = max(offlineEnergy, 0) |
|
| 1401 |
+ usedOfflineMeterCounters = true |
|
| 1402 |
+ sourceMode = .offline |
|
| 1403 |
+ } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
|
|
| 1404 |
+ let delta = meterEnergyCounterWh - lastEnergy |
|
| 1405 |
+ if delta > 0 {
|
|
| 1406 |
+ measuredEnergyWh += delta |
|
| 1407 |
+ usedOfflineMeterCounters = true |
|
| 1408 |
+ sourceMode = .blended |
|
| 1409 |
+ } |
|
| 1410 |
+ } |
|
| 1411 |
+ session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh") |
|
| 1412 |
+ } |
|
| 1413 |
+ |
|
| 1414 |
+ if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
|
|
| 1415 |
+ let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh |
|
| 1416 |
+ let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh") |
|
| 1417 |
+ if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
|
|
| 1418 |
+ session.setValue(baselineCharge, forKey: "meterChargeBaselineAh") |
|
| 1419 |
+ } |
|
| 1420 |
+ |
|
| 1421 |
+ if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
|
|
| 1422 |
+ let offlineCharge = meterChargeCounterAh - baselineCharge |
|
| 1423 |
+ measuredChargeAh = max(offlineCharge, 0) |
|
| 1424 |
+ usedOfflineMeterCounters = true |
|
| 1425 |
+ } else if let lastCharge, meterChargeCounterAh > lastCharge {
|
|
| 1426 |
+ let delta = meterChargeCounterAh - lastCharge |
|
| 1427 |
+ if delta > 0 {
|
|
| 1428 |
+ measuredChargeAh += delta |
|
| 1429 |
+ usedOfflineMeterCounters = true |
|
| 1430 |
+ } |
|
| 1431 |
+ } |
|
| 1432 |
+ session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh") |
|
| 1433 |
+ } |
|
| 1434 |
+ |
|
| 1435 |
+ if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
|
|
| 1436 |
+ let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds |
|
| 1437 |
+ if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
|
|
| 1438 |
+ setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds") |
|
| 1439 |
+ } |
|
| 1440 |
+ setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds") |
|
| 1441 |
+ } |
|
| 1442 |
+ |
|
| 1443 |
+ let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps") |
|
| 1444 |
+ let updatedMinimum: Double |
|
| 1445 |
+ if snapshot.currentAmps > 0 {
|
|
| 1446 |
+ updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps) |
|
| 1447 |
+ } else {
|
|
| 1448 |
+ updatedMinimum = existingMinimum ?? 0 |
|
| 1449 |
+ } |
|
| 1450 |
+ |
|
| 1451 |
+ let effectiveCurrent = effectiveCurrentAmps( |
|
| 1452 |
+ fromMeasuredCurrent: snapshot.currentAmps, |
|
| 1453 |
+ chargingTransportMode: sessionChargingTransportMode, |
|
| 1454 |
+ charger: charger |
|
| 1455 |
+ ) |
|
| 1456 |
+ let observedChargeFlow = boolValue(session, key: "hasObservedChargeFlow") |
|
| 1457 |
+ || hasObservedChargeFlow( |
|
| 1458 |
+ currentAmps: snapshot.currentAmps, |
|
| 1459 |
+ chargingTransportMode: sessionChargingTransportMode, |
|
| 1460 |
+ charger: charger, |
|
| 1461 |
+ stopThreshold: stopThreshold |
|
| 1462 |
+ ) |
|
| 1463 |
+ |
|
| 1464 |
+ session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh") |
|
| 1465 |
+ session.setValue(measuredChargeAh, forKey: "measuredChargeAh") |
|
| 1466 |
+ session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps") |
|
| 1467 |
+ session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps") |
|
| 1468 |
+ session.setValue(snapshot.observedAt, forKey: "lastObservedAt") |
|
| 1469 |
+ session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps") |
|
| 1470 |
+ session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts") |
|
| 1471 |
+ session.setValue( |
|
| 1472 |
+ sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil, |
|
| 1473 |
+ forKey: "lastObservedVoltageVolts" |
|
| 1474 |
+ ) |
|
| 1475 |
+ session.setValue(observedChargeFlow, forKey: "hasObservedChargeFlow") |
|
| 1476 |
+ session.setValue( |
|
| 1477 |
+ max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps), |
|
| 1478 |
+ forKey: "maximumObservedCurrentAmps" |
|
| 1479 |
+ ) |
|
| 1480 |
+ session.setValue( |
|
| 1481 |
+ max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts), |
|
| 1482 |
+ forKey: "maximumObservedPowerWatts" |
|
| 1483 |
+ ) |
|
| 1484 |
+ session.setValue( |
|
| 1485 |
+ sessionChargingTransportMode == .wired |
|
| 1486 |
+ ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts) |
|
| 1487 |
+ : nil, |
|
| 1488 |
+ forKey: "maximumObservedVoltageVolts" |
|
| 1489 |
+ ) |
|
| 1490 |
+ session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters") |
|
| 1491 |
+ session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue") |
|
| 1492 |
+ maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt) |
|
| 1493 |
+ |
|
| 1494 |
+ guard boolValue(session, key: "autoStopEnabled"), let stopThreshold, stopThreshold > 0, observedChargeFlow else {
|
|
| 1495 |
+ session.setValue(nil, forKey: "belowThresholdSince") |
|
| 1496 |
+ clearCompletionConfirmationState(for: session) |
|
| 1497 |
+ session.setValue(nil, forKey: "completionConfirmationCooldownUntil") |
|
| 1498 |
+ return |
|
| 1499 |
+ } |
|
| 1500 |
+ |
|
| 1501 |
+ if effectiveCurrent <= stopThreshold {
|
|
| 1502 |
+ let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt |
|
| 1503 |
+ session.setValue(belowThresholdSince, forKey: "belowThresholdSince") |
|
| 1504 |
+ if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
|
|
| 1505 |
+ if boolValue(session, key: "requiresCompletionConfirmation") {
|
|
| 1506 |
+ // Leave the session active until the user explicitly confirms or charging resumes. |
|
| 1507 |
+ return |
|
| 1508 |
+ } |
|
| 1509 |
+ |
|
| 1510 |
+ if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
|
|
| 1511 |
+ requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt) |
|
| 1512 |
+ } else {
|
|
| 1513 |
+ finishSession( |
|
| 1514 |
+ session, |
|
| 1515 |
+ observedAt: snapshot.observedAt, |
|
| 1516 |
+ finalBatteryPercent: nil, |
|
| 1517 |
+ status: .completed |
|
| 1518 |
+ ) |
|
| 1519 |
+ } |
|
| 1520 |
+ } |
|
| 1521 |
+ } else {
|
|
| 1522 |
+ session.setValue(nil, forKey: "belowThresholdSince") |
|
| 1523 |
+ clearCompletionConfirmationState(for: session) |
|
| 1524 |
+ session.setValue(nil, forKey: "completionConfirmationCooldownUntil") |
|
| 1525 |
+ } |
|
| 1526 |
+ } |
|
| 1527 |
+ |
|
| 1528 |
+ private func updateAggregatedSample( |
|
| 1529 |
+ session: NSManagedObject, |
|
| 1530 |
+ with snapshot: ChargingMonitorSnapshot |
|
| 1531 |
+ ) -> NSManagedObject? {
|
|
| 1532 |
+ guard |
|
| 1533 |
+ let sessionID = stringValue(session, key: "id"), |
|
| 1534 |
+ let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), |
|
| 1535 |
+ let startedAt = dateValue(session, key: "startedAt"), |
|
| 1536 |
+ let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context) |
|
| 1537 |
+ else {
|
|
| 1538 |
+ return nil |
|
| 1539 |
+ } |
|
| 1540 |
+ |
|
| 1541 |
+ let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0) |
|
| 1542 |
+ let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration)) |
|
| 1543 |
+ let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration) |
|
| 1544 |
+ let bucketIdentifier = "\(sessionID)-\(bucketIndex)" |
|
| 1545 |
+ let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex) |
|
| 1546 |
+ ?? NSManagedObject(entity: entity, insertInto: context) |
|
| 1547 |
+ let sessionChargingTransportMode = chargingTransportMode(for: session) |
|
| 1548 |
+ let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil |
|
| 1549 |
+ |
|
| 1550 |
+ let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0) |
|
| 1551 |
+ let updatedCount = existingCount + 1 |
|
| 1552 |
+ |
|
| 1553 |
+ sample.setValue(bucketIdentifier, forKey: "id") |
|
| 1554 |
+ sample.setValue(sessionID, forKey: "sessionID") |
|
| 1555 |
+ sample.setValue(chargedDeviceID, forKey: "chargedDeviceID") |
|
| 1556 |
+ sample.setValue(bucketIndex, forKey: "bucketIndex") |
|
| 1557 |
+ sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp") |
|
| 1558 |
+ sample.setValue( |
|
| 1559 |
+ runningAverage( |
|
| 1560 |
+ currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps, |
|
| 1561 |
+ currentCount: Int(existingCount), |
|
| 1562 |
+ newValue: snapshot.currentAmps |
|
| 1563 |
+ ), |
|
| 1564 |
+ forKey: "averageCurrentAmps" |
|
| 1565 |
+ ) |
|
| 1566 |
+ sample.setValue( |
|
| 1567 |
+ sampleVoltage.flatMap { voltage in
|
|
| 1568 |
+ runningAverage( |
|
| 1569 |
+ currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage, |
|
| 1570 |
+ currentCount: Int(existingCount), |
|
| 1571 |
+ newValue: voltage |
|
| 1572 |
+ ) |
|
| 1573 |
+ }, |
|
| 1574 |
+ forKey: "averageVoltageVolts" |
|
| 1575 |
+ ) |
|
| 1576 |
+ sample.setValue( |
|
| 1577 |
+ runningAverage( |
|
| 1578 |
+ currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts, |
|
| 1579 |
+ currentCount: Int(existingCount), |
|
| 1580 |
+ newValue: snapshot.powerWatts |
|
| 1581 |
+ ), |
|
| 1582 |
+ forKey: "averagePowerWatts" |
|
| 1583 |
+ ) |
|
| 1584 |
+ sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh") |
|
| 1585 |
+ sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh") |
|
| 1586 |
+ sample.setValue(Int16(updatedCount), forKey: "sampleCount") |
|
| 1587 |
+ sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt") |
|
| 1588 |
+ sample.setValue(snapshot.observedAt, forKey: "updatedAt") |
|
| 1589 |
+ return sample |
|
| 1590 |
+ } |
|
| 1591 |
+ |
|
| 1592 |
+ private func maybeTriggerTargetBatteryAlert( |
|
| 1593 |
+ for session: NSManagedObject, |
|
| 1594 |
+ observedAt: Date, |
|
| 1595 |
+ completionFallbackPercent: Double? = nil |
|
| 1596 |
+ ) {
|
|
| 1597 |
+ guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
|
|
| 1598 |
+ return |
|
| 1599 |
+ } |
|
| 1600 |
+ |
|
| 1601 |
+ guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
|
|
| 1602 |
+ return |
|
| 1603 |
+ } |
|
| 1604 |
+ |
|
| 1605 |
+ let predictedBatteryPercent = predictedBatteryPercent(for: session) |
|
| 1606 |
+ ?? optionalDoubleValue(session, key: "endBatteryPercent") |
|
| 1607 |
+ ?? completionFallbackPercent |
|
| 1608 |
+ |
|
| 1609 |
+ guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
|
|
| 1610 |
+ return |
|
| 1611 |
+ } |
|
| 1612 |
+ |
|
| 1613 |
+ session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt") |
|
| 1614 |
+ } |
|
| 1615 |
+ |
|
| 1616 |
+ private func shouldRequireCompletionConfirmation( |
|
| 1617 |
+ for session: NSManagedObject, |
|
| 1618 |
+ observedAt: Date |
|
| 1619 |
+ ) -> Bool {
|
|
| 1620 |
+ if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"), |
|
| 1621 |
+ cooldownUntil > observedAt {
|
|
| 1622 |
+ return false |
|
| 1623 |
+ } |
|
| 1624 |
+ |
|
| 1625 |
+ guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
|
|
| 1626 |
+ return false |
|
| 1627 |
+ } |
|
| 1628 |
+ |
|
| 1629 |
+ let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent") |
|
| 1630 |
+ ?? defaultCompletionPercentThreshold |
|
| 1631 |
+ |
|
| 1632 |
+ return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent |
|
| 1633 |
+ } |
|
| 1634 |
+ |
|
| 1635 |
+ private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
|
|
| 1636 |
+ guard !boolValue(session, key: "requiresCompletionConfirmation") else {
|
|
| 1637 |
+ return |
|
| 1638 |
+ } |
|
| 1639 |
+ |
|
| 1640 |
+ session.setValue(true, forKey: "requiresCompletionConfirmation") |
|
| 1641 |
+ session.setValue(observedAt, forKey: "completionConfirmationRequestedAt") |
|
| 1642 |
+ session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent") |
|
| 1643 |
+ } |
|
| 1644 |
+ |
|
| 1645 |
+ private func clearCompletionConfirmationState(for session: NSManagedObject) {
|
|
| 1646 |
+ session.setValue(false, forKey: "requiresCompletionConfirmation") |
|
| 1647 |
+ session.setValue(nil, forKey: "completionConfirmationRequestedAt") |
|
| 1648 |
+ session.setValue(nil, forKey: "completionContradictionPercent") |
|
| 1649 |
+ } |
|
| 1650 |
+ |
|
| 1651 |
+ private func snapshotDateForManualStop(_ session: NSManagedObject) -> Date {
|
|
| 1652 |
+ if statusValue(session, key: "statusRawValue") == .paused {
|
|
| 1653 |
+ return dateValue(session, key: "pausedAt") |
|
| 1654 |
+ ?? dateValue(session, key: "lastObservedAt") |
|
| 1655 |
+ ?? Date() |
|
| 1656 |
+ } |
|
| 1657 |
+ return dateValue(session, key: "lastObservedAt") ?? Date() |
|
| 1658 |
+ } |
|
| 1659 |
+ |
|
| 1660 |
+ private func snapshotClampedToMaximumDuration( |
|
| 1661 |
+ _ snapshot: ChargingMonitorSnapshot, |
|
| 1662 |
+ for session: NSManagedObject |
|
| 1663 |
+ ) -> ChargingMonitorSnapshot {
|
|
| 1664 |
+ guard let maximumEndDate = maximumEndDate(for: session), |
|
| 1665 |
+ snapshot.observedAt > maximumEndDate else {
|
|
| 1666 |
+ return snapshot |
|
| 1667 |
+ } |
|
| 1668 |
+ |
|
| 1669 |
+ return ChargingMonitorSnapshot( |
|
| 1670 |
+ meterMACAddress: snapshot.meterMACAddress, |
|
| 1671 |
+ meterName: snapshot.meterName, |
|
| 1672 |
+ meterModel: snapshot.meterModel, |
|
| 1673 |
+ observedAt: maximumEndDate, |
|
| 1674 |
+ voltageVolts: snapshot.voltageVolts, |
|
| 1675 |
+ currentAmps: snapshot.currentAmps, |
|
| 1676 |
+ powerWatts: snapshot.powerWatts, |
|
| 1677 |
+ selectedDataGroup: snapshot.selectedDataGroup, |
|
| 1678 |
+ meterChargeCounterAh: snapshot.meterChargeCounterAh, |
|
| 1679 |
+ meterEnergyCounterWh: snapshot.meterEnergyCounterWh, |
|
| 1680 |
+ meterRecordingDurationSeconds: snapshot.meterRecordingDurationSeconds, |
|
| 1681 |
+ fallbackStopThresholdAmps: snapshot.fallbackStopThresholdAmps |
|
| 1682 |
+ ) |
|
| 1683 |
+ } |
|
| 1684 |
+ |
|
| 1685 |
+ private func automaticCompletionDate( |
|
| 1686 |
+ for session: NSManagedObject, |
|
| 1687 |
+ referenceDate: Date |
|
| 1688 |
+ ) -> Date? {
|
|
| 1689 |
+ guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
|
|
| 1690 |
+ return nil |
|
| 1691 |
+ } |
|
| 1692 |
+ |
|
| 1693 |
+ var completionDates: [Date] = [] |
|
| 1694 |
+ |
|
| 1695 |
+ if let maximumEndDate = maximumEndDate(for: session) {
|
|
| 1696 |
+ completionDates.append(maximumEndDate) |
|
| 1697 |
+ } |
|
| 1698 |
+ |
|
| 1699 |
+ if statusValue(session, key: "statusRawValue") == .paused, |
|
| 1700 |
+ let pausedAt = dateValue(session, key: "pausedAt") {
|
|
| 1701 |
+ completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout)) |
|
| 1702 |
+ } |
|
| 1703 |
+ |
|
| 1704 |
+ guard let completionDate = completionDates.min(), |
|
| 1705 |
+ referenceDate >= completionDate else {
|
|
| 1706 |
+ return nil |
|
| 1707 |
+ } |
|
| 1708 |
+ |
|
| 1709 |
+ return completionDate |
|
| 1710 |
+ } |
|
| 1711 |
+ |
|
| 1712 |
+ private func maximumEndDate(for session: NSManagedObject) -> Date? {
|
|
| 1713 |
+ dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration) |
|
| 1714 |
+ } |
|
| 1715 |
+ |
|
| 1716 |
+ @discardableResult |
|
| 1717 |
+ private func maybeCompleteOpenSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
|
|
| 1718 |
+ guard statusValue(session, key: "statusRawValue")?.isOpen == true, |
|
| 1719 |
+ let completionDate = automaticCompletionDate(for: session, referenceDate: observedAt) else {
|
|
| 1720 |
+ return false |
|
| 1721 |
+ } |
|
| 1722 |
+ |
|
| 1723 |
+ finishSession( |
|
| 1724 |
+ session, |
|
| 1725 |
+ observedAt: completionDate, |
|
| 1726 |
+ finalBatteryPercent: nil, |
|
| 1727 |
+ status: .completed |
|
| 1728 |
+ ) |
|
| 1729 |
+ |
|
| 1730 |
+ guard saveContext() else {
|
|
| 1731 |
+ return false |
|
| 1732 |
+ } |
|
| 1733 |
+ |
|
| 1734 |
+ if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
|
|
| 1735 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 1736 |
+ return saveContext() |
|
| 1737 |
+ } |
|
| 1738 |
+ |
|
| 1739 |
+ return true |
|
| 1740 |
+ } |
|
| 1741 |
+ |
|
| 1742 |
+ private func completionCurrentForSessionEnd(_ session: NSManagedObject) -> Double? {
|
|
| 1743 |
+ let chargingTransportMode = chargingTransportMode(for: session) |
|
| 1744 |
+ let measuredCurrent = optionalDoubleValue(session, key: "lastObservedCurrentAmps") |
|
| 1745 |
+ ?? doubleValue(session, key: "lastObservedCurrentAmps") |
|
| 1746 |
+ |
|
| 1747 |
+ guard measuredCurrent > 0 else {
|
|
| 1748 |
+ return nil |
|
| 1749 |
+ } |
|
| 1750 |
+ |
|
| 1751 |
+ let charger = chargingTransportMode == .wireless |
|
| 1752 |
+ ? stringValue(session, key: "chargerID").flatMap(fetchChargedDeviceObject(id:)) |
|
| 1753 |
+ : nil |
|
| 1754 |
+ |
|
| 1755 |
+ if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
|
|
| 1756 |
+ return nil |
|
| 1757 |
+ } |
|
| 1758 |
+ |
|
| 1759 |
+ let effectiveCurrent = effectiveCurrentAmps( |
|
| 1760 |
+ fromMeasuredCurrent: measuredCurrent, |
|
| 1761 |
+ chargingTransportMode: chargingTransportMode, |
|
| 1762 |
+ charger: charger |
|
| 1763 |
+ ) |
|
| 1764 |
+ guard effectiveCurrent > 0 else {
|
|
| 1765 |
+ return nil |
|
| 1766 |
+ } |
|
| 1767 |
+ return effectiveCurrent |
|
| 1768 |
+ } |
|
| 1769 |
+ |
|
| 1770 |
+ private func finishSession( |
|
| 1771 |
+ _ session: NSManagedObject, |
|
| 1772 |
+ observedAt: Date, |
|
| 1773 |
+ finalBatteryPercent: Double?, |
|
| 1774 |
+ status: ChargeSessionStatus |
|
| 1775 |
+ ) {
|
|
| 1776 |
+ if let finalBatteryPercent {
|
|
| 1777 |
+ _ = insertBatteryCheckpoint( |
|
| 1778 |
+ percent: finalBatteryPercent, |
|
| 1779 |
+ flag: .final, |
|
| 1780 |
+ timestamp: observedAt, |
|
| 1781 |
+ to: session |
|
| 1782 |
+ ) |
|
| 1783 |
+ } |
|
| 1784 |
+ |
|
| 1785 |
+ session.setValue(status.rawValue, forKey: "statusRawValue") |
|
| 1786 |
+ session.setValue(nil, forKey: "pausedAt") |
|
| 1787 |
+ session.setValue(nil, forKey: "belowThresholdSince") |
|
| 1788 |
+ session.setValue(observedAt, forKey: "endedAt") |
|
| 1789 |
+ session.setValue(observedAt, forKey: "lastObservedAt") |
|
| 1790 |
+ session.setValue(completionCurrentForSessionEnd(session), forKey: "completionCurrentAmps") |
|
| 1791 |
+ clearCompletionConfirmationState(for: session) |
|
| 1792 |
+ session.setValue(nil, forKey: "completionConfirmationCooldownUntil") |
|
| 1793 |
+ updateCapacityEstimate(for: session) |
|
| 1794 |
+ session.setValue(observedAt, forKey: "updatedAt") |
|
| 1795 |
+ |
|
| 1796 |
+ if status == .completed {
|
|
| 1797 |
+ maybeTriggerTargetBatteryAlert( |
|
| 1798 |
+ for: session, |
|
| 1799 |
+ observedAt: observedAt, |
|
| 1800 |
+ completionFallbackPercent: defaultCompletionPercentThreshold |
|
| 1801 |
+ ) |
|
| 1802 |
+ } |
|
| 1803 |
+ } |
|
| 1804 |
+ |
|
| 1805 |
+ private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
|
|
| 1806 |
+ guard |
|
| 1807 |
+ let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), |
|
| 1808 |
+ let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID), |
|
| 1809 |
+ let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice), |
|
| 1810 |
+ estimatedCapacityWh > 0 |
|
| 1811 |
+ else {
|
|
| 1812 |
+ return nil |
|
| 1813 |
+ } |
|
| 1814 |
+ |
|
| 1815 |
+ // Compute effective battery energy dynamically so the prediction uses the |
|
| 1816 |
+ // most current measuredEnergyWh rather than the stale effectiveBatteryEnergyWh |
|
| 1817 |
+ // (which is only refreshed at session start, checkpoint insertion, and finish). |
|
| 1818 |
+ let rawMeasuredEnergyWh = doubleValue(session, key: "measuredEnergyWh") |
|
| 1819 |
+ let measuredEnergyWh: Double |
|
| 1820 |
+ switch chargingTransportMode(for: session) {
|
|
| 1821 |
+ case .wired: |
|
| 1822 |
+ measuredEnergyWh = rawMeasuredEnergyWh |
|
| 1823 |
+ case .wireless: |
|
| 1824 |
+ if let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 {
|
|
| 1825 |
+ measuredEnergyWh = rawMeasuredEnergyWh * factor |
|
| 1826 |
+ } else {
|
|
| 1827 |
+ measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh") |
|
| 1828 |
+ ?? rawMeasuredEnergyWh |
|
| 1829 |
+ } |
|
| 1830 |
+ } |
|
| 1831 |
+ let sessionID = stringValue(session, key: "id") ?? "" |
|
| 1832 |
+ |
|
| 1833 |
+ struct Anchor {
|
|
| 1834 |
+ let percent: Double |
|
| 1835 |
+ let energyWh: Double |
|
| 1836 |
+ let timestamp: Date |
|
| 1837 |
+ let isCheckpoint: Bool |
|
| 1838 |
+ } |
|
| 1839 |
+ |
|
| 1840 |
+ var anchors: [Anchor] = [] |
|
| 1841 |
+ if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), |
|
| 1842 |
+ startBatteryPercent >= 0 {
|
|
| 1843 |
+ anchors.append( |
|
| 1844 |
+ Anchor( |
|
| 1845 |
+ percent: startBatteryPercent, |
|
| 1846 |
+ energyWh: 0, |
|
| 1847 |
+ timestamp: dateValue(session, key: "trimStart") |
|
| 1848 |
+ ?? dateValue(session, key: "startedAt") |
|
| 1849 |
+ ?? Date.distantPast, |
|
| 1850 |
+ isCheckpoint: false |
|
| 1851 |
+ ) |
|
| 1852 |
+ ) |
|
| 1853 |
+ } |
|
| 1854 |
+ |
|
| 1855 |
+ let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID) |
|
| 1856 |
+ .compactMap(makeCheckpointSummary(from:)) |
|
| 1857 |
+ .sorted { lhs, rhs in
|
|
| 1858 |
+ if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
|
|
| 1859 |
+ return lhs.measuredEnergyWh < rhs.measuredEnergyWh |
|
| 1860 |
+ } |
|
| 1861 |
+ return lhs.timestamp < rhs.timestamp |
|
| 1862 |
+ } |
|
| 1863 |
+ .filter { $0.batteryPercent >= 0 }
|
|
| 1864 |
+ .map {
|
|
| 1865 |
+ Anchor( |
|
| 1866 |
+ percent: $0.batteryPercent, |
|
| 1867 |
+ energyWh: $0.measuredEnergyWh, |
|
| 1868 |
+ timestamp: $0.timestamp, |
|
| 1869 |
+ isCheckpoint: true |
|
| 1870 |
+ ) |
|
| 1871 |
+ } |
|
| 1872 |
+ anchors.append(contentsOf: checkpointAnchors) |
|
| 1873 |
+ |
|
| 1874 |
+ guard !anchors.isEmpty else {
|
|
| 1875 |
+ return optionalDoubleValue(session, key: "endBatteryPercent") |
|
| 1876 |
+ } |
|
| 1877 |
+ |
|
| 1878 |
+ let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
|
|
| 1879 |
+ return BatteryLevelPredictionTuning.predictedPercent( |
|
| 1880 |
+ anchorPercent: anchor.percent, |
|
| 1881 |
+ anchorEnergyWh: anchor.energyWh, |
|
| 1882 |
+ anchorTimestamp: anchor.timestamp, |
|
| 1883 |
+ anchorIsCheckpoint: anchor.isCheckpoint, |
|
| 1884 |
+ effectiveEnergyWh: measuredEnergyWh, |
|
| 1885 |
+ referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp, |
|
| 1886 |
+ estimatedCapacityWh: estimatedCapacityWh |
|
| 1887 |
+ ) |
|
| 1888 |
+ } |
|
| 1889 |
+ |
|
| 1890 |
+ private func resolvedEstimatedBatteryCapacityWh( |
|
| 1891 |
+ for session: NSManagedObject, |
|
| 1892 |
+ chargedDevice: NSManagedObject |
|
| 1893 |
+ ) -> Double? {
|
|
| 1894 |
+ if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), |
|
| 1895 |
+ sessionCapacityEstimate > 0 {
|
|
| 1896 |
+ return sessionCapacityEstimate |
|
| 1897 |
+ } |
|
| 1898 |
+ |
|
| 1899 |
+ switch chargingTransportMode(for: session) {
|
|
| 1900 |
+ case .wired: |
|
| 1901 |
+ return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh") |
|
| 1902 |
+ ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh") |
|
| 1903 |
+ case .wireless: |
|
| 1904 |
+ return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh") |
|
| 1905 |
+ ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh") |
|
| 1906 |
+ } |
|
| 1907 |
+ } |
|
| 1908 |
+ |
|
| 1909 |
+ private func updateCapacityEstimate(for session: NSManagedObject) {
|
|
| 1910 |
+ guard |
|
| 1911 |
+ let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), |
|
| 1912 |
+ let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) |
|
| 1913 |
+ else {
|
|
| 1914 |
+ session.setValue(nil, forKey: "effectiveBatteryEnergyWh") |
|
| 1915 |
+ session.setValue(nil, forKey: "capacityEstimateWh") |
|
| 1916 |
+ return |
|
| 1917 |
+ } |
|
| 1918 |
+ |
|
| 1919 |
+ let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh") |
|
| 1920 |
+ let chargingMode = chargingTransportMode(for: session) |
|
| 1921 |
+ let wirelessResolution = chargingMode == .wireless |
|
| 1922 |
+ ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice) |
|
| 1923 |
+ : nil |
|
| 1924 |
+ let effectiveBatteryEnergyWh = chargingMode == .wired |
|
| 1925 |
+ ? measuredEnergyWh |
|
| 1926 |
+ : wirelessResolution.map { measuredEnergyWh * $0.factor }
|
|
| 1927 |
+ |
|
| 1928 |
+ session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh") |
|
| 1929 |
+ session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor") |
|
| 1930 |
+ session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency") |
|
| 1931 |
+ session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency") |
|
| 1932 |
+ |
|
| 1933 |
+ let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff") |
|
| 1934 |
+ |
|
| 1935 |
+ guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
|
|
| 1936 |
+ session.setValue(nil, forKey: "capacityEstimateWh") |
|
| 1937 |
+ return |
|
| 1938 |
+ } |
|
| 1939 |
+ |
|
| 1940 |
+ struct CapacityAnchor {
|
|
| 1941 |
+ let percent: Double |
|
| 1942 |
+ let energyWh: Double |
|
| 1943 |
+ let timestamp: Date |
|
| 1944 |
+ } |
|
| 1945 |
+ |
|
| 1946 |
+ var anchors: [CapacityAnchor] = [] |
|
| 1947 |
+ |
|
| 1948 |
+ if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), |
|
| 1949 |
+ startBatteryPercent >= 0 {
|
|
| 1950 |
+ anchors.append( |
|
| 1951 |
+ CapacityAnchor( |
|
| 1952 |
+ percent: startBatteryPercent, |
|
| 1953 |
+ energyWh: 0, |
|
| 1954 |
+ timestamp: dateValue(session, key: "trimStart") |
|
| 1955 |
+ ?? dateValue(session, key: "startedAt") |
|
| 1956 |
+ ?? Date.distantPast |
|
| 1957 |
+ ) |
|
| 1958 |
+ ) |
|
| 1959 |
+ } |
|
| 1960 |
+ |
|
| 1961 |
+ if let sessionID = stringValue(session, key: "id") {
|
|
| 1962 |
+ anchors.append( |
|
| 1963 |
+ contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
|
|
| 1964 |
+ guard |
|
| 1965 |
+ let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"), |
|
| 1966 |
+ percent >= 0, |
|
| 1967 |
+ let timestamp = dateValue(checkpoint, key: "timestamp") |
|
| 1968 |
+ else {
|
|
| 1969 |
+ return nil |
|
| 1970 |
+ } |
|
| 1971 |
+ |
|
| 1972 |
+ return CapacityAnchor( |
|
| 1973 |
+ percent: percent, |
|
| 1974 |
+ energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"), |
|
| 1975 |
+ timestamp: timestamp |
|
| 1976 |
+ ) |
|
| 1977 |
+ } |
|
| 1978 |
+ ) |
|
| 1979 |
+ } |
|
| 1980 |
+ |
|
| 1981 |
+ if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), |
|
| 1982 |
+ endBatteryPercent >= 0 {
|
|
| 1983 |
+ anchors.append( |
|
| 1984 |
+ CapacityAnchor( |
|
| 1985 |
+ percent: endBatteryPercent, |
|
| 1986 |
+ energyWh: effectiveBatteryEnergyWh, |
|
| 1987 |
+ timestamp: dateValue(session, key: "endedAt") |
|
| 1988 |
+ ?? dateValue(session, key: "lastObservedAt") |
|
| 1989 |
+ ?? Date.distantPast |
|
| 1990 |
+ ) |
|
| 1991 |
+ ) |
|
| 1992 |
+ } |
|
| 1993 |
+ |
|
| 1994 |
+ let sortedAnchors = anchors.sorted { lhs, rhs in
|
|
| 1995 |
+ if lhs.energyWh != rhs.energyWh {
|
|
| 1996 |
+ return lhs.energyWh < rhs.energyWh |
|
| 1997 |
+ } |
|
| 1998 |
+ return lhs.timestamp < rhs.timestamp |
|
| 1999 |
+ } |
|
| 2000 |
+ |
|
| 2001 |
+ guard let firstAnchor = sortedAnchors.first, |
|
| 2002 |
+ let lastAnchor = sortedAnchors.last else {
|
|
| 2003 |
+ session.setValue(nil, forKey: "capacityEstimateWh") |
|
| 2004 |
+ return |
|
| 2005 |
+ } |
|
| 2006 |
+ |
|
| 2007 |
+ let percentDelta = lastAnchor.percent - firstAnchor.percent |
|
| 2008 |
+ let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh |
|
| 2009 |
+ |
|
| 2010 |
+ guard percentDelta >= 20, energyDelta > 0 else {
|
|
| 2011 |
+ session.setValue(nil, forKey: "capacityEstimateWh") |
|
| 2012 |
+ return |
|
| 2013 |
+ } |
|
| 2014 |
+ |
|
| 2015 |
+ if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
|
|
| 2016 |
+ session.setValue(nil, forKey: "capacityEstimateWh") |
|
| 2017 |
+ return |
|
| 2018 |
+ } |
|
| 2019 |
+ |
|
| 2020 |
+ let capacityEstimateWh = energyDelta / (percentDelta / 100) |
|
| 2021 |
+ session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh") |
|
| 2022 |
+ } |
|
| 2023 |
+ |
|
| 2024 |
+ @discardableResult |
|
| 2025 |
+ private func insertBatteryCheckpoint( |
|
| 2026 |
+ percent: Double, |
|
| 2027 |
+ flag: ChargeCheckpointFlag, |
|
| 2028 |
+ timestamp: Date = Date(), |
|
| 2029 |
+ measuredEnergyWhOverride: Double? = nil, |
|
| 2030 |
+ measuredChargeAhOverride: Double? = nil, |
|
| 2031 |
+ to session: NSManagedObject |
|
| 2032 |
+ ) -> String? {
|
|
| 2033 |
+ guard |
|
| 2034 |
+ let sessionID = stringValue(session, key: "id"), |
|
| 2035 |
+ let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), |
|
| 2036 |
+ let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context) |
|
| 2037 |
+ else {
|
|
| 2038 |
+ return nil |
|
| 2039 |
+ } |
|
| 2040 |
+ |
|
| 2041 |
+ let checkpoint = NSManagedObject(entity: entity, insertInto: context) |
|
| 2042 |
+ let checkpointEnergyWh = measuredEnergyWhOverride |
|
| 2043 |
+ ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh") |
|
| 2044 |
+ ?? doubleValue(session, key: "measuredEnergyWh") |
|
| 2045 |
+ let checkpointChargeAh = measuredChargeAhOverride |
|
| 2046 |
+ ?? doubleValue(session, key: "measuredChargeAh") |
|
| 2047 |
+ checkpoint.setValue(UUID().uuidString, forKey: "id") |
|
| 2048 |
+ checkpoint.setValue(sessionID, forKey: "sessionID") |
|
| 2049 |
+ checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID") |
|
| 2050 |
+ checkpoint.setValue(timestamp, forKey: "timestamp") |
|
| 2051 |
+ checkpoint.setValue(percent, forKey: "batteryPercent") |
|
| 2052 |
+ checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh") |
|
| 2053 |
+ checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh") |
|
| 2054 |
+ checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps") |
|
| 2055 |
+ checkpoint.setValue( |
|
| 2056 |
+ chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil, |
|
| 2057 |
+ forKey: "voltageVolts" |
|
| 2058 |
+ ) |
|
| 2059 |
+ checkpoint.setValue(flag.rawValue, forKey: "label") |
|
| 2060 |
+ checkpoint.setValue(timestamp, forKey: "createdAt") |
|
| 2061 |
+ |
|
| 2062 |
+ let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") |
|
| 2063 |
+ if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
|
|
| 2064 |
+ session.setValue(percent, forKey: "startBatteryPercent") |
|
| 2065 |
+ } |
|
| 2066 |
+ if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
|
|
| 2067 |
+ session.setValue(percent, forKey: "endBatteryPercent") |
|
| 2068 |
+ } |
|
| 2069 |
+ session.setValue(timestamp, forKey: "updatedAt") |
|
| 2070 |
+ updateCapacityEstimate(for: session) |
|
| 2071 |
+ |
|
| 2072 |
+ return chargedDeviceID |
|
| 2073 |
+ } |
|
| 2074 |
+ |
|
| 2075 |
+ private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
|
|
| 2076 |
+ guard let sessionID = stringValue(session, key: "id") else {
|
|
| 2077 |
+ return |
|
| 2078 |
+ } |
|
| 2079 |
+ |
|
| 2080 |
+ let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID) |
|
| 2081 |
+ if let latestCheckpoint = remainingCheckpoints.last {
|
|
| 2082 |
+ session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent") |
|
| 2083 |
+ } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), |
|
| 2084 |
+ startBatteryPercent >= 0 {
|
|
| 2085 |
+ session.setValue(startBatteryPercent, forKey: "endBatteryPercent") |
|
| 2086 |
+ } else {
|
|
| 2087 |
+ session.setValue(nil, forKey: "endBatteryPercent") |
|
| 2088 |
+ } |
|
| 2089 |
+ |
|
| 2090 |
+ session.setValue(Date(), forKey: "updatedAt") |
|
| 2091 |
+ updateCapacityEstimate(for: session) |
|
| 2092 |
+ } |
|
| 2093 |
+ |
|
| 2094 |
+ @discardableResult |
|
| 2095 |
+ private func addBatteryCheckpoint( |
|
| 2096 |
+ percent: Double, |
|
| 2097 |
+ measuredEnergyWh: Double? = nil, |
|
| 2098 |
+ measuredChargeAh: Double? = nil, |
|
| 2099 |
+ flag: ChargeCheckpointFlag, |
|
| 2100 |
+ to session: NSManagedObject, |
|
| 2101 |
+ timestamp: Date = Date() |
|
| 2102 |
+ ) -> Bool {
|
|
| 2103 |
+ if let measuredEnergyWh, measuredEnergyWh.isFinite {
|
|
| 2104 |
+ session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh") |
|
| 2105 |
+ } |
|
| 2106 |
+ if let measuredChargeAh, measuredChargeAh.isFinite {
|
|
| 2107 |
+ session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh") |
|
| 2108 |
+ } |
|
| 2109 |
+ |
|
| 2110 |
+ guard let chargedDeviceID = insertBatteryCheckpoint( |
|
| 2111 |
+ percent: percent, |
|
| 2112 |
+ flag: flag, |
|
| 2113 |
+ timestamp: timestamp, |
|
| 2114 |
+ measuredEnergyWhOverride: measuredEnergyWh, |
|
| 2115 |
+ measuredChargeAhOverride: measuredChargeAh, |
|
| 2116 |
+ to: session |
|
| 2117 |
+ ) else {
|
|
| 2118 |
+ return false |
|
| 2119 |
+ } |
|
| 2120 |
+ |
|
| 2121 |
+ guard saveContext() else {
|
|
| 2122 |
+ return false |
|
| 2123 |
+ } |
|
| 2124 |
+ |
|
| 2125 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 2126 |
+ return saveContext() |
|
| 2127 |
+ } |
|
| 2128 |
+ |
|
| 2129 |
+ private func resolvedWirelessEfficiency( |
|
| 2130 |
+ for session: NSManagedObject, |
|
| 2131 |
+ chargedDevice: NSManagedObject |
|
| 2132 |
+ ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
|
|
| 2133 |
+ if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), |
|
| 2134 |
+ storedFactor > 0 {
|
|
| 2135 |
+ return ( |
|
| 2136 |
+ factor: storedFactor, |
|
| 2137 |
+ usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"), |
|
| 2138 |
+ shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency") |
|
| 2139 |
+ ) |
|
| 2140 |
+ } |
|
| 2141 |
+ |
|
| 2142 |
+ let chargingProfile = wirelessChargingProfile(for: chargedDevice) |
|
| 2143 |
+ let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh") |
|
| 2144 |
+ guard measuredEnergyWh > 0 else {
|
|
| 2145 |
+ return nil |
|
| 2146 |
+ } |
|
| 2147 |
+ |
|
| 2148 |
+ if chargingProfile == .magsafe, |
|
| 2149 |
+ let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"), |
|
| 2150 |
+ calibratedFactor > 0 {
|
|
| 2151 |
+ return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false) |
|
| 2152 |
+ } |
|
| 2153 |
+ |
|
| 2154 |
+ guard |
|
| 2155 |
+ let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), |
|
| 2156 |
+ let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent") |
|
| 2157 |
+ else {
|
|
| 2158 |
+ return nil |
|
| 2159 |
+ } |
|
| 2160 |
+ |
|
| 2161 |
+ let percentDelta = endBatteryPercent - startBatteryPercent |
|
| 2162 |
+ guard percentDelta >= 20 else {
|
|
| 2163 |
+ return nil |
|
| 2164 |
+ } |
|
| 2165 |
+ |
|
| 2166 |
+ guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh") |
|
| 2167 |
+ ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired) |
|
| 2168 |
+ ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh") |
|
| 2169 |
+ : nil), |
|
| 2170 |
+ wiredCapacityWh > 0 |
|
| 2171 |
+ else {
|
|
| 2172 |
+ return nil |
|
| 2173 |
+ } |
|
| 2174 |
+ |
|
| 2175 |
+ let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100) |
|
| 2176 |
+ let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh |
|
| 2177 |
+ let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor) |
|
| 2178 |
+ let usesEstimated = chargingProfile != .magsafe |
|
| 2179 |
+ let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold |
|
| 2180 |
+ |
|
| 2181 |
+ return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn) |
|
| 2182 |
+ } |
|
| 2183 |
+ |
|
| 2184 |
+ private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
|
|
| 2185 |
+ guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
|
|
| 2186 |
+ return |
|
| 2187 |
+ } |
|
| 2188 |
+ |
|
| 2189 |
+ let chargingStateAvailability = chargingStateAvailability(for: chargedDevice) |
|
| 2190 |
+ let supportsChargingWhileOff = chargingStateAvailability.supportsChargingWhileOff |
|
| 2191 |
+ let wirelessProfile = wirelessChargingProfile(for: chargedDevice) |
|
| 2192 |
+ let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other |
|
| 2193 |
+ let sessions = relevantSessionObjects( |
|
| 2194 |
+ for: chargedDeviceID, |
|
| 2195 |
+ deviceClass: deviceClass, |
|
| 2196 |
+ sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)], |
|
| 2197 |
+ sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)] |
|
| 2198 |
+ ) |
|
| 2199 |
+ let learnedCompletionCurrents = derivedCompletionCurrents(from: sessions) |
|
| 2200 |
+ let wiredMinimumCurrent = derivedMinimumCurrent( |
|
| 2201 |
+ from: sessions, |
|
| 2202 |
+ chargingTransportMode: .wired |
|
| 2203 |
+ ) |
|
| 2204 |
+ let wirelessMinimumCurrent = derivedMinimumCurrent( |
|
| 2205 |
+ from: sessions, |
|
| 2206 |
+ chargingTransportMode: .wireless |
|
| 2207 |
+ ) |
|
| 2208 |
+ |
|
| 2209 |
+ let wiredCapacity = derivedCapacity( |
|
| 2210 |
+ from: sessions, |
|
| 2211 |
+ chargingTransportMode: .wired, |
|
| 2212 |
+ supportsChargingWhileOff: supportsChargingWhileOff |
|
| 2213 |
+ ) |
|
| 2214 |
+ let wirelessCapacity = derivedCapacity( |
|
| 2215 |
+ from: sessions, |
|
| 2216 |
+ chargingTransportMode: .wireless, |
|
| 2217 |
+ supportsChargingWhileOff: supportsChargingWhileOff |
|
| 2218 |
+ ) |
|
| 2219 |
+ let wirelessEfficiency = derivedWirelessEfficiency( |
|
| 2220 |
+ from: sessions, |
|
| 2221 |
+ chargingProfile: wirelessProfile |
|
| 2222 |
+ ) |
|
| 2223 |
+ let configuredCompletionCurrents = decodedCompletionCurrents( |
|
| 2224 |
+ from: chargedDevice, |
|
| 2225 |
+ key: "configuredCompletionCurrentsRawValue" |
|
| 2226 |
+ ) |
|
| 2227 |
+ let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps") |
|
| 2228 |
+ let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps") |
|
| 2229 |
+ let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : [] |
|
| 2230 |
+ let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil |
|
| 2231 |
+ let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil |
|
| 2232 |
+ let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil |
|
| 2233 |
+ |
|
| 2234 |
+ let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice) |
|
| 2235 |
+ let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on |
|
| 2236 |
+ let preferredMinimumCurrent: Double? |
|
| 2237 |
+ let preferredCapacity: Double? |
|
| 2238 |
+ switch preferredChargingTransportMode {
|
|
| 2239 |
+ case .wired: |
|
| 2240 |
+ preferredMinimumCurrent = configuredCompletionCurrents[ |
|
| 2241 |
+ ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode) |
|
| 2242 |
+ ] ?? learnedCompletionCurrents[ |
|
| 2243 |
+ ChargeSessionKind(chargingTransportMode: .wired, chargingStateMode: preferredChargingStateMode) |
|
| 2244 |
+ ] ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent |
|
| 2245 |
+ preferredCapacity = wiredCapacity ?? wirelessCapacity |
|
| 2246 |
+ case .wireless: |
|
| 2247 |
+ preferredMinimumCurrent = configuredCompletionCurrents[ |
|
| 2248 |
+ ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode) |
|
| 2249 |
+ ] ?? learnedCompletionCurrents[ |
|
| 2250 |
+ ChargeSessionKind(chargingTransportMode: .wireless, chargingStateMode: preferredChargingStateMode) |
|
| 2251 |
+ ] ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent |
|
| 2252 |
+ preferredCapacity = wirelessCapacity ?? wiredCapacity |
|
| 2253 |
+ } |
|
| 2254 |
+ |
|
| 2255 |
+ setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps") |
|
| 2256 |
+ setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps") |
|
| 2257 |
+ setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue") |
|
| 2258 |
+ setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh") |
|
| 2259 |
+ setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh") |
|
| 2260 |
+ setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor") |
|
| 2261 |
+ setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue") |
|
| 2262 |
+ setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps") |
|
| 2263 |
+ setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor") |
|
| 2264 |
+ setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts") |
|
| 2265 |
+ setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps") |
|
| 2266 |
+ setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh") |
|
| 2267 |
+ setValue(Date(), on: chargedDevice, key: "updatedAt") |
|
| 2268 |
+ } |
|
| 2269 |
+ |
|
| 2270 |
+ private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
|
|
| 2271 |
+ sessions |
|
| 2272 |
+ .filter { $0.status == .completed }
|
|
| 2273 |
+ .compactMap { session in
|
|
| 2274 |
+ guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
|
|
| 2275 |
+ let timestamp = session.endedAt ?? session.lastObservedAt |
|
| 2276 |
+ return CapacityTrendPoint( |
|
| 2277 |
+ sessionID: session.id, |
|
| 2278 |
+ timestamp: timestamp, |
|
| 2279 |
+ capacityWh: capacityEstimateWh, |
|
| 2280 |
+ chargingTransportMode: session.chargingTransportMode |
|
| 2281 |
+ ) |
|
| 2282 |
+ } |
|
| 2283 |
+ .sorted { $0.timestamp < $1.timestamp }
|
|
| 2284 |
+ } |
|
| 2285 |
+ |
|
| 2286 |
+ private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
|
|
| 2287 |
+ var groupedEnergyByBin: [Int: [Double]] = [:] |
|
| 2288 |
+ var groupedChargeByBin: [Int: [Double]] = [:] |
|
| 2289 |
+ |
|
| 2290 |
+ for session in sessions where session.status == .completed {
|
|
| 2291 |
+ let anchors = normalizedTypicalCurveAnchors(for: session) |
|
| 2292 |
+ guard anchors.count >= 2 else {
|
|
| 2293 |
+ continue |
|
| 2294 |
+ } |
|
| 2295 |
+ |
|
| 2296 |
+ for percentBin in stride(from: 0, through: 100, by: 10) {
|
|
| 2297 |
+ guard let interpolatedPoint = interpolatedTypicalCurvePoint( |
|
| 2298 |
+ for: Double(percentBin), |
|
| 2299 |
+ anchors: anchors |
|
| 2300 |
+ ) else {
|
|
| 2301 |
+ continue |
|
| 2302 |
+ } |
|
| 2303 |
+ |
|
| 2304 |
+ groupedEnergyByBin[percentBin, default: []].append(interpolatedPoint.energyWh) |
|
| 2305 |
+ groupedChargeByBin[percentBin, default: []].append(interpolatedPoint.chargeAh) |
|
| 2306 |
+ } |
|
| 2307 |
+ } |
|
| 2308 |
+ |
|
| 2309 |
+ let averagedPoints = groupedEnergyByBin.keys.sorted().compactMap { percentBin -> TypicalChargeCurvePoint? in
|
|
| 2310 |
+ guard |
|
| 2311 |
+ let energies = groupedEnergyByBin[percentBin], |
|
| 2312 |
+ let charges = groupedChargeByBin[percentBin], |
|
| 2313 |
+ !energies.isEmpty, |
|
| 2314 |
+ !charges.isEmpty |
|
| 2315 |
+ else {
|
|
| 2316 |
+ return nil |
|
| 2317 |
+ } |
|
| 2318 |
+ |
|
| 2319 |
+ return TypicalChargeCurvePoint( |
|
| 2320 |
+ percentBin: percentBin, |
|
| 2321 |
+ averageEnergyWh: energies.reduce(0, +) / Double(energies.count), |
|
| 2322 |
+ averageChargeAh: charges.reduce(0, +) / Double(charges.count), |
|
| 2323 |
+ sampleCount: min(energies.count, charges.count) |
|
| 2324 |
+ ) |
|
| 2325 |
+ } |
|
| 2326 |
+ |
|
| 2327 |
+ var runningMaximumEnergyWh = 0.0 |
|
| 2328 |
+ var runningMaximumChargeAh = 0.0 |
|
| 2329 |
+ |
|
| 2330 |
+ return averagedPoints.map { point in
|
|
| 2331 |
+ runningMaximumEnergyWh = max(runningMaximumEnergyWh, point.averageEnergyWh) |
|
| 2332 |
+ runningMaximumChargeAh = max(runningMaximumChargeAh, point.averageChargeAh) |
|
| 2333 |
+ return TypicalChargeCurvePoint( |
|
| 2334 |
+ percentBin: point.percentBin, |
|
| 2335 |
+ averageEnergyWh: runningMaximumEnergyWh, |
|
| 2336 |
+ averageChargeAh: runningMaximumChargeAh, |
|
| 2337 |
+ sampleCount: point.sampleCount |
|
| 2338 |
+ ) |
|
| 2339 |
+ } |
|
| 2340 |
+ } |
|
| 2341 |
+ |
|
| 2342 |
+ private func normalizedTypicalCurveAnchors( |
|
| 2343 |
+ for session: ChargeSessionSummary |
|
| 2344 |
+ ) -> [(percent: Double, energyWh: Double, chargeAh: Double)] {
|
|
| 2345 |
+ struct Anchor {
|
|
| 2346 |
+ let percent: Double |
|
| 2347 |
+ let energyWh: Double |
|
| 2348 |
+ let chargeAh: Double |
|
| 2349 |
+ let timestamp: Date |
|
| 2350 |
+ } |
|
| 2351 |
+ |
|
| 2352 |
+ var anchors: [Anchor] = session.checkpoints.compactMap { checkpoint in
|
|
| 2353 |
+ guard checkpoint.batteryPercent.isFinite, |
|
| 2354 |
+ checkpoint.measuredEnergyWh.isFinite, |
|
| 2355 |
+ checkpoint.measuredChargeAh.isFinite, |
|
| 2356 |
+ checkpoint.batteryPercent >= 0, |
|
| 2357 |
+ checkpoint.batteryPercent <= 100, |
|
| 2358 |
+ checkpoint.measuredEnergyWh >= 0, |
|
| 2359 |
+ checkpoint.measuredChargeAh >= 0 else {
|
|
| 2360 |
+ return nil |
|
| 2361 |
+ } |
|
| 2362 |
+ |
|
| 2363 |
+ return Anchor( |
|
| 2364 |
+ percent: checkpoint.batteryPercent, |
|
| 2365 |
+ energyWh: checkpoint.measuredEnergyWh, |
|
| 2366 |
+ chargeAh: checkpoint.measuredChargeAh, |
|
| 2367 |
+ timestamp: checkpoint.timestamp |
|
| 2368 |
+ ) |
|
| 2369 |
+ } |
|
| 2370 |
+ |
|
| 2371 |
+ if let startBatteryPercent = session.startBatteryPercent, |
|
| 2372 |
+ startBatteryPercent.isFinite, |
|
| 2373 |
+ startBatteryPercent >= 0, |
|
| 2374 |
+ startBatteryPercent <= 100 {
|
|
| 2375 |
+ anchors.append( |
|
| 2376 |
+ Anchor( |
|
| 2377 |
+ percent: startBatteryPercent, |
|
| 2378 |
+ energyWh: 0, |
|
| 2379 |
+ chargeAh: 0, |
|
| 2380 |
+ timestamp: session.startedAt |
|
| 2381 |
+ ) |
|
| 2382 |
+ ) |
|
| 2383 |
+ } |
|
| 2384 |
+ |
|
| 2385 |
+ if let endBatteryPercent = session.endBatteryPercent, |
|
| 2386 |
+ endBatteryPercent.isFinite, |
|
| 2387 |
+ endBatteryPercent >= 0, |
|
| 2388 |
+ endBatteryPercent <= 100 {
|
|
| 2389 |
+ anchors.append( |
|
| 2390 |
+ Anchor( |
|
| 2391 |
+ percent: endBatteryPercent, |
|
| 2392 |
+ energyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh, |
|
| 2393 |
+ chargeAh: session.measuredChargeAh, |
|
| 2394 |
+ timestamp: session.endedAt ?? session.lastObservedAt |
|
| 2395 |
+ ) |
|
| 2396 |
+ ) |
|
| 2397 |
+ } |
|
| 2398 |
+ |
|
| 2399 |
+ let sortedAnchors = anchors.sorted { lhs, rhs in
|
|
| 2400 |
+ if lhs.percent != rhs.percent {
|
|
| 2401 |
+ return lhs.percent < rhs.percent |
|
| 2402 |
+ } |
|
| 2403 |
+ if lhs.energyWh != rhs.energyWh {
|
|
| 2404 |
+ return lhs.energyWh < rhs.energyWh |
|
| 2405 |
+ } |
|
| 2406 |
+ return lhs.timestamp < rhs.timestamp |
|
| 2407 |
+ } |
|
| 2408 |
+ |
|
| 2409 |
+ var collapsedAnchors: [(percent: Double, energyWh: Double, chargeAh: Double)] = [] |
|
| 2410 |
+ |
|
| 2411 |
+ for anchor in sortedAnchors {
|
|
| 2412 |
+ if let lastIndex = collapsedAnchors.indices.last, |
|
| 2413 |
+ abs(collapsedAnchors[lastIndex].percent - anchor.percent) < 0.000_1 {
|
|
| 2414 |
+ collapsedAnchors[lastIndex] = ( |
|
| 2415 |
+ percent: collapsedAnchors[lastIndex].percent, |
|
| 2416 |
+ energyWh: max(collapsedAnchors[lastIndex].energyWh, anchor.energyWh), |
|
| 2417 |
+ chargeAh: max(collapsedAnchors[lastIndex].chargeAh, anchor.chargeAh) |
|
| 2418 |
+ ) |
|
| 2419 |
+ } else {
|
|
| 2420 |
+ collapsedAnchors.append( |
|
| 2421 |
+ (percent: anchor.percent, energyWh: anchor.energyWh, chargeAh: anchor.chargeAh) |
|
| 2422 |
+ ) |
|
| 2423 |
+ } |
|
| 2424 |
+ } |
|
| 2425 |
+ |
|
| 2426 |
+ var runningMaximumEnergyWh = 0.0 |
|
| 2427 |
+ var runningMaximumChargeAh = 0.0 |
|
| 2428 |
+ |
|
| 2429 |
+ return collapsedAnchors.map { anchor in
|
|
| 2430 |
+ runningMaximumEnergyWh = max(runningMaximumEnergyWh, anchor.energyWh) |
|
| 2431 |
+ runningMaximumChargeAh = max(runningMaximumChargeAh, anchor.chargeAh) |
|
| 2432 |
+ return ( |
|
| 2433 |
+ percent: anchor.percent, |
|
| 2434 |
+ energyWh: runningMaximumEnergyWh, |
|
| 2435 |
+ chargeAh: runningMaximumChargeAh |
|
| 2436 |
+ ) |
|
| 2437 |
+ } |
|
| 2438 |
+ } |
|
| 2439 |
+ |
|
| 2440 |
+ private func interpolatedTypicalCurvePoint( |
|
| 2441 |
+ for percent: Double, |
|
| 2442 |
+ anchors: [(percent: Double, energyWh: Double, chargeAh: Double)] |
|
| 2443 |
+ ) -> (energyWh: Double, chargeAh: Double)? {
|
|
| 2444 |
+ guard |
|
| 2445 |
+ let firstAnchor = anchors.first, |
|
| 2446 |
+ let lastAnchor = anchors.last, |
|
| 2447 |
+ percent >= firstAnchor.percent, |
|
| 2448 |
+ percent <= lastAnchor.percent |
|
| 2449 |
+ else {
|
|
| 2450 |
+ return nil |
|
| 2451 |
+ } |
|
| 2452 |
+ |
|
| 2453 |
+ if let exactAnchor = anchors.first(where: { abs($0.percent - percent) < 0.000_1 }) {
|
|
| 2454 |
+ return (energyWh: exactAnchor.energyWh, chargeAh: exactAnchor.chargeAh) |
|
| 2455 |
+ } |
|
| 2456 |
+ |
|
| 2457 |
+ guard let upperIndex = anchors.firstIndex(where: { $0.percent > percent }),
|
|
| 2458 |
+ upperIndex > 0 else {
|
|
| 2459 |
+ return nil |
|
| 2460 |
+ } |
|
| 2461 |
+ |
|
| 2462 |
+ let lowerAnchor = anchors[upperIndex - 1] |
|
| 2463 |
+ let upperAnchor = anchors[upperIndex] |
|
| 2464 |
+ let span = upperAnchor.percent - lowerAnchor.percent |
|
| 2465 |
+ guard span > 0.000_1 else {
|
|
| 2466 |
+ return nil |
|
| 2467 |
+ } |
|
| 2468 |
+ |
|
| 2469 |
+ let ratio = (percent - lowerAnchor.percent) / span |
|
| 2470 |
+ let energyWh = lowerAnchor.energyWh + ((upperAnchor.energyWh - lowerAnchor.energyWh) * ratio) |
|
| 2471 |
+ let chargeAh = lowerAnchor.chargeAh + ((upperAnchor.chargeAh - lowerAnchor.chargeAh) * ratio) |
|
| 2472 |
+ return (energyWh: energyWh, chargeAh: chargeAh) |
|
| 2473 |
+ } |
|
| 2474 |
+ |
|
| 2475 |
+ private func makeSessionSummary( |
|
| 2476 |
+ from object: NSManagedObject, |
|
| 2477 |
+ checkpoints: [NSManagedObject], |
|
| 2478 |
+ samples: [NSManagedObject] |
|
| 2479 |
+ ) -> ChargeSessionSummary? {
|
|
| 2480 |
+ let chargingTransportMode = chargingTransportMode(for: object) |
|
| 2481 |
+ |
|
| 2482 |
+ guard |
|
| 2483 |
+ let id = uuidValue(object, key: "id"), |
|
| 2484 |
+ let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"), |
|
| 2485 |
+ let startedAt = dateValue(object, key: "startedAt"), |
|
| 2486 |
+ let lastObservedAt = dateValue(object, key: "lastObservedAt"), |
|
| 2487 |
+ let status = statusValue(object, key: "statusRawValue"), |
|
| 2488 |
+ let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "") |
|
| 2489 |
+ else {
|
|
| 2490 |
+ return nil |
|
| 2491 |
+ } |
|
| 2492 |
+ |
|
| 2493 |
+ let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:)) |
|
| 2494 |
+ .sorted { $0.timestamp < $1.timestamp }
|
|
| 2495 |
+ let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:)) |
|
| 2496 |
+ .sorted { lhs, rhs in
|
|
| 2497 |
+ if lhs.bucketIndex != rhs.bucketIndex {
|
|
| 2498 |
+ return lhs.bucketIndex < rhs.bucketIndex |
|
| 2499 |
+ } |
|
| 2500 |
+ return lhs.timestamp < rhs.timestamp |
|
| 2501 |
+ } |
|
| 2502 |
+ |
|
| 2503 |
+ return ChargeSessionSummary( |
|
| 2504 |
+ id: id, |
|
| 2505 |
+ chargedDeviceID: chargedDeviceID, |
|
| 2506 |
+ chargerID: uuidValue(object, key: "chargerID"), |
|
| 2507 |
+ meterMACAddress: stringValue(object, key: "meterMACAddress"), |
|
| 2508 |
+ meterName: stringValue(object, key: "meterName"), |
|
| 2509 |
+ meterModel: stringValue(object, key: "meterModel"), |
|
| 2510 |
+ startedAt: startedAt, |
|
| 2511 |
+ endedAt: dateValue(object, key: "endedAt"), |
|
| 2512 |
+ lastObservedAt: lastObservedAt, |
|
| 2513 |
+ pausedAt: dateValue(object, key: "pausedAt"), |
|
| 2514 |
+ status: status, |
|
| 2515 |
+ sourceMode: sourceMode, |
|
| 2516 |
+ chargingTransportMode: chargingTransportMode, |
|
| 2517 |
+ chargingStateMode: chargingStateMode(for: object), |
|
| 2518 |
+ autoStopEnabled: boolValue(object, key: "autoStopEnabled"), |
|
| 2519 |
+ measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"), |
|
| 2520 |
+ effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"), |
|
| 2521 |
+ measuredChargeAh: doubleValue(object, key: "measuredChargeAh"), |
|
| 2522 |
+ meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"), |
|
| 2523 |
+ meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"), |
|
| 2524 |
+ meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"), |
|
| 2525 |
+ meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"), |
|
| 2526 |
+ minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"), |
|
| 2527 |
+ maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"), |
|
| 2528 |
+ maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"), |
|
| 2529 |
+ maximumObservedVoltageVolts: chargingTransportMode == .wired |
|
| 2530 |
+ ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts") |
|
| 2531 |
+ : nil, |
|
| 2532 |
+ hasObservedChargeFlow: boolValue(object, key: "hasObservedChargeFlow"), |
|
| 2533 |
+ selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"), |
|
| 2534 |
+ completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"), |
|
| 2535 |
+ stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"), |
|
| 2536 |
+ startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"), |
|
| 2537 |
+ endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"), |
|
| 2538 |
+ capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"), |
|
| 2539 |
+ wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"), |
|
| 2540 |
+ usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"), |
|
| 2541 |
+ shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"), |
|
| 2542 |
+ supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"), |
|
| 2543 |
+ usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"), |
|
| 2544 |
+ targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"), |
|
| 2545 |
+ targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"), |
|
| 2546 |
+ requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"), |
|
| 2547 |
+ completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"), |
|
| 2548 |
+ completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"), |
|
| 2549 |
+ selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init), |
|
| 2550 |
+ trimStart: dateValue(object, key: "trimStart"), |
|
| 2551 |
+ trimEnd: dateValue(object, key: "trimEnd"), |
|
| 2552 |
+ wasConflictHealed: boolValue(object, key: "wasConflictHealed"), |
|
| 2553 |
+ checkpoints: checkpointSummaries, |
|
| 2554 |
+ aggregatedSamples: sampleSummaries |
|
| 2555 |
+ ) |
|
| 2556 |
+ } |
|
| 2557 |
+ |
|
| 2558 |
+ private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
|
|
| 2559 |
+ guard |
|
| 2560 |
+ let id = uuidValue(object, key: "id"), |
|
| 2561 |
+ let sessionID = uuidValue(object, key: "sessionID"), |
|
| 2562 |
+ let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"), |
|
| 2563 |
+ let timestamp = dateValue(object, key: "timestamp") |
|
| 2564 |
+ else {
|
|
| 2565 |
+ return nil |
|
| 2566 |
+ } |
|
| 2567 |
+ |
|
| 2568 |
+ return ChargeCheckpointSummary( |
|
| 2569 |
+ id: id, |
|
| 2570 |
+ sessionID: sessionID, |
|
| 2571 |
+ chargedDeviceID: chargedDeviceID, |
|
| 2572 |
+ timestamp: timestamp, |
|
| 2573 |
+ batteryPercent: doubleValue(object, key: "batteryPercent"), |
|
| 2574 |
+ measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"), |
|
| 2575 |
+ measuredChargeAh: doubleValue(object, key: "measuredChargeAh"), |
|
| 2576 |
+ currentAmps: doubleValue(object, key: "currentAmps"), |
|
| 2577 |
+ voltageVolts: optionalDoubleValue(object, key: "voltageVolts"), |
|
| 2578 |
+ label: stringValue(object, key: "label") |
|
| 2579 |
+ ) |
|
| 2580 |
+ } |
|
| 2581 |
+ |
|
| 2582 |
+ private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
|
|
| 2583 |
+ guard |
|
| 2584 |
+ let sessionID = uuidValue(object, key: "sessionID"), |
|
| 2585 |
+ let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"), |
|
| 2586 |
+ let timestamp = dateValue(object, key: "timestamp") |
|
| 2587 |
+ else {
|
|
| 2588 |
+ return nil |
|
| 2589 |
+ } |
|
| 2590 |
+ |
|
| 2591 |
+ return ChargeSessionSampleSummary( |
|
| 2592 |
+ sessionID: sessionID, |
|
| 2593 |
+ chargedDeviceID: chargedDeviceID, |
|
| 2594 |
+ bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0), |
|
| 2595 |
+ timestamp: timestamp, |
|
| 2596 |
+ averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"), |
|
| 2597 |
+ averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"), |
|
| 2598 |
+ averagePowerWatts: doubleValue(object, key: "averagePowerWatts"), |
|
| 2599 |
+ measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"), |
|
| 2600 |
+ measuredChargeAh: doubleValue(object, key: "measuredChargeAh"), |
|
| 2601 |
+ sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0) |
|
| 2602 |
+ ) |
|
| 2603 |
+ } |
|
| 2604 |
+ |
|
| 2605 |
+ private func fetchOpenSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
|
|
| 2606 |
+ fetchSessionObject( |
|
| 2607 |
+ predicate: NSPredicate( |
|
| 2608 |
+ format: "meterMACAddress == %@ AND (statusRawValue == %@ OR statusRawValue == %@)", |
|
| 2609 |
+ normalizedMACAddress(meterMACAddress), |
|
| 2610 |
+ ChargeSessionStatus.active.rawValue, |
|
| 2611 |
+ ChargeSessionStatus.paused.rawValue |
|
| 2612 |
+ ) |
|
| 2613 |
+ ) |
|
| 2614 |
+ } |
|
| 2615 |
+ |
|
| 2616 |
+ private func fetchOpenSessionObjects() -> [NSManagedObject] {
|
|
| 2617 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession) |
|
| 2618 |
+ request.predicate = NSPredicate( |
|
| 2619 |
+ format: "statusRawValue == %@ OR statusRawValue == %@", |
|
| 2620 |
+ ChargeSessionStatus.active.rawValue, |
|
| 2621 |
+ ChargeSessionStatus.paused.rawValue |
|
| 2622 |
+ ) |
|
| 2623 |
+ request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)] |
|
| 2624 |
+ return (try? context.fetch(request)) ?? [] |
|
| 2625 |
+ } |
|
| 2626 |
+ |
|
| 2627 |
+ private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
|
|
| 2628 |
+ fetchSessionObject( |
|
| 2629 |
+ predicate: NSPredicate( |
|
| 2630 |
+ format: "meterMACAddress == %@ AND statusRawValue == %@", |
|
| 2631 |
+ normalizedMACAddress(meterMACAddress), |
|
| 2632 |
+ ChargeSessionStatus.active.rawValue |
|
| 2633 |
+ ) |
|
| 2634 |
+ ) |
|
| 2635 |
+ } |
|
| 2636 |
+ |
|
| 2637 |
+ private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
|
|
| 2638 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession) |
|
| 2639 |
+ request.predicate = predicate |
|
| 2640 |
+ request.fetchLimit = 1 |
|
| 2641 |
+ request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)] |
|
| 2642 |
+ return (try? context.fetch(request))?.first |
|
| 2643 |
+ } |
|
| 2644 |
+ |
|
| 2645 |
+ private func fetchSessionObject(id: String) -> NSManagedObject? {
|
|
| 2646 |
+ fetchSessionObject( |
|
| 2647 |
+ predicate: NSPredicate(format: "id == %@", id) |
|
| 2648 |
+ ) |
|
| 2649 |
+ } |
|
| 2650 |
+ |
|
| 2651 |
+ private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
|
|
| 2652 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample) |
|
| 2653 |
+ request.predicate = NSPredicate( |
|
| 2654 |
+ format: "sessionID == %@ AND bucketIndex == %d", |
|
| 2655 |
+ sessionID, |
|
| 2656 |
+ bucketIndex |
|
| 2657 |
+ ) |
|
| 2658 |
+ request.fetchLimit = 1 |
|
| 2659 |
+ return (try? context.fetch(request))?.first |
|
| 2660 |
+ } |
|
| 2661 |
+ |
|
| 2662 |
+ private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
|
|
| 2663 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample) |
|
| 2664 |
+ request.predicate = NSPredicate(format: "sessionID == %@", sessionID) |
|
| 2665 |
+ return (try? context.fetch(request)) ?? [] |
|
| 2666 |
+ } |
|
| 2667 |
+ |
|
| 2668 |
+ private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
|
|
| 2669 |
+ guard !sessionIDs.isEmpty else {
|
|
| 2670 |
+ return [] |
|
| 2671 |
+ } |
|
| 2672 |
+ |
|
| 2673 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample) |
|
| 2674 |
+ request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs) |
|
| 2675 |
+ return (try? context.fetch(request)) ?? [] |
|
| 2676 |
+ } |
|
| 2677 |
+ |
|
| 2678 |
+ private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
|
|
| 2679 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint) |
|
| 2680 |
+ request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID) |
|
| 2681 |
+ request.fetchLimit = 1 |
|
| 2682 |
+ return (try? context.fetch(request))?.first |
|
| 2683 |
+ } |
|
| 2684 |
+ |
|
| 2685 |
+ private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
|
|
| 2686 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint) |
|
| 2687 |
+ request.predicate = NSPredicate(format: "sessionID == %@", sessionID) |
|
| 2688 |
+ request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)] |
|
| 2689 |
+ return (try? context.fetch(request)) ?? [] |
|
| 2690 |
+ } |
|
| 2691 |
+ |
|
| 2692 |
+ private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
|
|
| 2693 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession) |
|
| 2694 |
+ request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID) |
|
| 2695 |
+ request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)] |
|
| 2696 |
+ return (try? context.fetch(request)) ?? [] |
|
| 2697 |
+ } |
|
| 2698 |
+ |
|
| 2699 |
+ private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
|
|
| 2700 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession) |
|
| 2701 |
+ request.predicate = NSPredicate(format: "chargerID == %@", chargerID) |
|
| 2702 |
+ request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)] |
|
| 2703 |
+ return (try? context.fetch(request)) ?? [] |
|
| 2704 |
+ } |
|
| 2705 |
+ |
|
| 2706 |
+ private func sampleBackedSessionIDs( |
|
| 2707 |
+ devices: [NSManagedObject], |
|
| 2708 |
+ sessionsByDeviceID: [String: [NSManagedObject]], |
|
| 2709 |
+ sessionsByChargerID: [String: [NSManagedObject]] |
|
| 2710 |
+ ) -> Set<String> {
|
|
| 2711 |
+ var sessionIDs: Set<String> = [] |
|
| 2712 |
+ |
|
| 2713 |
+ for device in devices {
|
|
| 2714 |
+ guard |
|
| 2715 |
+ let deviceID = stringValue(device, key: "id"), |
|
| 2716 |
+ let rawClass = stringValue(device, key: "deviceClassRawValue"), |
|
| 2717 |
+ let deviceClass = ChargedDeviceClass(rawValue: rawClass) |
|
| 2718 |
+ else {
|
|
| 2719 |
+ continue |
|
| 2720 |
+ } |
|
| 2721 |
+ |
|
| 2722 |
+ let relevantSessions = relevantSessionObjects( |
|
| 2723 |
+ for: deviceID, |
|
| 2724 |
+ deviceClass: deviceClass, |
|
| 2725 |
+ sessionsByDeviceID: sessionsByDeviceID, |
|
| 2726 |
+ sessionsByChargerID: sessionsByChargerID |
|
| 2727 |
+ ) |
|
| 2728 |
+ .sorted { lhs, rhs in
|
|
| 2729 |
+ let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed |
|
| 2730 |
+ let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed |
|
| 2731 |
+ |
|
| 2732 |
+ if lhsStatus.isOpen && !rhsStatus.isOpen {
|
|
| 2733 |
+ return true |
|
| 2734 |
+ } |
|
| 2735 |
+ if !lhsStatus.isOpen && rhsStatus.isOpen {
|
|
| 2736 |
+ return false |
|
| 2737 |
+ } |
|
| 2738 |
+ |
|
| 2739 |
+ return (dateValue(lhs, key: "startedAt") ?? .distantPast) |
|
| 2740 |
+ > (dateValue(rhs, key: "startedAt") ?? .distantPast) |
|
| 2741 |
+ } |
|
| 2742 |
+ |
|
| 2743 |
+ var recentCompletedSamplesIncluded = 0 |
|
| 2744 |
+ |
|
| 2745 |
+ for session in relevantSessions {
|
|
| 2746 |
+ guard let sessionID = stringValue(session, key: "id"), |
|
| 2747 |
+ let status = statusValue(session, key: "statusRawValue") else {
|
|
| 2748 |
+ continue |
|
| 2749 |
+ } |
|
| 2750 |
+ |
|
| 2751 |
+ if status.isOpen {
|
|
| 2752 |
+ sessionIDs.insert(sessionID) |
|
| 2753 |
+ continue |
|
| 2754 |
+ } |
|
| 2755 |
+ |
|
| 2756 |
+ guard recentCompletedSamplesIncluded < 2 else {
|
|
| 2757 |
+ continue |
|
| 2758 |
+ } |
|
| 2759 |
+ |
|
| 2760 |
+ sessionIDs.insert(sessionID) |
|
| 2761 |
+ recentCompletedSamplesIncluded += 1 |
|
| 2762 |
+ } |
|
| 2763 |
+ } |
|
| 2764 |
+ |
|
| 2765 |
+ return sessionIDs |
|
| 2766 |
+ } |
|
| 2767 |
+ |
|
| 2768 |
+ private func relevantSessionObjects( |
|
| 2769 |
+ for chargedDeviceID: String, |
|
| 2770 |
+ deviceClass: ChargedDeviceClass, |
|
| 2771 |
+ sessionsByDeviceID: [String: [NSManagedObject]], |
|
| 2772 |
+ sessionsByChargerID: [String: [NSManagedObject]] |
|
| 2773 |
+ ) -> [NSManagedObject] {
|
|
| 2774 |
+ let directSessions = sessionsByDeviceID[chargedDeviceID] ?? [] |
|
| 2775 |
+ guard deviceClass == .charger else {
|
|
| 2776 |
+ return directSessions |
|
| 2777 |
+ } |
|
| 2778 |
+ |
|
| 2779 |
+ var seenSessionIDs = Set<String>() |
|
| 2780 |
+ return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? [])) |
|
| 2781 |
+ .filter { session in
|
|
| 2782 |
+ let sessionID = stringValue(session, key: "id") ?? UUID().uuidString |
|
| 2783 |
+ return seenSessionIDs.insert(sessionID).inserted |
|
| 2784 |
+ } |
|
| 2785 |
+ .sorted {
|
|
| 2786 |
+ let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast |
|
| 2787 |
+ let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast |
|
| 2788 |
+ return lhsDate < rhsDate |
|
| 2789 |
+ } |
|
| 2790 |
+ } |
|
| 2791 |
+ |
|
| 2792 |
+ private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
|
|
| 2793 |
+ resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false) |
|
| 2794 |
+ } |
|
| 2795 |
+ |
|
| 2796 |
+ private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
|
|
| 2797 |
+ resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true) |
|
| 2798 |
+ } |
|
| 2799 |
+ |
|
| 2800 |
+ private func resolvedAssignedObject( |
|
| 2801 |
+ for meterMACAddress: String, |
|
| 2802 |
+ expectsChargerClass: Bool |
|
| 2803 |
+ ) -> NSManagedObject? {
|
|
| 2804 |
+ let normalizedMAC = normalizedMACAddress(meterMACAddress) |
|
| 2805 |
+ guard !normalizedMAC.isEmpty else { return nil }
|
|
| 2806 |
+ |
|
| 2807 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice) |
|
| 2808 |
+ request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC) |
|
| 2809 |
+ request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)] |
|
| 2810 |
+ let matches = (try? context.fetch(request)) ?? [] |
|
| 2811 |
+ return matches.first { object in
|
|
| 2812 |
+ isChargerObject(object) == expectsChargerClass |
|
| 2813 |
+ } |
|
| 2814 |
+ } |
|
| 2815 |
+ |
|
| 2816 |
+ private func isChargerObject(_ object: NSManagedObject) -> Bool {
|
|
| 2817 |
+ ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger |
|
| 2818 |
+ } |
|
| 2819 |
+ |
|
| 2820 |
+ private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
|
|
| 2821 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice) |
|
| 2822 |
+ request.predicate = NSPredicate(format: "id == %@", id) |
|
| 2823 |
+ request.fetchLimit = 1 |
|
| 2824 |
+ return (try? context.fetch(request))?.first |
|
| 2825 |
+ } |
|
| 2826 |
+ |
|
| 2827 |
+ private func fetchObjects(entityName: String) -> [NSManagedObject] {
|
|
| 2828 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: entityName) |
|
| 2829 |
+ return (try? context.fetch(request)) ?? [] |
|
| 2830 |
+ } |
|
| 2831 |
+ |
|
| 2832 |
+ private func resolvedStopThreshold( |
|
| 2833 |
+ for chargedDevice: NSManagedObject, |
|
| 2834 |
+ chargingTransportMode: ChargingTransportMode, |
|
| 2835 |
+ chargingStateMode: ChargingStateMode, |
|
| 2836 |
+ charger: NSManagedObject?, |
|
| 2837 |
+ fallback: Double? |
|
| 2838 |
+ ) -> Double? {
|
|
| 2839 |
+ if chargingTransportMode == .wireless, chargerIdleCurrent(for: charger) == nil {
|
|
| 2840 |
+ return nil |
|
| 2841 |
+ } |
|
| 2842 |
+ |
|
| 2843 |
+ let sessionKind = ChargeSessionKind( |
|
| 2844 |
+ chargingTransportMode: chargingTransportMode, |
|
| 2845 |
+ chargingStateMode: chargingStateMode |
|
| 2846 |
+ ) |
|
| 2847 |
+ let configuredCurrents = decodedCompletionCurrents( |
|
| 2848 |
+ from: chargedDevice, |
|
| 2849 |
+ key: "configuredCompletionCurrentsRawValue" |
|
| 2850 |
+ ) |
|
| 2851 |
+ let learnedCurrents = decodedCompletionCurrents( |
|
| 2852 |
+ from: chargedDevice, |
|
| 2853 |
+ key: "learnedCompletionCurrentsRawValue" |
|
| 2854 |
+ ) |
|
| 2855 |
+ let legacyCurrent: Double? |
|
| 2856 |
+ switch chargingTransportMode {
|
|
| 2857 |
+ case .wired: |
|
| 2858 |
+ legacyCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps") |
|
| 2859 |
+ ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps") |
|
| 2860 |
+ ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps") |
|
| 2861 |
+ case .wireless: |
|
| 2862 |
+ legacyCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps") |
|
| 2863 |
+ ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps") |
|
| 2864 |
+ ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps") |
|
| 2865 |
+ } |
|
| 2866 |
+ |
|
| 2867 |
+ let resolvedCurrent = configuredCurrents[sessionKind] |
|
| 2868 |
+ ?? learnedCurrents[sessionKind] |
|
| 2869 |
+ ?? legacyCurrent |
|
| 2870 |
+ ?? fallback |
|
| 2871 |
+ guard let resolvedCurrent, resolvedCurrent > 0 else {
|
|
| 2872 |
+ return nil |
|
| 2873 |
+ } |
|
| 2874 |
+ return max(resolvedCurrent, 0.01) |
|
| 2875 |
+ } |
|
| 2876 |
+ |
|
| 2877 |
+ private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
|
|
| 2878 |
+ let supportsWiredCharging = supportsWiredCharging(for: chargedDevice) |
|
| 2879 |
+ let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice) |
|
| 2880 |
+ return resolvedPreferredChargingTransportMode( |
|
| 2881 |
+ .wired, |
|
| 2882 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 2883 |
+ supportsWirelessCharging: supportsWirelessCharging |
|
| 2884 |
+ ) |
|
| 2885 |
+ } |
|
| 2886 |
+ |
|
| 2887 |
+ private func deviceClass(for object: NSManagedObject) -> ChargedDeviceClass {
|
|
| 2888 |
+ ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") ?? .other |
|
| 2889 |
+ } |
|
| 2890 |
+ |
|
| 2891 |
+ private func normalizedTemplateID( |
|
| 2892 |
+ _ templateID: String?, |
|
| 2893 |
+ kind: ChargedDeviceKind |
|
| 2894 |
+ ) -> String? {
|
|
| 2895 |
+ guard let templateID, |
|
| 2896 |
+ let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID), |
|
| 2897 |
+ templateDefinition.kind == kind else {
|
|
| 2898 |
+ return nil |
|
| 2899 |
+ } |
|
| 2900 |
+ return templateDefinition.id |
|
| 2901 |
+ } |
|
| 2902 |
+ |
|
| 2903 |
+ private func templateDefinition(for chargedDevice: NSManagedObject) -> ChargedDeviceTemplateDefinition? {
|
|
| 2904 |
+ guard let templateID = stringValue(chargedDevice, key: "deviceTemplateID"), |
|
| 2905 |
+ let templateDefinition = ChargedDeviceTemplateCatalog.shared.template(id: templateID), |
|
| 2906 |
+ templateDefinition.kind == deviceClass(for: chargedDevice).kind else {
|
|
| 2907 |
+ return nil |
|
| 2908 |
+ } |
|
| 2909 |
+ return templateDefinition |
|
| 2910 |
+ } |
|
| 2911 |
+ |
|
| 2912 |
+ private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
|
|
| 2913 |
+ let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil |
|
| 2914 |
+ ? true |
|
| 2915 |
+ : boolValue(chargedDevice, key: "supportsWiredCharging") |
|
| 2916 |
+ let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil |
|
| 2917 |
+ ? false |
|
| 2918 |
+ : boolValue(chargedDevice, key: "supportsWirelessCharging") |
|
| 2919 |
+ return deviceClass(for: chargedDevice).normalizedChargingSupport( |
|
| 2920 |
+ supportsWiredCharging: persistedWiredCharging, |
|
| 2921 |
+ supportsWirelessCharging: persistedWirelessCharging |
|
| 2922 |
+ ).wired |
|
| 2923 |
+ } |
|
| 2924 |
+ |
|
| 2925 |
+ private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
|
|
| 2926 |
+ let persistedWiredCharging = chargedDevice.value(forKey: "supportsWiredCharging") == nil |
|
| 2927 |
+ ? true |
|
| 2928 |
+ : boolValue(chargedDevice, key: "supportsWiredCharging") |
|
| 2929 |
+ let persistedWirelessCharging = chargedDevice.value(forKey: "supportsWirelessCharging") == nil |
|
| 2930 |
+ ? false |
|
| 2931 |
+ : boolValue(chargedDevice, key: "supportsWirelessCharging") |
|
| 2932 |
+ return deviceClass(for: chargedDevice).normalizedChargingSupport( |
|
| 2933 |
+ supportsWiredCharging: persistedWiredCharging, |
|
| 2934 |
+ supportsWirelessCharging: persistedWirelessCharging |
|
| 2935 |
+ ).wireless |
|
| 2936 |
+ } |
|
| 2937 |
+ |
|
| 2938 |
+ private func chargingStateAvailability(for chargedDevice: NSManagedObject) -> ChargingStateAvailability {
|
|
| 2939 |
+ let persistedAvailability = stringValue(chargedDevice, key: "chargingStateAvailabilityRawValue") |
|
| 2940 |
+ .flatMap(ChargingStateAvailability.init(rawValue:)) |
|
| 2941 |
+ ?? ChargingStateAvailability.fallback( |
|
| 2942 |
+ for: boolValue(chargedDevice, key: "supportsChargingWhileOff") |
|
| 2943 |
+ ) |
|
| 2944 |
+ return deviceClass(for: chargedDevice).normalizedChargingStateAvailability(persistedAvailability) |
|
| 2945 |
+ } |
|
| 2946 |
+ |
|
| 2947 |
+ private func chargingStateMode(for session: NSManagedObject) -> ChargingStateMode {
|
|
| 2948 |
+ if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), |
|
| 2949 |
+ let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
|
|
| 2950 |
+ let persistedChargingStateMode = stringValue(session, key: "chargingStateRawValue") |
|
| 2951 |
+ .flatMap(ChargingStateMode.init(rawValue:)) |
|
| 2952 |
+ ?? .on |
|
| 2953 |
+ return resolvedChargingStateMode( |
|
| 2954 |
+ persistedChargingStateMode, |
|
| 2955 |
+ availability: chargingStateAvailability(for: chargedDevice) |
|
| 2956 |
+ ) |
|
| 2957 |
+ } |
|
| 2958 |
+ |
|
| 2959 |
+ if let rawValue = stringValue(session, key: "chargingStateRawValue"), |
|
| 2960 |
+ let chargingStateMode = ChargingStateMode(rawValue: rawValue) {
|
|
| 2961 |
+ return chargingStateMode |
|
| 2962 |
+ } |
|
| 2963 |
+ |
|
| 2964 |
+ return .on |
|
| 2965 |
+ } |
|
| 2966 |
+ |
|
| 2967 |
+ private func resolvedChargingStateMode( |
|
| 2968 |
+ _ chargingStateMode: ChargingStateMode, |
|
| 2969 |
+ availability: ChargingStateAvailability |
|
| 2970 |
+ ) -> ChargingStateMode {
|
|
| 2971 |
+ if availability.supportedModes.contains(chargingStateMode) {
|
|
| 2972 |
+ return chargingStateMode |
|
| 2973 |
+ } |
|
| 2974 |
+ return availability.supportedModes.first ?? .on |
|
| 2975 |
+ } |
|
| 2976 |
+ |
|
| 2977 |
+ private func chargerType(for chargedDevice: NSManagedObject) -> ChargerType? {
|
|
| 2978 |
+ let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other |
|
| 2979 |
+ guard deviceClass == .charger else { return nil }
|
|
| 2980 |
+ |
|
| 2981 |
+ // Primary: chargerTypeRawValue (set on v13+) |
|
| 2982 |
+ if let rawValue = stringValue(chargedDevice, key: "chargerTypeRawValue"), |
|
| 2983 |
+ let type = ChargerType(rawValue: rawValue) {
|
|
| 2984 |
+ return type |
|
| 2985 |
+ } |
|
| 2986 |
+ |
|
| 2987 |
+ // Migration fallback: derive from old deviceTemplateID |
|
| 2988 |
+ switch stringValue(chargedDevice, key: "deviceTemplateID") {
|
|
| 2989 |
+ case "apple-magsafe-charger": return .appleMagSafe |
|
| 2990 |
+ case "apple-watch-charger": return .appleWatch |
|
| 2991 |
+ default: break |
|
| 2992 |
+ } |
|
| 2993 |
+ |
|
| 2994 |
+ // Last resort: derive from wirelessChargingProfileRawValue |
|
| 2995 |
+ if let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"), |
|
| 2996 |
+ let profile = WirelessChargingProfile(rawValue: rawValue), |
|
| 2997 |
+ profile == .magsafe {
|
|
| 2998 |
+ return .genericMagSafe |
|
| 2999 |
+ } |
|
| 3000 |
+ |
|
| 3001 |
+ return .genericQi |
|
| 3002 |
+ } |
|
| 3003 |
+ |
|
| 3004 |
+ private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
|
|
| 3005 |
+ if let type = chargerType(for: chargedDevice) {
|
|
| 3006 |
+ return type.wirelessChargingProfile |
|
| 3007 |
+ } |
|
| 3008 |
+ guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"), |
|
| 3009 |
+ let profile = WirelessChargingProfile(rawValue: rawValue) else {
|
|
| 3010 |
+ return .genericQi |
|
| 3011 |
+ } |
|
| 3012 |
+ return profile |
|
| 3013 |
+ } |
|
| 3014 |
+ |
|
| 3015 |
+ private func resolvedPreferredChargingTransportMode( |
|
| 3016 |
+ _ preferredChargingTransportMode: ChargingTransportMode, |
|
| 3017 |
+ supportsWiredCharging: Bool, |
|
| 3018 |
+ supportsWirelessCharging: Bool |
|
| 3019 |
+ ) -> ChargingTransportMode {
|
|
| 3020 |
+ switch preferredChargingTransportMode {
|
|
| 3021 |
+ case .wired where supportsWiredCharging: |
|
| 3022 |
+ return .wired |
|
| 3023 |
+ case .wireless where supportsWirelessCharging: |
|
| 3024 |
+ return .wireless |
|
| 3025 |
+ default: |
|
| 3026 |
+ if supportsWiredCharging {
|
|
| 3027 |
+ return .wired |
|
| 3028 |
+ } |
|
| 3029 |
+ if supportsWirelessCharging {
|
|
| 3030 |
+ return .wireless |
|
| 3031 |
+ } |
|
| 3032 |
+ return .wired |
|
| 3033 |
+ } |
|
| 3034 |
+ } |
|
| 3035 |
+ |
|
| 3036 |
+ private func encodedCompletionCurrents(_ currents: [ChargeSessionKind: Double]) -> String? {
|
|
| 3037 |
+ let payload = Dictionary( |
|
| 3038 |
+ uniqueKeysWithValues: currents.map { key, value in
|
|
| 3039 |
+ (key.rawValue, value) |
|
| 3040 |
+ } |
|
| 3041 |
+ ) |
|
| 3042 |
+ guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else {
|
|
| 3043 |
+ return nil |
|
| 3044 |
+ } |
|
| 3045 |
+ return String(data: data, encoding: .utf8) |
|
| 3046 |
+ } |
|
| 3047 |
+ |
|
| 3048 |
+ private func decodedCompletionCurrents( |
|
| 3049 |
+ from object: NSManagedObject, |
|
| 3050 |
+ key: String |
|
| 3051 |
+ ) -> [ChargeSessionKind: Double] {
|
|
| 3052 |
+ guard let rawValue = stringValue(object, key: key), |
|
| 3053 |
+ let data = rawValue.data(using: .utf8), |
|
| 3054 |
+ let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else {
|
|
| 3055 |
+ return [:] |
|
| 3056 |
+ } |
|
| 3057 |
+ |
|
| 3058 |
+ return payload.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
|
|
| 3059 |
+ guard let sessionKind = ChargeSessionKind(rawValue: entry.key), entry.value > 0 else {
|
|
| 3060 |
+ return |
|
| 3061 |
+ } |
|
| 3062 |
+ result[sessionKind] = entry.value |
|
| 3063 |
+ } |
|
| 3064 |
+ } |
|
| 3065 |
+ |
|
| 3066 |
+ private func legacyConfiguredCompletionCurrent( |
|
| 3067 |
+ for currents: [ChargeSessionKind: Double], |
|
| 3068 |
+ chargingTransportMode: ChargingTransportMode |
|
| 3069 |
+ ) -> Double? {
|
|
| 3070 |
+ let candidates = currents |
|
| 3071 |
+ .filter { $0.key.chargingTransportMode == chargingTransportMode }
|
|
| 3072 |
+ .sorted { lhs, rhs in
|
|
| 3073 |
+ lhs.key.rawValue < rhs.key.rawValue |
|
| 3074 |
+ } |
|
| 3075 |
+ .map(\.value) |
|
| 3076 |
+ return candidates.first |
|
| 3077 |
+ } |
|
| 3078 |
+ |
|
| 3079 |
+ private func chargerIdleCurrent(for charger: NSManagedObject?) -> Double? {
|
|
| 3080 |
+ guard let charger else {
|
|
| 3081 |
+ return nil |
|
| 3082 |
+ } |
|
| 3083 |
+ let idleCurrent = optionalDoubleValue(charger, key: "chargerIdleCurrentAmps") |
|
| 3084 |
+ guard let idleCurrent, idleCurrent >= 0 else {
|
|
| 3085 |
+ return nil |
|
| 3086 |
+ } |
|
| 3087 |
+ return idleCurrent |
|
| 3088 |
+ } |
|
| 3089 |
+ |
|
| 3090 |
+ private func effectiveCurrentAmps( |
|
| 3091 |
+ fromMeasuredCurrent currentAmps: Double, |
|
| 3092 |
+ chargingTransportMode: ChargingTransportMode, |
|
| 3093 |
+ charger: NSManagedObject? |
|
| 3094 |
+ ) -> Double {
|
|
| 3095 |
+ switch chargingTransportMode {
|
|
| 3096 |
+ case .wired: |
|
| 3097 |
+ return max(currentAmps, 0) |
|
| 3098 |
+ case .wireless: |
|
| 3099 |
+ guard let idleCurrent = chargerIdleCurrent(for: charger) else {
|
|
| 3100 |
+ return max(currentAmps, 0) |
|
| 3101 |
+ } |
|
| 3102 |
+ return max(currentAmps - idleCurrent, 0) |
|
| 3103 |
+ } |
|
| 3104 |
+ } |
|
| 3105 |
+ |
|
| 3106 |
+ private func hasObservedChargeFlow( |
|
| 3107 |
+ currentAmps: Double, |
|
| 3108 |
+ chargingTransportMode: ChargingTransportMode, |
|
| 3109 |
+ charger: NSManagedObject?, |
|
| 3110 |
+ stopThreshold: Double? |
|
| 3111 |
+ ) -> Bool {
|
|
| 3112 |
+ let effectiveCurrent = effectiveCurrentAmps( |
|
| 3113 |
+ fromMeasuredCurrent: currentAmps, |
|
| 3114 |
+ chargingTransportMode: chargingTransportMode, |
|
| 3115 |
+ charger: charger |
|
| 3116 |
+ ) |
|
| 3117 |
+ return effectiveCurrent > max(stopThreshold ?? 0, 0.05) |
|
| 3118 |
+ } |
|
| 3119 |
+ |
|
| 3120 |
+ private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
|
|
| 3121 |
+ if boolValue(session, key: "hasObservedChargeFlow") |
|
| 3122 |
+ || doubleValue(session, key: "measuredEnergyWh") > 0 |
|
| 3123 |
+ || doubleValue(session, key: "measuredChargeAh") > 0 |
|
| 3124 |
+ || (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0 |
|
| 3125 |
+ || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
|
|
| 3126 |
+ return true |
|
| 3127 |
+ } |
|
| 3128 |
+ |
|
| 3129 |
+ guard let sessionID = stringValue(session, key: "id") else {
|
|
| 3130 |
+ return false |
|
| 3131 |
+ } |
|
| 3132 |
+ |
|
| 3133 |
+ return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
|
|
| 3134 |
+ doubleValue(sample, key: "measuredEnergyWh") > 0 |
|
| 3135 |
+ || doubleValue(sample, key: "measuredChargeAh") > 0 |
|
| 3136 |
+ || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0 |
|
| 3137 |
+ || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0 |
|
| 3138 |
+ } |
|
| 3139 |
+ } |
|
| 3140 |
+ |
|
| 3141 |
+ private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
|
|
| 3142 |
+ guard let sessionID = stringValue(session, key: "id"), |
|
| 3143 |
+ let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
|
|
| 3144 |
+ (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast) |
|
| 3145 |
+ }) else {
|
|
| 3146 |
+ return |
|
| 3147 |
+ } |
|
| 3148 |
+ |
|
| 3149 |
+ let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh") |
|
| 3150 |
+ if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
|
|
| 3151 |
+ session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh") |
|
| 3152 |
+ } |
|
| 3153 |
+ |
|
| 3154 |
+ let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh") |
|
| 3155 |
+ if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
|
|
| 3156 |
+ session.setValue(sampleChargeAh, forKey: "measuredChargeAh") |
|
| 3157 |
+ } |
|
| 3158 |
+ } |
|
| 3159 |
+ |
|
| 3160 |
+ private func derivedMinimumCurrent( |
|
| 3161 |
+ from sessions: [NSManagedObject], |
|
| 3162 |
+ chargingTransportMode: ChargingTransportMode |
|
| 3163 |
+ ) -> Double? {
|
|
| 3164 |
+ let completionCurrents = sessions.compactMap { session -> Double? in
|
|
| 3165 |
+ guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
|
|
| 3166 |
+ guard self.chargingTransportMode(for: session) == chargingTransportMode else {
|
|
| 3167 |
+ return nil |
|
| 3168 |
+ } |
|
| 3169 |
+ guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
|
|
| 3170 |
+ return nil |
|
| 3171 |
+ } |
|
| 3172 |
+ guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
|
|
| 3173 |
+ return nil |
|
| 3174 |
+ } |
|
| 3175 |
+ return completionCurrent |
|
| 3176 |
+ } |
|
| 3177 |
+ |
|
| 3178 |
+ let recentCompletionCurrents = Array(completionCurrents.suffix(5)) |
|
| 3179 |
+ guard !recentCompletionCurrents.isEmpty else { return nil }
|
|
| 3180 |
+ return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count) |
|
| 3181 |
+ } |
|
| 3182 |
+ |
|
| 3183 |
+ private func derivedCompletionCurrents(from sessions: [NSManagedObject]) -> [ChargeSessionKind: Double] {
|
|
| 3184 |
+ var groupedCurrents: [ChargeSessionKind: [Double]] = [:] |
|
| 3185 |
+ |
|
| 3186 |
+ for session in sessions {
|
|
| 3187 |
+ guard statusValue(session, key: "statusRawValue") == .completed else {
|
|
| 3188 |
+ continue |
|
| 3189 |
+ } |
|
| 3190 |
+ guard (optionalDoubleValue(session, key: "endBatteryPercent") ?? 0) >= 99.5 else {
|
|
| 3191 |
+ continue |
|
| 3192 |
+ } |
|
| 3193 |
+ guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), |
|
| 3194 |
+ completionCurrent > 0 else {
|
|
| 3195 |
+ continue |
|
| 3196 |
+ } |
|
| 3197 |
+ |
|
| 3198 |
+ let sessionKind = ChargeSessionKind( |
|
| 3199 |
+ chargingTransportMode: chargingTransportMode(for: session), |
|
| 3200 |
+ chargingStateMode: chargingStateMode(for: session) |
|
| 3201 |
+ ) |
|
| 3202 |
+ groupedCurrents[sessionKind, default: []].append(completionCurrent) |
|
| 3203 |
+ } |
|
| 3204 |
+ |
|
| 3205 |
+ return groupedCurrents.reduce(into: [ChargeSessionKind: Double]()) { result, entry in
|
|
| 3206 |
+ let recentCurrents = Array(entry.value.suffix(5)) |
|
| 3207 |
+ guard !recentCurrents.isEmpty else {
|
|
| 3208 |
+ return |
|
| 3209 |
+ } |
|
| 3210 |
+ result[entry.key] = recentCurrents.reduce(0, +) / Double(recentCurrents.count) |
|
| 3211 |
+ } |
|
| 3212 |
+ } |
|
| 3213 |
+ |
|
| 3214 |
+ private func derivedCapacity( |
|
| 3215 |
+ from sessions: [NSManagedObject], |
|
| 3216 |
+ chargingTransportMode: ChargingTransportMode, |
|
| 3217 |
+ supportsChargingWhileOff: Bool |
|
| 3218 |
+ ) -> Double? {
|
|
| 3219 |
+ let capacityCandidates = sessions.compactMap { session -> Double? in
|
|
| 3220 |
+ guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
|
|
| 3221 |
+ guard self.chargingTransportMode(for: session) == chargingTransportMode else {
|
|
| 3222 |
+ return nil |
|
| 3223 |
+ } |
|
| 3224 |
+ guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
|
|
| 3225 |
+ return nil |
|
| 3226 |
+ } |
|
| 3227 |
+ if supportsChargingWhileOff {
|
|
| 3228 |
+ return capacityEstimate |
|
| 3229 |
+ } |
|
| 3230 |
+ guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
|
|
| 3231 |
+ return nil |
|
| 3232 |
+ } |
|
| 3233 |
+ return capacityEstimate |
|
| 3234 |
+ } |
|
| 3235 |
+ |
|
| 3236 |
+ let recentCapacityCandidates = Array(capacityCandidates.suffix(6)) |
|
| 3237 |
+ guard !recentCapacityCandidates.isEmpty else { return nil }
|
|
| 3238 |
+ return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count) |
|
| 3239 |
+ } |
|
| 3240 |
+ |
|
| 3241 |
+ private func derivedWirelessEfficiency( |
|
| 3242 |
+ from sessions: [NSManagedObject], |
|
| 3243 |
+ chargingProfile: WirelessChargingProfile |
|
| 3244 |
+ ) -> Double? {
|
|
| 3245 |
+ guard chargingProfile == .magsafe else {
|
|
| 3246 |
+ return nil |
|
| 3247 |
+ } |
|
| 3248 |
+ |
|
| 3249 |
+ let candidates = sessions.compactMap { session -> Double? in
|
|
| 3250 |
+ guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
|
|
| 3251 |
+ guard chargingTransportMode(for: session) == .wireless else { return nil }
|
|
| 3252 |
+ guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
|
|
| 3253 |
+ guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
|
|
| 3254 |
+ return nil |
|
| 3255 |
+ } |
|
| 3256 |
+ return factor |
|
| 3257 |
+ } |
|
| 3258 |
+ |
|
| 3259 |
+ let recentCandidates = Array(candidates.suffix(6)) |
|
| 3260 |
+ guard !recentCandidates.isEmpty else { return nil }
|
|
| 3261 |
+ return recentCandidates.reduce(0, +) / Double(recentCandidates.count) |
|
| 3262 |
+ } |
|
| 3263 |
+ |
|
| 3264 |
+ private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
|
|
| 3265 |
+ let candidates = sessions.compactMap { session -> Double? in
|
|
| 3266 |
+ guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
|
|
| 3267 |
+ guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
|
|
| 3268 |
+ return nil |
|
| 3269 |
+ } |
|
| 3270 |
+ return (sourceVoltage * 10).rounded() / 10 |
|
| 3271 |
+ } |
|
| 3272 |
+ |
|
| 3273 |
+ let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
|
|
| 3274 |
+ return counts.keys.sorted() |
|
| 3275 |
+ } |
|
| 3276 |
+ |
|
| 3277 |
+ private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
|
|
| 3278 |
+ let candidates = sessions.compactMap { session -> Double? in
|
|
| 3279 |
+ guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
|
|
| 3280 |
+ guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
|
|
| 3281 |
+ return nil |
|
| 3282 |
+ } |
|
| 3283 |
+ return minimumObservedCurrent |
|
| 3284 |
+ } |
|
| 3285 |
+ |
|
| 3286 |
+ let recentCandidates = Array(candidates.suffix(6)) |
|
| 3287 |
+ guard !recentCandidates.isEmpty else { return nil }
|
|
| 3288 |
+ return recentCandidates.reduce(0, +) / Double(recentCandidates.count) |
|
| 3289 |
+ } |
|
| 3290 |
+ |
|
| 3291 |
+ private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
|
|
| 3292 |
+ let candidates = sessions.compactMap { session -> Double? in
|
|
| 3293 |
+ guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
|
|
| 3294 |
+ guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
|
|
| 3295 |
+ return nil |
|
| 3296 |
+ } |
|
| 3297 |
+ return factor |
|
| 3298 |
+ } |
|
| 3299 |
+ |
|
| 3300 |
+ let recentCandidates = Array(candidates.suffix(6)) |
|
| 3301 |
+ guard !recentCandidates.isEmpty else { return nil }
|
|
| 3302 |
+ return recentCandidates.reduce(0, +) / Double(recentCandidates.count) |
|
| 3303 |
+ } |
|
| 3304 |
+ |
|
| 3305 |
+ private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
|
|
| 3306 |
+ sessions.compactMap { session -> Double? in
|
|
| 3307 |
+ guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
|
|
| 3308 |
+ guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
|
|
| 3309 |
+ return nil |
|
| 3310 |
+ } |
|
| 3311 |
+ return maximumObservedPower |
|
| 3312 |
+ } |
|
| 3313 |
+ .max() |
|
| 3314 |
+ } |
|
| 3315 |
+ |
|
| 3316 |
+ private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
|
|
| 3317 |
+ if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), |
|
| 3318 |
+ let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
|
|
| 3319 |
+ return resolvedPreferredChargingTransportMode( |
|
| 3320 |
+ chargingTransportModeValue(session, key: "chargingTransportRawValue") ?? .wired, |
|
| 3321 |
+ supportsWiredCharging: supportsWiredCharging(for: chargedDevice), |
|
| 3322 |
+ supportsWirelessCharging: supportsWirelessCharging(for: chargedDevice) |
|
| 3323 |
+ ) |
|
| 3324 |
+ } |
|
| 3325 |
+ |
|
| 3326 |
+ if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
|
|
| 3327 |
+ return persistedChargingTransportMode |
|
| 3328 |
+ } |
|
| 3329 |
+ |
|
| 3330 |
+ return .wired |
|
| 3331 |
+ } |
|
| 3332 |
+ |
|
| 3333 |
+ private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
|
|
| 3334 |
+ if session.isInserted {
|
|
| 3335 |
+ return .created |
|
| 3336 |
+ } |
|
| 3337 |
+ |
|
| 3338 |
+ let committedValues = session.committedValues( |
|
| 3339 |
+ forKeys: [ |
|
| 3340 |
+ "statusRawValue", |
|
| 3341 |
+ "updatedAt", |
|
| 3342 |
+ "targetBatteryAlertTriggeredAt", |
|
| 3343 |
+ "requiresCompletionConfirmation" |
|
| 3344 |
+ ] |
|
| 3345 |
+ ) |
|
| 3346 |
+ let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:)) |
|
| 3347 |
+ let currentStatus = statusValue(session, key: "statusRawValue") |
|
| 3348 |
+ let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date |
|
| 3349 |
+ let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt") |
|
| 3350 |
+ let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue |
|
| 3351 |
+ ?? (committedValues["requiresCompletionConfirmation"] as? Bool) |
|
| 3352 |
+ ?? false |
|
| 3353 |
+ let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation") |
|
| 3354 |
+ |
|
| 3355 |
+ if currentStatus == .completed, committedStatus != .completed {
|
|
| 3356 |
+ return .completed |
|
| 3357 |
+ } |
|
| 3358 |
+ |
|
| 3359 |
+ if currentStatus != committedStatus {
|
|
| 3360 |
+ return .event |
|
| 3361 |
+ } |
|
| 3362 |
+ |
|
| 3363 |
+ if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt |
|
| 3364 |
+ || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
|
|
| 3365 |
+ return .event |
|
| 3366 |
+ } |
|
| 3367 |
+ |
|
| 3368 |
+ let lastPersistedAt = (committedValues["updatedAt"] as? Date) |
|
| 3369 |
+ ?? dateValue(session, key: "createdAt") |
|
| 3370 |
+ ?? observedAt |
|
| 3371 |
+ |
|
| 3372 |
+ if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
|
|
| 3373 |
+ return .periodic |
|
| 3374 |
+ } |
|
| 3375 |
+ |
|
| 3376 |
+ return .none |
|
| 3377 |
+ } |
|
| 3378 |
+ |
|
| 3379 |
+ private func shouldPersistAggregatedSample( |
|
| 3380 |
+ _ sample: NSManagedObject, |
|
| 3381 |
+ observedAt: Date |
|
| 3382 |
+ ) -> Bool {
|
|
| 3383 |
+ if sample.isInserted {
|
|
| 3384 |
+ return true |
|
| 3385 |
+ } |
|
| 3386 |
+ |
|
| 3387 |
+ let committedValues = sample.committedValues(forKeys: ["updatedAt"]) |
|
| 3388 |
+ let lastPersistedAt = (committedValues["updatedAt"] as? Date) |
|
| 3389 |
+ ?? dateValue(sample, key: "createdAt") |
|
| 3390 |
+ ?? observedAt |
|
| 3391 |
+ |
|
| 3392 |
+ return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval |
|
| 3393 |
+ } |
|
| 3394 |
+ |
|
| 3395 |
+ private func generateQRIdentifier() -> String {
|
|
| 3396 |
+ "device:\(UUID().uuidString)" |
|
| 3397 |
+ } |
|
| 3398 |
+ |
|
| 3399 |
+ @discardableResult |
|
| 3400 |
+ private func saveContext() -> Bool {
|
|
| 3401 |
+ guard context.hasChanges else { return true }
|
|
| 3402 |
+ do {
|
|
| 3403 |
+ try context.save() |
|
| 3404 |
+ return true |
|
| 3405 |
+ } catch {
|
|
| 3406 |
+ track("Failed saving charge insights context: \(error)")
|
|
| 3407 |
+ context.rollback() |
|
| 3408 |
+ return false |
|
| 3409 |
+ } |
|
| 3410 |
+ } |
|
| 3411 |
+ |
|
| 3412 |
+ private func normalizedText(_ text: String) -> String {
|
|
| 3413 |
+ text.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 3414 |
+ } |
|
| 3415 |
+ |
|
| 3416 |
+ private func normalizedOptionalText(_ text: String?) -> String? {
|
|
| 3417 |
+ guard let text else { return nil }
|
|
| 3418 |
+ let normalized = normalizedText(text) |
|
| 3419 |
+ return normalized.isEmpty ? nil : normalized |
|
| 3420 |
+ } |
|
| 3421 |
+ |
|
| 3422 |
+ private func normalizedMACAddress(_ macAddress: String) -> String {
|
|
| 3423 |
+ normalizedText(macAddress).uppercased() |
|
| 3424 |
+ } |
|
| 3425 |
+ |
|
| 3426 |
+ private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
|
|
| 3427 |
+ guard object.entity.propertiesByName[key] != nil else {
|
|
| 3428 |
+ return nil |
|
| 3429 |
+ } |
|
| 3430 |
+ return object.value(forKey: key) |
|
| 3431 |
+ } |
|
| 3432 |
+ |
|
| 3433 |
+ private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
|
|
| 3434 |
+ guard object.entity.propertiesByName[key] != nil else {
|
|
| 3435 |
+ return |
|
| 3436 |
+ } |
|
| 3437 |
+ object.setValue(value, forKey: key) |
|
| 3438 |
+ } |
|
| 3439 |
+ |
|
| 3440 |
+ private func stringValue(_ object: NSManagedObject, key: String) -> String? {
|
|
| 3441 |
+ guard let value = rawValue(object, key: key) as? String else { return nil }
|
|
| 3442 |
+ let normalized = normalizedOptionalText(value) |
|
| 3443 |
+ return normalized |
|
| 3444 |
+ } |
|
| 3445 |
+ |
|
| 3446 |
+ private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
|
|
| 3447 |
+ rawValue(object, key: key) as? Date |
|
| 3448 |
+ } |
|
| 3449 |
+ |
|
| 3450 |
+ private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
|
|
| 3451 |
+ if let value = rawValue(object, key: key) as? Double {
|
|
| 3452 |
+ return value |
|
| 3453 |
+ } |
|
| 3454 |
+ if let value = rawValue(object, key: key) as? NSNumber {
|
|
| 3455 |
+ return value.doubleValue |
|
| 3456 |
+ } |
|
| 3457 |
+ return 0 |
|
| 3458 |
+ } |
|
| 3459 |
+ |
|
| 3460 |
+ private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
|
|
| 3461 |
+ let value = rawValue(object, key: key) |
|
| 3462 |
+ if value == nil {
|
|
| 3463 |
+ return nil |
|
| 3464 |
+ } |
|
| 3465 |
+ return doubleValue(object, key: key) |
|
| 3466 |
+ } |
|
| 3467 |
+ |
|
| 3468 |
+ private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
|
|
| 3469 |
+ if let value = rawValue(object, key: key) as? Int16 {
|
|
| 3470 |
+ return value |
|
| 3471 |
+ } |
|
| 3472 |
+ if let value = rawValue(object, key: key) as? NSNumber {
|
|
| 3473 |
+ return value.int16Value |
|
| 3474 |
+ } |
|
| 3475 |
+ return nil |
|
| 3476 |
+ } |
|
| 3477 |
+ |
|
| 3478 |
+ private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
|
|
| 3479 |
+ if let value = rawValue(object, key: key) as? Int32 {
|
|
| 3480 |
+ return value |
|
| 3481 |
+ } |
|
| 3482 |
+ if let value = rawValue(object, key: key) as? NSNumber {
|
|
| 3483 |
+ return value.int32Value |
|
| 3484 |
+ } |
|
| 3485 |
+ return nil |
|
| 3486 |
+ } |
|
| 3487 |
+ |
|
| 3488 |
+ private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
|
|
| 3489 |
+ if let value = rawValue(object, key: key) as? Bool {
|
|
| 3490 |
+ return value |
|
| 3491 |
+ } |
|
| 3492 |
+ if let value = rawValue(object, key: key) as? NSNumber {
|
|
| 3493 |
+ return value.boolValue |
|
| 3494 |
+ } |
|
| 3495 |
+ return false |
|
| 3496 |
+ } |
|
| 3497 |
+ |
|
| 3498 |
+ private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
|
|
| 3499 |
+ guard let value = stringValue(object, key: key) else { return nil }
|
|
| 3500 |
+ return UUID(uuidString: value) |
|
| 3501 |
+ } |
|
| 3502 |
+ |
|
| 3503 |
+ private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
|
|
| 3504 |
+ guard let value = stringValue(object, key: key) else { return nil }
|
|
| 3505 |
+ return ChargeSessionStatus(rawValue: value) |
|
| 3506 |
+ } |
|
| 3507 |
+ |
|
| 3508 |
+ private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
|
|
| 3509 |
+ guard let value = stringValue(object, key: key) else { return nil }
|
|
| 3510 |
+ return ChargingTransportMode(rawValue: value) |
|
| 3511 |
+ } |
|
| 3512 |
+ |
|
| 3513 |
+ private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
|
|
| 3514 |
+ guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
|
|
| 3515 |
+ return [] |
|
| 3516 |
+ } |
|
| 3517 |
+ return rawValue |
|
| 3518 |
+ .split(separator: ",") |
|
| 3519 |
+ .compactMap { Double($0) }
|
|
| 3520 |
+ .sorted() |
|
| 3521 |
+ } |
|
| 3522 |
+ |
|
| 3523 |
+ private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
|
|
| 3524 |
+ let uniqueVoltages = Array(Set(voltages)).sorted() |
|
| 3525 |
+ guard !uniqueVoltages.isEmpty else {
|
|
| 3526 |
+ return nil |
|
| 3527 |
+ } |
|
| 3528 |
+ return uniqueVoltages |
|
| 3529 |
+ .map { String(format: "%.1f", $0) }
|
|
| 3530 |
+ .joined(separator: ",") |
|
| 3531 |
+ } |
|
| 3532 |
+ |
|
| 3533 |
+ private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
|
|
| 3534 |
+ guard currentCount > 0 else {
|
|
| 3535 |
+ return newValue |
|
| 3536 |
+ } |
|
| 3537 |
+ let total = (currentAverage * Double(currentCount)) + newValue |
|
| 3538 |
+ return total / Double(currentCount + 1) |
|
| 3539 |
+ } |
|
| 3540 |
+} |
|
| 3541 |
+ |
|
| 3542 |
+private enum ObservationSaveReason {
|
|
| 3543 |
+ case none |
|
| 3544 |
+ case created |
|
| 3545 |
+ case periodic |
|
| 3546 |
+ case completed |
|
| 3547 |
+ case event |
|
| 3548 |
+} |
|
@@ -0,0 +1,460 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargerStandbyPowerStore.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 13/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import Foundation |
|
| 9 |
+ |
|
| 10 |
+final class ChargerStandbyPowerStore {
|
|
| 11 |
+ private struct Snapshot: Codable {
|
|
| 12 |
+ var measurements: [ChargerStandbyPowerMeasurementSummary] |
|
| 13 |
+ } |
|
| 14 |
+ |
|
| 15 |
+ private enum Keys {
|
|
| 16 |
+ static let cloudMeasurements = "ChargerStandbyPowerStore.measurements" |
|
| 17 |
+ } |
|
| 18 |
+ |
|
| 19 |
+ private let fileManager: FileManager |
|
| 20 |
+ private let fileURL: URL |
|
| 21 |
+ private let encoder: JSONEncoder |
|
| 22 |
+ private let decoder: JSONDecoder |
|
| 23 |
+ private let ubiquitousStore = NSUbiquitousKeyValueStore.default |
|
| 24 |
+ private let workQueue = DispatchQueue(label: "ChargerStandbyPowerStore.Queue") |
|
| 25 |
+ private var ubiquitousObserver: NSObjectProtocol? |
|
| 26 |
+ private var ubiquityIdentityObserver: NSObjectProtocol? |
|
| 27 |
+ |
|
| 28 |
+ private var cachedMeasurements: [ChargerStandbyPowerMeasurementSummary]? |
|
| 29 |
+ |
|
| 30 |
+ init(fileManager: FileManager = .default) {
|
|
| 31 |
+ self.fileManager = fileManager |
|
| 32 |
+ |
|
| 33 |
+ let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first |
|
| 34 |
+ ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first |
|
| 35 |
+ ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) |
|
| 36 |
+ |
|
| 37 |
+ let directoryURL = applicationSupportURL.appendingPathComponent("ChargeInsights", isDirectory: true)
|
|
| 38 |
+ fileURL = directoryURL.appendingPathComponent("charger-standby-power.json", isDirectory: false)
|
|
| 39 |
+ |
|
| 40 |
+ encoder = JSONEncoder() |
|
| 41 |
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys] |
|
| 42 |
+ encoder.dateEncodingStrategy = .iso8601 |
|
| 43 |
+ |
|
| 44 |
+ decoder = JSONDecoder() |
|
| 45 |
+ decoder.dateDecodingStrategy = .iso8601 |
|
| 46 |
+ |
|
| 47 |
+ ubiquitousObserver = NotificationCenter.default.addObserver( |
|
| 48 |
+ forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, |
|
| 49 |
+ object: ubiquitousStore, |
|
| 50 |
+ queue: nil |
|
| 51 |
+ ) { [weak self] notification in
|
|
| 52 |
+ self?.handleUbiquitousStoreChange(notification) |
|
| 53 |
+ } |
|
| 54 |
+ |
|
| 55 |
+ ubiquityIdentityObserver = NotificationCenter.default.addObserver( |
|
| 56 |
+ forName: NSNotification.Name.NSUbiquityIdentityDidChange, |
|
| 57 |
+ object: nil, |
|
| 58 |
+ queue: nil |
|
| 59 |
+ ) { [weak self] _ in
|
|
| 60 |
+ self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed") |
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 63 |
+ ubiquitousStore.synchronize() |
|
| 64 |
+ syncLocalValuesToCloudIfPossible(reason: "startup") |
|
| 65 |
+ } |
|
| 66 |
+ |
|
| 67 |
+ func measurementsByChargerID() -> [UUID: [ChargerStandbyPowerMeasurementSummary]] {
|
|
| 68 |
+ Dictionary(grouping: loadMeasurements()) { $0.chargerID }
|
|
| 69 |
+ .mapValues { measurements in
|
|
| 70 |
+ measurements.sorted { lhs, rhs in
|
|
| 71 |
+ if lhs.endedAt != rhs.endedAt {
|
|
| 72 |
+ return lhs.endedAt > rhs.endedAt |
|
| 73 |
+ } |
|
| 74 |
+ return lhs.id.uuidString > rhs.id.uuidString |
|
| 75 |
+ } |
|
| 76 |
+ } |
|
| 77 |
+ } |
|
| 78 |
+ |
|
| 79 |
+ @discardableResult |
|
| 80 |
+ func save(_ measurement: ChargerStandbyPowerMeasurementSummary) -> Bool {
|
|
| 81 |
+ var measurements = loadMeasurements() |
|
| 82 |
+ measurements.append(measurement) |
|
| 83 |
+ measurements.sort { lhs, rhs in
|
|
| 84 |
+ if lhs.endedAt != rhs.endedAt {
|
|
| 85 |
+ return lhs.endedAt > rhs.endedAt |
|
| 86 |
+ } |
|
| 87 |
+ return lhs.id.uuidString > rhs.id.uuidString |
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ return persist(measurements) |
|
| 91 |
+ } |
|
| 92 |
+ |
|
| 93 |
+ @discardableResult |
|
| 94 |
+ func removeMeasurements(for chargerID: UUID) -> Bool {
|
|
| 95 |
+ let previousMeasurements = loadMeasurements() |
|
| 96 |
+ let filteredMeasurements = previousMeasurements.filter { $0.chargerID != chargerID }
|
|
| 97 |
+ guard filteredMeasurements.count != previousMeasurements.count else {
|
|
| 98 |
+ return true |
|
| 99 |
+ } |
|
| 100 |
+ |
|
| 101 |
+ return persist(filteredMeasurements) |
|
| 102 |
+ } |
|
| 103 |
+ |
|
| 104 |
+ @discardableResult |
|
| 105 |
+ func removeMeasurement(id: UUID, chargerID: UUID? = nil) -> Bool {
|
|
| 106 |
+ let previousMeasurements = loadMeasurements() |
|
| 107 |
+ let filteredMeasurements = previousMeasurements.filter { measurement in
|
|
| 108 |
+ guard measurement.id == id else {
|
|
| 109 |
+ return true |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ if let chargerID {
|
|
| 113 |
+ return measurement.chargerID != chargerID |
|
| 114 |
+ } |
|
| 115 |
+ |
|
| 116 |
+ return false |
|
| 117 |
+ } |
|
| 118 |
+ |
|
| 119 |
+ guard filteredMeasurements.count != previousMeasurements.count else {
|
|
| 120 |
+ return true |
|
| 121 |
+ } |
|
| 122 |
+ |
|
| 123 |
+ return persist(filteredMeasurements) |
|
| 124 |
+ } |
|
| 125 |
+ |
|
| 126 |
+ private func loadMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
|
|
| 127 |
+ if let cachedMeasurements {
|
|
| 128 |
+ return cachedMeasurements |
|
| 129 |
+ } |
|
| 130 |
+ |
|
| 131 |
+ let localMeasurements = loadLocalMeasurements() |
|
| 132 |
+ let cloudMeasurements = loadCloudMeasurements() |
|
| 133 |
+ let mergedMeasurements = merge(localMeasurements: localMeasurements, cloudMeasurements: cloudMeasurements) |
|
| 134 |
+ |
|
| 135 |
+ cachedMeasurements = mergedMeasurements |
|
| 136 |
+ return mergedMeasurements |
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 139 |
+ private func loadLocalMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
|
|
| 140 |
+ guard fileManager.fileExists(atPath: fileURL.path) else {
|
|
| 141 |
+ return [] |
|
| 142 |
+ } |
|
| 143 |
+ do {
|
|
| 144 |
+ let data = try Data(contentsOf: fileURL) |
|
| 145 |
+ let snapshot = try decoder.decode(Snapshot.self, from: data) |
|
| 146 |
+ return snapshot.measurements |
|
| 147 |
+ } catch {
|
|
| 148 |
+ track("Failed to load charger standby power history: \(error.localizedDescription)")
|
|
| 149 |
+ return [] |
|
| 150 |
+ } |
|
| 151 |
+ } |
|
| 152 |
+ |
|
| 153 |
+ private func loadCloudMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
|
|
| 154 |
+ guard isICloudDriveAvailable, |
|
| 155 |
+ let data = ubiquitousStore.data(forKey: Keys.cloudMeasurements) else {
|
|
| 156 |
+ return [] |
|
| 157 |
+ } |
|
| 158 |
+ |
|
| 159 |
+ do {
|
|
| 160 |
+ let snapshot = try decoder.decode(Snapshot.self, from: data) |
|
| 161 |
+ return snapshot.measurements |
|
| 162 |
+ } catch {
|
|
| 163 |
+ track("Failed to decode charger standby power history from iCloud KVS: \(error.localizedDescription)")
|
|
| 164 |
+ return [] |
|
| 165 |
+ } |
|
| 166 |
+ } |
|
| 167 |
+ |
|
| 168 |
+ @discardableResult |
|
| 169 |
+ private func persist(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
|
|
| 170 |
+ let sortedMeasurements = sortMeasurements(measurements) |
|
| 171 |
+ let didPersistLocal = persistLocally(sortedMeasurements) |
|
| 172 |
+ let didPersistCloud = persistToCloudIfPossible(sortedMeasurements) |
|
| 173 |
+ |
|
| 174 |
+ if didPersistLocal || didPersistCloud {
|
|
| 175 |
+ cachedMeasurements = sortedMeasurements |
|
| 176 |
+ } |
|
| 177 |
+ |
|
| 178 |
+ return didPersistLocal || didPersistCloud |
|
| 179 |
+ } |
|
| 180 |
+ |
|
| 181 |
+ @discardableResult |
|
| 182 |
+ private func persistLocally(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
|
|
| 183 |
+ do {
|
|
| 184 |
+ try fileManager.createDirectory( |
|
| 185 |
+ at: fileURL.deletingLastPathComponent(), |
|
| 186 |
+ withIntermediateDirectories: true, |
|
| 187 |
+ attributes: nil |
|
| 188 |
+ ) |
|
| 189 |
+ let snapshot = Snapshot(measurements: measurements) |
|
| 190 |
+ let data = try encoder.encode(snapshot) |
|
| 191 |
+ try data.write(to: fileURL, options: .atomic) |
|
| 192 |
+ return true |
|
| 193 |
+ } catch {
|
|
| 194 |
+ track("Failed to save charger standby power history: \(error.localizedDescription)")
|
|
| 195 |
+ return false |
|
| 196 |
+ } |
|
| 197 |
+ } |
|
| 198 |
+ |
|
| 199 |
+ @discardableResult |
|
| 200 |
+ private func persistToCloudIfPossible(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
|
|
| 201 |
+ guard isICloudDriveAvailable else {
|
|
| 202 |
+ return false |
|
| 203 |
+ } |
|
| 204 |
+ |
|
| 205 |
+ do {
|
|
| 206 |
+ let snapshot = Snapshot(measurements: measurements) |
|
| 207 |
+ let data = try encoder.encode(snapshot) |
|
| 208 |
+ ubiquitousStore.set(data, forKey: Keys.cloudMeasurements) |
|
| 209 |
+ ubiquitousStore.synchronize() |
|
| 210 |
+ return true |
|
| 211 |
+ } catch {
|
|
| 212 |
+ track("Failed to encode charger standby power history for iCloud KVS: \(error.localizedDescription)")
|
|
| 213 |
+ return false |
|
| 214 |
+ } |
|
| 215 |
+ } |
|
| 216 |
+ |
|
| 217 |
+ private func merge( |
|
| 218 |
+ localMeasurements: [ChargerStandbyPowerMeasurementSummary], |
|
| 219 |
+ cloudMeasurements: [ChargerStandbyPowerMeasurementSummary] |
|
| 220 |
+ ) -> [ChargerStandbyPowerMeasurementSummary] {
|
|
| 221 |
+ var mergedByID: [UUID: ChargerStandbyPowerMeasurementSummary] = [:] |
|
| 222 |
+ |
|
| 223 |
+ for measurement in localMeasurements {
|
|
| 224 |
+ mergedByID[measurement.id] = measurement |
|
| 225 |
+ } |
|
| 226 |
+ |
|
| 227 |
+ for measurement in cloudMeasurements {
|
|
| 228 |
+ mergedByID[measurement.id] = measurement |
|
| 229 |
+ } |
|
| 230 |
+ |
|
| 231 |
+ return sortMeasurements(Array(mergedByID.values)) |
|
| 232 |
+ } |
|
| 233 |
+ |
|
| 234 |
+ private func sortMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> [ChargerStandbyPowerMeasurementSummary] {
|
|
| 235 |
+ measurements.sorted { lhs, rhs in
|
|
| 236 |
+ if lhs.endedAt != rhs.endedAt {
|
|
| 237 |
+ return lhs.endedAt > rhs.endedAt |
|
| 238 |
+ } |
|
| 239 |
+ return lhs.id.uuidString > rhs.id.uuidString |
|
| 240 |
+ } |
|
| 241 |
+ } |
|
| 242 |
+ |
|
| 243 |
+ private var isICloudDriveAvailable: Bool {
|
|
| 244 |
+ FileManager.default.ubiquityIdentityToken != nil |
|
| 245 |
+ } |
|
| 246 |
+ |
|
| 247 |
+ private func handleUbiquitousStoreChange(_ notification: Notification) {
|
|
| 248 |
+ if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], |
|
| 249 |
+ changedKeys.contains(Keys.cloudMeasurements) == false {
|
|
| 250 |
+ return |
|
| 251 |
+ } |
|
| 252 |
+ |
|
| 253 |
+ workQueue.async { [weak self] in
|
|
| 254 |
+ guard let self else { return }
|
|
| 255 |
+ let mergedMeasurements = self.merge( |
|
| 256 |
+ localMeasurements: self.loadLocalMeasurements(), |
|
| 257 |
+ cloudMeasurements: self.loadCloudMeasurements() |
|
| 258 |
+ ) |
|
| 259 |
+ self.cachedMeasurements = mergedMeasurements |
|
| 260 |
+ _ = self.persistLocally(mergedMeasurements) |
|
| 261 |
+ DispatchQueue.main.async {
|
|
| 262 |
+ NotificationCenter.default.post(name: .chargerStandbyPowerStoreDidChange, object: nil) |
|
| 263 |
+ } |
|
| 264 |
+ } |
|
| 265 |
+ } |
|
| 266 |
+ |
|
| 267 |
+ private func syncLocalValuesToCloudIfPossible(reason: String) {
|
|
| 268 |
+ guard isICloudDriveAvailable else {
|
|
| 269 |
+ return |
|
| 270 |
+ } |
|
| 271 |
+ |
|
| 272 |
+ workQueue.async { [weak self] in
|
|
| 273 |
+ guard let self else { return }
|
|
| 274 |
+ let mergedMeasurements = self.merge( |
|
| 275 |
+ localMeasurements: self.loadLocalMeasurements(), |
|
| 276 |
+ cloudMeasurements: self.loadCloudMeasurements() |
|
| 277 |
+ ) |
|
| 278 |
+ let didPersistLocal = self.persistLocally(mergedMeasurements) |
|
| 279 |
+ let didPersistCloud = self.persistToCloudIfPossible(mergedMeasurements) |
|
| 280 |
+ self.cachedMeasurements = mergedMeasurements |
|
| 281 |
+ |
|
| 282 |
+ if didPersistLocal || didPersistCloud {
|
|
| 283 |
+ track("ChargerStandbyPowerStore synchronized standby measurements with iCloud KVS (\(reason)).")
|
|
| 284 |
+ DispatchQueue.main.async {
|
|
| 285 |
+ NotificationCenter.default.post(name: .chargerStandbyPowerStoreDidChange, object: nil) |
|
| 286 |
+ } |
|
| 287 |
+ } |
|
| 288 |
+ } |
|
| 289 |
+ } |
|
| 290 |
+ |
|
| 291 |
+ deinit {
|
|
| 292 |
+ if let observer = ubiquitousObserver {
|
|
| 293 |
+ NotificationCenter.default.removeObserver(observer) |
|
| 294 |
+ } |
|
| 295 |
+ if let observer = ubiquityIdentityObserver {
|
|
| 296 |
+ NotificationCenter.default.removeObserver(observer) |
|
| 297 |
+ } |
|
| 298 |
+ } |
|
| 299 |
+} |
|
| 300 |
+ |
|
| 301 |
+final class ChargerStandbyPowerMonitorSession: ObservableObject, Identifiable {
|
|
| 302 |
+ let id = UUID() |
|
| 303 |
+ let chargerID: UUID |
|
| 304 |
+ let meterMACAddress: String |
|
| 305 |
+ let meterName: String |
|
| 306 |
+ let meterModel: String |
|
| 307 |
+ let startedAt: Date |
|
| 308 |
+ |
|
| 309 |
+ @Published private(set) var samples: [ChargerStandbyPowerSample] = [] |
|
| 310 |
+ @Published private(set) var statistics: ChargerStandbyPowerMeasurementStatistics? |
|
| 311 |
+ @Published private(set) var stabilizedAt: Date? |
|
| 312 |
+ @Published private(set) var lastObservedAt: Date? |
|
| 313 |
+ @Published private(set) var isRunning = false |
|
| 314 |
+ |
|
| 315 |
+ var onChange: (() -> Void)? |
|
| 316 |
+ var onStabilized: (() -> Void)? |
|
| 317 |
+ |
|
| 318 |
+ private weak var meter: Meter? |
|
| 319 |
+ private var timer: Timer? |
|
| 320 |
+ private var hasTriggeredStabilityCallback = false |
|
| 321 |
+ private let sampleInterval: TimeInterval = 1 |
|
| 322 |
+ |
|
| 323 |
+ init(chargerID: UUID, meter: Meter, startedAt: Date = Date()) {
|
|
| 324 |
+ self.chargerID = chargerID |
|
| 325 |
+ meterMACAddress = meter.btSerial.macAddress.description |
|
| 326 |
+ meterName = meter.name |
|
| 327 |
+ meterModel = meter.deviceModelSummary |
|
| 328 |
+ self.startedAt = startedAt |
|
| 329 |
+ self.meter = meter |
|
| 330 |
+ } |
|
| 331 |
+ |
|
| 332 |
+ deinit {
|
|
| 333 |
+ stop() |
|
| 334 |
+ } |
|
| 335 |
+ |
|
| 336 |
+ var sampleCount: Int {
|
|
| 337 |
+ statistics?.sampleCount ?? samples.count |
|
| 338 |
+ } |
|
| 339 |
+ |
|
| 340 |
+ var hasSamples: Bool {
|
|
| 341 |
+ sampleCount > 0 |
|
| 342 |
+ } |
|
| 343 |
+ |
|
| 344 |
+ var readinessDescription: String {
|
|
| 345 |
+ guard let statistics else {
|
|
| 346 |
+ if let meter {
|
|
| 347 |
+ switch meter.operationalState {
|
|
| 348 |
+ case .peripheralConnectionPending, .peripheralConnected, .peripheralReady, .comunicating: |
|
| 349 |
+ return "Connecting to meter" |
|
| 350 |
+ case .peripheralNotConnected: |
|
| 351 |
+ return "Starting meter connection" |
|
| 352 |
+ case .notPresent, .dataIsAvailable: |
|
| 353 |
+ break |
|
| 354 |
+ } |
|
| 355 |
+ } |
|
| 356 |
+ |
|
| 357 |
+ return "Waiting for live samples" |
|
| 358 |
+ } |
|
| 359 |
+ |
|
| 360 |
+ if statistics.isStable {
|
|
| 361 |
+ return "Stable average reached" |
|
| 362 |
+ } |
|
| 363 |
+ |
|
| 364 |
+ return "Collecting baseline" |
|
| 365 |
+ } |
|
| 366 |
+ |
|
| 367 |
+ func start() {
|
|
| 368 |
+ guard isRunning == false else {
|
|
| 369 |
+ return |
|
| 370 |
+ } |
|
| 371 |
+ |
|
| 372 |
+ isRunning = true |
|
| 373 |
+ captureSampleIfPossible(at: Date()) |
|
| 374 |
+ |
|
| 375 |
+ let timer = Timer(timeInterval: sampleInterval, repeats: true) { [weak self] _ in
|
|
| 376 |
+ self?.captureSampleIfPossible(at: Date()) |
|
| 377 |
+ } |
|
| 378 |
+ self.timer = timer |
|
| 379 |
+ RunLoop.main.add(timer, forMode: .common) |
|
| 380 |
+ onChange?() |
|
| 381 |
+ } |
|
| 382 |
+ |
|
| 383 |
+ func stop() {
|
|
| 384 |
+ timer?.invalidate() |
|
| 385 |
+ timer = nil |
|
| 386 |
+ guard isRunning else {
|
|
| 387 |
+ return |
|
| 388 |
+ } |
|
| 389 |
+ isRunning = false |
|
| 390 |
+ onChange?() |
|
| 391 |
+ } |
|
| 392 |
+ |
|
| 393 |
+ func makeSummary(endedAt: Date = Date()) -> ChargerStandbyPowerMeasurementSummary? {
|
|
| 394 |
+ ChargerStandbyPowerMeasurementAnalyzer.measurementSummary( |
|
| 395 |
+ chargerID: chargerID, |
|
| 396 |
+ meterMACAddress: meterMACAddress, |
|
| 397 |
+ meterName: meterName, |
|
| 398 |
+ meterModel: meterModel, |
|
| 399 |
+ startedAt: startedAt, |
|
| 400 |
+ endedAt: endedAt, |
|
| 401 |
+ samples: samples, |
|
| 402 |
+ stabilizedAt: stabilizedAt |
|
| 403 |
+ ) |
|
| 404 |
+ } |
|
| 405 |
+ |
|
| 406 |
+ private func captureSampleIfPossible(at timestamp: Date) {
|
|
| 407 |
+ defer {
|
|
| 408 |
+ statistics = ChargerStandbyPowerMeasurementAnalyzer.statistics( |
|
| 409 |
+ from: samples, |
|
| 410 |
+ startedAt: startedAt, |
|
| 411 |
+ referenceDate: timestamp |
|
| 412 |
+ ) |
|
| 413 |
+ onChange?() |
|
| 414 |
+ } |
|
| 415 |
+ |
|
| 416 |
+ guard let meter else {
|
|
| 417 |
+ return |
|
| 418 |
+ } |
|
| 419 |
+ |
|
| 420 |
+ guard meter.operationalState == .dataIsAvailable else {
|
|
| 421 |
+ return |
|
| 422 |
+ } |
|
| 423 |
+ |
|
| 424 |
+ let powerWatts = meter.power |
|
| 425 |
+ let currentAmps = meter.current |
|
| 426 |
+ let voltageVolts = meter.voltage |
|
| 427 |
+ |
|
| 428 |
+ guard powerWatts.isFinite, currentAmps.isFinite, voltageVolts.isFinite else {
|
|
| 429 |
+ return |
|
| 430 |
+ } |
|
| 431 |
+ |
|
| 432 |
+ lastObservedAt = timestamp |
|
| 433 |
+ samples.append( |
|
| 434 |
+ ChargerStandbyPowerSample( |
|
| 435 |
+ timestamp: timestamp, |
|
| 436 |
+ powerWatts: powerWatts, |
|
| 437 |
+ currentAmps: currentAmps, |
|
| 438 |
+ voltageVolts: voltageVolts |
|
| 439 |
+ ) |
|
| 440 |
+ ) |
|
| 441 |
+ |
|
| 442 |
+ if stabilizedAt == nil, |
|
| 443 |
+ let refreshedStatistics = ChargerStandbyPowerMeasurementAnalyzer.statistics( |
|
| 444 |
+ from: samples, |
|
| 445 |
+ startedAt: startedAt, |
|
| 446 |
+ referenceDate: timestamp |
|
| 447 |
+ ), |
|
| 448 |
+ refreshedStatistics.isStable {
|
|
| 449 |
+ stabilizedAt = timestamp |
|
| 450 |
+ if hasTriggeredStabilityCallback == false {
|
|
| 451 |
+ hasTriggeredStabilityCallback = true |
|
| 452 |
+ onStabilized?() |
|
| 453 |
+ } |
|
| 454 |
+ } |
|
| 455 |
+ } |
|
| 456 |
+} |
|
| 457 |
+ |
|
| 458 |
+extension Notification.Name {
|
|
| 459 |
+ static let chargerStandbyPowerStoreDidChange = Notification.Name("ChargerStandbyPowerStoreDidChange")
|
|
| 460 |
+} |
|
@@ -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 |
+} |
|
@@ -88,6 +88,16 @@ class ChartContext {
|
||
| 88 | 88 |
self.rect = rect |
| 89 | 89 |
padding() |
| 90 | 90 |
} |
| 91 |
+ |
|
| 92 |
+ func setBounds(xMin: CGFloat, xMax: CGFloat, yMin: CGFloat, yMax: CGFloat) {
|
|
| 93 |
+ rect = CGRect( |
|
| 94 |
+ x: min(xMin, xMax), |
|
| 95 |
+ y: min(yMin, yMax), |
|
| 96 |
+ width: abs(xMax - xMin), |
|
| 97 |
+ height: max(abs(yMax - yMin), 0.1) |
|
| 98 |
+ ) |
|
| 99 |
+ padding() |
|
| 100 |
+ } |
|
| 91 | 101 |
|
| 92 | 102 |
func yAxisLabel( for itemNo: Int, of items: Int ) -> Double {
|
| 93 | 103 |
let labelSpace = Double(rect!.height) / Double(items - 1) |
@@ -10,12 +10,69 @@ import Foundation |
||
| 10 | 10 |
import CoreGraphics |
| 11 | 11 |
|
| 12 | 12 |
class Measurements : ObservableObject {
|
| 13 |
+ private static let restoredSampleDiscontinuityThreshold: TimeInterval = 90 |
|
| 14 |
+ |
|
| 15 |
+ struct EnergyProjectionSnapshot {
|
|
| 16 |
+ let accumulatedEnergy: Double |
|
| 17 |
+ let observedDuration: TimeInterval |
|
| 18 |
+ let sampleCount: Int |
|
| 19 |
+ let averagePower: Double? |
|
| 20 |
+ |
|
| 21 |
+ var projectedDailyEnergy: Double? {
|
|
| 22 |
+ projectedEnergy(forHours: 24) |
|
| 23 |
+ } |
|
| 24 |
+ |
|
| 25 |
+ var projectedMonthlyEnergy: Double? {
|
|
| 26 |
+ projectedEnergy(forHours: 24 * 30) |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ var projectedYearlyEnergy: Double? {
|
|
| 30 |
+ projectedEnergy(forHours: 24 * 365) |
|
| 31 |
+ } |
|
| 32 |
+ |
|
| 33 |
+ private func projectedEnergy(forHours hours: Double) -> Double? {
|
|
| 34 |
+ guard let averagePower, averagePower.isFinite else { return nil }
|
|
| 35 |
+ return averagePower * hours |
|
| 36 |
+ } |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 39 |
+ struct EnergyProjectionVariant: Identifiable {
|
|
| 40 |
+ let id: String |
|
| 41 |
+ let title: String |
|
| 42 |
+ let observedDuration: TimeInterval |
|
| 43 |
+ let accumulatedEnergy: Double |
|
| 44 |
+ let sampleCount: Int |
|
| 45 |
+ let averagePower: Double |
|
| 46 |
+ |
|
| 47 |
+ var projectedMonthlyEnergy: Double {
|
|
| 48 |
+ averagePower * 24 * 30 |
|
| 49 |
+ } |
|
| 50 |
+ |
|
| 51 |
+ var projectedYearlyEnergy: Double {
|
|
| 52 |
+ averagePower * 24 * 365 |
|
| 53 |
+ } |
|
| 54 |
+ } |
|
| 13 | 55 |
|
| 14 | 56 |
class Measurement : ObservableObject {
|
| 15 | 57 |
struct Point : Identifiable , Hashable {
|
| 58 |
+ enum Kind: Hashable {
|
|
| 59 |
+ case sample |
|
| 60 |
+ case discontinuity |
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 16 | 63 |
var id : Int |
| 17 | 64 |
var timestamp: Date |
| 18 | 65 |
var value: Double |
| 66 |
+ var kind: Kind = .sample |
|
| 67 |
+ |
|
| 68 |
+ var isSample: Bool {
|
|
| 69 |
+ kind == .sample |
|
| 70 |
+ } |
|
| 71 |
+ |
|
| 72 |
+ var isDiscontinuity: Bool {
|
|
| 73 |
+ kind == .discontinuity |
|
| 74 |
+ } |
|
| 75 |
+ |
|
| 19 | 76 |
func point() -> CGPoint {
|
| 20 | 77 |
return CGPoint(x: timestamp.timeIntervalSince1970, y: value) |
| 21 | 78 |
} |
@@ -24,103 +81,736 @@ class Measurements : ObservableObject {
|
||
| 24 | 81 |
var points: [Point] = [] |
| 25 | 82 |
var context = ChartContext() |
| 26 | 83 |
|
| 84 |
+ var samplePoints: [Point] {
|
|
| 85 |
+ points.filter { $0.isSample }
|
|
| 86 |
+ } |
|
| 87 |
+ |
|
| 88 |
+ func points(in range: ClosedRange<Date>) -> [Point] {
|
|
| 89 |
+ guard !points.isEmpty else { return [] }
|
|
| 90 |
+ |
|
| 91 |
+ let startIndex = indexOfFirstPoint(onOrAfter: range.lowerBound) |
|
| 92 |
+ let endIndex = indexOfFirstPoint(after: range.upperBound) |
|
| 93 |
+ guard startIndex < endIndex else { return [] }
|
|
| 94 |
+ return Array(points[startIndex..<endIndex]) |
|
| 95 |
+ } |
|
| 96 |
+ |
|
| 97 |
+ private func rebuildContext() {
|
|
| 98 |
+ context.reset() |
|
| 99 |
+ for point in points where point.isSample {
|
|
| 100 |
+ context.include(point: point.point()) |
|
| 101 |
+ } |
|
| 102 |
+ } |
|
| 103 |
+ |
|
| 104 |
+ private func appendPoint(timestamp: Date, value: Double, kind: Point.Kind) {
|
|
| 105 |
+ let newPoint = Measurement.Point(id: points.count, timestamp: timestamp, value: value, kind: kind) |
|
| 106 |
+ points.append(newPoint) |
|
| 107 |
+ if newPoint.isSample {
|
|
| 108 |
+ context.include(point: newPoint.point()) |
|
| 109 |
+ } |
|
| 110 |
+ self.objectWillChange.send() |
|
| 111 |
+ } |
|
| 112 |
+ |
|
| 27 | 113 |
func removeValue(index: Int) {
|
| 114 |
+ guard points.indices.contains(index) else { return }
|
|
| 28 | 115 |
points.remove(at: index) |
| 29 |
- context.reset() |
|
| 30 |
- for point in points {
|
|
| 31 |
- context.include( point: point.point() ) |
|
| 116 |
+ for index in points.indices {
|
|
| 117 |
+ points[index].id = index |
|
| 32 | 118 |
} |
| 119 |
+ rebuildContext() |
|
| 33 | 120 |
self.objectWillChange.send() |
| 34 | 121 |
} |
| 35 | 122 |
|
| 36 | 123 |
func addPoint(timestamp: Date, value: Double) {
|
| 37 |
- let newPoint = Measurement.Point(id: points.count, timestamp: timestamp, value: value) |
|
| 38 |
- points.append(newPoint) |
|
| 39 |
- context.include( point: newPoint.point() ) |
|
| 40 |
- self.objectWillChange.send() |
|
| 124 |
+ appendPoint(timestamp: timestamp, value: value, kind: .sample) |
|
| 125 |
+ } |
|
| 126 |
+ |
|
| 127 |
+ func addDiscontinuity(timestamp: Date) {
|
|
| 128 |
+ guard !points.isEmpty else { return }
|
|
| 129 |
+ guard points.last?.isDiscontinuity == false else { return }
|
|
| 130 |
+ appendPoint(timestamp: timestamp, value: points.last?.value ?? 0, kind: .discontinuity) |
|
| 41 | 131 |
} |
| 42 | 132 |
|
| 43 |
- func reset() {
|
|
| 133 |
+ func resetSeries() {
|
|
| 44 | 134 |
points.removeAll() |
| 45 | 135 |
context.reset() |
| 46 | 136 |
self.objectWillChange.send() |
| 47 | 137 |
} |
| 48 | 138 |
|
| 139 |
+ func replacePoints(_ points: [Point]) {
|
|
| 140 |
+ self.points = points.enumerated().map { index, point in
|
|
| 141 |
+ Point( |
|
| 142 |
+ id: index, |
|
| 143 |
+ timestamp: point.timestamp, |
|
| 144 |
+ value: point.value, |
|
| 145 |
+ kind: point.kind |
|
| 146 |
+ ) |
|
| 147 |
+ } |
|
| 148 |
+ rebuildContext() |
|
| 149 |
+ self.objectWillChange.send() |
|
| 150 |
+ } |
|
| 151 |
+ |
|
| 49 | 152 |
func trim(before cutoff: Date) {
|
| 50 | 153 |
points = points |
| 51 | 154 |
.filter { $0.timestamp >= cutoff }
|
| 52 | 155 |
.enumerated() |
| 53 | 156 |
.map { index, point in
|
| 54 |
- Measurement.Point(id: index, timestamp: point.timestamp, value: point.value) |
|
| 157 |
+ Measurement.Point(id: index, timestamp: point.timestamp, value: point.value, kind: point.kind) |
|
| 55 | 158 |
} |
| 56 |
- context.reset() |
|
| 57 |
- for point in points {
|
|
| 58 |
- context.include(point: point.point()) |
|
| 159 |
+ rebuildContext() |
|
| 160 |
+ self.objectWillChange.send() |
|
| 161 |
+ } |
|
| 162 |
+ |
|
| 163 |
+ func filterSamples(keeping shouldKeepSampleAt: (Date) -> Bool) {
|
|
| 164 |
+ let originalSamples = samplePoints |
|
| 165 |
+ guard !originalSamples.isEmpty else { return }
|
|
| 166 |
+ |
|
| 167 |
+ var rebuiltPoints: [Point] = [] |
|
| 168 |
+ var lastKeptSampleIndex: Int? |
|
| 169 |
+ |
|
| 170 |
+ for (sampleIndex, sample) in originalSamples.enumerated() where shouldKeepSampleAt(sample.timestamp) {
|
|
| 171 |
+ if let lastKeptSampleIndex {
|
|
| 172 |
+ let hasRemovedSamplesBetween = sampleIndex - lastKeptSampleIndex > 1 |
|
| 173 |
+ let previousSample = originalSamples[lastKeptSampleIndex] |
|
| 174 |
+ let originalHadDiscontinuityBetween = points.contains { point in
|
|
| 175 |
+ point.isDiscontinuity && |
|
| 176 |
+ point.timestamp > previousSample.timestamp && |
|
| 177 |
+ point.timestamp <= sample.timestamp |
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ if hasRemovedSamplesBetween || originalHadDiscontinuityBetween {
|
|
| 181 |
+ rebuiltPoints.append( |
|
| 182 |
+ Point( |
|
| 183 |
+ id: rebuiltPoints.count, |
|
| 184 |
+ timestamp: sample.timestamp, |
|
| 185 |
+ value: rebuiltPoints.last?.value ?? sample.value, |
|
| 186 |
+ kind: .discontinuity |
|
| 187 |
+ ) |
|
| 188 |
+ ) |
|
| 189 |
+ } |
|
| 190 |
+ } |
|
| 191 |
+ |
|
| 192 |
+ rebuiltPoints.append( |
|
| 193 |
+ Point( |
|
| 194 |
+ id: rebuiltPoints.count, |
|
| 195 |
+ timestamp: sample.timestamp, |
|
| 196 |
+ value: sample.value, |
|
| 197 |
+ kind: .sample |
|
| 198 |
+ ) |
|
| 199 |
+ ) |
|
| 200 |
+ lastKeptSampleIndex = sampleIndex |
|
| 201 |
+ } |
|
| 202 |
+ |
|
| 203 |
+ points = rebuiltPoints |
|
| 204 |
+ rebuildContext() |
|
| 205 |
+ self.objectWillChange.send() |
|
| 206 |
+ } |
|
| 207 |
+ |
|
| 208 |
+ func alignCounterToStartAtZero() {
|
|
| 209 |
+ guard let firstSampleIndex = points.firstIndex(where: \.isSample) else {
|
|
| 210 |
+ if !points.isEmpty {
|
|
| 211 |
+ resetSeries() |
|
| 212 |
+ } |
|
| 213 |
+ return |
|
| 59 | 214 |
} |
| 215 |
+ |
|
| 216 |
+ let baselineValue = points[firstSampleIndex].value |
|
| 217 |
+ points = points[firstSampleIndex...] |
|
| 218 |
+ .enumerated() |
|
| 219 |
+ .map { index, point in
|
|
| 220 |
+ Point( |
|
| 221 |
+ id: index, |
|
| 222 |
+ timestamp: point.timestamp, |
|
| 223 |
+ value: point.value - baselineValue, |
|
| 224 |
+ kind: point.kind |
|
| 225 |
+ ) |
|
| 226 |
+ } |
|
| 227 |
+ rebuildContext() |
|
| 60 | 228 |
self.objectWillChange.send() |
| 61 | 229 |
} |
| 230 |
+ |
|
| 231 |
+ private func indexOfFirstPoint(onOrAfter date: Date) -> Int {
|
|
| 232 |
+ var lowerBound = 0 |
|
| 233 |
+ var upperBound = points.count |
|
| 234 |
+ |
|
| 235 |
+ while lowerBound < upperBound {
|
|
| 236 |
+ let midIndex = (lowerBound + upperBound) / 2 |
|
| 237 |
+ if points[midIndex].timestamp < date {
|
|
| 238 |
+ lowerBound = midIndex + 1 |
|
| 239 |
+ } else {
|
|
| 240 |
+ upperBound = midIndex |
|
| 241 |
+ } |
|
| 242 |
+ } |
|
| 243 |
+ |
|
| 244 |
+ return lowerBound |
|
| 245 |
+ } |
|
| 246 |
+ |
|
| 247 |
+ private func indexOfFirstPoint(after date: Date) -> Int {
|
|
| 248 |
+ var lowerBound = 0 |
|
| 249 |
+ var upperBound = points.count |
|
| 250 |
+ |
|
| 251 |
+ while lowerBound < upperBound {
|
|
| 252 |
+ let midIndex = (lowerBound + upperBound) / 2 |
|
| 253 |
+ if points[midIndex].timestamp <= date {
|
|
| 254 |
+ lowerBound = midIndex + 1 |
|
| 255 |
+ } else {
|
|
| 256 |
+ upperBound = midIndex |
|
| 257 |
+ } |
|
| 258 |
+ } |
|
| 259 |
+ |
|
| 260 |
+ return lowerBound |
|
| 261 |
+ } |
|
| 62 | 262 |
} |
| 63 | 263 |
|
| 64 | 264 |
@Published var power = Measurement() |
| 65 | 265 |
@Published var voltage = Measurement() |
| 66 | 266 |
@Published var current = Measurement() |
| 267 |
+ @Published var temperature = Measurement() |
|
| 268 |
+ @Published var energy = Measurement() |
|
| 269 |
+ @Published var rssi = Measurement() |
|
| 270 |
+ |
|
| 271 |
+ let averagePowerSampleOptions: [Int] = [5, 10, 20, 50, 100, 250] |
|
| 67 | 272 |
|
| 68 |
- private var lastPointTimestamp = 0 |
|
| 273 |
+ private var pendingBucketSecond: Int? |
|
| 274 |
+ private var pendingBucketTimestamp: Date? |
|
| 275 |
+ private let energyResetEpsilon = 0.0005 |
|
| 276 |
+ private var lastEnergyCounterValue: Double? |
|
| 277 |
+ private var lastEnergyGroupID: UInt8? |
|
| 278 |
+ private var accumulatedEnergyValue: Double = 0 |
|
| 69 | 279 |
|
| 70 | 280 |
private var itemsInSum: Double = 0 |
| 71 | 281 |
private var powerSum: Double = 0 |
| 72 | 282 |
private var voltageSum: Double = 0 |
| 73 | 283 |
private var currentSum: Double = 0 |
| 284 |
+ private var temperatureItemsInSum: Double = 0 |
|
| 285 |
+ private var temperatureSum: Double = 0 |
|
| 286 |
+ private var rssiSum: Double = 0 |
|
| 74 | 287 |
|
| 75 |
- func reset() {
|
|
| 76 |
- power.reset() |
|
| 77 |
- voltage.reset() |
|
| 78 |
- current.reset() |
|
| 79 |
- lastPointTimestamp = 0 |
|
| 288 |
+ private func resetPendingAggregation() {
|
|
| 289 |
+ pendingBucketSecond = nil |
|
| 290 |
+ pendingBucketTimestamp = nil |
|
| 80 | 291 |
itemsInSum = 0 |
| 81 | 292 |
powerSum = 0 |
| 82 | 293 |
voltageSum = 0 |
| 83 | 294 |
currentSum = 0 |
| 295 |
+ temperatureItemsInSum = 0 |
|
| 296 |
+ temperatureSum = 0 |
|
| 297 |
+ rssiSum = 0 |
|
| 298 |
+ } |
|
| 299 |
+ |
|
| 300 |
+ private func flushPendingValues() {
|
|
| 301 |
+ guard let pendingBucketTimestamp, itemsInSum > 0 else { return }
|
|
| 302 |
+ self.power.addPoint(timestamp: pendingBucketTimestamp, value: powerSum / itemsInSum) |
|
| 303 |
+ self.voltage.addPoint(timestamp: pendingBucketTimestamp, value: voltageSum / itemsInSum) |
|
| 304 |
+ self.current.addPoint(timestamp: pendingBucketTimestamp, value: currentSum / itemsInSum) |
|
| 305 |
+ if temperatureItemsInSum > 0 {
|
|
| 306 |
+ self.temperature.addPoint(timestamp: pendingBucketTimestamp, value: temperatureSum / temperatureItemsInSum) |
|
| 307 |
+ } |
|
| 308 |
+ self.rssi.addPoint(timestamp: pendingBucketTimestamp, value: rssiSum / itemsInSum) |
|
| 309 |
+ resetPendingAggregation() |
|
| 84 | 310 |
self.objectWillChange.send() |
| 85 | 311 |
} |
| 312 |
+ |
|
| 313 |
+ private func realignEnergyBufferStart() {
|
|
| 314 |
+ energy.alignCounterToStartAtZero() |
|
| 315 |
+ lastEnergyCounterValue = nil |
|
| 316 |
+ lastEnergyGroupID = nil |
|
| 317 |
+ accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0 |
|
| 318 |
+ } |
|
| 319 |
+ |
|
| 320 |
+ @discardableResult |
|
| 321 |
+ func restorePersistedChargeSessionSamplesIfNeeded( |
|
| 322 |
+ from session: ChargeSessionSummary, |
|
| 323 |
+ replacingLiveBufferIfNeeded: Bool = false |
|
| 324 |
+ ) -> Bool {
|
|
| 325 |
+ let hasExistingBuffer = |
|
| 326 |
+ power.points.isEmpty == false || |
|
| 327 |
+ voltage.points.isEmpty == false || |
|
| 328 |
+ current.points.isEmpty == false || |
|
| 329 |
+ temperature.points.isEmpty == false || |
|
| 330 |
+ energy.points.isEmpty == false || |
|
| 331 |
+ rssi.points.isEmpty == false |
|
| 332 |
+ |
|
| 333 |
+ restoreTrace( |
|
| 334 |
+ "measurements-restore-start session=\(session.id.uuidString) status=\(session.status.rawValue) persistedSamples=\(session.aggregatedSamples.count) replaceLive=\(replacingLiveBufferIfNeeded) existingBuffer=\(hasExistingBuffer) existingCounts=p:\(power.points.count),v:\(voltage.points.count),c:\(current.points.count),t:\(temperature.points.count),e:\(energy.points.count),r:\(rssi.points.count)" |
|
| 335 |
+ ) |
|
| 336 |
+ |
|
| 337 |
+ guard hasExistingBuffer == false || replacingLiveBufferIfNeeded else {
|
|
| 338 |
+ restoreTrace("measurements-restore-skip session=\(session.id.uuidString) reason=live-buffer-not-replaced")
|
|
| 339 |
+ return false |
|
| 340 |
+ } |
|
| 341 |
+ |
|
| 342 |
+ let sortedSamples = session.aggregatedSamples.sorted { lhs, rhs in
|
|
| 343 |
+ if lhs.bucketIndex != rhs.bucketIndex {
|
|
| 344 |
+ return lhs.bucketIndex < rhs.bucketIndex |
|
| 345 |
+ } |
|
| 346 |
+ return lhs.timestamp < rhs.timestamp |
|
| 347 |
+ } |
|
| 348 |
+ |
|
| 349 |
+ guard !sortedSamples.isEmpty else {
|
|
| 350 |
+ restoreTrace("measurements-restore-skip session=\(session.id.uuidString) reason=no-persisted-samples")
|
|
| 351 |
+ return false |
|
| 352 |
+ } |
|
| 353 |
+ |
|
| 354 |
+ let preservedEnergyCounterValue = lastEnergyCounterValue |
|
| 355 |
+ let preservedEnergyGroupID = lastEnergyGroupID |
|
| 356 |
+ let persistedRangeUpperBound = sortedSamples.last?.timestamp |
|
| 357 |
+ if hasExistingBuffer {
|
|
| 358 |
+ flushPendingValues() |
|
| 359 |
+ } |
|
| 360 |
+ |
|
| 361 |
+ resetPendingAggregation() |
|
| 362 |
+ |
|
| 363 |
+ let restoredPowerPoints = restoredPoints(from: sortedSamples) { sample in
|
|
| 364 |
+ sample.averagePowerWatts |
|
| 365 |
+ } |
|
| 366 |
+ let restoredCurrentPoints = restoredPoints(from: sortedSamples) { sample in
|
|
| 367 |
+ sample.averageCurrentAmps |
|
| 368 |
+ } |
|
| 369 |
+ let restoredVoltagePoints = restoredPoints(from: sortedSamples) { sample in
|
|
| 370 |
+ sample.averageVoltageVolts |
|
| 371 |
+ } |
|
| 372 |
+ let restoredEnergyPoints = restoredPoints(from: sortedSamples) { sample in
|
|
| 373 |
+ sample.measuredEnergyWh |
|
| 374 |
+ } |
|
| 375 |
+ |
|
| 376 |
+ let mergedPowerPoints = mergedRestoredPoints( |
|
| 377 |
+ restored: restoredPowerPoints, |
|
| 378 |
+ existing: power.points, |
|
| 379 |
+ persistedRangeUpperBound: persistedRangeUpperBound |
|
| 380 |
+ ) |
|
| 381 |
+ let mergedCurrentPoints = mergedRestoredPoints( |
|
| 382 |
+ restored: restoredCurrentPoints, |
|
| 383 |
+ existing: current.points, |
|
| 384 |
+ persistedRangeUpperBound: persistedRangeUpperBound |
|
| 385 |
+ ) |
|
| 386 |
+ let mergedVoltagePoints = mergedRestoredPoints( |
|
| 387 |
+ restored: restoredVoltagePoints, |
|
| 388 |
+ existing: voltage.points, |
|
| 389 |
+ persistedRangeUpperBound: persistedRangeUpperBound |
|
| 390 |
+ ) |
|
| 391 |
+ let mergedEnergyPoints = mergedRestoredPoints( |
|
| 392 |
+ restored: restoredEnergyPoints, |
|
| 393 |
+ existing: energy.points, |
|
| 394 |
+ persistedRangeUpperBound: persistedRangeUpperBound |
|
| 395 |
+ ) |
|
| 396 |
+ let preservedRssiTail = preservedTailPoints( |
|
| 397 |
+ from: rssi.points, |
|
| 398 |
+ after: persistedRangeUpperBound |
|
| 399 |
+ ) |
|
| 400 |
+ |
|
| 401 |
+ restoreTrace( |
|
| 402 |
+ "measurements-restore-merge session=\(session.id.uuidString) restored=p:\(restoredPowerPoints.count),v:\(restoredVoltagePoints.count),c:\(restoredCurrentPoints.count),e:\(restoredEnergyPoints.count) discontinuities=p:\(restoredPowerPoints.filter(\.isDiscontinuity).count),v:\(restoredVoltagePoints.filter(\.isDiscontinuity).count),c:\(restoredCurrentPoints.filter(\.isDiscontinuity).count),e:\(restoredEnergyPoints.filter(\.isDiscontinuity).count) merged=p:\(mergedPowerPoints.count),v:\(mergedVoltagePoints.count),c:\(mergedCurrentPoints.count),e:\(mergedEnergyPoints.count) tails=r:\(preservedRssiTail.count) upperBound=\(persistedRangeUpperBound?.description ?? "nil")" |
|
| 403 |
+ ) |
|
| 404 |
+ |
|
| 405 |
+ power.replacePoints(mergedPowerPoints) |
|
| 406 |
+ current.replacePoints(mergedCurrentPoints) |
|
| 407 |
+ voltage.replacePoints(mergedVoltagePoints) |
|
| 408 |
+ energy.replacePoints(mergedEnergyPoints) |
|
| 409 |
+ temperature.resetSeries() |
|
| 410 |
+ rssi.replacePoints(preservedRssiTail) |
|
| 411 |
+ |
|
| 412 |
+ lastEnergyCounterValue = hasExistingBuffer ? preservedEnergyCounterValue : nil |
|
| 413 |
+ lastEnergyGroupID = hasExistingBuffer ? preservedEnergyGroupID : nil |
|
| 414 |
+ accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0 |
|
| 415 |
+ self.objectWillChange.send() |
|
| 416 |
+ restoreTrace( |
|
| 417 |
+ "measurements-restore-complete session=\(session.id.uuidString) counts=p:\(power.samplePoints.count),v:\(voltage.samplePoints.count),c:\(current.samplePoints.count),t:\(temperature.samplePoints.count),e:\(energy.samplePoints.count),r:\(rssi.samplePoints.count) accumulatedEnergy=\(accumulatedEnergyValue)" |
|
| 418 |
+ ) |
|
| 419 |
+ return true |
|
| 420 |
+ } |
|
| 421 |
+ |
|
| 422 |
+ private func restoredPoints( |
|
| 423 |
+ from samples: [ChargeSessionSampleSummary], |
|
| 424 |
+ value: (ChargeSessionSampleSummary) -> Double? |
|
| 425 |
+ ) -> [Measurement.Point] {
|
|
| 426 |
+ var restored: [Measurement.Point] = [] |
|
| 427 |
+ var previousSample: ChargeSessionSampleSummary? |
|
| 428 |
+ |
|
| 429 |
+ for sample in samples {
|
|
| 430 |
+ guard let pointValue = value(sample) else { continue }
|
|
| 431 |
+ |
|
| 432 |
+ if let previousSample, |
|
| 433 |
+ sample.timestamp.timeIntervalSince(previousSample.timestamp) > Self.restoredSampleDiscontinuityThreshold {
|
|
| 434 |
+ restored.append( |
|
| 435 |
+ Measurement.Point( |
|
| 436 |
+ id: restored.count, |
|
| 437 |
+ timestamp: sample.timestamp, |
|
| 438 |
+ value: restored.last?.value ?? pointValue, |
|
| 439 |
+ kind: .discontinuity |
|
| 440 |
+ ) |
|
| 441 |
+ ) |
|
| 442 |
+ } |
|
| 443 |
+ |
|
| 444 |
+ restored.append( |
|
| 445 |
+ Measurement.Point( |
|
| 446 |
+ id: restored.count, |
|
| 447 |
+ timestamp: sample.timestamp, |
|
| 448 |
+ value: pointValue, |
|
| 449 |
+ kind: .sample |
|
| 450 |
+ ) |
|
| 451 |
+ ) |
|
| 452 |
+ previousSample = sample |
|
| 453 |
+ } |
|
| 454 |
+ |
|
| 455 |
+ return restored |
|
| 456 |
+ } |
|
| 457 |
+ |
|
| 458 |
+ private func mergedRestoredPoints( |
|
| 459 |
+ restored: [Measurement.Point], |
|
| 460 |
+ existing: [Measurement.Point], |
|
| 461 |
+ persistedRangeUpperBound: Date? |
|
| 462 |
+ ) -> [Measurement.Point] {
|
|
| 463 |
+ var merged = restored |
|
| 464 |
+ let preservedTail = preservedTailPoints(from: existing, after: persistedRangeUpperBound) |
|
| 465 |
+ |
|
| 466 |
+ guard preservedTail.isEmpty == false else {
|
|
| 467 |
+ return merged |
|
| 468 |
+ } |
|
| 469 |
+ |
|
| 470 |
+ if let tailFirst = preservedTail.first, |
|
| 471 |
+ tailFirst.isSample, |
|
| 472 |
+ let lastRestoredSample = merged.last(where: \.isSample), |
|
| 473 |
+ lastRestoredSample.timestamp < tailFirst.timestamp {
|
|
| 474 |
+ merged.append( |
|
| 475 |
+ Measurement.Point( |
|
| 476 |
+ id: merged.count, |
|
| 477 |
+ timestamp: tailFirst.timestamp, |
|
| 478 |
+ value: merged.last?.value ?? tailFirst.value, |
|
| 479 |
+ kind: .discontinuity |
|
| 480 |
+ ) |
|
| 481 |
+ ) |
|
| 482 |
+ } |
|
| 483 |
+ |
|
| 484 |
+ merged.append(contentsOf: preservedTail.enumerated().map { offset, point in
|
|
| 485 |
+ Measurement.Point( |
|
| 486 |
+ id: merged.count + offset, |
|
| 487 |
+ timestamp: point.timestamp, |
|
| 488 |
+ value: point.value, |
|
| 489 |
+ kind: point.kind |
|
| 490 |
+ ) |
|
| 491 |
+ }) |
|
| 492 |
+ return merged |
|
| 493 |
+ } |
|
| 494 |
+ |
|
| 495 |
+ private func preservedTailPoints( |
|
| 496 |
+ from existing: [Measurement.Point], |
|
| 497 |
+ after persistedRangeUpperBound: Date? |
|
| 498 |
+ ) -> [Measurement.Point] {
|
|
| 499 |
+ guard let persistedRangeUpperBound else {
|
|
| 500 |
+ return existing |
|
| 501 |
+ } |
|
| 502 |
+ |
|
| 503 |
+ let tail = existing.filter { $0.timestamp > persistedRangeUpperBound }
|
|
| 504 |
+ guard tail.isEmpty == false else {
|
|
| 505 |
+ return [] |
|
| 506 |
+ } |
|
| 507 |
+ |
|
| 508 |
+ if let firstSampleIndex = tail.firstIndex(where: \.isSample) {
|
|
| 509 |
+ return Array(tail[firstSampleIndex...]) |
|
| 510 |
+ } |
|
| 511 |
+ |
|
| 512 |
+ return [] |
|
| 513 |
+ } |
|
| 514 |
+ |
|
| 515 |
+ func resetSeries() {
|
|
| 516 |
+ power.resetSeries() |
|
| 517 |
+ voltage.resetSeries() |
|
| 518 |
+ current.resetSeries() |
|
| 519 |
+ temperature.resetSeries() |
|
| 520 |
+ energy.resetSeries() |
|
| 521 |
+ rssi.resetSeries() |
|
| 522 |
+ resetPendingAggregation() |
|
| 523 |
+ lastEnergyCounterValue = nil |
|
| 524 |
+ lastEnergyGroupID = nil |
|
| 525 |
+ accumulatedEnergyValue = 0 |
|
| 526 |
+ self.objectWillChange.send() |
|
| 527 |
+ } |
|
| 528 |
+ |
|
| 529 |
+ func reset() {
|
|
| 530 |
+ resetSeries() |
|
| 531 |
+ } |
|
| 86 | 532 |
|
| 87 | 533 |
func remove(at idx: Int) {
|
| 88 | 534 |
power.removeValue(index: idx) |
| 89 | 535 |
voltage.removeValue(index: idx) |
| 90 | 536 |
current.removeValue(index: idx) |
| 537 |
+ temperature.removeValue(index: idx) |
|
| 538 |
+ energy.removeValue(index: idx) |
|
| 539 |
+ rssi.removeValue(index: idx) |
|
| 540 |
+ realignEnergyBufferStart() |
|
| 91 | 541 |
self.objectWillChange.send() |
| 92 | 542 |
} |
| 93 | 543 |
|
| 94 | 544 |
func trim(before cutoff: Date) {
|
| 545 |
+ flushPendingValues() |
|
| 95 | 546 |
power.trim(before: cutoff) |
| 96 | 547 |
voltage.trim(before: cutoff) |
| 97 | 548 |
current.trim(before: cutoff) |
| 549 |
+ temperature.trim(before: cutoff) |
|
| 550 |
+ energy.trim(before: cutoff) |
|
| 551 |
+ rssi.trim(before: cutoff) |
|
| 552 |
+ realignEnergyBufferStart() |
|
| 98 | 553 |
self.objectWillChange.send() |
| 99 | 554 |
} |
| 100 | 555 |
|
| 556 |
+ func keepOnly(in range: ClosedRange<Date>) {
|
|
| 557 |
+ flushPendingValues() |
|
| 558 |
+ power.filterSamples { range.contains($0) }
|
|
| 559 |
+ voltage.filterSamples { range.contains($0) }
|
|
| 560 |
+ current.filterSamples { range.contains($0) }
|
|
| 561 |
+ temperature.filterSamples { range.contains($0) }
|
|
| 562 |
+ energy.filterSamples { range.contains($0) }
|
|
| 563 |
+ rssi.filterSamples { range.contains($0) }
|
|
| 564 |
+ realignEnergyBufferStart() |
|
| 565 |
+ self.objectWillChange.send() |
|
| 566 |
+ } |
|
| 101 | 567 |
|
| 102 |
- |
|
| 103 |
- func addValues(timestamp: Date, power: Double, voltage: Double, current: Double) {
|
|
| 568 |
+ func removeValues(in range: ClosedRange<Date>) {
|
|
| 569 |
+ flushPendingValues() |
|
| 570 |
+ power.filterSamples { !range.contains($0) }
|
|
| 571 |
+ voltage.filterSamples { !range.contains($0) }
|
|
| 572 |
+ current.filterSamples { !range.contains($0) }
|
|
| 573 |
+ temperature.filterSamples { !range.contains($0) }
|
|
| 574 |
+ energy.filterSamples { !range.contains($0) }
|
|
| 575 |
+ rssi.filterSamples { !range.contains($0) }
|
|
| 576 |
+ realignEnergyBufferStart() |
|
| 577 |
+ self.objectWillChange.send() |
|
| 578 |
+ } |
|
| 579 |
+ |
|
| 580 |
+ func addValues(timestamp: Date, power: Double, voltage: Double, current: Double, temperature: Double?, rssi: Double) {
|
|
| 104 | 581 |
let valuesTimestamp = timestamp.timeIntervalSinceReferenceDate.intValue |
| 105 |
- if lastPointTimestamp == 0 {
|
|
| 106 |
- lastPointTimestamp = valuesTimestamp |
|
| 107 |
- } |
|
| 108 |
- if lastPointTimestamp == valuesTimestamp {
|
|
| 582 |
+ |
|
| 583 |
+ if pendingBucketSecond == valuesTimestamp {
|
|
| 584 |
+ pendingBucketTimestamp = timestamp |
|
| 109 | 585 |
itemsInSum += 1 |
| 110 | 586 |
powerSum += power |
| 111 | 587 |
voltageSum += voltage |
| 112 | 588 |
currentSum += current |
| 589 |
+ if let temperature {
|
|
| 590 |
+ temperatureItemsInSum += 1 |
|
| 591 |
+ temperatureSum += temperature |
|
| 592 |
+ } |
|
| 593 |
+ rssiSum += rssi |
|
| 594 |
+ return |
|
| 113 | 595 |
} |
| 114 |
- else {
|
|
| 115 |
- self.power.addPoint( timestamp: timestamp, value: powerSum / itemsInSum ) |
|
| 116 |
- self.voltage.addPoint( timestamp: timestamp, value: voltageSum / itemsInSum ) |
|
| 117 |
- self.current.addPoint( timestamp: timestamp, value: currentSum / itemsInSum ) |
|
| 118 |
- lastPointTimestamp = valuesTimestamp |
|
| 119 |
- itemsInSum = 1 |
|
| 120 |
- powerSum = power |
|
| 121 |
- voltageSum = voltage |
|
| 122 |
- currentSum = current |
|
| 123 |
- self.objectWillChange.send() |
|
| 596 |
+ |
|
| 597 |
+ flushPendingValues() |
|
| 598 |
+ |
|
| 599 |
+ pendingBucketSecond = valuesTimestamp |
|
| 600 |
+ pendingBucketTimestamp = timestamp |
|
| 601 |
+ itemsInSum = 1 |
|
| 602 |
+ powerSum = power |
|
| 603 |
+ voltageSum = voltage |
|
| 604 |
+ currentSum = current |
|
| 605 |
+ if let temperature {
|
|
| 606 |
+ temperatureItemsInSum = 1 |
|
| 607 |
+ temperatureSum = temperature |
|
| 608 |
+ } else {
|
|
| 609 |
+ temperatureItemsInSum = 0 |
|
| 610 |
+ temperatureSum = 0 |
|
| 611 |
+ } |
|
| 612 |
+ rssiSum = rssi |
|
| 613 |
+ } |
|
| 614 |
+ |
|
| 615 |
+ func markDiscontinuity(at timestamp: Date) {
|
|
| 616 |
+ flushPendingValues() |
|
| 617 |
+ power.addDiscontinuity(timestamp: timestamp) |
|
| 618 |
+ voltage.addDiscontinuity(timestamp: timestamp) |
|
| 619 |
+ current.addDiscontinuity(timestamp: timestamp) |
|
| 620 |
+ temperature.addDiscontinuity(timestamp: timestamp) |
|
| 621 |
+ energy.addDiscontinuity(timestamp: timestamp) |
|
| 622 |
+ rssi.addDiscontinuity(timestamp: timestamp) |
|
| 623 |
+ self.objectWillChange.send() |
|
| 624 |
+ } |
|
| 625 |
+ |
|
| 626 |
+ func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
|
|
| 627 |
+ if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
|
|
| 628 |
+ let delta = value - lastEnergyCounterValue |
|
| 629 |
+ if delta > energyResetEpsilon {
|
|
| 630 |
+ accumulatedEnergyValue += delta |
|
| 631 |
+ } else if delta < -energyResetEpsilon {
|
|
| 632 |
+ energy.addDiscontinuity(timestamp: timestamp) |
|
| 633 |
+ accumulatedEnergyValue = 0 |
|
| 634 |
+ } |
|
| 635 |
+ } |
|
| 636 |
+ |
|
| 637 |
+ energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue) |
|
| 638 |
+ lastEnergyCounterValue = value |
|
| 639 |
+ lastEnergyGroupID = groupID |
|
| 640 |
+ self.objectWillChange.send() |
|
| 641 |
+ } |
|
| 642 |
+ |
|
| 643 |
+ func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
|
|
| 644 |
+ if shouldFlushPendingValues {
|
|
| 645 |
+ flushPendingValues() |
|
| 646 |
+ } |
|
| 647 |
+ return power.samplePoints.count |
|
| 648 |
+ } |
|
| 649 |
+ |
|
| 650 |
+ func recentPowerPoints(limit: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> [Measurement.Point] {
|
|
| 651 |
+ if shouldFlushPendingValues {
|
|
| 652 |
+ flushPendingValues() |
|
| 653 |
+ } |
|
| 654 |
+ |
|
| 655 |
+ let samplePoints = power.samplePoints |
|
| 656 |
+ guard limit > 0, samplePoints.count > limit else {
|
|
| 657 |
+ return samplePoints |
|
| 658 |
+ } |
|
| 659 |
+ |
|
| 660 |
+ return Array(samplePoints.suffix(limit)) |
|
| 661 |
+ } |
|
| 662 |
+ |
|
| 663 |
+ func averagePower(forRecentSampleCount sampleCount: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> Double? {
|
|
| 664 |
+ let points = recentPowerPoints(limit: sampleCount, flushPendingValues: shouldFlushPendingValues) |
|
| 665 |
+ guard !points.isEmpty else { return nil }
|
|
| 666 |
+ |
|
| 667 |
+ let sum = points.reduce(0) { partialResult, point in
|
|
| 668 |
+ partialResult + point.value |
|
| 669 |
+ } |
|
| 670 |
+ |
|
| 671 |
+ return sum / Double(points.count) |
|
| 672 |
+ } |
|
| 673 |
+ |
|
| 674 |
+ func energyProjectionSnapshot(flushPendingValues shouldFlushPendingValues: Bool = true) -> EnergyProjectionSnapshot? {
|
|
| 675 |
+ if shouldFlushPendingValues {
|
|
| 676 |
+ flushPendingValues() |
|
| 677 |
+ } |
|
| 678 |
+ |
|
| 679 |
+ let samplePoints = energy.samplePoints |
|
| 680 |
+ guard !samplePoints.isEmpty else { return nil }
|
|
| 681 |
+ |
|
| 682 |
+ let accumulatedEnergy = samplePoints.last?.value ?? 0 |
|
| 683 |
+ var observedDuration: TimeInterval = 0 |
|
| 684 |
+ var previousSample: Measurement.Point? |
|
| 685 |
+ |
|
| 686 |
+ for point in energy.points {
|
|
| 687 |
+ if point.isDiscontinuity {
|
|
| 688 |
+ previousSample = nil |
|
| 689 |
+ continue |
|
| 690 |
+ } |
|
| 691 |
+ |
|
| 692 |
+ if let previousSample {
|
|
| 693 |
+ observedDuration += max(0, point.timestamp.timeIntervalSince(previousSample.timestamp)) |
|
| 694 |
+ } |
|
| 695 |
+ |
|
| 696 |
+ previousSample = point |
|
| 697 |
+ } |
|
| 698 |
+ |
|
| 699 |
+ let averagePower: Double? |
|
| 700 |
+ if observedDuration > 0, accumulatedEnergy.isFinite {
|
|
| 701 |
+ averagePower = accumulatedEnergy / (observedDuration / 3600) |
|
| 702 |
+ } else {
|
|
| 703 |
+ averagePower = nil |
|
| 704 |
+ } |
|
| 705 |
+ |
|
| 706 |
+ return EnergyProjectionSnapshot( |
|
| 707 |
+ accumulatedEnergy: accumulatedEnergy, |
|
| 708 |
+ observedDuration: observedDuration, |
|
| 709 |
+ sampleCount: samplePoints.count, |
|
| 710 |
+ averagePower: averagePower |
|
| 711 |
+ ) |
|
| 712 |
+ } |
|
| 713 |
+ |
|
| 714 |
+ func energyProjectionVariants(flushPendingValues shouldFlushPendingValues: Bool = true) -> [EnergyProjectionVariant] {
|
|
| 715 |
+ if shouldFlushPendingValues {
|
|
| 716 |
+ flushPendingValues() |
|
| 717 |
+ } |
|
| 718 |
+ |
|
| 719 |
+ let contiguousSamples = latestContiguousEnergySamples() |
|
| 720 |
+ guard contiguousSamples.count >= 2 else { return [] }
|
|
| 721 |
+ |
|
| 722 |
+ let latestTimestamp = contiguousSamples.last?.timestamp ?? Date() |
|
| 723 |
+ let windowCandidates: [(duration: TimeInterval, title: String, id: String)] = [ |
|
| 724 |
+ (60, "Last 1 Minute", "last-1m"), |
|
| 725 |
+ (5 * 60, "Last 5 Minutes", "last-5m"), |
|
| 726 |
+ (15 * 60, "Last 15 Minutes", "last-15m"), |
|
| 727 |
+ (60 * 60, "Last 1 Hour", "last-1h"), |
|
| 728 |
+ (6 * 60 * 60, "Last 6 Hours", "last-6h") |
|
| 729 |
+ ] |
|
| 730 |
+ |
|
| 731 |
+ var variants: [EnergyProjectionVariant] = [] |
|
| 732 |
+ |
|
| 733 |
+ for candidate in windowCandidates {
|
|
| 734 |
+ let cutoff = latestTimestamp.addingTimeInterval(-candidate.duration) |
|
| 735 |
+ guard |
|
| 736 |
+ let startIndex = contiguousSamples.lastIndex(where: { $0.timestamp <= cutoff }),
|
|
| 737 |
+ startIndex < contiguousSamples.count - 1 |
|
| 738 |
+ else {
|
|
| 739 |
+ continue |
|
| 740 |
+ } |
|
| 741 |
+ |
|
| 742 |
+ let relevantSamples = Array(contiguousSamples[startIndex...]) |
|
| 743 |
+ if let variant = projectionVariant( |
|
| 744 |
+ id: candidate.id, |
|
| 745 |
+ title: candidate.title, |
|
| 746 |
+ samples: relevantSamples |
|
| 747 |
+ ) {
|
|
| 748 |
+ variants.append(variant) |
|
| 749 |
+ } |
|
| 750 |
+ } |
|
| 751 |
+ |
|
| 752 |
+ if let fullBufferVariant = projectionVariant( |
|
| 753 |
+ id: "full-buffer", |
|
| 754 |
+ title: "Whole Buffer", |
|
| 755 |
+ samples: contiguousSamples |
|
| 756 |
+ ) {
|
|
| 757 |
+ variants.append(fullBufferVariant) |
|
| 758 |
+ } |
|
| 759 |
+ |
|
| 760 |
+ return variants |
|
| 761 |
+ } |
|
| 762 |
+ |
|
| 763 |
+ private func latestContiguousEnergySamples() -> [Measurement.Point] {
|
|
| 764 |
+ let latestSegment = energy.points.split(whereSeparator: \.isDiscontinuity).last ?? [] |
|
| 765 |
+ return latestSegment.filter(\.isSample) |
|
| 766 |
+ } |
|
| 767 |
+ |
|
| 768 |
+ private func projectionVariant( |
|
| 769 |
+ id: String, |
|
| 770 |
+ title: String, |
|
| 771 |
+ samples: [Measurement.Point] |
|
| 772 |
+ ) -> EnergyProjectionVariant? {
|
|
| 773 |
+ guard let firstSample = samples.first, let lastSample = samples.last else { return nil }
|
|
| 774 |
+ |
|
| 775 |
+ let observedDuration = lastSample.timestamp.timeIntervalSince(firstSample.timestamp) |
|
| 776 |
+ guard observedDuration > 0 else { return nil }
|
|
| 777 |
+ |
|
| 778 |
+ let accumulatedEnergy = lastSample.value - firstSample.value |
|
| 779 |
+ guard accumulatedEnergy >= 0, accumulatedEnergy.isFinite else { return nil }
|
|
| 780 |
+ |
|
| 781 |
+ let averagePower = accumulatedEnergy / (observedDuration / 3600) |
|
| 782 |
+ guard averagePower.isFinite else { return nil }
|
|
| 783 |
+ |
|
| 784 |
+ return EnergyProjectionVariant( |
|
| 785 |
+ id: id, |
|
| 786 |
+ title: title, |
|
| 787 |
+ observedDuration: observedDuration, |
|
| 788 |
+ accumulatedEnergy: accumulatedEnergy, |
|
| 789 |
+ sampleCount: samples.count, |
|
| 790 |
+ averagePower: averagePower |
|
| 791 |
+ ) |
|
| 792 |
+ } |
|
| 793 |
+} |
|
| 794 |
+ |
|
| 795 |
+extension Measurements.Measurement.Point: TimeSeriesChartPointRepresentable {
|
|
| 796 |
+ var chartPointID: Int {
|
|
| 797 |
+ id |
|
| 798 |
+ } |
|
| 799 |
+ |
|
| 800 |
+ var chartTimestamp: Date {
|
|
| 801 |
+ timestamp |
|
| 802 |
+ } |
|
| 803 |
+ |
|
| 804 |
+ var chartValue: Double {
|
|
| 805 |
+ value |
|
| 806 |
+ } |
|
| 807 |
+ |
|
| 808 |
+ var chartPointKind: TimeSeriesChartPointKind {
|
|
| 809 |
+ switch kind {
|
|
| 810 |
+ case .sample: |
|
| 811 |
+ return .sample |
|
| 812 |
+ case .discontinuity: |
|
| 813 |
+ return .discontinuity |
|
| 124 | 814 |
} |
| 125 | 815 |
} |
| 126 | 816 |
} |
@@ -22,7 +22,7 @@ import SwiftUI |
||
| 22 | 22 |
[UM Series](https://sigrok.org/wiki/RDTech_UM_series) |
| 23 | 23 |
[TC66C](https://sigrok.org/wiki/RDTech_TC66C) |
| 24 | 24 |
*/ |
| 25 |
-enum Model: CaseIterable {
|
|
| 25 |
+enum Model: CaseIterable, Hashable {
|
|
| 26 | 26 |
case UM25C |
| 27 | 27 |
case UM34C |
| 28 | 28 |
case TC66C |
@@ -71,6 +71,12 @@ enum ChargeRecordState {
|
||
| 71 | 71 |
} |
| 72 | 72 |
|
| 73 | 73 |
class Meter : NSObject, ObservableObject, Identifiable {
|
| 74 |
+ private struct ChargeRecordRestoreSignature: Equatable {
|
|
| 75 |
+ let sessionID: UUID |
|
| 76 |
+ let sampleCount: Int |
|
| 77 |
+ let lastSampleTimestamp: Date? |
|
| 78 |
+ } |
|
| 79 |
+ |
|
| 74 | 80 |
|
| 75 | 81 |
private static func shouldLogOperationalStateTransition(from oldValue: OperationalState, to newValue: OperationalState) -> Bool {
|
| 76 | 82 |
switch (oldValue, newValue) {
|
@@ -107,6 +113,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 107 | 113 |
break |
| 108 | 114 |
case .peripheralNotConnected: |
| 109 | 115 |
cancelPendingDataDumpRequest(reason: "peripheral disconnected") |
| 116 |
+ handleMeasurementDiscontinuity(at: Date()) |
|
| 110 | 117 |
if !commandQueue.isEmpty {
|
| 111 | 118 |
track("\(name) - Clearing \(commandQueue.count) queued commands after disconnect")
|
| 112 | 119 |
commandQueue.removeAll() |
@@ -152,9 +159,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 152 | 159 |
|
| 153 | 160 |
private var wdTimer: Timer? |
| 154 | 161 |
|
| 155 |
- var lastSeen = Date() {
|
|
| 162 |
+ @Published var lastSeen: Date? {
|
|
| 156 | 163 |
didSet {
|
| 157 | 164 |
wdTimer?.invalidate() |
| 165 |
+ guard lastSeen != nil else { return }
|
|
| 166 |
+ appData.noteMeterSeen(at: lastSeen!, macAddress: btSerial.macAddress.description) |
|
| 158 | 167 |
if operationalState == .peripheralNotConnected {
|
| 159 | 168 |
wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
|
| 160 | 169 |
track("\(self.name) - Lost advertisments...")
|
@@ -170,11 +179,19 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 170 | 179 |
var model: Model |
| 171 | 180 |
var modelString: String |
| 172 | 181 |
|
| 173 |
- var name: String {
|
|
| 182 |
+ private var isSyncingNameFromStore = false |
|
| 183 |
+ |
|
| 184 |
+ @Published var name: String {
|
|
| 174 | 185 |
didSet {
|
| 175 |
- appData.meterNames[btSerial.macAddress.description] = name |
|
| 186 |
+ guard !isSyncingNameFromStore else { return }
|
|
| 187 |
+ guard oldValue != name else { return }
|
|
| 188 |
+ appData.setMeterName(name, for: btSerial.macAddress.description) |
|
| 176 | 189 |
} |
| 177 | 190 |
} |
| 191 |
+ |
|
| 192 |
+ var preferredTabIdentifier: String = "home" |
|
| 193 |
+ |
|
| 194 |
+ @Published private(set) var lastConnectedAt: Date? |
|
| 178 | 195 |
|
| 179 | 196 |
var color : Color {
|
| 180 | 197 |
get {
|
@@ -417,8 +434,10 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 417 | 434 |
|
| 418 | 435 |
var btSerial: BluetoothSerial |
| 419 | 436 |
|
| 420 |
- var measurements = Measurements() |
|
| 437 |
+ let measurements = Measurements() |
|
| 438 |
+ let chargeRecordMeasurements = Measurements() |
|
| 421 | 439 |
|
| 440 |
+ private let minimumLivePollingInterval: TimeInterval = 0.4 |
|
| 422 | 441 |
private var commandQueue: [Data] = [] |
| 423 | 442 |
private var dataDumpRequestTimestamp = Date() |
| 424 | 443 |
private var pendingDataDumpWorkItem: DispatchWorkItem? |
@@ -441,9 +460,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 441 | 460 |
didSet {
|
| 442 | 461 |
guard supportsManualTemperatureUnitSelection else { return }
|
| 443 | 462 |
guard oldValue != tc66TemperatureUnitPreference else { return }
|
| 444 |
- var settings = appData.tc66TemperatureUnits |
|
| 445 |
- settings[btSerial.macAddress.description] = tc66TemperatureUnitPreference.rawValue |
|
| 446 |
- appData.tc66TemperatureUnits = settings |
|
| 463 |
+ appData.setTemperatureUnitPreference(tc66TemperatureUnitPreference, for: btSerial.macAddress.description) |
|
| 447 | 464 |
} |
| 448 | 465 |
} |
| 449 | 466 |
|
@@ -519,6 +536,9 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 519 | 536 |
private var pendingVolatileMemoryResetIgnoreCount = 0 |
| 520 | 537 |
private var pendingVolatileMemoryResetDeadline: Date? |
| 521 | 538 |
private var liveDataChanged = false |
| 539 |
+ private var restoredChargeSessionID: UUID? |
|
| 540 |
+ private var restoredChargeRecordSignature: ChargeRecordRestoreSignature? |
|
| 541 |
+ private var lastRecorderObservationAt: Date? |
|
| 522 | 542 |
|
| 523 | 543 |
@discardableResult |
| 524 | 544 |
private func setIfChanged<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<Meter, T>, to value: T) -> Bool {
|
@@ -544,7 +564,9 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 544 | 564 |
modelString = serialPort.peripheral.name! |
| 545 | 565 |
self.model = model |
| 546 | 566 |
btSerial = serialPort |
| 547 |
- name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description |
|
| 567 |
+ name = appData.meterName(for: serialPort.macAddress.description) ?? serialPort.macAddress.description |
|
| 568 |
+ lastSeen = appData.lastSeen(for: serialPort.macAddress.description) |
|
| 569 |
+ lastConnectedAt = appData.lastConnected(for: serialPort.macAddress.description) |
|
| 548 | 570 |
super.init() |
| 549 | 571 |
btSerial.delegate = self |
| 550 | 572 |
reloadTemperatureUnitPreference() |
@@ -556,13 +578,89 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 556 | 578 |
|
| 557 | 579 |
func reloadTemperatureUnitPreference() {
|
| 558 | 580 |
guard supportsManualTemperatureUnitSelection else { return }
|
| 559 |
- let rawValue = appData.tc66TemperatureUnits[btSerial.macAddress.description] ?? TemperatureUnitPreference.celsius.rawValue |
|
| 560 |
- let persistedPreference = TemperatureUnitPreference(rawValue: rawValue) ?? .celsius |
|
| 581 |
+ let persistedPreference = appData.temperatureUnitPreference(for: btSerial.macAddress.description) |
|
| 561 | 582 |
if tc66TemperatureUnitPreference != persistedPreference {
|
| 562 | 583 |
tc66TemperatureUnitPreference = persistedPreference |
| 563 | 584 |
} |
| 564 | 585 |
} |
| 565 | 586 |
|
| 587 |
+ func updateNameFromStore(_ newName: String) {
|
|
| 588 |
+ guard newName != name else { return }
|
|
| 589 |
+ isSyncingNameFromStore = true |
|
| 590 |
+ name = newName |
|
| 591 |
+ isSyncingNameFromStore = false |
|
| 592 |
+ } |
|
| 593 |
+ |
|
| 594 |
+ private func noteConnectionEstablished(at date: Date) {
|
|
| 595 |
+ lastConnectedAt = date |
|
| 596 |
+ appData.noteMeterConnected(at: date, macAddress: btSerial.macAddress.description) |
|
| 597 |
+ } |
|
| 598 |
+ |
|
| 599 |
+ private func handleMeasurementDiscontinuity(at timestamp: Date) {
|
|
| 600 |
+ measurements.markDiscontinuity(at: timestamp) |
|
| 601 |
+ chargeRecordMeasurements.markDiscontinuity(at: timestamp) |
|
| 602 |
+ |
|
| 603 |
+ guard chargeRecordState == .active else { return }
|
|
| 604 |
+ chargeRecordLastTimestamp = nil |
|
| 605 |
+ chargeRecordLastCurrent = 0 |
|
| 606 |
+ chargeRecordLastPower = 0 |
|
| 607 |
+ } |
|
| 608 |
+ |
|
| 609 |
+ private func currentEnergySample() -> (groupID: UInt8, value: Double)? {
|
|
| 610 |
+ guard showsDataGroupEnergy else { return nil }
|
|
| 611 |
+ |
|
| 612 |
+ if model == .TC66C && !hasObservedActiveDataGroup {
|
|
| 613 |
+ return nil |
|
| 614 |
+ } |
|
| 615 |
+ |
|
| 616 |
+ let groupID = selectedDataGroup |
|
| 617 |
+ guard let record = dataGroupRecords[Int(groupID)] else { return nil }
|
|
| 618 |
+ return (groupID, record.wh) |
|
| 619 |
+ } |
|
| 620 |
+ |
|
| 621 |
+ private func currentChargeSample() -> (groupID: UInt8, value: Double)? {
|
|
| 622 |
+ guard showsDataGroupEnergy else { return nil }
|
|
| 623 |
+ |
|
| 624 |
+ if model == .TC66C && !hasObservedActiveDataGroup {
|
|
| 625 |
+ return nil |
|
| 626 |
+ } |
|
| 627 |
+ |
|
| 628 |
+ let groupID = selectedDataGroup |
|
| 629 |
+ guard let record = dataGroupRecords[Int(groupID)] else { return nil }
|
|
| 630 |
+ return (groupID, record.ah) |
|
| 631 |
+ } |
|
| 632 |
+ |
|
| 633 |
+ func chargingMonitorSnapshot(at observedAt: Date) -> ChargingMonitorSnapshot? {
|
|
| 634 |
+ let usesNativeRecordingCounters = supportsRecordingView |
|
| 635 |
+ let nativeChargeCounter = usesNativeRecordingCounters ? recordedAH : nil |
|
| 636 |
+ let nativeEnergyCounter = usesNativeRecordingCounters ? recordedWH : nil |
|
| 637 |
+ |
|
| 638 |
+ return ChargingMonitorSnapshot( |
|
| 639 |
+ meterMACAddress: btSerial.macAddress.description, |
|
| 640 |
+ meterName: name, |
|
| 641 |
+ meterModel: deviceModelSummary, |
|
| 642 |
+ observedAt: observedAt, |
|
| 643 |
+ voltageVolts: voltage, |
|
| 644 |
+ currentAmps: current, |
|
| 645 |
+ powerWatts: power, |
|
| 646 |
+ selectedDataGroup: usesNativeRecordingCounters ? nil : (currentEnergySample()?.groupID ?? currentChargeSample()?.groupID), |
|
| 647 |
+ meterChargeCounterAh: nativeChargeCounter ?? currentChargeSample()?.value, |
|
| 648 |
+ meterEnergyCounterWh: nativeEnergyCounter ?? currentEnergySample()?.value, |
|
| 649 |
+ meterRecordingDurationSeconds: usesNativeRecordingCounters ? TimeInterval(recordingDuration) : nil, |
|
| 650 |
+ fallbackStopThresholdAmps: supportsRecordingThreshold ? recordingTreshold : chargeRecordStopThreshold |
|
| 651 |
+ ) |
|
| 652 |
+ } |
|
| 653 |
+ |
|
| 654 |
+ var recordingBootedAt: Date? {
|
|
| 655 |
+ guard supportsRecordingView else { return nil }
|
|
| 656 |
+ guard let lastRecorderObservationAt else { return nil }
|
|
| 657 |
+ return lastRecorderObservationAt.addingTimeInterval(-TimeInterval(recordingDuration)) |
|
| 658 |
+ } |
|
| 659 |
+ |
|
| 660 |
+ var chargingMonitorSnapshot: ChargingMonitorSnapshot? {
|
|
| 661 |
+ chargingMonitorSnapshot(at: Date()) |
|
| 662 |
+ } |
|
| 663 |
+ |
|
| 566 | 664 |
private func cancelPendingDataDumpRequest(reason: String) {
|
| 567 | 665 |
guard let pendingDataDumpWorkItem else { return }
|
| 568 | 666 |
track("\(name) - Cancel scheduled data request (\(reason))")
|
@@ -583,6 +681,12 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 583 | 681 |
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) |
| 584 | 682 |
} |
| 585 | 683 |
|
| 684 |
+ private func scheduleNextLiveDataDumpRequest() {
|
|
| 685 |
+ let elapsedSinceLastRequest = Date().timeIntervalSince(dataDumpRequestTimestamp) |
|
| 686 |
+ let delay = max(minimumLivePollingInterval - elapsedSinceLastRequest, 0) |
|
| 687 |
+ scheduleDataDumpRequest(after: delay, reason: "continuous live polling") |
|
| 688 |
+ } |
|
| 689 |
+ |
|
| 586 | 690 |
private func noteInitiatedVolatileMemoryResetIfNeeded(for groupID: UInt8) {
|
| 587 | 691 |
guard groupID == 0 else { return }
|
| 588 | 692 |
pendingVolatileMemoryResetIgnoreCount += 1 |
@@ -712,7 +816,15 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 712 | 816 |
} |
| 713 | 817 |
} |
| 714 | 818 |
updateChargeRecord(at: dataDumpRequestTimestamp) |
| 715 |
- measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current) |
|
| 819 |
+ captureLiveMeasurements(at: dataDumpRequestTimestamp, in: measurements) |
|
| 820 |
+ if chargeRecordState != .waitingForStart {
|
|
| 821 |
+ captureLiveMeasurements( |
|
| 822 |
+ at: dataDumpRequestTimestamp, |
|
| 823 |
+ in: chargeRecordMeasurements, |
|
| 824 |
+ includesTemperature: false |
|
| 825 |
+ ) |
|
| 826 |
+ } |
|
| 827 |
+ appData.observeChargeSnapshot(from: self, observedAt: dataDumpRequestTimestamp) |
|
| 716 | 828 |
// DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
|
| 717 | 829 |
// //track("\(name) - Scheduled new request.")
|
| 718 | 830 |
// } |
@@ -721,11 +833,12 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 721 | 833 |
} else if liveDataChanged {
|
| 722 | 834 |
objectWillChange.send() |
| 723 | 835 |
} |
| 724 |
- dataDumpRequest() |
|
| 836 |
+ scheduleNextLiveDataDumpRequest() |
|
| 725 | 837 |
} |
| 726 | 838 |
|
| 727 | 839 |
private func apply(umSnapshot snapshot: UMSnapshot) {
|
| 728 | 840 |
let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp) |
| 841 |
+ lastRecorderObservationAt = dataDumpRequestTimestamp |
|
| 729 | 842 |
setIfChanged(\.modelNumber, to: snapshot.modelNumber) |
| 730 | 843 |
setIfChanged(\.voltage, to: snapshot.voltage) |
| 731 | 844 |
setIfChanged(\.current, to: snapshot.current) |
@@ -857,13 +970,162 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 857 | 970 |
chargeRecordLastTimestamp = nil |
| 858 | 971 |
chargeRecordLastCurrent = 0 |
| 859 | 972 |
chargeRecordLastPower = 0 |
| 973 |
+ restoredChargeSessionID = nil |
|
| 974 |
+ restoredChargeRecordSignature = nil |
|
| 975 |
+ chargeRecordMeasurements.resetSeries() |
|
| 860 | 976 |
objectWillChange.send() |
| 861 | 977 |
} |
| 862 | 978 |
|
| 863 | 979 |
func resetChargeRecordGraph() {
|
| 864 |
- let cutoff = Date() |
|
| 865 | 980 |
resetChargeRecord() |
| 866 |
- measurements.trim(before: cutoff) |
|
| 981 |
+ } |
|
| 982 |
+ |
|
| 983 |
+ func restoreChargeRecordIfNeeded( |
|
| 984 |
+ from activeSession: ChargeSessionSummary, |
|
| 985 |
+ replacingLiveBufferIfNeeded: Bool = false |
|
| 986 |
+ ) {
|
|
| 987 |
+ var didChange = false |
|
| 988 |
+ let restoreSignature = ChargeRecordRestoreSignature( |
|
| 989 |
+ sessionID: activeSession.id, |
|
| 990 |
+ sampleCount: activeSession.aggregatedSamples.count, |
|
| 991 |
+ lastSampleTimestamp: activeSession.aggregatedSamples.last?.timestamp |
|
| 992 |
+ ) |
|
| 993 |
+ |
|
| 994 |
+ if restoreSignature != restoredChargeRecordSignature {
|
|
| 995 |
+ restoreTrace("meter=\(name) charge-record-restore-start session=\(activeSession.id.uuidString) status=\(activeSession.status.rawValue) samples=\(restoreSignature.sampleCount) lastTs=\(restoreSignature.lastSampleTimestamp?.description ?? "nil") replaceLive=\(replacingLiveBufferIfNeeded) state=\(chargeRecordState) existingPower=\(chargeRecordMeasurements.power.samplePoints.count)")
|
|
| 996 |
+ let didRestorePersistedSamples = chargeRecordMeasurements.restorePersistedChargeSessionSamplesIfNeeded( |
|
| 997 |
+ from: activeSession, |
|
| 998 |
+ replacingLiveBufferIfNeeded: replacingLiveBufferIfNeeded |
|
| 999 |
+ ) |
|
| 1000 |
+ restoreTrace("meter=\(name) charge-record-restore-result session=\(activeSession.id.uuidString) didRestore=\(didRestorePersistedSamples) priorSignatureSamples=\(restoredChargeRecordSignature?.sampleCount.description ?? "nil")")
|
|
| 1001 |
+ if didRestorePersistedSamples || activeSession.aggregatedSamples.isEmpty == false {
|
|
| 1002 |
+ restoredChargeRecordSignature = restoreSignature |
|
| 1003 |
+ } |
|
| 1004 |
+ if didRestorePersistedSamples {
|
|
| 1005 |
+ didChange = true |
|
| 1006 |
+ } |
|
| 1007 |
+ } |
|
| 1008 |
+ |
|
| 1009 |
+ if chargeRecordState != .active {
|
|
| 1010 |
+ chargeRecordState = .active |
|
| 1011 |
+ didChange = true |
|
| 1012 |
+ } |
|
| 1013 |
+ |
|
| 1014 |
+ let resolvedChargeAH = max(chargeRecordAH, activeSession.measuredChargeAh) |
|
| 1015 |
+ if resolvedChargeAH != chargeRecordAH {
|
|
| 1016 |
+ chargeRecordAH = resolvedChargeAH |
|
| 1017 |
+ didChange = true |
|
| 1018 |
+ } |
|
| 1019 |
+ |
|
| 1020 |
+ let resolvedChargeWH = max(chargeRecordWH, activeSession.measuredEnergyWh) |
|
| 1021 |
+ if resolvedChargeWH != chargeRecordWH {
|
|
| 1022 |
+ chargeRecordWH = resolvedChargeWH |
|
| 1023 |
+ didChange = true |
|
| 1024 |
+ } |
|
| 1025 |
+ |
|
| 1026 |
+ let resolvedDuration = max(chargeRecordDuration, max(activeSession.effectiveDuration, 0)) |
|
| 1027 |
+ if resolvedDuration != chargeRecordDuration {
|
|
| 1028 |
+ chargeRecordDuration = resolvedDuration |
|
| 1029 |
+ didChange = true |
|
| 1030 |
+ } |
|
| 1031 |
+ |
|
| 1032 |
+ if chargeRecordStopThreshold != activeSession.stopThresholdAmps {
|
|
| 1033 |
+ chargeRecordStopThreshold = activeSession.stopThresholdAmps |
|
| 1034 |
+ didChange = true |
|
| 1035 |
+ } |
|
| 1036 |
+ |
|
| 1037 |
+ if let chargeRecordStartTimestamp {
|
|
| 1038 |
+ let restoredStart = min(chargeRecordStartTimestamp, activeSession.startedAt) |
|
| 1039 |
+ if restoredStart != chargeRecordStartTimestamp {
|
|
| 1040 |
+ self.chargeRecordStartTimestamp = restoredStart |
|
| 1041 |
+ didChange = true |
|
| 1042 |
+ } |
|
| 1043 |
+ } else {
|
|
| 1044 |
+ chargeRecordStartTimestamp = activeSession.startedAt |
|
| 1045 |
+ didChange = true |
|
| 1046 |
+ } |
|
| 1047 |
+ |
|
| 1048 |
+ if let chargeRecordEndTimestamp {
|
|
| 1049 |
+ let restoredEnd = max(chargeRecordEndTimestamp, activeSession.lastObservedAt) |
|
| 1050 |
+ if restoredEnd != chargeRecordEndTimestamp {
|
|
| 1051 |
+ self.chargeRecordEndTimestamp = restoredEnd |
|
| 1052 |
+ didChange = true |
|
| 1053 |
+ } |
|
| 1054 |
+ } else {
|
|
| 1055 |
+ chargeRecordEndTimestamp = activeSession.lastObservedAt |
|
| 1056 |
+ didChange = true |
|
| 1057 |
+ } |
|
| 1058 |
+ |
|
| 1059 |
+ if let selectedDataGroup = activeSession.selectedDataGroup {
|
|
| 1060 |
+ if self.selectedDataGroup != selectedDataGroup {
|
|
| 1061 |
+ self.selectedDataGroup = selectedDataGroup |
|
| 1062 |
+ didChange = true |
|
| 1063 |
+ } |
|
| 1064 |
+ } |
|
| 1065 |
+ |
|
| 1066 |
+ if didChange {
|
|
| 1067 |
+ objectWillChange.send() |
|
| 1068 |
+ } |
|
| 1069 |
+ } |
|
| 1070 |
+ |
|
| 1071 |
+ private func captureLiveMeasurements( |
|
| 1072 |
+ at timestamp: Date, |
|
| 1073 |
+ in destination: Measurements, |
|
| 1074 |
+ includesTemperature: Bool = true |
|
| 1075 |
+ ) {
|
|
| 1076 |
+ if supportsRecordingView {
|
|
| 1077 |
+ destination.captureEnergyValue( |
|
| 1078 |
+ timestamp: timestamp, |
|
| 1079 |
+ value: recordedWH, |
|
| 1080 |
+ groupID: .max |
|
| 1081 |
+ ) |
|
| 1082 |
+ } else if let energySample = currentEnergySample() {
|
|
| 1083 |
+ destination.captureEnergyValue( |
|
| 1084 |
+ timestamp: timestamp, |
|
| 1085 |
+ value: energySample.value, |
|
| 1086 |
+ groupID: energySample.groupID |
|
| 1087 |
+ ) |
|
| 1088 |
+ } |
|
| 1089 |
+ |
|
| 1090 |
+ destination.addValues( |
|
| 1091 |
+ timestamp: timestamp, |
|
| 1092 |
+ power: power, |
|
| 1093 |
+ voltage: voltage, |
|
| 1094 |
+ current: current, |
|
| 1095 |
+ temperature: includesTemperature ? displayedTemperatureValue : nil, |
|
| 1096 |
+ rssi: Double(btSerial.averageRSSI) |
|
| 1097 |
+ ) |
|
| 1098 |
+ } |
|
| 1099 |
+ |
|
| 1100 |
+ func restoreChargeMonitoringIfNeeded(from activeSession: ChargeSessionSummary) {
|
|
| 1101 |
+ let shouldReplaceLiveBuffer = restoredChargeSessionID != activeSession.id |
|
| 1102 |
+ if shouldReplaceLiveBuffer {
|
|
| 1103 |
+ restoreTrace("meter=\(name) charge-monitoring-restore-request session=\(activeSession.id.uuidString) status=\(activeSession.status.rawValue) replaceLive=\(shouldReplaceLiveBuffer) restoredSession=\(restoredChargeSessionID?.uuidString ?? "nil")")
|
|
| 1104 |
+ } |
|
| 1105 |
+ restoreChargeRecordIfNeeded( |
|
| 1106 |
+ from: activeSession, |
|
| 1107 |
+ replacingLiveBufferIfNeeded: shouldReplaceLiveBuffer |
|
| 1108 |
+ ) |
|
| 1109 |
+ |
|
| 1110 |
+ guard restoredChargeSessionID != activeSession.id else {
|
|
| 1111 |
+ return |
|
| 1112 |
+ } |
|
| 1113 |
+ |
|
| 1114 |
+ restoredChargeSessionID = activeSession.id |
|
| 1115 |
+ |
|
| 1116 |
+ guard activeSession.status == .active else {
|
|
| 1117 |
+ restoreTrace("meter=\(name) charge-monitoring-restore-no-reconnect session=\(activeSession.id.uuidString) status=\(activeSession.status.rawValue)")
|
|
| 1118 |
+ return |
|
| 1119 |
+ } |
|
| 1120 |
+ |
|
| 1121 |
+ enableAutoConnect = true |
|
| 1122 |
+ |
|
| 1123 |
+ guard operationalState < .peripheralConnectionPending else {
|
|
| 1124 |
+ return |
|
| 1125 |
+ } |
|
| 1126 |
+ |
|
| 1127 |
+ track("\(name) - Restoring active charge session and reconnecting to meter")
|
|
| 1128 |
+ btSerial.connect() |
|
| 867 | 1129 |
} |
| 868 | 1130 |
|
| 869 | 1131 |
func nextScreen() {
|
@@ -904,6 +1166,21 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 904 | 1166 |
noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup) |
| 905 | 1167 |
commandQueue.append(UMProtocol.clearCurrentGroup) |
| 906 | 1168 |
} |
| 1169 |
+ |
|
| 1170 |
+ func resetMeterCountersForNewSession() {
|
|
| 1171 |
+ guard supportsDataGroupCommands else { return }
|
|
| 1172 |
+ |
|
| 1173 |
+ clear() |
|
| 1174 |
+ |
|
| 1175 |
+ if let record = dataGroupRecords[Int(selectedDataGroup)] {
|
|
| 1176 |
+ record.ah = 0 |
|
| 1177 |
+ record.wh = 0 |
|
| 1178 |
+ } |
|
| 1179 |
+ recordedAH = 0 |
|
| 1180 |
+ recordedWH = 0 |
|
| 1181 |
+ recording = false |
|
| 1182 |
+ objectWillChange.send() |
|
| 1183 |
+ } |
|
| 907 | 1184 |
|
| 908 | 1185 |
func clear(group id: UInt8) {
|
| 909 | 1186 |
guard supportsDataGroupCommands else { return }
|
@@ -966,6 +1243,7 @@ extension Meter : SerialPortDelegate {
|
||
| 966 | 1243 |
case .peripheralConnectionPending: |
| 967 | 1244 |
self.operationalState = .peripheralConnectionPending |
| 968 | 1245 |
case .peripheralConnected: |
| 1246 |
+ self.noteConnectionEstablished(at: Date()) |
|
| 969 | 1247 |
self.operationalState = .peripheralConnected |
| 970 | 1248 |
case .peripheralReady: |
| 971 | 1249 |
self.operationalState = .peripheralReady |
@@ -0,0 +1,497 @@ |
||
| 1 |
+// MeterNameStore.swift |
|
| 2 |
+// USB Meter |
|
| 3 |
+// |
|
| 4 |
+// Created by Codex on 2026. |
|
| 5 |
+// |
|
| 6 |
+ |
|
| 7 |
+import Foundation |
|
| 8 |
+ |
|
| 9 |
+final class MeterNameStore {
|
|
| 10 |
+ struct Record: Identifiable {
|
|
| 11 |
+ let macAddress: String |
|
| 12 |
+ let customName: String? |
|
| 13 |
+ let temperatureUnit: String? |
|
| 14 |
+ let modelName: String? |
|
| 15 |
+ let advertisedName: String? |
|
| 16 |
+ let lastSeen: Date? |
|
| 17 |
+ let lastConnected: Date? |
|
| 18 |
+ |
|
| 19 |
+ var id: String {
|
|
| 20 |
+ macAddress |
|
| 21 |
+ } |
|
| 22 |
+ } |
|
| 23 |
+ |
|
| 24 |
+ enum CloudAvailability: Equatable {
|
|
| 25 |
+ case unknown |
|
| 26 |
+ case available |
|
| 27 |
+ case noAccount |
|
| 28 |
+ case error(String) |
|
| 29 |
+ |
|
| 30 |
+ var helpTitle: String {
|
|
| 31 |
+ switch self {
|
|
| 32 |
+ case .unknown: |
|
| 33 |
+ return "Cloud Sync Status Unknown" |
|
| 34 |
+ case .available: |
|
| 35 |
+ return "Cloud Sync Ready" |
|
| 36 |
+ case .noAccount: |
|
| 37 |
+ return "Enable iCloud Drive" |
|
| 38 |
+ case .error: |
|
| 39 |
+ return "Cloud Sync Error" |
|
| 40 |
+ } |
|
| 41 |
+ } |
|
| 42 |
+ |
|
| 43 |
+ var helpMessage: String {
|
|
| 44 |
+ switch self {
|
|
| 45 |
+ case .unknown: |
|
| 46 |
+ return "The app is still checking whether iCloud sync is available on this device." |
|
| 47 |
+ case .available: |
|
| 48 |
+ return "iCloud sync is available for meter names and TC66 temperature preferences." |
|
| 49 |
+ case .noAccount: |
|
| 50 |
+ return "Meter names and TC66 temperature preferences sync through iCloud Drive. The app keeps a local copy too, but cross-device sync stays off until iCloud Drive is available." |
|
| 51 |
+ case .error(let description): |
|
| 52 |
+ return "The app keeps local values, but iCloud sync reported an error: \(description)" |
|
| 53 |
+ } |
|
| 54 |
+ } |
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 57 |
+ static let shared = MeterNameStore() |
|
| 58 |
+ |
|
| 59 |
+ private enum Keys {
|
|
| 60 |
+ static let meters = "MeterNameStore.meters" |
|
| 61 |
+ static let localMeterNames = "MeterNameStore.localMeterNames" |
|
| 62 |
+ static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits" |
|
| 63 |
+ static let localModelNames = "MeterNameStore.localModelNames" |
|
| 64 |
+ static let localAdvertisedNames = "MeterNameStore.localAdvertisedNames" |
|
| 65 |
+ static let localLastSeen = "MeterNameStore.localLastSeen" |
|
| 66 |
+ static let localLastConnected = "MeterNameStore.localLastConnected" |
|
| 67 |
+ static let cloudMeterNames = "MeterNameStore.cloudMeterNames" |
|
| 68 |
+ static let cloudTemperatureUnits = "MeterNameStore.cloudTemperatureUnits" |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ private let defaults = UserDefaults.standard |
|
| 72 |
+ private let ubiquitousStore = NSUbiquitousKeyValueStore.default |
|
| 73 |
+ private let workQueue = DispatchQueue(label: "MeterNameStore.Queue") |
|
| 74 |
+ private var cloudAvailability: CloudAvailability = .unknown |
|
| 75 |
+ private var ubiquitousObserver: NSObjectProtocol? |
|
| 76 |
+ private var ubiquityIdentityObserver: NSObjectProtocol? |
|
| 77 |
+ |
|
| 78 |
+ private init() {
|
|
| 79 |
+ ubiquitousObserver = NotificationCenter.default.addObserver( |
|
| 80 |
+ forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, |
|
| 81 |
+ object: ubiquitousStore, |
|
| 82 |
+ queue: nil |
|
| 83 |
+ ) { [weak self] notification in
|
|
| 84 |
+ self?.handleUbiquitousStoreChange(notification) |
|
| 85 |
+ } |
|
| 86 |
+ |
|
| 87 |
+ ubiquityIdentityObserver = NotificationCenter.default.addObserver( |
|
| 88 |
+ forName: NSNotification.Name.NSUbiquityIdentityDidChange, |
|
| 89 |
+ object: nil, |
|
| 90 |
+ queue: nil |
|
| 91 |
+ ) { [weak self] _ in
|
|
| 92 |
+ self?.refreshCloudAvailability(reason: "identity-changed") |
|
| 93 |
+ self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed") |
|
| 94 |
+ } |
|
| 95 |
+ |
|
| 96 |
+ refreshCloudAvailability(reason: "startup") |
|
| 97 |
+ ubiquitousStore.synchronize() |
|
| 98 |
+ syncLocalValuesToCloudIfPossible(reason: "startup") |
|
| 99 |
+ } |
|
| 100 |
+ |
|
| 101 |
+ var currentCloudAvailability: CloudAvailability {
|
|
| 102 |
+ workQueue.sync {
|
|
| 103 |
+ cloudAvailability |
|
| 104 |
+ } |
|
| 105 |
+ } |
|
| 106 |
+ |
|
| 107 |
+ func name(for macAddress: String) -> String? {
|
|
| 108 |
+ let normalizedMAC = normalizedMACAddress(macAddress) |
|
| 109 |
+ guard !normalizedMAC.isEmpty else { return nil }
|
|
| 110 |
+ return mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)[normalizedMAC] |
|
| 111 |
+ } |
|
| 112 |
+ |
|
| 113 |
+ func temperatureUnitRawValue(for macAddress: String) -> String? {
|
|
| 114 |
+ let normalizedMAC = normalizedMACAddress(macAddress) |
|
| 115 |
+ guard !normalizedMAC.isEmpty else { return nil }
|
|
| 116 |
+ return mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)[normalizedMAC] |
|
| 117 |
+ } |
|
| 118 |
+ |
|
| 119 |
+ func lastSeen(for macAddress: String) -> Date? {
|
|
| 120 |
+ let normalizedMAC = normalizedMACAddress(macAddress) |
|
| 121 |
+ guard !normalizedMAC.isEmpty else { return nil }
|
|
| 122 |
+ return dateDictionary(for: Keys.localLastSeen)[normalizedMAC] |
|
| 123 |
+ } |
|
| 124 |
+ |
|
| 125 |
+ func lastConnected(for macAddress: String) -> Date? {
|
|
| 126 |
+ let normalizedMAC = normalizedMACAddress(macAddress) |
|
| 127 |
+ guard !normalizedMAC.isEmpty else { return nil }
|
|
| 128 |
+ return dateDictionary(for: Keys.localLastConnected)[normalizedMAC] |
|
| 129 |
+ } |
|
| 130 |
+ |
|
| 131 |
+ func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
|
|
| 132 |
+ let normalizedMAC = normalizedMACAddress(macAddress) |
|
| 133 |
+ guard !normalizedMAC.isEmpty else {
|
|
| 134 |
+ track("MeterNameStore ignored meter registration with invalid MAC '\(macAddress)'")
|
|
| 135 |
+ return |
|
| 136 |
+ } |
|
| 137 |
+ |
|
| 138 |
+ var didChange = false |
|
| 139 |
+ didChange = updateMetersSet(normalizedMAC) || didChange |
|
| 140 |
+ didChange = updateDictionaryValue( |
|
| 141 |
+ for: normalizedMAC, |
|
| 142 |
+ value: normalizedName(modelName), |
|
| 143 |
+ localKey: Keys.localModelNames, |
|
| 144 |
+ cloudKey: nil |
|
| 145 |
+ ) || didChange |
|
| 146 |
+ didChange = updateDictionaryValue( |
|
| 147 |
+ for: normalizedMAC, |
|
| 148 |
+ value: normalizedName(advertisedName), |
|
| 149 |
+ localKey: Keys.localAdvertisedNames, |
|
| 150 |
+ cloudKey: nil |
|
| 151 |
+ ) || didChange |
|
| 152 |
+ |
|
| 153 |
+ if didChange {
|
|
| 154 |
+ notifyChange() |
|
| 155 |
+ } |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ func noteLastSeen(_ date: Date, for macAddress: String) {
|
|
| 159 |
+ updateDate(date, for: macAddress, key: Keys.localLastSeen) |
|
| 160 |
+ } |
|
| 161 |
+ |
|
| 162 |
+ func noteLastConnected(_ date: Date, for macAddress: String) {
|
|
| 163 |
+ updateDate(date, for: macAddress, key: Keys.localLastConnected) |
|
| 164 |
+ } |
|
| 165 |
+ |
|
| 166 |
+ func upsert(macAddress: String, name: String?, temperatureUnitRawValue: String?) {
|
|
| 167 |
+ let normalizedMAC = normalizedMACAddress(macAddress) |
|
| 168 |
+ guard !normalizedMAC.isEmpty else {
|
|
| 169 |
+ track("MeterNameStore ignored upsert with invalid MAC '\(macAddress)'")
|
|
| 170 |
+ return |
|
| 171 |
+ } |
|
| 172 |
+ |
|
| 173 |
+ var didChange = false |
|
| 174 |
+ didChange = updateMetersSet(normalizedMAC) || didChange |
|
| 175 |
+ |
|
| 176 |
+ if let name {
|
|
| 177 |
+ didChange = updateDictionaryValue( |
|
| 178 |
+ for: normalizedMAC, |
|
| 179 |
+ value: normalizedName(name), |
|
| 180 |
+ localKey: Keys.localMeterNames, |
|
| 181 |
+ cloudKey: Keys.cloudMeterNames |
|
| 182 |
+ ) || didChange |
|
| 183 |
+ } |
|
| 184 |
+ |
|
| 185 |
+ if let temperatureUnitRawValue {
|
|
| 186 |
+ didChange = updateDictionaryValue( |
|
| 187 |
+ for: normalizedMAC, |
|
| 188 |
+ value: normalizedTemperatureUnit(temperatureUnitRawValue), |
|
| 189 |
+ localKey: Keys.localTemperatureUnits, |
|
| 190 |
+ cloudKey: Keys.cloudTemperatureUnits |
|
| 191 |
+ ) || didChange |
|
| 192 |
+ } |
|
| 193 |
+ |
|
| 194 |
+ if didChange {
|
|
| 195 |
+ notifyChange() |
|
| 196 |
+ } |
|
| 197 |
+ } |
|
| 198 |
+ |
|
| 199 |
+ @discardableResult |
|
| 200 |
+ func remove(macAddress: String) -> Bool {
|
|
| 201 |
+ let normalizedMAC = normalizedMACAddress(macAddress) |
|
| 202 |
+ guard !normalizedMAC.isEmpty else {
|
|
| 203 |
+ track("MeterNameStore ignored remove with invalid MAC '\(macAddress)'")
|
|
| 204 |
+ return false |
|
| 205 |
+ } |
|
| 206 |
+ |
|
| 207 |
+ var didChange = false |
|
| 208 |
+ |
|
| 209 |
+ var knownMeters = meters() |
|
| 210 |
+ if knownMeters.remove(normalizedMAC) != nil {
|
|
| 211 |
+ defaults.set(Array(knownMeters).sorted(), forKey: Keys.meters) |
|
| 212 |
+ didChange = true |
|
| 213 |
+ } |
|
| 214 |
+ |
|
| 215 |
+ didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames) || didChange |
|
| 216 |
+ didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits) || didChange |
|
| 217 |
+ didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localModelNames, cloudKey: nil) || didChange |
|
| 218 |
+ didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localAdvertisedNames, cloudKey: nil) || didChange |
|
| 219 |
+ didChange = removeDateEntry(for: normalizedMAC, key: Keys.localLastSeen) || didChange |
|
| 220 |
+ didChange = removeDateEntry(for: normalizedMAC, key: Keys.localLastConnected) || didChange |
|
| 221 |
+ |
|
| 222 |
+ if didChange {
|
|
| 223 |
+ notifyChange() |
|
| 224 |
+ } |
|
| 225 |
+ |
|
| 226 |
+ return didChange |
|
| 227 |
+ } |
|
| 228 |
+ |
|
| 229 |
+ func allRecords() -> [Record] {
|
|
| 230 |
+ let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames) |
|
| 231 |
+ let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits) |
|
| 232 |
+ let modelNames = dictionary(for: Keys.localModelNames, store: defaults) |
|
| 233 |
+ let advertisedNames = dictionary(for: Keys.localAdvertisedNames, store: defaults) |
|
| 234 |
+ let lastSeenValues = dateDictionary(for: Keys.localLastSeen) |
|
| 235 |
+ let lastConnectedValues = dateDictionary(for: Keys.localLastConnected) |
|
| 236 |
+ let macAddresses = meters() |
|
| 237 |
+ .union(names.keys) |
|
| 238 |
+ .union(temperatureUnits.keys) |
|
| 239 |
+ .union(modelNames.keys) |
|
| 240 |
+ .union(advertisedNames.keys) |
|
| 241 |
+ .union(lastSeenValues.keys) |
|
| 242 |
+ .union(lastConnectedValues.keys) |
|
| 243 |
+ |
|
| 244 |
+ return macAddresses.sorted().map { macAddress in
|
|
| 245 |
+ Record( |
|
| 246 |
+ macAddress: macAddress, |
|
| 247 |
+ customName: names[macAddress], |
|
| 248 |
+ temperatureUnit: temperatureUnits[macAddress], |
|
| 249 |
+ modelName: modelNames[macAddress], |
|
| 250 |
+ advertisedName: advertisedNames[macAddress], |
|
| 251 |
+ lastSeen: lastSeenValues[macAddress], |
|
| 252 |
+ lastConnected: lastConnectedValues[macAddress] |
|
| 253 |
+ ) |
|
| 254 |
+ } |
|
| 255 |
+ } |
|
| 256 |
+ |
|
| 257 |
+ private func normalizedMACAddress(_ macAddress: String) -> String {
|
|
| 258 |
+ macAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() |
|
| 259 |
+ } |
|
| 260 |
+ |
|
| 261 |
+ private func normalizedName(_ name: String?) -> String? {
|
|
| 262 |
+ guard let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines), |
|
| 263 |
+ !trimmed.isEmpty else {
|
|
| 264 |
+ return nil |
|
| 265 |
+ } |
|
| 266 |
+ return trimmed |
|
| 267 |
+ } |
|
| 268 |
+ |
|
| 269 |
+ private func normalizedTemperatureUnit(_ value: String?) -> String? {
|
|
| 270 |
+ guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), |
|
| 271 |
+ !trimmed.isEmpty else {
|
|
| 272 |
+ return nil |
|
| 273 |
+ } |
|
| 274 |
+ return trimmed |
|
| 275 |
+ } |
|
| 276 |
+ |
|
| 277 |
+ private func dictionary(for key: String, store: KeyValueReading) -> [String: String] {
|
|
| 278 |
+ (store.object(forKey: key) as? [String: String]) ?? [:] |
|
| 279 |
+ } |
|
| 280 |
+ |
|
| 281 |
+ private func dateDictionary(for key: String) -> [String: Date] {
|
|
| 282 |
+ let rawValues = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:] |
|
| 283 |
+ return rawValues.mapValues(Date.init(timeIntervalSince1970:)) |
|
| 284 |
+ } |
|
| 285 |
+ |
|
| 286 |
+ private func meters() -> Set<String> {
|
|
| 287 |
+ Set((defaults.array(forKey: Keys.meters) as? [String]) ?? []) |
|
| 288 |
+ } |
|
| 289 |
+ |
|
| 290 |
+ private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
|
|
| 291 |
+ let localValues = dictionary(for: localKey, store: defaults) |
|
| 292 |
+ let cloudValues = dictionary(for: cloudKey, store: ubiquitousStore) |
|
| 293 |
+ return localValues.merging(cloudValues) { _, cloudValue in
|
|
| 294 |
+ cloudValue |
|
| 295 |
+ } |
|
| 296 |
+ } |
|
| 297 |
+ |
|
| 298 |
+ @discardableResult |
|
| 299 |
+ private func updateMetersSet(_ macAddress: String) -> Bool {
|
|
| 300 |
+ var known = meters() |
|
| 301 |
+ let initialCount = known.count |
|
| 302 |
+ known.insert(macAddress) |
|
| 303 |
+ guard known.count != initialCount else { return false }
|
|
| 304 |
+ defaults.set(Array(known).sorted(), forKey: Keys.meters) |
|
| 305 |
+ return true |
|
| 306 |
+ } |
|
| 307 |
+ |
|
| 308 |
+ @discardableResult |
|
| 309 |
+ private func updateDictionaryValue( |
|
| 310 |
+ for macAddress: String, |
|
| 311 |
+ value: String?, |
|
| 312 |
+ localKey: String, |
|
| 313 |
+ cloudKey: String? |
|
| 314 |
+ ) -> Bool {
|
|
| 315 |
+ var localValues = dictionary(for: localKey, store: defaults) |
|
| 316 |
+ let didChangeLocal = setDictionaryValue(&localValues, for: macAddress, value: value) |
|
| 317 |
+ if didChangeLocal {
|
|
| 318 |
+ defaults.set(localValues, forKey: localKey) |
|
| 319 |
+ } |
|
| 320 |
+ |
|
| 321 |
+ var didChangeCloud = false |
|
| 322 |
+ if let cloudKey, isICloudDriveAvailable {
|
|
| 323 |
+ var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore) |
|
| 324 |
+ didChangeCloud = setDictionaryValue(&cloudValues, for: macAddress, value: value) |
|
| 325 |
+ if didChangeCloud {
|
|
| 326 |
+ ubiquitousStore.set(cloudValues, forKey: cloudKey) |
|
| 327 |
+ ubiquitousStore.synchronize() |
|
| 328 |
+ } |
|
| 329 |
+ } |
|
| 330 |
+ |
|
| 331 |
+ return didChangeLocal || didChangeCloud |
|
| 332 |
+ } |
|
| 333 |
+ |
|
| 334 |
+ @discardableResult |
|
| 335 |
+ private func setDictionaryValue( |
|
| 336 |
+ _ dictionary: inout [String: String], |
|
| 337 |
+ for macAddress: String, |
|
| 338 |
+ value: String? |
|
| 339 |
+ ) -> Bool {
|
|
| 340 |
+ let currentValue = dictionary[macAddress] |
|
| 341 |
+ guard currentValue != value else { return false }
|
|
| 342 |
+ if let value {
|
|
| 343 |
+ dictionary[macAddress] = value |
|
| 344 |
+ } else {
|
|
| 345 |
+ dictionary.removeValue(forKey: macAddress) |
|
| 346 |
+ } |
|
| 347 |
+ return true |
|
| 348 |
+ } |
|
| 349 |
+ |
|
| 350 |
+ private func updateDate(_ date: Date, for macAddress: String, key: String) {
|
|
| 351 |
+ let normalizedMAC = normalizedMACAddress(macAddress) |
|
| 352 |
+ guard !normalizedMAC.isEmpty else {
|
|
| 353 |
+ track("MeterNameStore ignored date update with invalid MAC '\(macAddress)'")
|
|
| 354 |
+ return |
|
| 355 |
+ } |
|
| 356 |
+ |
|
| 357 |
+ var values = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:] |
|
| 358 |
+ let timeInterval = date.timeIntervalSince1970 |
|
| 359 |
+ guard values[normalizedMAC] != timeInterval else { return }
|
|
| 360 |
+ values[normalizedMAC] = timeInterval |
|
| 361 |
+ defaults.set(values, forKey: key) |
|
| 362 |
+ _ = updateMetersSet(normalizedMAC) |
|
| 363 |
+ notifyChange() |
|
| 364 |
+ } |
|
| 365 |
+ |
|
| 366 |
+ @discardableResult |
|
| 367 |
+ private func removeDictionaryEntry( |
|
| 368 |
+ for macAddress: String, |
|
| 369 |
+ localKey: String, |
|
| 370 |
+ cloudKey: String? |
|
| 371 |
+ ) -> Bool {
|
|
| 372 |
+ var didChange = false |
|
| 373 |
+ |
|
| 374 |
+ var localValues = dictionary(for: localKey, store: defaults) |
|
| 375 |
+ if localValues.removeValue(forKey: macAddress) != nil {
|
|
| 376 |
+ defaults.set(localValues, forKey: localKey) |
|
| 377 |
+ didChange = true |
|
| 378 |
+ } |
|
| 379 |
+ |
|
| 380 |
+ if let cloudKey, isICloudDriveAvailable {
|
|
| 381 |
+ var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore) |
|
| 382 |
+ if cloudValues.removeValue(forKey: macAddress) != nil {
|
|
| 383 |
+ ubiquitousStore.set(cloudValues, forKey: cloudKey) |
|
| 384 |
+ ubiquitousStore.synchronize() |
|
| 385 |
+ didChange = true |
|
| 386 |
+ } |
|
| 387 |
+ } |
|
| 388 |
+ |
|
| 389 |
+ return didChange |
|
| 390 |
+ } |
|
| 391 |
+ |
|
| 392 |
+ @discardableResult |
|
| 393 |
+ private func removeDateEntry(for macAddress: String, key: String) -> Bool {
|
|
| 394 |
+ var values = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:] |
|
| 395 |
+ guard values.removeValue(forKey: macAddress) != nil else {
|
|
| 396 |
+ return false |
|
| 397 |
+ } |
|
| 398 |
+ defaults.set(values, forKey: key) |
|
| 399 |
+ return true |
|
| 400 |
+ } |
|
| 401 |
+ |
|
| 402 |
+ private var isICloudDriveAvailable: Bool {
|
|
| 403 |
+ FileManager.default.ubiquityIdentityToken != nil |
|
| 404 |
+ } |
|
| 405 |
+ |
|
| 406 |
+ private func refreshCloudAvailability(reason: String) {
|
|
| 407 |
+ let newAvailability: CloudAvailability = isICloudDriveAvailable ? .available : .noAccount |
|
| 408 |
+ |
|
| 409 |
+ var shouldNotify = false |
|
| 410 |
+ workQueue.sync {
|
|
| 411 |
+ guard cloudAvailability != newAvailability else { return }
|
|
| 412 |
+ cloudAvailability = newAvailability |
|
| 413 |
+ shouldNotify = true |
|
| 414 |
+ } |
|
| 415 |
+ |
|
| 416 |
+ guard shouldNotify else { return }
|
|
| 417 |
+ track("MeterNameStore iCloud availability (\(reason)): \(newAvailability)")
|
|
| 418 |
+ DispatchQueue.main.async {
|
|
| 419 |
+ NotificationCenter.default.post(name: .meterNameStoreCloudStatusDidChange, object: nil) |
|
| 420 |
+ } |
|
| 421 |
+ } |
|
| 422 |
+ |
|
| 423 |
+ private func handleUbiquitousStoreChange(_ notification: Notification) {
|
|
| 424 |
+ refreshCloudAvailability(reason: "ubiquitous-store-change") |
|
| 425 |
+ if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], !changedKeys.isEmpty {
|
|
| 426 |
+ track("MeterNameStore received ubiquitous changes for keys: \(changedKeys.joined(separator: ", "))")
|
|
| 427 |
+ } |
|
| 428 |
+ notifyChange() |
|
| 429 |
+ } |
|
| 430 |
+ |
|
| 431 |
+ private func syncLocalValuesToCloudIfPossible(reason: String) {
|
|
| 432 |
+ guard isICloudDriveAvailable else {
|
|
| 433 |
+ refreshCloudAvailability(reason: reason) |
|
| 434 |
+ return |
|
| 435 |
+ } |
|
| 436 |
+ |
|
| 437 |
+ let localNames = dictionary(for: Keys.localMeterNames, store: defaults) |
|
| 438 |
+ let localTemperatureUnits = dictionary(for: Keys.localTemperatureUnits, store: defaults) |
|
| 439 |
+ |
|
| 440 |
+ var cloudNames = dictionary(for: Keys.cloudMeterNames, store: ubiquitousStore) |
|
| 441 |
+ var cloudTemperatureUnits = dictionary(for: Keys.cloudTemperatureUnits, store: ubiquitousStore) |
|
| 442 |
+ |
|
| 443 |
+ let mergedNames = cloudNames.merging(localNames) { cloudValue, _ in
|
|
| 444 |
+ cloudValue |
|
| 445 |
+ } |
|
| 446 |
+ let mergedTemperatureUnits = cloudTemperatureUnits.merging(localTemperatureUnits) { cloudValue, _ in
|
|
| 447 |
+ cloudValue |
|
| 448 |
+ } |
|
| 449 |
+ |
|
| 450 |
+ var didChange = false |
|
| 451 |
+ if cloudNames != mergedNames {
|
|
| 452 |
+ cloudNames = mergedNames |
|
| 453 |
+ ubiquitousStore.set(cloudNames, forKey: Keys.cloudMeterNames) |
|
| 454 |
+ didChange = true |
|
| 455 |
+ } |
|
| 456 |
+ if cloudTemperatureUnits != mergedTemperatureUnits {
|
|
| 457 |
+ cloudTemperatureUnits = mergedTemperatureUnits |
|
| 458 |
+ ubiquitousStore.set(cloudTemperatureUnits, forKey: Keys.cloudTemperatureUnits) |
|
| 459 |
+ didChange = true |
|
| 460 |
+ } |
|
| 461 |
+ |
|
| 462 |
+ refreshCloudAvailability(reason: reason) |
|
| 463 |
+ |
|
| 464 |
+ if didChange {
|
|
| 465 |
+ ubiquitousStore.synchronize() |
|
| 466 |
+ track("MeterNameStore pushed local fallback values into iCloud KVS (\(reason)).")
|
|
| 467 |
+ notifyChange() |
|
| 468 |
+ } |
|
| 469 |
+ } |
|
| 470 |
+ |
|
| 471 |
+ private func notifyChange() {
|
|
| 472 |
+ DispatchQueue.main.async {
|
|
| 473 |
+ NotificationCenter.default.post(name: .meterNameStoreDidChange, object: nil) |
|
| 474 |
+ } |
|
| 475 |
+ } |
|
| 476 |
+ |
|
| 477 |
+ deinit {
|
|
| 478 |
+ if let observer = ubiquitousObserver {
|
|
| 479 |
+ NotificationCenter.default.removeObserver(observer) |
|
| 480 |
+ } |
|
| 481 |
+ if let observer = ubiquityIdentityObserver {
|
|
| 482 |
+ NotificationCenter.default.removeObserver(observer) |
|
| 483 |
+ } |
|
| 484 |
+ } |
|
| 485 |
+} |
|
| 486 |
+ |
|
| 487 |
+private protocol KeyValueReading {
|
|
| 488 |
+ func object(forKey defaultName: String) -> Any? |
|
| 489 |
+} |
|
| 490 |
+ |
|
| 491 |
+extension UserDefaults: KeyValueReading {}
|
|
| 492 |
+extension NSUbiquitousKeyValueStore: KeyValueReading {}
|
|
| 493 |
+ |
|
| 494 |
+extension Notification.Name {
|
|
| 495 |
+ static let meterNameStoreDidChange = Notification.Name("MeterNameStoreDidChange")
|
|
| 496 |
+ static let meterNameStoreCloudStatusDidChange = Notification.Name("MeterNameStoreCloudStatusDidChange")
|
|
| 497 |
+} |
|
@@ -20,13 +20,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||
| 20 | 20 |
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene. |
| 21 | 21 |
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). |
| 22 | 22 |
|
| 23 |
- // Get the managed object context from the shared persistent container. |
|
| 24 |
- let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext |
|
| 25 |
- |
|
| 26 |
- // Create the SwiftUI view and set the context as the value for the managedObjectContext environment keyPath. |
|
| 27 |
- // Add `@Environment(\.managedObjectContext)` in the views that will need the context. |
|
| 28 | 23 |
let contentView = ContentView() |
| 29 |
- .environment(\.managedObjectContext, context) |
|
| 30 | 24 |
.environmentObject(appData) |
| 31 | 25 |
|
| 32 | 26 |
// Use a UIHostingController as window root view controller. |
@@ -53,6 +47,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||
| 53 | 47 |
func sceneWillResignActive(_ scene: UIScene) {
|
| 54 | 48 |
// Called when the scene will move from an active state to an inactive state. |
| 55 | 49 |
// This may occur due to temporary interruptions (ex. an incoming phone call). |
| 50 |
+ _ = appData.flushChargeInsights() |
|
| 56 | 51 |
} |
| 57 | 52 |
|
| 58 | 53 |
func sceneWillEnterForeground(_ scene: UIScene) {
|
@@ -64,11 +59,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||
| 64 | 59 |
// Called as the scene transitions from the foreground to the background. |
| 65 | 60 |
// Use this method to save data, release shared resources, and store enough scene-specific state information |
| 66 | 61 |
// to restore the scene back to its current state. |
| 67 |
- |
|
| 68 |
- // Save changes in the application's managed object context when the application transitions to the background. |
|
| 69 |
- (UIApplication.shared.delegate as? AppDelegate)?.saveContext() |
|
| 62 |
+ _ = appData.flushChargeInsights() |
|
| 70 | 63 |
} |
| 71 | 64 |
|
| 72 | 65 |
|
| 73 | 66 |
} |
| 74 |
- |
|
@@ -0,0 +1,191 @@ |
||
| 1 |
+{
|
|
| 2 |
+ "templates": [ |
|
| 3 |
+ {
|
|
| 4 |
+ "id": "apple-iphone", |
|
| 5 |
+ "name": "iPhone", |
|
| 6 |
+ "group": "Apple", |
|
| 7 |
+ "kind": "device", |
|
| 8 |
+ "deviceClass": "iphone", |
|
| 9 |
+ "icon": {
|
|
| 10 |
+ "type": "systemSymbol", |
|
| 11 |
+ "name": "iphone", |
|
| 12 |
+ "fallbackSystemName": "smartphone" |
|
| 13 |
+ }, |
|
| 14 |
+ "chargingStateAvailability": "onOrOff", |
|
| 15 |
+ "supportsWiredCharging": true, |
|
| 16 |
+ "supportsWirelessCharging": true, |
|
| 17 |
+ "wirelessChargingProfile": "magsafe", |
|
| 18 |
+ "sortOrder": 10 |
|
| 19 |
+ }, |
|
| 20 |
+ {
|
|
| 21 |
+ "id": "apple-ipad", |
|
| 22 |
+ "name": "iPad", |
|
| 23 |
+ "group": "Apple", |
|
| 24 |
+ "kind": "device", |
|
| 25 |
+ "deviceClass": "iphone", |
|
| 26 |
+ "icon": {
|
|
| 27 |
+ "type": "systemSymbol", |
|
| 28 |
+ "name": "ipad", |
|
| 29 |
+ "fallbackSystemName": "rectangle" |
|
| 30 |
+ }, |
|
| 31 |
+ "chargingStateAvailability": "onOrOff", |
|
| 32 |
+ "supportsWiredCharging": true, |
|
| 33 |
+ "supportsWirelessCharging": false, |
|
| 34 |
+ "wirelessChargingProfile": "genericQi", |
|
| 35 |
+ "sortOrder": 20 |
|
| 36 |
+ }, |
|
| 37 |
+ {
|
|
| 38 |
+ "id": "apple-watch", |
|
| 39 |
+ "name": "Apple Watch", |
|
| 40 |
+ "group": "Apple", |
|
| 41 |
+ "kind": "device", |
|
| 42 |
+ "deviceClass": "watch", |
|
| 43 |
+ "icon": {
|
|
| 44 |
+ "type": "systemSymbol", |
|
| 45 |
+ "name": "applewatch", |
|
| 46 |
+ "fallbackSystemName": "watch.analog" |
|
| 47 |
+ }, |
|
| 48 |
+ "chargingStateAvailability": "onOnly", |
|
| 49 |
+ "supportsWiredCharging": false, |
|
| 50 |
+ "supportsWirelessCharging": true, |
|
| 51 |
+ "wirelessChargingProfile": "genericQi", |
|
| 52 |
+ "sortOrder": 30 |
|
| 53 |
+ }, |
|
| 54 |
+ {
|
|
| 55 |
+ "id": "apple-airpods", |
|
| 56 |
+ "name": "AirPods", |
|
| 57 |
+ "group": "Apple", |
|
| 58 |
+ "kind": "device", |
|
| 59 |
+ "deviceClass": "other", |
|
| 60 |
+ "icon": {
|
|
| 61 |
+ "type": "systemSymbol", |
|
| 62 |
+ "name": "airpods", |
|
| 63 |
+ "fallbackSystemName": "earbuds.case" |
|
| 64 |
+ }, |
|
| 65 |
+ "chargingStateAvailability": "onOnly", |
|
| 66 |
+ "supportsWiredCharging": true, |
|
| 67 |
+ "supportsWirelessCharging": true, |
|
| 68 |
+ "wirelessChargingProfile": "genericQi", |
|
| 69 |
+ "sortOrder": 40 |
|
| 70 |
+ }, |
|
| 71 |
+ {
|
|
| 72 |
+ "id": "generic-phone", |
|
| 73 |
+ "name": "Phone", |
|
| 74 |
+ "group": "Generic", |
|
| 75 |
+ "kind": "device", |
|
| 76 |
+ "deviceClass": "iphone", |
|
| 77 |
+ "icon": {
|
|
| 78 |
+ "type": "systemSymbol", |
|
| 79 |
+ "name": "smartphone", |
|
| 80 |
+ "fallbackSystemName": "rectangle.portrait" |
|
| 81 |
+ }, |
|
| 82 |
+ "chargingStateAvailability": "onOrOff", |
|
| 83 |
+ "supportsWiredCharging": true, |
|
| 84 |
+ "supportsWirelessCharging": true, |
|
| 85 |
+ "wirelessChargingProfile": "genericQi", |
|
| 86 |
+ "sortOrder": 110 |
|
| 87 |
+ }, |
|
| 88 |
+ {
|
|
| 89 |
+ "id": "generic-tablet", |
|
| 90 |
+ "name": "Tablet", |
|
| 91 |
+ "group": "Generic", |
|
| 92 |
+ "kind": "device", |
|
| 93 |
+ "deviceClass": "other", |
|
| 94 |
+ "icon": {
|
|
| 95 |
+ "type": "systemSymbol", |
|
| 96 |
+ "name": "rectangle", |
|
| 97 |
+ "fallbackSystemName": "rectangle" |
|
| 98 |
+ }, |
|
| 99 |
+ "chargingStateAvailability": "onOrOff", |
|
| 100 |
+ "supportsWiredCharging": true, |
|
| 101 |
+ "supportsWirelessCharging": false, |
|
| 102 |
+ "wirelessChargingProfile": "genericQi", |
|
| 103 |
+ "sortOrder": 120 |
|
| 104 |
+ }, |
|
| 105 |
+ {
|
|
| 106 |
+ "id": "generic-watch", |
|
| 107 |
+ "name": "Watch", |
|
| 108 |
+ "group": "Generic", |
|
| 109 |
+ "kind": "device", |
|
| 110 |
+ "deviceClass": "watch", |
|
| 111 |
+ "icon": {
|
|
| 112 |
+ "type": "systemSymbol", |
|
| 113 |
+ "name": "watch.analog", |
|
| 114 |
+ "fallbackSystemName": "clock" |
|
| 115 |
+ }, |
|
| 116 |
+ "chargingStateAvailability": "onOnly", |
|
| 117 |
+ "supportsWiredCharging": false, |
|
| 118 |
+ "supportsWirelessCharging": true, |
|
| 119 |
+ "wirelessChargingProfile": "genericQi", |
|
| 120 |
+ "sortOrder": 130 |
|
| 121 |
+ }, |
|
| 122 |
+ {
|
|
| 123 |
+ "id": "generic-laptop", |
|
| 124 |
+ "name": "Laptop", |
|
| 125 |
+ "group": "Generic", |
|
| 126 |
+ "kind": "device", |
|
| 127 |
+ "deviceClass": "other", |
|
| 128 |
+ "icon": {
|
|
| 129 |
+ "type": "systemSymbol", |
|
| 130 |
+ "name": "laptopcomputer", |
|
| 131 |
+ "fallbackSystemName": "display" |
|
| 132 |
+ }, |
|
| 133 |
+ "chargingStateAvailability": "onOrOff", |
|
| 134 |
+ "supportsWiredCharging": true, |
|
| 135 |
+ "supportsWirelessCharging": false, |
|
| 136 |
+ "wirelessChargingProfile": "genericQi", |
|
| 137 |
+ "sortOrder": 140 |
|
| 138 |
+ }, |
|
| 139 |
+ {
|
|
| 140 |
+ "id": "generic-powerbank", |
|
| 141 |
+ "name": "Powerbank", |
|
| 142 |
+ "group": "Generic", |
|
| 143 |
+ "kind": "device", |
|
| 144 |
+ "deviceClass": "powerbank", |
|
| 145 |
+ "icon": {
|
|
| 146 |
+ "type": "systemSymbol", |
|
| 147 |
+ "name": "battery.100.bolt", |
|
| 148 |
+ "fallbackSystemName": "battery.100.bolt" |
|
| 149 |
+ }, |
|
| 150 |
+ "chargingStateAvailability": "offOnly", |
|
| 151 |
+ "supportsWiredCharging": true, |
|
| 152 |
+ "supportsWirelessCharging": false, |
|
| 153 |
+ "wirelessChargingProfile": "genericQi", |
|
| 154 |
+ "sortOrder": 150 |
|
| 155 |
+ }, |
|
| 156 |
+ {
|
|
| 157 |
+ "id": "generic-audio-accessory", |
|
| 158 |
+ "name": "Audio Accessory", |
|
| 159 |
+ "group": "Generic", |
|
| 160 |
+ "kind": "device", |
|
| 161 |
+ "deviceClass": "other", |
|
| 162 |
+ "icon": {
|
|
| 163 |
+ "type": "systemSymbol", |
|
| 164 |
+ "name": "earbuds.case", |
|
| 165 |
+ "fallbackSystemName": "headphones" |
|
| 166 |
+ }, |
|
| 167 |
+ "chargingStateAvailability": "onOnly", |
|
| 168 |
+ "supportsWiredCharging": true, |
|
| 169 |
+ "supportsWirelessCharging": true, |
|
| 170 |
+ "wirelessChargingProfile": "genericQi", |
|
| 171 |
+ "sortOrder": 160 |
|
| 172 |
+ }, |
|
| 173 |
+ {
|
|
| 174 |
+ "id": "generic-device", |
|
| 175 |
+ "name": "Other Device", |
|
| 176 |
+ "group": "Generic", |
|
| 177 |
+ "kind": "device", |
|
| 178 |
+ "deviceClass": "other", |
|
| 179 |
+ "icon": {
|
|
| 180 |
+ "type": "systemSymbol", |
|
| 181 |
+ "name": "shippingbox", |
|
| 182 |
+ "fallbackSystemName": "shippingbox" |
|
| 183 |
+ }, |
|
| 184 |
+ "chargingStateAvailability": "onOnly", |
|
| 185 |
+ "supportsWiredCharging": true, |
|
| 186 |
+ "supportsWirelessCharging": false, |
|
| 187 |
+ "wirelessChargingProfile": "genericQi", |
|
| 188 |
+ "sortOrder": 170 |
|
| 189 |
+ } |
|
| 190 |
+ ] |
|
| 191 |
+} |
|
@@ -1,29 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// ICloudDefault.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Bogdan Timofte on 12/04/2020. |
|
| 6 |
-// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
-// |
|
| 8 |
- |
|
| 9 |
-import Foundation |
|
| 10 |
-// https://github.com/lobodpav/Xcode11.4Issues/blob/master/Sources/Xcode11.4Test/CloudListener.swift |
|
| 11 |
-// https://medium.com/@craiggrummitt/boss-level-property-wrappers-and-user-defaults-6a28c7527cf |
|
| 12 |
-@propertyWrapper struct ICloudDefault<T> {
|
|
| 13 |
- let key: String |
|
| 14 |
- let defaultValue: T |
|
| 15 |
- |
|
| 16 |
- var wrappedValue: T {
|
|
| 17 |
- get {
|
|
| 18 |
- return NSUbiquitousKeyValueStore.default.object(forKey: key) as? T ?? defaultValue |
|
| 19 |
- } |
|
| 20 |
- set {
|
|
| 21 |
- NSUbiquitousKeyValueStore.default.set(newValue, forKey: key) |
|
| 22 |
- /* MARK: Sincronizarea forțată |
|
| 23 |
- Face ca sincronizarea intre dispozitive mai repidă dar există o limita de update-uri catre iloud |
|
| 24 |
- */ |
|
| 25 |
- NSUbiquitousKeyValueStore.default.synchronize() |
|
| 26 |
- track("Pushed into iCloud value: '\(newValue)' for key: '\(key)'")
|
|
| 27 |
- } |
|
| 28 |
- } |
|
| 29 |
-} |
|
@@ -3,7 +3,17 @@ |
||
| 3 | 3 |
<plist version="1.0"> |
| 4 | 4 |
<dict> |
| 5 | 5 |
<key>com.apple.developer.icloud-container-identifiers</key> |
| 6 |
- <array/> |
|
| 6 |
+ <array> |
|
| 7 |
+ <string>iCloud.ro.xdev.USB-Meter</string> |
|
| 8 |
+ </array> |
|
| 9 |
+ <key>com.apple.developer.icloud-services</key> |
|
| 10 |
+ <array> |
|
| 11 |
+ <string>CloudKit</string> |
|
| 12 |
+ </array> |
|
| 13 |
+ <key>com.apple.developer.ubiquity-container-identifiers</key> |
|
| 14 |
+ <array> |
|
| 15 |
+ <string>iCloud.ro.xdev.USB-Meter</string> |
|
| 16 |
+ </array> |
|
| 7 | 17 |
<key>com.apple.developer.ubiquity-kvstore-identifier</key> |
| 8 | 18 |
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string> |
| 9 | 19 |
</dict> |
@@ -1,32 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// BorderView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Bogdan Timofte on 11/04/2020. |
|
| 6 |
-// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
-// |
|
| 8 |
- |
|
| 9 |
-import SwiftUI |
|
| 10 |
- |
|
| 11 |
-struct BorderView: View {
|
|
| 12 |
- let show: Bool |
|
| 13 |
- var fillColor: Color = .clear |
|
| 14 |
- var opacity = 0.5 |
|
| 15 |
- |
|
| 16 |
- var body: some View {
|
|
| 17 |
- ZStack {
|
|
| 18 |
- RoundedRectangle(cornerRadius: 10) |
|
| 19 |
- .foregroundColor(fillColor).opacity(opacity) |
|
| 20 |
- |
|
| 21 |
- RoundedRectangle(cornerRadius: 10) |
|
| 22 |
- .stroke(lineWidth: 3.0).foregroundColor(show ? fillColor : Color.clear) |
|
| 23 |
- .animation(.linear(duration: 0.1), value: show) |
|
| 24 |
- } |
|
| 25 |
- } |
|
| 26 |
-} |
|
| 27 |
- |
|
| 28 |
-struct BorderView_Previews: PreviewProvider {
|
|
| 29 |
- static var previews: some View {
|
|
| 30 |
- BorderView(show: true) |
|
| 31 |
- } |
|
| 32 |
-} |
|
@@ -0,0 +1,82 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargedDeviceDetailTabBarView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 22/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+struct ChargedDeviceDetailTabBarView<Tab: Hashable>: View {
|
|
| 11 |
+ let tabs: [Tab] |
|
| 12 |
+ @Binding var selection: Tab |
|
| 13 |
+ let tint: Color |
|
| 14 |
+ let presentation: AdaptiveTabBarPresentation |
|
| 15 |
+ let title: (Tab) -> String |
|
| 16 |
+ let systemImage: (Tab) -> String |
|
| 17 |
+ |
|
| 18 |
+ var body: some View {
|
|
| 19 |
+ HStack {
|
|
| 20 |
+ Spacer(minLength: 0) |
|
| 21 |
+ |
|
| 22 |
+ HStack(spacing: 8) {
|
|
| 23 |
+ ForEach(tabs, id: \.self) { tab in
|
|
| 24 |
+ let isSelected = selection == tab |
|
| 25 |
+ |
|
| 26 |
+ Button {
|
|
| 27 |
+ withAnimation(.easeInOut(duration: 0.2)) {
|
|
| 28 |
+ selection = tab |
|
| 29 |
+ } |
|
| 30 |
+ } label: {
|
|
| 31 |
+ HStack(spacing: 6) {
|
|
| 32 |
+ Image(systemName: systemImage(tab)) |
|
| 33 |
+ .font(.subheadline.weight(.semibold)) |
|
| 34 |
+ if presentation.showsTitles {
|
|
| 35 |
+ Text(title(tab)) |
|
| 36 |
+ .font(.subheadline.weight(.semibold)) |
|
| 37 |
+ .lineLimit(1) |
|
| 38 |
+ } |
|
| 39 |
+ } |
|
| 40 |
+ .foregroundColor(isSelected ? .white : .primary) |
|
| 41 |
+ .padding(.horizontal, presentation.showsTitles ? 10 : 12) |
|
| 42 |
+ .padding(.vertical, presentation.showsTitles ? 7 : 10) |
|
| 43 |
+ .frame(maxWidth: .infinity) |
|
| 44 |
+ .background( |
|
| 45 |
+ Capsule() |
|
| 46 |
+ .fill(isSelected ? tint : Color.secondary.opacity(0.12)) |
|
| 47 |
+ ) |
|
| 48 |
+ } |
|
| 49 |
+ .buttonStyle(.plain) |
|
| 50 |
+ .accessibilityLabel(title(tab)) |
|
| 51 |
+ } |
|
| 52 |
+ } |
|
| 53 |
+ .frame(maxWidth: presentation.maxWidth) |
|
| 54 |
+ .padding(6) |
|
| 55 |
+ .background( |
|
| 56 |
+ RoundedRectangle(cornerRadius: presentation.showsTitles ? 14 : 22, style: .continuous) |
|
| 57 |
+ .fill(Color.secondary.opacity(0.10)) |
|
| 58 |
+ ) |
|
| 59 |
+ .background {
|
|
| 60 |
+ RoundedRectangle(cornerRadius: presentation.showsTitles ? 14 : 22, style: .continuous) |
|
| 61 |
+ .fill(.ultraThinMaterial) |
|
| 62 |
+ .opacity(0.78) |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ Spacer(minLength: 0) |
|
| 66 |
+ } |
|
| 67 |
+ .padding(.horizontal, 16) |
|
| 68 |
+ .padding(.top, 10) |
|
| 69 |
+ .padding(.bottom, 8) |
|
| 70 |
+ .background {
|
|
| 71 |
+ Rectangle() |
|
| 72 |
+ .fill(.ultraThinMaterial) |
|
| 73 |
+ .opacity(0.78) |
|
| 74 |
+ .ignoresSafeArea(edges: .top) |
|
| 75 |
+ } |
|
| 76 |
+ .overlay(alignment: .bottom) {
|
|
| 77 |
+ Rectangle() |
|
| 78 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 79 |
+ .frame(height: 1) |
|
| 80 |
+ } |
|
| 81 |
+ } |
|
| 82 |
+} |
|
@@ -0,0 +1,47 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargedDeviceEditorScaffoldView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 22/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+struct ChargedDeviceEditorScaffoldView<Content: View>: View {
|
|
| 11 |
+ @Environment(\.dismiss) private var dismiss |
|
| 12 |
+ |
|
| 13 |
+ let title: String |
|
| 14 |
+ let saveButtonTitle: String |
|
| 15 |
+ let canSave: Bool |
|
| 16 |
+ let standalone: Bool |
|
| 17 |
+ let save: () -> Void |
|
| 18 |
+ @ViewBuilder let content: () -> Content |
|
| 19 |
+ |
|
| 20 |
+ var body: some View {
|
|
| 21 |
+ if standalone {
|
|
| 22 |
+ NavigationView { formContent }
|
|
| 23 |
+ .navigationViewStyle(StackNavigationViewStyle()) |
|
| 24 |
+ } else {
|
|
| 25 |
+ formContent |
|
| 26 |
+ } |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ private var formContent: some View {
|
|
| 30 |
+ Form {
|
|
| 31 |
+ content() |
|
| 32 |
+ } |
|
| 33 |
+ .navigationTitle(title) |
|
| 34 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 35 |
+ .toolbar {
|
|
| 36 |
+ ToolbarItem(placement: .cancellationAction) {
|
|
| 37 |
+ Button("Cancel") {
|
|
| 38 |
+ dismiss() |
|
| 39 |
+ } |
|
| 40 |
+ } |
|
| 41 |
+ ToolbarItem(placement: .confirmationAction) {
|
|
| 42 |
+ Button(saveButtonTitle, action: save) |
|
| 43 |
+ .disabled(!canSave) |
|
| 44 |
+ } |
|
| 45 |
+ } |
|
| 46 |
+ } |
|
| 47 |
+} |
|
@@ -0,0 +1,83 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargedDeviceIdentityViews.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 22/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+import UIKit |
|
| 10 |
+ |
|
| 11 |
+struct ChargedDeviceIdentityLabelView: View {
|
|
| 12 |
+ let chargedDevice: ChargedDeviceSummary |
|
| 13 |
+ var iconPointSize: CGFloat = 15 |
|
| 14 |
+ |
|
| 15 |
+ var body: some View {
|
|
| 16 |
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
| 17 |
+ ChargedDeviceTemplateIconView( |
|
| 18 |
+ icon: chargedDevice.identityIcon, |
|
| 19 |
+ fallbackSystemName: chargedDevice.fallbackIdentitySymbolName, |
|
| 20 |
+ pointSize: iconPointSize |
|
| 21 |
+ ) |
|
| 22 |
+ Text(chargedDevice.name) |
|
| 23 |
+ } |
|
| 24 |
+ } |
|
| 25 |
+} |
|
| 26 |
+ |
|
| 27 |
+struct ChargedDeviceTemplateLabelView: View {
|
|
| 28 |
+ let template: ChargedDeviceTemplateDefinition |
|
| 29 |
+ var iconPointSize: CGFloat = 15 |
|
| 30 |
+ |
|
| 31 |
+ var body: some View {
|
|
| 32 |
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
| 33 |
+ ChargedDeviceTemplateIconView( |
|
| 34 |
+ icon: template.icon, |
|
| 35 |
+ fallbackSystemName: template.deviceClass.symbolName, |
|
| 36 |
+ pointSize: iconPointSize |
|
| 37 |
+ ) |
|
| 38 |
+ Text(template.name) |
|
| 39 |
+ } |
|
| 40 |
+ } |
|
| 41 |
+} |
|
| 42 |
+ |
|
| 43 |
+struct ChargedDeviceTemplateIconView: View {
|
|
| 44 |
+ let icon: ChargedDeviceTemplateIcon |
|
| 45 |
+ let fallbackSystemName: String |
|
| 46 |
+ var pointSize: CGFloat = 15 |
|
| 47 |
+ |
|
| 48 |
+ var body: some View {
|
|
| 49 |
+ Group {
|
|
| 50 |
+ if let assetName = resolvedAssetName {
|
|
| 51 |
+ Image(assetName) |
|
| 52 |
+ .renderingMode(.template) |
|
| 53 |
+ .resizable() |
|
| 54 |
+ .scaledToFit() |
|
| 55 |
+ } else {
|
|
| 56 |
+ Image(systemName: resolvedSystemSymbolName) |
|
| 57 |
+ .font(.system(size: pointSize)) |
|
| 58 |
+ } |
|
| 59 |
+ } |
|
| 60 |
+ .frame(width: pointSize + 2, height: pointSize + 2) |
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 63 |
+ private var resolvedAssetName: String? {
|
|
| 64 |
+ guard icon.type == .asset, UIImage(named: icon.name) != nil else {
|
|
| 65 |
+ return nil |
|
| 66 |
+ } |
|
| 67 |
+ return icon.name |
|
| 68 |
+ } |
|
| 69 |
+ |
|
| 70 |
+ private var resolvedSystemSymbolName: String {
|
|
| 71 |
+ let candidate = icon.resolvedSystemSymbolName(fallbackSystemName: fallbackSystemName) |
|
| 72 |
+ if UIImage(systemName: candidate) != nil {
|
|
| 73 |
+ return candidate |
|
| 74 |
+ } |
|
| 75 |
+ |
|
| 76 |
+ if let fallbackSystemName = icon.fallbackSystemName, |
|
| 77 |
+ UIImage(systemName: fallbackSystemName) != nil {
|
|
| 78 |
+ return fallbackSystemName |
|
| 79 |
+ } |
|
| 80 |
+ |
|
| 81 |
+ return fallbackSystemName |
|
| 82 |
+ } |
|
| 83 |
+} |
|
@@ -0,0 +1,92 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargedDeviceLibraryRowView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 22/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+struct ChargedDeviceLibraryRowView: View {
|
|
| 11 |
+ let chargedDevice: ChargedDeviceSummary |
|
| 12 |
+ let isSelected: Bool |
|
| 13 |
+ |
|
| 14 |
+ var body: some View {
|
|
| 15 |
+ HStack(alignment: .top, spacing: 14) {
|
|
| 16 |
+ ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 58) |
|
| 17 |
+ |
|
| 18 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 19 |
+ header |
|
| 20 |
+ summaryText |
|
| 21 |
+ } |
|
| 22 |
+ } |
|
| 23 |
+ .padding(.vertical, 4) |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 26 |
+ private var header: some View {
|
|
| 27 |
+ HStack {
|
|
| 28 |
+ ChargedDeviceIdentityLabelView( |
|
| 29 |
+ chargedDevice: chargedDevice, |
|
| 30 |
+ iconPointSize: 17 |
|
| 31 |
+ ) |
|
| 32 |
+ .font(.headline) |
|
| 33 |
+ .foregroundColor(.primary) |
|
| 34 |
+ |
|
| 35 |
+ Spacer() |
|
| 36 |
+ |
|
| 37 |
+ if isSelected {
|
|
| 38 |
+ Image(systemName: "checkmark.circle.fill") |
|
| 39 |
+ .foregroundColor(.green) |
|
| 40 |
+ } |
|
| 41 |
+ } |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ @ViewBuilder |
|
| 45 |
+ private var summaryText: some View {
|
|
| 46 |
+ Text(chargedDevice.identityTitle) |
|
| 47 |
+ .font(.caption.weight(.semibold)) |
|
| 48 |
+ .foregroundColor(.secondary) |
|
| 49 |
+ |
|
| 50 |
+ if chargedDevice.isCharger {
|
|
| 51 |
+ chargerSummaryText |
|
| 52 |
+ } else {
|
|
| 53 |
+ deviceSummaryText |
|
| 54 |
+ } |
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 57 |
+ @ViewBuilder |
|
| 58 |
+ private var chargerSummaryText: some View {
|
|
| 59 |
+ if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
|
|
| 60 |
+ Text(chargedDevice.chargerObservedVoltageSelections.map { "\($0.format(decimalDigits: 1)) V" }.joined(separator: ", "))
|
|
| 61 |
+ .font(.caption2) |
|
| 62 |
+ .foregroundColor(.secondary) |
|
| 63 |
+ } else if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
|
|
| 64 |
+ Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
|
|
| 65 |
+ .font(.caption2) |
|
| 66 |
+ .foregroundColor(.secondary) |
|
| 67 |
+ } else {
|
|
| 68 |
+ Text("Wireless charger")
|
|
| 69 |
+ .font(.caption2) |
|
| 70 |
+ .foregroundColor(.secondary) |
|
| 71 |
+ } |
|
| 72 |
+ } |
|
| 73 |
+ |
|
| 74 |
+ @ViewBuilder |
|
| 75 |
+ private var deviceSummaryText: some View {
|
|
| 76 |
+ Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")) |
|
| 77 |
+ .font(.caption2) |
|
| 78 |
+ .foregroundColor(.secondary) |
|
| 79 |
+ |
|
| 80 |
+ if let capacity = chargedDevice.estimatedBatteryCapacityWh {
|
|
| 81 |
+ Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
|
|
| 82 |
+ .font(.caption) |
|
| 83 |
+ .foregroundColor(.secondary) |
|
| 84 |
+ } |
|
| 85 |
+ |
|
| 86 |
+ if let minimumCurrent = chargedDevice.minimumCurrentAmps {
|
|
| 87 |
+ Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
|
|
| 88 |
+ .font(.caption2) |
|
| 89 |
+ .foregroundColor(.secondary) |
|
| 90 |
+ } |
|
| 91 |
+ } |
|
| 92 |
+} |
|
@@ -0,0 +1,60 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargedDeviceQRCodeView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 10/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import CoreImage.CIFilterBuiltins |
|
| 9 |
+import SwiftUI |
|
| 10 |
+ |
|
| 11 |
+struct ChargedDeviceQRCodeView: View {
|
|
| 12 |
+ let qrIdentifier: String |
|
| 13 |
+ let side: CGFloat |
|
| 14 |
+ |
|
| 15 |
+ private let context = CIContext() |
|
| 16 |
+ private let filter = CIFilter.qrCodeGenerator() |
|
| 17 |
+ |
|
| 18 |
+ var body: some View {
|
|
| 19 |
+ Group {
|
|
| 20 |
+ if let image = qrImage {
|
|
| 21 |
+ Image(uiImage: image) |
|
| 22 |
+ .interpolation(.none) |
|
| 23 |
+ .resizable() |
|
| 24 |
+ .scaledToFit() |
|
| 25 |
+ } else {
|
|
| 26 |
+ RoundedRectangle(cornerRadius: 14) |
|
| 27 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 28 |
+ .overlay( |
|
| 29 |
+ Image(systemName: "qrcode") |
|
| 30 |
+ .font(.system(size: side * 0.34, weight: .semibold)) |
|
| 31 |
+ .foregroundColor(.secondary) |
|
| 32 |
+ ) |
|
| 33 |
+ } |
|
| 34 |
+ } |
|
| 35 |
+ .frame(width: side, height: side) |
|
| 36 |
+ .padding(8) |
|
| 37 |
+ .background( |
|
| 38 |
+ RoundedRectangle(cornerRadius: 18) |
|
| 39 |
+ .fill(Color.white.opacity(0.92)) |
|
| 40 |
+ ) |
|
| 41 |
+ } |
|
| 42 |
+ |
|
| 43 |
+ private var qrImage: UIImage? {
|
|
| 44 |
+ filter.setValue(Data(qrIdentifier.utf8), forKey: "inputMessage") |
|
| 45 |
+ filter.correctionLevel = "M" |
|
| 46 |
+ |
|
| 47 |
+ guard let outputImage = filter.outputImage else {
|
|
| 48 |
+ return nil |
|
| 49 |
+ } |
|
| 50 |
+ |
|
| 51 |
+ let transform = CGAffineTransform(scaleX: 12, y: 12) |
|
| 52 |
+ let scaledImage = outputImage.transformed(by: transform) |
|
| 53 |
+ |
|
| 54 |
+ guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else {
|
|
| 55 |
+ return nil |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ return UIImage(cgImage: cgImage) |
|
| 59 |
+ } |
|
| 60 |
+} |
|
@@ -0,0 +1,73 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargedDeviceSidebarCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 22/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+struct ChargedDeviceSidebarCardView: View {
|
|
| 11 |
+ let chargedDevice: ChargedDeviceSummary |
|
| 12 |
+ |
|
| 13 |
+ var body: some View {
|
|
| 14 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 15 |
+ ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 54) |
|
| 16 |
+ |
|
| 17 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 18 |
+ header |
|
| 19 |
+ Text(chargedDevice.identityTitle) |
|
| 20 |
+ .font(.caption.weight(.semibold)) |
|
| 21 |
+ .foregroundColor(.secondary) |
|
| 22 |
+ details |
|
| 23 |
+ } |
|
| 24 |
+ } |
|
| 25 |
+ .padding(.vertical, 4) |
|
| 26 |
+ } |
|
| 27 |
+ |
|
| 28 |
+ private var header: some View {
|
|
| 29 |
+ HStack {
|
|
| 30 |
+ ChargedDeviceIdentityLabelView( |
|
| 31 |
+ chargedDevice: chargedDevice, |
|
| 32 |
+ iconPointSize: 17 |
|
| 33 |
+ ) |
|
| 34 |
+ .font(.headline) |
|
| 35 |
+ |
|
| 36 |
+ if chargedDevice.activeSession != nil {
|
|
| 37 |
+ Spacer() |
|
| 38 |
+ Text("Live")
|
|
| 39 |
+ .font(.caption.weight(.bold)) |
|
| 40 |
+ .foregroundColor(.green) |
|
| 41 |
+ } |
|
| 42 |
+ } |
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ @ViewBuilder |
|
| 46 |
+ private var details: some View {
|
|
| 47 |
+ if chargedDevice.isCharger {
|
|
| 48 |
+ if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
|
|
| 49 |
+ Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
|
|
| 50 |
+ .font(.caption2) |
|
| 51 |
+ .foregroundColor(.secondary) |
|
| 52 |
+ } else {
|
|
| 53 |
+ Text("Wireless charger")
|
|
| 54 |
+ .font(.caption2) |
|
| 55 |
+ .foregroundColor(.secondary) |
|
| 56 |
+ } |
|
| 57 |
+ } else {
|
|
| 58 |
+ Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")) |
|
| 59 |
+ .font(.caption2) |
|
| 60 |
+ .foregroundColor(.secondary) |
|
| 61 |
+ |
|
| 62 |
+ if let estimatedCapacityWh = chargedDevice.estimatedBatteryCapacityWh {
|
|
| 63 |
+ Text("Capacity: \(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
|
|
| 64 |
+ .font(.caption2) |
|
| 65 |
+ .foregroundColor(.secondary) |
|
| 66 |
+ } else {
|
|
| 67 |
+ Text("Capacity: learning")
|
|
| 68 |
+ .font(.caption2) |
|
| 69 |
+ .foregroundColor(.secondary) |
|
| 70 |
+ } |
|
| 71 |
+ } |
|
| 72 |
+ } |
|
| 73 |
+} |
|
@@ -0,0 +1,1102 @@ |
||
| 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 |
+ } else {
|
|
| 37 |
+ Text("This device is no longer available.")
|
|
| 38 |
+ .foregroundColor(.secondary) |
|
| 39 |
+ .navigationTitle("Device")
|
|
| 40 |
+ } |
|
| 41 |
+ } |
|
| 42 |
+ .sheet(isPresented: $editorVisibility) {
|
|
| 43 |
+ if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
|
|
| 44 |
+ if chargedDevice.isCharger {
|
|
| 45 |
+ ChargerEditorSheetView(chargedDevice: chargedDevice) |
|
| 46 |
+ .environmentObject(appData) |
|
| 47 |
+ } else {
|
|
| 48 |
+ ChargedDeviceEditorSheetView( |
|
| 49 |
+ meterMACAddress: nil, |
|
| 50 |
+ chargedDevice: chargedDevice |
|
| 51 |
+ ) |
|
| 52 |
+ .environmentObject(appData) |
|
| 53 |
+ } |
|
| 54 |
+ } |
|
| 55 |
+ } |
|
| 56 |
+ .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
|
|
| 57 |
+ Button("Delete", role: .destructive) {
|
|
| 58 |
+ if appData.deleteChargedDevice(id: chargedDeviceID) {
|
|
| 59 |
+ dismiss() |
|
| 60 |
+ } |
|
| 61 |
+ } |
|
| 62 |
+ Button("Cancel", role: .cancel) {}
|
|
| 63 |
+ } message: {
|
|
| 64 |
+ Text(deletionMessage) |
|
| 65 |
+ } |
|
| 66 |
+ .confirmationDialog( |
|
| 67 |
+ "Delete \(selectedSessionIDs.count) Session\(selectedSessionIDs.count == 1 ? "" : "s")?", |
|
| 68 |
+ isPresented: $pendingBatchDeletion, |
|
| 69 |
+ titleVisibility: .visible |
|
| 70 |
+ ) {
|
|
| 71 |
+ Button("Delete", role: .destructive, action: deleteSelectedSessions)
|
|
| 72 |
+ Button("Cancel", role: .cancel) {}
|
|
| 73 |
+ } message: {
|
|
| 74 |
+ Text("Deleting these sessions also recalculates capacity and every derived metric that used them.")
|
|
| 75 |
+ } |
|
| 76 |
+ } |
|
| 77 |
+ |
|
| 78 |
+ private func tabbedDetailView(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 79 |
+ GeometryReader { proxy in
|
|
| 80 |
+ let tabs = availableTabs(for: chargedDevice) |
|
| 81 |
+ let displayedTab = displayedTab(from: tabs) |
|
| 82 |
+ let tabBarPresentation = AdaptiveTabBarPresentation.standard(for: proxy.size) |
|
| 83 |
+ |
|
| 84 |
+ VStack(spacing: 0) {
|
|
| 85 |
+ ChargedDeviceDetailTabBarView( |
|
| 86 |
+ tabs: tabs, |
|
| 87 |
+ selection: $selectedTab, |
|
| 88 |
+ tint: tint(for: chargedDevice), |
|
| 89 |
+ presentation: tabBarPresentation, |
|
| 90 |
+ title: title(for:), |
|
| 91 |
+ systemImage: systemImage(for:) |
|
| 92 |
+ ) |
|
| 93 |
+ |
|
| 94 |
+ Group {
|
|
| 95 |
+ if displayedTab == .sessions {
|
|
| 96 |
+ sessionsTabLayout(chargedDevice) |
|
| 97 |
+ } else {
|
|
| 98 |
+ ScrollView {
|
|
| 99 |
+ tabContent(displayedTab, chargedDevice: chargedDevice) |
|
| 100 |
+ .padding() |
|
| 101 |
+ } |
|
| 102 |
+ } |
|
| 103 |
+ } |
|
| 104 |
+ .id(displayedTab) |
|
| 105 |
+ .transition(.opacity.combined(with: .move(edge: .trailing))) |
|
| 106 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
|
| 107 |
+ } |
|
| 108 |
+ .animation(.easeInOut(duration: 0.22), value: displayedTab) |
|
| 109 |
+ .animation(.easeInOut(duration: 0.22), value: tabs) |
|
| 110 |
+ .onChange(of: selectedTab) { _ in
|
|
| 111 |
+ sessionSelectMode = false |
|
| 112 |
+ selectedSessionIDs.removeAll() |
|
| 113 |
+ } |
|
| 114 |
+ } |
|
| 115 |
+ .background(detailBackground(for: chargedDevice)) |
|
| 116 |
+ .onAppear {
|
|
| 117 |
+ ensureSelectedTabExists(for: chargedDevice) |
|
| 118 |
+ } |
|
| 119 |
+ .onChange(of: chargedDevice.isCharger) { _ in
|
|
| 120 |
+ ensureSelectedTabExists(for: chargedDevice) |
|
| 121 |
+ } |
|
| 122 |
+ } |
|
| 123 |
+ |
|
| 124 |
+ @ViewBuilder |
|
| 125 |
+ private func tabContent(_ tab: DetailTab, chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 126 |
+ VStack(spacing: 18) {
|
|
| 127 |
+ switch tab {
|
|
| 128 |
+ case .overview: |
|
| 129 |
+ overviewTab(chargedDevice) |
|
| 130 |
+ case .standby: |
|
| 131 |
+ standbyTab(chargedDevice) |
|
| 132 |
+ case .sessions: |
|
| 133 |
+ sessionsTab(chargedDevice) |
|
| 134 |
+ case .trends: |
|
| 135 |
+ trendsTab(chargedDevice) |
|
| 136 |
+ case .settings: |
|
| 137 |
+ settingsTab(chargedDevice) |
|
| 138 |
+ } |
|
| 139 |
+ } |
|
| 140 |
+ } |
|
| 141 |
+ |
|
| 142 |
+ @ViewBuilder |
|
| 143 |
+ private func overviewTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 144 |
+ headerCard(chargedDevice) |
|
| 145 |
+ insightsCard(chargedDevice) |
|
| 146 |
+ |
|
| 147 |
+ if let activeSession = chargedDevice.activeSession {
|
|
| 148 |
+ activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice) |
|
| 149 |
+ } |
|
| 150 |
+ } |
|
| 151 |
+ |
|
| 152 |
+ @ViewBuilder |
|
| 153 |
+ private func standbyTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 154 |
+ standbyPowerCard(chargedDevice) |
|
| 155 |
+ } |
|
| 156 |
+ |
|
| 157 |
+ @ViewBuilder |
|
| 158 |
+ private func sessionsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 159 |
+ if let activeSession = chargedDevice.activeSession {
|
|
| 160 |
+ activeSessionSummaryCard(activeSession, chargedDevice: chargedDevice) |
|
| 161 |
+ } |
|
| 162 |
+ |
|
| 163 |
+ let sessions = closedSessions(for: chargedDevice) |
|
| 164 |
+ if !sessions.isEmpty {
|
|
| 165 |
+ sessionListCard(sessions, chargedDevice: chargedDevice) |
|
| 166 |
+ } else if chargedDevice.activeSession == nil {
|
|
| 167 |
+ emptyStateCard( |
|
| 168 |
+ title: "No Sessions", |
|
| 169 |
+ message: "Charging sessions will appear here after this device is used in a recording.", |
|
| 170 |
+ tint: .teal |
|
| 171 |
+ ) |
|
| 172 |
+ } |
|
| 173 |
+ } |
|
| 174 |
+ |
|
| 175 |
+ @ViewBuilder |
|
| 176 |
+ private func trendsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 177 |
+ if !chargedDevice.capacityHistory.isEmpty {
|
|
| 178 |
+ capacityEvolutionCard(chargedDevice) |
|
| 179 |
+ } |
|
| 180 |
+ |
|
| 181 |
+ if !chargedDevice.typicalCurve.isEmpty {
|
|
| 182 |
+ typicalCurveCard(chargedDevice) |
|
| 183 |
+ } |
|
| 184 |
+ |
|
| 185 |
+ if chargedDevice.capacityHistory.isEmpty && chargedDevice.typicalCurve.isEmpty {
|
|
| 186 |
+ emptyStateCard( |
|
| 187 |
+ title: "Learning Trends", |
|
| 188 |
+ message: "Capacity history and charge curves will appear after enough completed sessions are available.", |
|
| 189 |
+ tint: .blue |
|
| 190 |
+ ) |
|
| 191 |
+ } |
|
| 192 |
+ } |
|
| 193 |
+ |
|
| 194 |
+ @ViewBuilder |
|
| 195 |
+ private func settingsTab(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 196 |
+ settingsCard(chargedDevice) |
|
| 197 |
+ } |
|
| 198 |
+ |
|
| 199 |
+ @ViewBuilder |
|
| 200 |
+ private func sessionsTabLayout(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 201 |
+ let allSessions = chargedDevice.sessions.sorted { lhs, rhs in
|
|
| 202 |
+ let lOpen = lhs.status.isOpen, rOpen = rhs.status.isOpen |
|
| 203 |
+ if lOpen != rOpen { return lOpen }
|
|
| 204 |
+ return lhs.startedAt > rhs.startedAt |
|
| 205 |
+ } |
|
| 206 |
+ let totalEnergyWh = allSessions.reduce(0.0) { $0 + $1.effectiveOrMeasuredEnergyWh }
|
|
| 207 |
+ let totalDuration = allSessions.reduce(0.0) { $0 + max($1.effectiveDuration, 0) }
|
|
| 208 |
+ |
|
| 209 |
+ VStack(spacing: 0) {
|
|
| 210 |
+ // Fixed non-scrolling header |
|
| 211 |
+ VStack(spacing: 10) {
|
|
| 212 |
+ sessionsSummaryStrip( |
|
| 213 |
+ count: allSessions.count, |
|
| 214 |
+ totalEnergyWh: totalEnergyWh, |
|
| 215 |
+ totalDuration: totalDuration, |
|
| 216 |
+ hasActive: chargedDevice.activeSession != nil |
|
| 217 |
+ ) |
|
| 218 |
+ |
|
| 219 |
+ if !allSessions.isEmpty {
|
|
| 220 |
+ HStack(spacing: 12) {
|
|
| 221 |
+ if sessionSelectMode && !selectedSessionIDs.isEmpty {
|
|
| 222 |
+ Text("\(selectedSessionIDs.count) selected")
|
|
| 223 |
+ .font(.subheadline) |
|
| 224 |
+ .foregroundColor(.secondary) |
|
| 225 |
+ .transition(.opacity.combined(with: .move(edge: .leading))) |
|
| 226 |
+ } |
|
| 227 |
+ Spacer() |
|
| 228 |
+ if sessionSelectMode && !selectedSessionIDs.isEmpty {
|
|
| 229 |
+ Button {
|
|
| 230 |
+ pendingBatchDeletion = true |
|
| 231 |
+ } label: {
|
|
| 232 |
+ Image(systemName: "trash").foregroundColor(.red) |
|
| 233 |
+ } |
|
| 234 |
+ .transition(.opacity.combined(with: .scale)) |
|
| 235 |
+ } |
|
| 236 |
+ Button(sessionSelectMode ? "Cancel" : "Select") {
|
|
| 237 |
+ withAnimation(.easeInOut(duration: 0.2)) {
|
|
| 238 |
+ sessionSelectMode.toggle() |
|
| 239 |
+ if !sessionSelectMode { selectedSessionIDs.removeAll() }
|
|
| 240 |
+ } |
|
| 241 |
+ } |
|
| 242 |
+ } |
|
| 243 |
+ .animation(.easeInOut(duration: 0.2), value: sessionSelectMode) |
|
| 244 |
+ .animation(.easeInOut(duration: 0.2), value: selectedSessionIDs.isEmpty) |
|
| 245 |
+ } |
|
| 246 |
+ } |
|
| 247 |
+ .padding() |
|
| 248 |
+ |
|
| 249 |
+ // Scrollable session list |
|
| 250 |
+ if allSessions.isEmpty {
|
|
| 251 |
+ emptyStateCard( |
|
| 252 |
+ title: "No Sessions", |
|
| 253 |
+ message: "Charging sessions will appear here after this device is used in a recording.", |
|
| 254 |
+ tint: .teal |
|
| 255 |
+ ) |
|
| 256 |
+ .padding([.horizontal, .bottom]) |
|
| 257 |
+ } else {
|
|
| 258 |
+ ScrollView {
|
|
| 259 |
+ VStack(spacing: 10) {
|
|
| 260 |
+ ForEach(allSessions, id: \.id) { session in
|
|
| 261 |
+ sessionListItem(session, chargedDevice: chargedDevice) |
|
| 262 |
+ } |
|
| 263 |
+ } |
|
| 264 |
+ .padding([.horizontal, .bottom]) |
|
| 265 |
+ } |
|
| 266 |
+ } |
|
| 267 |
+ } |
|
| 268 |
+ } |
|
| 269 |
+ |
|
| 270 |
+ private func sessionsSummaryStrip( |
|
| 271 |
+ count: Int, |
|
| 272 |
+ totalEnergyWh: Double, |
|
| 273 |
+ totalDuration: TimeInterval, |
|
| 274 |
+ hasActive: Bool |
|
| 275 |
+ ) -> some View {
|
|
| 276 |
+ HStack(spacing: 0) {
|
|
| 277 |
+ summaryCell(value: "\(count)", label: count == 1 ? "session" : "sessions") |
|
| 278 |
+ Divider().frame(height: 30) |
|
| 279 |
+ summaryCell(value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh", label: "energy") |
|
| 280 |
+ Divider().frame(height: 30) |
|
| 281 |
+ summaryCell(value: formatAccumulatedDuration(totalDuration), label: "duration") |
|
| 282 |
+ if hasActive {
|
|
| 283 |
+ Divider().frame(height: 30) |
|
| 284 |
+ HStack(spacing: 4) {
|
|
| 285 |
+ Circle().fill(Color.green).frame(width: 6, height: 6) |
|
| 286 |
+ Text("Live")
|
|
| 287 |
+ .font(.caption2.weight(.semibold)) |
|
| 288 |
+ .foregroundColor(.green) |
|
| 289 |
+ } |
|
| 290 |
+ .frame(maxWidth: .infinity) |
|
| 291 |
+ } |
|
| 292 |
+ } |
|
| 293 |
+ .padding(.vertical, 8) |
|
| 294 |
+ .padding(.horizontal, 12) |
|
| 295 |
+ .meterCard(tint: .teal, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 14) |
|
| 296 |
+ } |
|
| 297 |
+ |
|
| 298 |
+ private func summaryCell(value: String, label: String) -> some View {
|
|
| 299 |
+ VStack(spacing: 2) {
|
|
| 300 |
+ Text(value) |
|
| 301 |
+ .font(.subheadline.weight(.bold)) |
|
| 302 |
+ .foregroundColor(.primary) |
|
| 303 |
+ .monospacedDigit() |
|
| 304 |
+ .lineLimit(1) |
|
| 305 |
+ .minimumScaleFactor(0.7) |
|
| 306 |
+ Text(label) |
|
| 307 |
+ .font(.caption2) |
|
| 308 |
+ .foregroundColor(.secondary) |
|
| 309 |
+ } |
|
| 310 |
+ .frame(maxWidth: .infinity) |
|
| 311 |
+ } |
|
| 312 |
+ |
|
| 313 |
+ private func formatAccumulatedDuration(_ duration: TimeInterval) -> String {
|
|
| 314 |
+ let formatter = DateComponentsFormatter() |
|
| 315 |
+ formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 316 |
+ formatter.unitsStyle = .abbreviated |
|
| 317 |
+ formatter.zeroFormattingBehavior = .dropAll |
|
| 318 |
+ return formatter.string(from: duration) ?? "0m" |
|
| 319 |
+ } |
|
| 320 |
+ |
|
| 321 |
+ private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 322 |
+ HStack(alignment: .top, spacing: 18) {
|
|
| 323 |
+ ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118) |
|
| 324 |
+ |
|
| 325 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 326 |
+ ChargedDeviceIdentityLabelView( |
|
| 327 |
+ chargedDevice: chargedDevice, |
|
| 328 |
+ iconPointSize: 22 |
|
| 329 |
+ ) |
|
| 330 |
+ .font(.title3.weight(.bold)) |
|
| 331 |
+ |
|
| 332 |
+ Text(chargedDevice.identityTitle) |
|
| 333 |
+ .font(.subheadline.weight(.semibold)) |
|
| 334 |
+ .foregroundColor(.secondary) |
|
| 335 |
+ |
|
| 336 |
+ if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
|
|
| 337 |
+ Text("Default meter: \(meterMAC)")
|
|
| 338 |
+ .font(.caption) |
|
| 339 |
+ .foregroundColor(.secondary) |
|
| 340 |
+ } |
|
| 341 |
+ |
|
| 342 |
+ Text(chargedDevice.qrIdentifier) |
|
| 343 |
+ .font(.caption2.monospaced()) |
|
| 344 |
+ .foregroundColor(.secondary) |
|
| 345 |
+ .textSelection(.enabled) |
|
| 346 |
+ } |
|
| 347 |
+ |
|
| 348 |
+ Spacer(minLength: 0) |
|
| 349 |
+ } |
|
| 350 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 351 |
+ .padding(18) |
|
| 352 |
+ .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20) |
|
| 353 |
+ } |
|
| 354 |
+ |
|
| 355 |
+ private func settingsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 356 |
+ MeterInfoCardView(title: "Settings", tint: tint(for: chargedDevice)) {
|
|
| 357 |
+ MeterInfoRowView( |
|
| 358 |
+ label: "Kind", |
|
| 359 |
+ value: chargedDevice.isCharger ? "Charger" : chargedDevice.deviceClass.title |
|
| 360 |
+ ) |
|
| 361 |
+ MeterInfoRowView(label: "Template", value: chargedDevice.identityTitle) |
|
| 362 |
+ MeterInfoRowView(label: "QR ID", value: chargedDevice.qrIdentifier) |
|
| 363 |
+ |
|
| 364 |
+ if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
|
|
| 365 |
+ MeterInfoRowView(label: "Default Meter", value: meterMAC) |
|
| 366 |
+ } |
|
| 367 |
+ |
|
| 368 |
+ MeterInfoRowView(label: "Created", value: chargedDevice.createdAt.format()) |
|
| 369 |
+ MeterInfoRowView(label: "Updated", value: chargedDevice.updatedAt.format()) |
|
| 370 |
+ |
|
| 371 |
+ Divider() |
|
| 372 |
+ |
|
| 373 |
+ Button(action: showEditor) {
|
|
| 374 |
+ Label("Edit \(chargedDevice.isCharger ? "Charger" : "Device")", systemImage: "pencil")
|
|
| 375 |
+ .font(.subheadline.weight(.semibold)) |
|
| 376 |
+ .frame(maxWidth: .infinity) |
|
| 377 |
+ .padding(.vertical, 10) |
|
| 378 |
+ .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 379 |
+ } |
|
| 380 |
+ .buttonStyle(.plain) |
|
| 381 |
+ |
|
| 382 |
+ Button(role: .destructive, action: showDeleteConfirmation) {
|
|
| 383 |
+ Label("Delete \(chargedDevice.isCharger ? "Charger" : "Device")", systemImage: "trash")
|
|
| 384 |
+ .font(.subheadline.weight(.semibold)) |
|
| 385 |
+ .frame(maxWidth: .infinity) |
|
| 386 |
+ .padding(.vertical, 10) |
|
| 387 |
+ .meterCard(tint: .red, fillOpacity: 0.10, strokeOpacity: 0.18, cornerRadius: 14) |
|
| 388 |
+ } |
|
| 389 |
+ .buttonStyle(.plain) |
|
| 390 |
+ } |
|
| 391 |
+ } |
|
| 392 |
+ |
|
| 393 |
+ private func emptyStateCard(title: String, message: String, tint: Color) -> some View {
|
|
| 394 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 395 |
+ Text(title) |
|
| 396 |
+ .font(.headline) |
|
| 397 |
+ Text(message) |
|
| 398 |
+ .font(.footnote) |
|
| 399 |
+ .foregroundColor(.secondary) |
|
| 400 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 401 |
+ } |
|
| 402 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 403 |
+ .padding(18) |
|
| 404 |
+ .meterCard(tint: tint, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18) |
|
| 405 |
+ } |
|
| 406 |
+ |
|
| 407 |
+ private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 408 |
+ MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
|
|
| 409 |
+ if chargedDevice.isCharger {
|
|
| 410 |
+ chargerInsights(chargedDevice) |
|
| 411 |
+ } else {
|
|
| 412 |
+ deviceInsights(chargedDevice) |
|
| 413 |
+ } |
|
| 414 |
+ |
|
| 415 |
+ if let notes = chargedDevice.notes, !notes.isEmpty {
|
|
| 416 |
+ Divider() |
|
| 417 |
+ Text(notes) |
|
| 418 |
+ .font(.footnote) |
|
| 419 |
+ .foregroundColor(.secondary) |
|
| 420 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 421 |
+ } |
|
| 422 |
+ } |
|
| 423 |
+ } |
|
| 424 |
+ |
|
| 425 |
+ @ViewBuilder |
|
| 426 |
+ private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 427 |
+ if chargedDevice.hasMultipleChargingStateModes {
|
|
| 428 |
+ MeterInfoRowView( |
|
| 429 |
+ label: "Charge Modes", |
|
| 430 |
+ value: chargedDevice.chargingStateAvailability.title |
|
| 431 |
+ ) |
|
| 432 |
+ } |
|
| 433 |
+ if chargedDevice.hasMultipleChargingTransports {
|
|
| 434 |
+ MeterInfoRowView( |
|
| 435 |
+ label: "Charging Support", |
|
| 436 |
+ value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ") |
|
| 437 |
+ ) |
|
| 438 |
+ } |
|
| 439 |
+ if chargedDevice.showsWirelessProfileDetails {
|
|
| 440 |
+ MeterInfoRowView( |
|
| 441 |
+ label: "Wireless Profile", |
|
| 442 |
+ value: chargedDevice.wirelessChargingProfile.title |
|
| 443 |
+ ) |
|
| 444 |
+ } |
|
| 445 |
+ |
|
| 446 |
+ ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
|
|
| 447 |
+ MeterInfoRowView( |
|
| 448 |
+ label: completionCurrentLabel(for: chargedDevice, sessionKind: sessionKind), |
|
| 449 |
+ value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind) |
|
| 450 |
+ ) |
|
| 451 |
+ } |
|
| 452 |
+ MeterInfoRowView( |
|
| 453 |
+ label: "Estimated Capacity", |
|
| 454 |
+ value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
|
|
| 455 |
+ ) |
|
| 456 |
+ if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
|
|
| 457 |
+ if chargedDevice.hasMultipleChargingTransports {
|
|
| 458 |
+ MeterInfoRowView( |
|
| 459 |
+ label: "Wired Capacity", |
|
| 460 |
+ value: "\(wiredCapacity.format(decimalDigits: 2)) Wh" |
|
| 461 |
+ ) |
|
| 462 |
+ } |
|
| 463 |
+ } |
|
| 464 |
+ if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
|
|
| 465 |
+ if chargedDevice.hasMultipleChargingTransports {
|
|
| 466 |
+ MeterInfoRowView( |
|
| 467 |
+ label: "Wireless Capacity", |
|
| 468 |
+ value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh" |
|
| 469 |
+ ) |
|
| 470 |
+ } |
|
| 471 |
+ } |
|
| 472 |
+ if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor, |
|
| 473 |
+ chargedDevice.showsWirelessProfileDetails {
|
|
| 474 |
+ MeterInfoRowView( |
|
| 475 |
+ label: "Wireless Efficiency", |
|
| 476 |
+ value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%" |
|
| 477 |
+ ) |
|
| 478 |
+ } |
|
| 479 |
+ MeterInfoRowView( |
|
| 480 |
+ label: "Charge Sessions", |
|
| 481 |
+ value: "\(chargedDevice.sessionCount)" |
|
| 482 |
+ ) |
|
| 483 |
+ } |
|
| 484 |
+ |
|
| 485 |
+ @ViewBuilder |
|
| 486 |
+ private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 487 |
+ if let chargerType = chargedDevice.chargerType {
|
|
| 488 |
+ MeterInfoRowView( |
|
| 489 |
+ label: "Type", |
|
| 490 |
+ value: chargerType.title |
|
| 491 |
+ ) |
|
| 492 |
+ } |
|
| 493 |
+ if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
|
|
| 494 |
+ MeterInfoRowView( |
|
| 495 |
+ label: "Observed Voltages", |
|
| 496 |
+ value: chargedDevice.chargerObservedVoltageSelections |
|
| 497 |
+ .map { "\($0.format(decimalDigits: 1)) V" }
|
|
| 498 |
+ .joined(separator: ", ") |
|
| 499 |
+ ) |
|
| 500 |
+ } |
|
| 501 |
+ if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
|
|
| 502 |
+ MeterInfoRowView( |
|
| 503 |
+ label: "Idle Current", |
|
| 504 |
+ value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A" |
|
| 505 |
+ ) |
|
| 506 |
+ } |
|
| 507 |
+ if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
|
|
| 508 |
+ MeterInfoRowView( |
|
| 509 |
+ label: "Efficiency", |
|
| 510 |
+ value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%" |
|
| 511 |
+ ) |
|
| 512 |
+ } |
|
| 513 |
+ if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
|
|
| 514 |
+ MeterInfoRowView( |
|
| 515 |
+ label: "Max Power", |
|
| 516 |
+ value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W" |
|
| 517 |
+ ) |
|
| 518 |
+ } |
|
| 519 |
+ if let latestStandbyPowerMeasurement = chargedDevice.latestStandbyPowerMeasurement {
|
|
| 520 |
+ MeterInfoRowView( |
|
| 521 |
+ label: "Standby Power", |
|
| 522 |
+ value: "\(latestStandbyPowerMeasurement.averagePowerWatts.format(decimalDigits: 3)) W" |
|
| 523 |
+ ) |
|
| 524 |
+ MeterInfoRowView( |
|
| 525 |
+ label: "Standby Projection", |
|
| 526 |
+ value: standbyEnergyLabel(latestStandbyPowerMeasurement.projectedYearlyEnergyWh) + " / year" |
|
| 527 |
+ ) |
|
| 528 |
+ } |
|
| 529 |
+ MeterInfoRowView( |
|
| 530 |
+ label: "Wireless Sessions", |
|
| 531 |
+ value: "\(chargedDevice.sessionCount)" |
|
| 532 |
+ ) |
|
| 533 |
+ |
|
| 534 |
+ } |
|
| 535 |
+ |
|
| 536 |
+ private func standbyPowerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 537 |
+ let latestMeasurement = chargedDevice.latestStandbyPowerMeasurement |
|
| 538 |
+ |
|
| 539 |
+ return MeterInfoCardView( |
|
| 540 |
+ title: "Standby Power", |
|
| 541 |
+ tint: .orange |
|
| 542 |
+ ) {
|
|
| 543 |
+ if standbyMeasurementMeters.isEmpty {
|
|
| 544 |
+ Text("Connect a meter first. Standby measurement is launched from a live meter feed, so only currently available meters can be selected here.")
|
|
| 545 |
+ .font(.footnote) |
|
| 546 |
+ .foregroundColor(.secondary) |
|
| 547 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 548 |
+ } else {
|
|
| 549 |
+ NavigationLink( |
|
| 550 |
+ destination: ChargerStandbyPowerWizardView( |
|
| 551 |
+ preferredChargerID: chargedDevice.id, |
|
| 552 |
+ locksChargerSelection: true |
|
| 553 |
+ ) |
|
| 554 |
+ ) {
|
|
| 555 |
+ Label("New Measurement", systemImage: "plus.circle.fill")
|
|
| 556 |
+ .font(.subheadline.weight(.semibold)) |
|
| 557 |
+ .foregroundColor(.orange) |
|
| 558 |
+ } |
|
| 559 |
+ .buttonStyle(.plain) |
|
| 560 |
+ } |
|
| 561 |
+ |
|
| 562 |
+ if let latestMeasurement {
|
|
| 563 |
+ Divider() |
|
| 564 |
+ |
|
| 565 |
+ NavigationLink( |
|
| 566 |
+ destination: ChargerStandbyPowerMeasurementDetailView( |
|
| 567 |
+ chargerID: chargedDevice.id, |
|
| 568 |
+ measurementID: latestMeasurement.id |
|
| 569 |
+ ) |
|
| 570 |
+ ) {
|
|
| 571 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 572 |
+ HStack {
|
|
| 573 |
+ Text("Latest Measurement")
|
|
| 574 |
+ .font(.subheadline.weight(.semibold)) |
|
| 575 |
+ .foregroundColor(.primary) |
|
| 576 |
+ Spacer() |
|
| 577 |
+ Text("\(latestMeasurement.averagePowerWatts.format(decimalDigits: 3)) W")
|
|
| 578 |
+ .font(.subheadline.weight(.bold)) |
|
| 579 |
+ .foregroundColor(.primary) |
|
| 580 |
+ .monospacedDigit() |
|
| 581 |
+ } |
|
| 582 |
+ |
|
| 583 |
+ Text( |
|
| 584 |
+ "\(latestMeasurement.endedAt.format()) • \(latestMeasurement.sampleCount) samples • \(standbyEnergyLabel(latestMeasurement.projectedYearlyEnergyWh)) / year" |
|
| 585 |
+ ) |
|
| 586 |
+ .font(.caption) |
|
| 587 |
+ .foregroundColor(.secondary) |
|
| 588 |
+ } |
|
| 589 |
+ } |
|
| 590 |
+ .buttonStyle(.plain) |
|
| 591 |
+ } |
|
| 592 |
+ |
|
| 593 |
+ if chargedDevice.standbyPowerMeasurements.isEmpty == false {
|
|
| 594 |
+ Divider() |
|
| 595 |
+ |
|
| 596 |
+ NavigationLink( |
|
| 597 |
+ destination: ChargerStandbyPowerMeasurementsView(chargerID: chargedDevice.id) |
|
| 598 |
+ ) {
|
|
| 599 |
+ Label("View Saved Measurements", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
|
|
| 600 |
+ .font(.subheadline.weight(.semibold)) |
|
| 601 |
+ .foregroundColor(.blue) |
|
| 602 |
+ } |
|
| 603 |
+ .buttonStyle(.plain) |
|
| 604 |
+ } |
|
| 605 |
+ } |
|
| 606 |
+ } |
|
| 607 |
+ |
|
| 608 |
+ private func activeSessionSummaryCard( |
|
| 609 |
+ _ activeSession: ChargeSessionSummary, |
|
| 610 |
+ chargedDevice: ChargedDeviceSummary |
|
| 611 |
+ ) -> some View {
|
|
| 612 |
+ NavigationLink( |
|
| 613 |
+ destination: ChargeSessionDetailView( |
|
| 614 |
+ chargedDeviceID: chargedDevice.id, |
|
| 615 |
+ sessionID: activeSession.id |
|
| 616 |
+ ) |
|
| 617 |
+ ) {
|
|
| 618 |
+ VStack(alignment: .leading, spacing: 14) {
|
|
| 619 |
+ HStack(alignment: .firstTextBaseline) {
|
|
| 620 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 621 |
+ Text("Current Session")
|
|
| 622 |
+ .font(.headline) |
|
| 623 |
+ .foregroundColor(.primary) |
|
| 624 |
+ Text(activeSession.status.title) |
|
| 625 |
+ .font(.caption.weight(.semibold)) |
|
| 626 |
+ .foregroundColor(statusTint(for: activeSession)) |
|
| 627 |
+ } |
|
| 628 |
+ |
|
| 629 |
+ Spacer() |
|
| 630 |
+ |
|
| 631 |
+ Image(systemName: "chevron.right") |
|
| 632 |
+ .font(.caption.weight(.semibold)) |
|
| 633 |
+ .foregroundColor(.secondary) |
|
| 634 |
+ } |
|
| 635 |
+ |
|
| 636 |
+ LazyVGrid(columns: activeSessionSummaryColumns, spacing: 8) {
|
|
| 637 |
+ activeSessionMetricCell( |
|
| 638 |
+ label: "Energy", |
|
| 639 |
+ value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", |
|
| 640 |
+ tint: .teal |
|
| 641 |
+ ) |
|
| 642 |
+ activeSessionMetricCell( |
|
| 643 |
+ label: "Duration", |
|
| 644 |
+ value: sessionDurationText(activeSession), |
|
| 645 |
+ tint: .orange |
|
| 646 |
+ ) |
|
| 647 |
+ if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
|
|
| 648 |
+ activeSessionMetricCell( |
|
| 649 |
+ label: "Max Power", |
|
| 650 |
+ value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W", |
|
| 651 |
+ tint: .blue |
|
| 652 |
+ ) |
|
| 653 |
+ } |
|
| 654 |
+ if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
|
|
| 655 |
+ activeSessionMetricCell( |
|
| 656 |
+ label: "Battery", |
|
| 657 |
+ value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%", |
|
| 658 |
+ tint: .green |
|
| 659 |
+ ) |
|
| 660 |
+ } else if let targetBatteryPercent = activeSession.targetBatteryPercent {
|
|
| 661 |
+ activeSessionMetricCell( |
|
| 662 |
+ label: "Target", |
|
| 663 |
+ value: "\(targetBatteryPercent.format(decimalDigits: 0))%", |
|
| 664 |
+ tint: .indigo |
|
| 665 |
+ ) |
|
| 666 |
+ } |
|
| 667 |
+ } |
|
| 668 |
+ |
|
| 669 |
+ Text("Started \(activeSession.startedAt.format())")
|
|
| 670 |
+ .font(.caption) |
|
| 671 |
+ .foregroundColor(.secondary) |
|
| 672 |
+ } |
|
| 673 |
+ } |
|
| 674 |
+ .buttonStyle(.plain) |
|
| 675 |
+ .padding(18) |
|
| 676 |
+ .meterCard(tint: statusTint(for: activeSession), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) |
|
| 677 |
+ } |
|
| 678 |
+ |
|
| 679 |
+ private var activeSessionSummaryColumns: [GridItem] {
|
|
| 680 |
+ [ |
|
| 681 |
+ GridItem(.flexible(minimum: 92), spacing: 8), |
|
| 682 |
+ GridItem(.flexible(minimum: 92), spacing: 8) |
|
| 683 |
+ ] |
|
| 684 |
+ } |
|
| 685 |
+ |
|
| 686 |
+ private func activeSessionMetricCell(label: String, value: String, tint: Color) -> some View {
|
|
| 687 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 688 |
+ Text(label) |
|
| 689 |
+ .font(.caption2) |
|
| 690 |
+ .foregroundColor(.secondary) |
|
| 691 |
+ Text(value) |
|
| 692 |
+ .font(.footnote.weight(.semibold)) |
|
| 693 |
+ .foregroundColor(.primary) |
|
| 694 |
+ .monospacedDigit() |
|
| 695 |
+ .lineLimit(1) |
|
| 696 |
+ .minimumScaleFactor(0.8) |
|
| 697 |
+ } |
|
| 698 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 699 |
+ .padding(10) |
|
| 700 |
+ .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) |
|
| 701 |
+ } |
|
| 702 |
+ |
|
| 703 |
+ private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 704 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 705 |
+ Text("Capacity Evolution")
|
|
| 706 |
+ .font(.headline) |
|
| 707 |
+ |
|
| 708 |
+ ForEach(chargedDevice.capacityHistory.suffix(6)) { point in
|
|
| 709 |
+ HStack {
|
|
| 710 |
+ Text(point.timestamp.format()) |
|
| 711 |
+ .font(.caption) |
|
| 712 |
+ .foregroundColor(.secondary) |
|
| 713 |
+ Spacer() |
|
| 714 |
+ if chargedDevice.shouldShowChargingTransport(point.chargingTransportMode) {
|
|
| 715 |
+ Text(point.chargingTransportMode.title) |
|
| 716 |
+ .font(.caption2) |
|
| 717 |
+ .foregroundColor(.secondary) |
|
| 718 |
+ Text("•")
|
|
| 719 |
+ .foregroundColor(.secondary) |
|
| 720 |
+ } |
|
| 721 |
+ Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
|
|
| 722 |
+ .font(.footnote.weight(.semibold)) |
|
| 723 |
+ } |
|
| 724 |
+ } |
|
| 725 |
+ } |
|
| 726 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 727 |
+ .padding(18) |
|
| 728 |
+ .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) |
|
| 729 |
+ } |
|
| 730 |
+ |
|
| 731 |
+ private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 732 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 733 |
+ Text("Typical Charge Curve")
|
|
| 734 |
+ .font(.headline) |
|
| 735 |
+ |
|
| 736 |
+ ForEach(chargedDevice.typicalCurve) { point in
|
|
| 737 |
+ HStack {
|
|
| 738 |
+ Text("\(point.percentBin)%")
|
|
| 739 |
+ .font(.footnote.weight(.semibold)) |
|
| 740 |
+ Spacer() |
|
| 741 |
+ Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
|
|
| 742 |
+ .font(.caption.weight(.semibold)) |
|
| 743 |
+ Text("•")
|
|
| 744 |
+ .foregroundColor(.secondary) |
|
| 745 |
+ Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
|
|
| 746 |
+ .font(.caption2) |
|
| 747 |
+ .foregroundColor(.secondary) |
|
| 748 |
+ } |
|
| 749 |
+ } |
|
| 750 |
+ } |
|
| 751 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 752 |
+ .padding(18) |
|
| 753 |
+ .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) |
|
| 754 |
+ } |
|
| 755 |
+ |
|
| 756 |
+ private func sessionListCard( |
|
| 757 |
+ _ sessions: [ChargeSessionSummary], |
|
| 758 |
+ chargedDevice: ChargedDeviceSummary |
|
| 759 |
+ ) -> some View {
|
|
| 760 |
+ let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
|
|
| 761 |
+ let completedCount = sessions.filter { $0.status == .completed }.count
|
|
| 762 |
+ let sortedSessions = sessions.sorted { $0.startedAt > $1.startedAt }
|
|
| 763 |
+ |
|
| 764 |
+ return VStack(alignment: .leading, spacing: 14) {
|
|
| 765 |
+ MeterInfoCardView(title: "Closed Sessions", tint: .teal) {
|
|
| 766 |
+ MeterInfoRowView(label: "Sessions", value: "\(sessions.count)") |
|
| 767 |
+ MeterInfoRowView(label: "Completed", value: "\(completedCount)") |
|
| 768 |
+ MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh") |
|
| 769 |
+ } |
|
| 770 |
+ |
|
| 771 |
+ HStack(spacing: 12) {
|
|
| 772 |
+ if sessionSelectMode && !selectedSessionIDs.isEmpty {
|
|
| 773 |
+ Text("\(selectedSessionIDs.count) selected")
|
|
| 774 |
+ .font(.subheadline) |
|
| 775 |
+ .foregroundColor(.secondary) |
|
| 776 |
+ .transition(.opacity.combined(with: .move(edge: .leading))) |
|
| 777 |
+ } |
|
| 778 |
+ Spacer() |
|
| 779 |
+ if sessionSelectMode && !selectedSessionIDs.isEmpty {
|
|
| 780 |
+ Button {
|
|
| 781 |
+ pendingBatchDeletion = true |
|
| 782 |
+ } label: {
|
|
| 783 |
+ Image(systemName: "trash") |
|
| 784 |
+ .foregroundColor(.red) |
|
| 785 |
+ } |
|
| 786 |
+ .transition(.opacity.combined(with: .scale)) |
|
| 787 |
+ } |
|
| 788 |
+ Button(sessionSelectMode ? "Cancel" : "Select") {
|
|
| 789 |
+ withAnimation(.easeInOut(duration: 0.2)) {
|
|
| 790 |
+ sessionSelectMode.toggle() |
|
| 791 |
+ if !sessionSelectMode { selectedSessionIDs.removeAll() }
|
|
| 792 |
+ } |
|
| 793 |
+ } |
|
| 794 |
+ } |
|
| 795 |
+ .animation(.easeInOut(duration: 0.2), value: sessionSelectMode) |
|
| 796 |
+ .animation(.easeInOut(duration: 0.2), value: selectedSessionIDs.isEmpty) |
|
| 797 |
+ |
|
| 798 |
+ VStack(spacing: 10) {
|
|
| 799 |
+ ForEach(sortedSessions, id: \.id) { session in
|
|
| 800 |
+ sessionListItem(session, chargedDevice: chargedDevice) |
|
| 801 |
+ } |
|
| 802 |
+ } |
|
| 803 |
+ } |
|
| 804 |
+ } |
|
| 805 |
+ |
|
| 806 |
+ private func sessionListItem( |
|
| 807 |
+ _ session: ChargeSessionSummary, |
|
| 808 |
+ chargedDevice: ChargedDeviceSummary |
|
| 809 |
+ ) -> some View {
|
|
| 810 |
+ let sessionTint = statusTint(for: session) |
|
| 811 |
+ let isOpen = session.status.isOpen |
|
| 812 |
+ let isSelected = selectedSessionIDs.contains(session.id) |
|
| 813 |
+ |
|
| 814 |
+ return Group {
|
|
| 815 |
+ if sessionSelectMode && !isOpen {
|
|
| 816 |
+ Button {
|
|
| 817 |
+ withAnimation(.easeInOut(duration: 0.15)) {
|
|
| 818 |
+ if isSelected { selectedSessionIDs.remove(session.id) }
|
|
| 819 |
+ else { selectedSessionIDs.insert(session.id) }
|
|
| 820 |
+ } |
|
| 821 |
+ } label: {
|
|
| 822 |
+ sessionRowContent(session, sessionTint: sessionTint, isOpen: isOpen, isSelected: isSelected) |
|
| 823 |
+ } |
|
| 824 |
+ .buttonStyle(.plain) |
|
| 825 |
+ } else {
|
|
| 826 |
+ NavigationLink( |
|
| 827 |
+ destination: ChargeSessionDetailView( |
|
| 828 |
+ chargedDeviceID: chargedDevice.id, |
|
| 829 |
+ sessionID: session.id |
|
| 830 |
+ ) |
|
| 831 |
+ ) {
|
|
| 832 |
+ sessionRowContent(session, sessionTint: sessionTint, isOpen: isOpen, isSelected: false) |
|
| 833 |
+ } |
|
| 834 |
+ .buttonStyle(.plain) |
|
| 835 |
+ } |
|
| 836 |
+ } |
|
| 837 |
+ } |
|
| 838 |
+ |
|
| 839 |
+ private func sessionRowContent( |
|
| 840 |
+ _ session: ChargeSessionSummary, |
|
| 841 |
+ sessionTint: Color, |
|
| 842 |
+ isOpen: Bool, |
|
| 843 |
+ isSelected: Bool |
|
| 844 |
+ ) -> some View {
|
|
| 845 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 846 |
+ HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
| 847 |
+ if sessionSelectMode {
|
|
| 848 |
+ Group {
|
|
| 849 |
+ if isOpen {
|
|
| 850 |
+ Image(systemName: "minus.circle") |
|
| 851 |
+ .foregroundColor(.secondary.opacity(0.35)) |
|
| 852 |
+ } else {
|
|
| 853 |
+ Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") |
|
| 854 |
+ .foregroundColor(isSelected ? .teal : .secondary) |
|
| 855 |
+ } |
|
| 856 |
+ } |
|
| 857 |
+ .font(.body) |
|
| 858 |
+ .transition(.opacity) |
|
| 859 |
+ } |
|
| 860 |
+ |
|
| 861 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 862 |
+ Text(session.startedAt.format()) |
|
| 863 |
+ .font(.subheadline.weight(.semibold)) |
|
| 864 |
+ Text(session.status.title) |
|
| 865 |
+ .font(.caption2) |
|
| 866 |
+ .foregroundColor(sessionTint) |
|
| 867 |
+ } |
|
| 868 |
+ |
|
| 869 |
+ Spacer() |
|
| 870 |
+ |
|
| 871 |
+ VStack(alignment: .trailing, spacing: 2) {
|
|
| 872 |
+ Text("\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh")
|
|
| 873 |
+ .font(.subheadline.weight(.semibold)) |
|
| 874 |
+ .foregroundColor(.primary) |
|
| 875 |
+ Text(sessionDurationText(session)) |
|
| 876 |
+ .font(.caption) |
|
| 877 |
+ .foregroundColor(.secondary) |
|
| 878 |
+ } |
|
| 879 |
+ } |
|
| 880 |
+ |
|
| 881 |
+ Divider() |
|
| 882 |
+ |
|
| 883 |
+ HStack(spacing: 8) {
|
|
| 884 |
+ if let batteryDelta = session.batteryDeltaPercent {
|
|
| 885 |
+ Label("\(batteryDelta >= 0 ? "+" : "")\(Int(batteryDelta.rounded()))% charged", systemImage: "battery.100percent")
|
|
| 886 |
+ .font(.caption2) |
|
| 887 |
+ .foregroundColor(.secondary) |
|
| 888 |
+ } |
|
| 889 |
+ |
|
| 890 |
+ if let capacityWh = session.capacityEstimateWh {
|
|
| 891 |
+ Text("est. \(capacityWh.format(decimalDigits: 1)) Wh")
|
|
| 892 |
+ .font(.caption2) |
|
| 893 |
+ .foregroundColor(.secondary) |
|
| 894 |
+ } |
|
| 895 |
+ |
|
| 896 |
+ Spacer() |
|
| 897 |
+ |
|
| 898 |
+ if !session.displayedAggregatedSamples.isEmpty {
|
|
| 899 |
+ Label("\(session.displayedAggregatedSamples.count) points", systemImage: "chart.xyaxis.line")
|
|
| 900 |
+ .font(.caption2) |
|
| 901 |
+ .foregroundColor(.secondary) |
|
| 902 |
+ } |
|
| 903 |
+ } |
|
| 904 |
+ } |
|
| 905 |
+ .padding(12) |
|
| 906 |
+ .meterCard( |
|
| 907 |
+ tint: sessionTint, |
|
| 908 |
+ fillOpacity: isSelected ? 0.16 : (isOpen ? 0.14 : 0.08), |
|
| 909 |
+ strokeOpacity: isSelected ? 0.22 : (isOpen ? 0.30 : 0.14), |
|
| 910 |
+ cornerRadius: 14 |
|
| 911 |
+ ) |
|
| 912 |
+ } |
|
| 913 |
+ |
|
| 914 |
+ private func closedSessions(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionSummary] {
|
|
| 915 |
+ chargedDevice.sessions.filter { !$0.status.isOpen }
|
|
| 916 |
+ } |
|
| 917 |
+ |
|
| 918 |
+ private func availableTabs(for chargedDevice: ChargedDeviceSummary) -> [DetailTab] {
|
|
| 919 |
+ if chargedDevice.isCharger {
|
|
| 920 |
+ return [.overview, .standby, .settings] |
|
| 921 |
+ } |
|
| 922 |
+ return [.overview, .sessions, .trends, .settings] |
|
| 923 |
+ } |
|
| 924 |
+ |
|
| 925 |
+ private func displayedTab(from tabs: [DetailTab]) -> DetailTab {
|
|
| 926 |
+ if tabs.contains(selectedTab) {
|
|
| 927 |
+ return selectedTab |
|
| 928 |
+ } |
|
| 929 |
+ return tabs.first ?? .overview |
|
| 930 |
+ } |
|
| 931 |
+ |
|
| 932 |
+ private func ensureSelectedTabExists(for chargedDevice: ChargedDeviceSummary) {
|
|
| 933 |
+ let tabs = availableTabs(for: chargedDevice) |
|
| 934 |
+ if !tabs.contains(selectedTab) {
|
|
| 935 |
+ selectedTab = tabs.first ?? .overview |
|
| 936 |
+ } |
|
| 937 |
+ } |
|
| 938 |
+ |
|
| 939 |
+ private func title(for tab: DetailTab) -> String {
|
|
| 940 |
+ switch tab {
|
|
| 941 |
+ case .overview: |
|
| 942 |
+ return "Overview" |
|
| 943 |
+ case .standby: |
|
| 944 |
+ return "Standby" |
|
| 945 |
+ case .sessions: |
|
| 946 |
+ return "Sessions" |
|
| 947 |
+ case .trends: |
|
| 948 |
+ return "Trends" |
|
| 949 |
+ case .settings: |
|
| 950 |
+ return "Settings" |
|
| 951 |
+ } |
|
| 952 |
+ } |
|
| 953 |
+ |
|
| 954 |
+ private func systemImage(for tab: DetailTab) -> String {
|
|
| 955 |
+ switch tab {
|
|
| 956 |
+ case .overview: |
|
| 957 |
+ return "house.fill" |
|
| 958 |
+ case .standby: |
|
| 959 |
+ return "bolt.badge.clock" |
|
| 960 |
+ case .sessions: |
|
| 961 |
+ return "clock.arrow.trianglehead.counterclockwise.rotate.90" |
|
| 962 |
+ case .trends: |
|
| 963 |
+ return "chart.xyaxis.line" |
|
| 964 |
+ case .settings: |
|
| 965 |
+ return "gearshape.fill" |
|
| 966 |
+ } |
|
| 967 |
+ } |
|
| 968 |
+ |
|
| 969 |
+ private func detailBackground(for chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 970 |
+ LinearGradient( |
|
| 971 |
+ colors: [tint(for: chargedDevice).opacity(0.18), Color.clear], |
|
| 972 |
+ startPoint: .topLeading, |
|
| 973 |
+ endPoint: .bottomTrailing |
|
| 974 |
+ ) |
|
| 975 |
+ .ignoresSafeArea() |
|
| 976 |
+ } |
|
| 977 |
+ |
|
| 978 |
+ private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
|
|
| 979 |
+ switch chargedDevice.deviceClass {
|
|
| 980 |
+ case .iphone: |
|
| 981 |
+ return .blue |
|
| 982 |
+ case .watch: |
|
| 983 |
+ return .green |
|
| 984 |
+ case .powerbank: |
|
| 985 |
+ return .orange |
|
| 986 |
+ case .charger: |
|
| 987 |
+ return .pink |
|
| 988 |
+ case .other: |
|
| 989 |
+ return .secondary |
|
| 990 |
+ } |
|
| 991 |
+ } |
|
| 992 |
+ |
|
| 993 |
+ private func statusTint(for session: ChargeSessionSummary) -> Color {
|
|
| 994 |
+ switch session.status {
|
|
| 995 |
+ case .active: |
|
| 996 |
+ return .green |
|
| 997 |
+ case .paused: |
|
| 998 |
+ return .orange |
|
| 999 |
+ case .completed: |
|
| 1000 |
+ return .teal |
|
| 1001 |
+ case .abandoned: |
|
| 1002 |
+ return .secondary |
|
| 1003 |
+ } |
|
| 1004 |
+ } |
|
| 1005 |
+ |
|
| 1006 |
+ private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
|
|
| 1007 |
+ let formatter = DateComponentsFormatter() |
|
| 1008 |
+ let effectiveDuration = max(session.effectiveDuration, 0) |
|
| 1009 |
+ formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 1010 |
+ formatter.unitsStyle = .abbreviated |
|
| 1011 |
+ formatter.zeroFormattingBehavior = .dropAll |
|
| 1012 |
+ return formatter.string(from: effectiveDuration) ?? "0m" |
|
| 1013 |
+ } |
|
| 1014 |
+ |
|
| 1015 |
+ private func standbyEnergyLabel(_ wattHours: Double) -> String {
|
|
| 1016 |
+ if wattHours >= 1000 {
|
|
| 1017 |
+ return "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 1018 |
+ } |
|
| 1019 |
+ return "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 1020 |
+ } |
|
| 1021 |
+ |
|
| 1022 |
+ private var standbyMeasurementMeters: [AppData.MeterSummary] {
|
|
| 1023 |
+ appData.meterSummaries.filter { $0.meter != nil }
|
|
| 1024 |
+ } |
|
| 1025 |
+ |
|
| 1026 |
+ private func completionCurrentDescription( |
|
| 1027 |
+ for chargedDevice: ChargedDeviceSummary, |
|
| 1028 |
+ sessionKind: ChargeSessionKind |
|
| 1029 |
+ ) -> String {
|
|
| 1030 |
+ if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) {
|
|
| 1031 |
+ if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind), |
|
| 1032 |
+ abs(configuredCurrent - learnedCurrent) >= 0.01 {
|
|
| 1033 |
+ return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned" |
|
| 1034 |
+ } |
|
| 1035 |
+ return "\(configuredCurrent.format(decimalDigits: 2)) A configured" |
|
| 1036 |
+ } |
|
| 1037 |
+ |
|
| 1038 |
+ if let learnedCurrent = chargedDevice.learnedCompletionCurrentAmps(for: sessionKind) {
|
|
| 1039 |
+ return "\(learnedCurrent.format(decimalDigits: 2)) A learned" |
|
| 1040 |
+ } |
|
| 1041 |
+ |
|
| 1042 |
+ return "Learning" |
|
| 1043 |
+ } |
|
| 1044 |
+ |
|
| 1045 |
+ private func completionCurrentLabel( |
|
| 1046 |
+ for chargedDevice: ChargedDeviceSummary, |
|
| 1047 |
+ sessionKind: ChargeSessionKind |
|
| 1048 |
+ ) -> String {
|
|
| 1049 |
+ let showsTransport = chargedDevice.shouldShowChargingTransport(sessionKind.chargingTransportMode) |
|
| 1050 |
+ let showsState = chargedDevice.shouldShowChargingStateMode(sessionKind.chargingStateMode) |
|
| 1051 |
+ |
|
| 1052 |
+ switch (showsTransport, showsState) {
|
|
| 1053 |
+ case (true, true): |
|
| 1054 |
+ return "\(sessionKind.shortTitle) Stop Current" |
|
| 1055 |
+ case (true, false): |
|
| 1056 |
+ return "\(sessionKind.chargingTransportMode.title) Stop Current" |
|
| 1057 |
+ case (false, true): |
|
| 1058 |
+ return "\(sessionKind.chargingStateMode.title) Stop Current" |
|
| 1059 |
+ case (false, false): |
|
| 1060 |
+ return "Stop Current" |
|
| 1061 |
+ } |
|
| 1062 |
+ } |
|
| 1063 |
+ |
|
| 1064 |
+ private func completionSessionKinds(for chargedDevice: ChargedDeviceSummary) -> [ChargeSessionKind] {
|
|
| 1065 |
+ chargedDevice.supportedChargingModes.flatMap { chargingTransportMode in
|
|
| 1066 |
+ chargedDevice.supportedChargingStateModes.map { chargingStateMode in
|
|
| 1067 |
+ ChargeSessionKind( |
|
| 1068 |
+ chargingTransportMode: chargingTransportMode, |
|
| 1069 |
+ chargingStateMode: chargingStateMode |
|
| 1070 |
+ ) |
|
| 1071 |
+ } |
|
| 1072 |
+ } |
|
| 1073 |
+ } |
|
| 1074 |
+ |
|
| 1075 |
+ private func deleteSelectedSessions() {
|
|
| 1076 |
+ for id in selectedSessionIDs {
|
|
| 1077 |
+ _ = appData.deleteChargeSession(sessionID: id) |
|
| 1078 |
+ } |
|
| 1079 |
+ selectedSessionIDs.removeAll() |
|
| 1080 |
+ sessionSelectMode = false |
|
| 1081 |
+ } |
|
| 1082 |
+ |
|
| 1083 |
+ private func showEditor() {
|
|
| 1084 |
+ editorVisibility = true |
|
| 1085 |
+ } |
|
| 1086 |
+ |
|
| 1087 |
+ private func showDeleteConfirmation() {
|
|
| 1088 |
+ deleteConfirmationVisibility = true |
|
| 1089 |
+ } |
|
| 1090 |
+ |
|
| 1091 |
+ private var deletionTitle: String {
|
|
| 1092 |
+ appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device" |
|
| 1093 |
+ } |
|
| 1094 |
+ |
|
| 1095 |
+ private var deletionMessage: String {
|
|
| 1096 |
+ if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
|
|
| 1097 |
+ return "This removes the charger from the library and unlinks it from wireless sessions that used it." |
|
| 1098 |
+ } |
|
| 1099 |
+ return "This removes the device and its stored charging history from the library." |
|
| 1100 |
+ } |
|
| 1101 |
+ |
|
| 1102 |
+} |
|
@@ -0,0 +1,1756 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargeSessionDetailView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 22/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+enum ChargeSessionDetailPresentation {
|
|
| 11 |
+ case navigation |
|
| 12 |
+ case embedded |
|
| 13 |
+} |
|
| 14 |
+ |
|
| 15 |
+struct ChargeSessionDetailView: View {
|
|
| 16 |
+ private enum FinalCheckpoint: Hashable {
|
|
| 17 |
+ case full |
|
| 18 |
+ case skip |
|
| 19 |
+ case custom |
|
| 20 |
+ |
|
| 21 |
+ var label: String {
|
|
| 22 |
+ switch self {
|
|
| 23 |
+ case .full: return "Full" |
|
| 24 |
+ case .skip: return "Skip" |
|
| 25 |
+ case .custom: return "Other %" |
|
| 26 |
+ } |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ var icon: String {
|
|
| 30 |
+ switch self {
|
|
| 31 |
+ case .full: return "battery.100percent" |
|
| 32 |
+ case .skip: return "minus.circle" |
|
| 33 |
+ case .custom: return "pencil" |
|
| 34 |
+ } |
|
| 35 |
+ } |
|
| 36 |
+ } |
|
| 37 |
+ |
|
| 38 |
+ @EnvironmentObject private var appData: AppData |
|
| 39 |
+ |
|
| 40 |
+ let chargedDeviceID: UUID |
|
| 41 |
+ let sessionID: UUID |
|
| 42 |
+ let monitoringMeter: Meter? |
|
| 43 |
+ let presentation: ChargeSessionDetailPresentation |
|
| 44 |
+ |
|
| 45 |
+ @State private var pendingCheckpointDeletion: ChargeCheckpointSummary? |
|
| 46 |
+ @State private var pendingSessionDeletion: ChargeSessionSummary? |
|
| 47 |
+ @State private var pendingSessionStopRequest: ChargeSessionStopRequest? |
|
| 48 |
+ @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow? |
|
| 49 |
+ @State private var trimBannerDismissedForSessionID: UUID? |
|
| 50 |
+ @State private var showingInlineTargetEditor = false |
|
| 51 |
+ @State private var draftTargetText = "" |
|
| 52 |
+ @State private var showingStopConfirm = false |
|
| 53 |
+ @State private var finalCheckpointMode: FinalCheckpoint = .skip |
|
| 54 |
+ @State private var finalCheckpointText = "" |
|
| 55 |
+ @State private var stopFailureMessage: String? |
|
| 56 |
+ |
|
| 57 |
+ init( |
|
| 58 |
+ chargedDeviceID: UUID, |
|
| 59 |
+ sessionID: UUID, |
|
| 60 |
+ monitoringMeter: Meter? = nil, |
|
| 61 |
+ presentation: ChargeSessionDetailPresentation = .navigation |
|
| 62 |
+ ) {
|
|
| 63 |
+ self.chargedDeviceID = chargedDeviceID |
|
| 64 |
+ self.sessionID = sessionID |
|
| 65 |
+ self.monitoringMeter = monitoringMeter |
|
| 66 |
+ self.presentation = presentation |
|
| 67 |
+ } |
|
| 68 |
+ |
|
| 69 |
+ private var chargedDevice: ChargedDeviceSummary? {
|
|
| 70 |
+ appData.chargedDeviceSummary(id: chargedDeviceID) |
|
| 71 |
+ } |
|
| 72 |
+ |
|
| 73 |
+ private var session: ChargeSessionSummary? {
|
|
| 74 |
+ chargedDevice?.sessions.first(where: { $0.id == sessionID })
|
|
| 75 |
+ } |
|
| 76 |
+ |
|
| 77 |
+ private var liveMonitoringMeter: Meter? {
|
|
| 78 |
+ guard let session, |
|
| 79 |
+ session.status.isOpen, |
|
| 80 |
+ let meterMACAddress = session.meterMACAddress else {
|
|
| 81 |
+ return nil |
|
| 82 |
+ } |
|
| 83 |
+ |
|
| 84 |
+ if let monitoringMeter, |
|
| 85 |
+ monitoringMeter.btSerial.macAddress.description == meterMACAddress {
|
|
| 86 |
+ return monitoringMeter |
|
| 87 |
+ } |
|
| 88 |
+ |
|
| 89 |
+ return appData.meters.values.first {
|
|
| 90 |
+ $0.btSerial.macAddress.description == meterMACAddress |
|
| 91 |
+ } |
|
| 92 |
+ } |
|
| 93 |
+ |
|
| 94 |
+ private var hasMonitoringControls: Bool {
|
|
| 95 |
+ session?.status.isOpen == true && liveMonitoringMeter != nil |
|
| 96 |
+ } |
|
| 97 |
+ |
|
| 98 |
+ private var shouldShowTrimBanner: Bool {
|
|
| 99 |
+ guard hasMonitoringControls, |
|
| 100 |
+ let session, |
|
| 101 |
+ session.isTrimmed == false, |
|
| 102 |
+ trimBannerDismissedForSessionID != session.id, |
|
| 103 |
+ let detectedTrimWindow else {
|
|
| 104 |
+ return false |
|
| 105 |
+ } |
|
| 106 |
+ return detectedTrimWindow.trimRatio > ChargingWindowDetector.significantTrimThreshold |
|
| 107 |
+ } |
|
| 108 |
+ |
|
| 109 |
+ var body: some View {
|
|
| 110 |
+ Group {
|
|
| 111 |
+ if let chargedDevice, let session {
|
|
| 112 |
+ content(chargedDevice: chargedDevice, session: session) |
|
| 113 |
+ } else {
|
|
| 114 |
+ unavailableState |
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 117 |
+ .sheet(item: $pendingSessionStopRequest) { request in
|
|
| 118 |
+ ChargeSessionCompletionSheetView( |
|
| 119 |
+ sessionID: request.sessionID, |
|
| 120 |
+ title: request.title, |
|
| 121 |
+ confirmTitle: request.confirmTitle, |
|
| 122 |
+ explanation: request.explanation, |
|
| 123 |
+ monitoringMeter: liveMonitoringMeter, |
|
| 124 |
+ appliesTrim: request.appliesTrim, |
|
| 125 |
+ trimStart: request.trimStart, |
|
| 126 |
+ trimEnd: request.trimEnd |
|
| 127 |
+ ) |
|
| 128 |
+ .environmentObject(appData) |
|
| 129 |
+ } |
|
| 130 |
+ .alert(item: $pendingCheckpointDeletion) { checkpoint in
|
|
| 131 |
+ Alert( |
|
| 132 |
+ title: Text("Delete Battery Checkpoint"),
|
|
| 133 |
+ message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
|
|
| 134 |
+ primaryButton: .destructive(Text("Delete")) {
|
|
| 135 |
+ _ = appData.deleteBatteryCheckpoint( |
|
| 136 |
+ checkpointID: checkpoint.id, |
|
| 137 |
+ for: checkpoint.sessionID |
|
| 138 |
+ ) |
|
| 139 |
+ }, |
|
| 140 |
+ secondaryButton: .cancel() |
|
| 141 |
+ ) |
|
| 142 |
+ } |
|
| 143 |
+ .alert(item: $pendingSessionDeletion) { session in
|
|
| 144 |
+ Alert( |
|
| 145 |
+ title: Text("Delete Session?"),
|
|
| 146 |
+ message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
|
|
| 147 |
+ primaryButton: .destructive(Text("Delete")) {
|
|
| 148 |
+ _ = appData.deleteChargeSession(sessionID: session.id) |
|
| 149 |
+ }, |
|
| 150 |
+ secondaryButton: .cancel() |
|
| 151 |
+ ) |
|
| 152 |
+ } |
|
| 153 |
+ .onAppear {
|
|
| 154 |
+ syncMonitoringRestore() |
|
| 155 |
+ runTrimDetection() |
|
| 156 |
+ } |
|
| 157 |
+ .onChange(of: session?.id) { _ in
|
|
| 158 |
+ pendingSessionStopRequest = nil |
|
| 159 |
+ detectedTrimWindow = nil |
|
| 160 |
+ trimBannerDismissedForSessionID = nil |
|
| 161 |
+ showingInlineTargetEditor = false |
|
| 162 |
+ draftTargetText = "" |
|
| 163 |
+ showingStopConfirm = false |
|
| 164 |
+ finalCheckpointMode = .skip |
|
| 165 |
+ finalCheckpointText = "" |
|
| 166 |
+ stopFailureMessage = nil |
|
| 167 |
+ syncMonitoringRestore() |
|
| 168 |
+ runTrimDetection() |
|
| 169 |
+ } |
|
| 170 |
+ .onChange(of: session?.aggregatedSamples.count) { _ in
|
|
| 171 |
+ syncMonitoringRestore() |
|
| 172 |
+ runTrimDetection() |
|
| 173 |
+ } |
|
| 174 |
+ .onChange(of: finalCheckpointMode) { _ in
|
|
| 175 |
+ stopFailureMessage = nil |
|
| 176 |
+ } |
|
| 177 |
+ .onChange(of: finalCheckpointText) { _ in
|
|
| 178 |
+ stopFailureMessage = nil |
|
| 179 |
+ } |
|
| 180 |
+ } |
|
| 181 |
+ |
|
| 182 |
+ private func content( |
|
| 183 |
+ chargedDevice: ChargedDeviceSummary, |
|
| 184 |
+ session: ChargeSessionSummary |
|
| 185 |
+ ) -> some View {
|
|
| 186 |
+ ScrollView {
|
|
| 187 |
+ VStack(spacing: 16) {
|
|
| 188 |
+ if hasMonitoringControls {
|
|
| 189 |
+ monitoringSessionCard(session, chargedDevice: chargedDevice) |
|
| 190 |
+ |
|
| 191 |
+ if shouldShowTrimBanner {
|
|
| 192 |
+ trimDetectionBanner(session) |
|
| 193 |
+ } |
|
| 194 |
+ |
|
| 195 |
+ if shouldShowSessionChart(session) {
|
|
| 196 |
+ chartCard(session) |
|
| 197 |
+ } |
|
| 198 |
+ } else {
|
|
| 199 |
+ overviewCard(session, chargedDevice: chargedDevice) |
|
| 200 |
+ energyCard(session, chargedDevice: chargedDevice) |
|
| 201 |
+ observedMetricsCard(session, chargedDevice: chargedDevice) |
|
| 202 |
+ batteryCard(session, chargedDevice: chargedDevice) |
|
| 203 |
+ |
|
| 204 |
+ if shouldShowSessionChart(session) {
|
|
| 205 |
+ chartCard(session) |
|
| 206 |
+ } |
|
| 207 |
+ |
|
| 208 |
+ if session.status.isOpen {
|
|
| 209 |
+ followerNoticeCard(session) |
|
| 210 |
+ } else {
|
|
| 211 |
+ managementCard(session) |
|
| 212 |
+ } |
|
| 213 |
+ } |
|
| 214 |
+ } |
|
| 215 |
+ .padding(presentation == .embedded ? 16 : 20) |
|
| 216 |
+ } |
|
| 217 |
+ .background( |
|
| 218 |
+ LinearGradient( |
|
| 219 |
+ colors: [statusTint(for: session).opacity(0.14), Color.clear], |
|
| 220 |
+ startPoint: .topLeading, |
|
| 221 |
+ endPoint: .bottomTrailing |
|
| 222 |
+ ) |
|
| 223 |
+ .ignoresSafeArea() |
|
| 224 |
+ ) |
|
| 225 |
+ .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details") |
|
| 226 |
+ } |
|
| 227 |
+ |
|
| 228 |
+ private var unavailableState: some View {
|
|
| 229 |
+ VStack(spacing: 12) {
|
|
| 230 |
+ Image(systemName: "bolt.slash") |
|
| 231 |
+ .font(.title2) |
|
| 232 |
+ .foregroundColor(.secondary) |
|
| 233 |
+ Text("This session is no longer available.")
|
|
| 234 |
+ .font(.headline) |
|
| 235 |
+ Text("It may have been deleted or synced from another device.")
|
|
| 236 |
+ .font(.footnote) |
|
| 237 |
+ .foregroundColor(.secondary) |
|
| 238 |
+ .multilineTextAlignment(.center) |
|
| 239 |
+ } |
|
| 240 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity) |
|
| 241 |
+ .padding(24) |
|
| 242 |
+ .navigationTitle("Session")
|
|
| 243 |
+ } |
|
| 244 |
+ |
|
| 245 |
+ private func monitoringSessionCard( |
|
| 246 |
+ _ session: ChargeSessionSummary, |
|
| 247 |
+ chargedDevice: ChargedDeviceSummary |
|
| 248 |
+ ) -> some View {
|
|
| 249 |
+ let displayedEnergyWh = displayedSessionEnergyWh(for: session) |
|
| 250 |
+ let displayedChargeAh = displayedSessionChargeAh(for: session) |
|
| 251 |
+ let batteryPrediction = chargedDevice.batteryLevelPrediction( |
|
| 252 |
+ for: session, |
|
| 253 |
+ effectiveEnergyWhOverride: displayedEnergyWh |
|
| 254 |
+ ) |
|
| 255 |
+ |
|
| 256 |
+ return VStack(alignment: .leading, spacing: 14) {
|
|
| 257 |
+ HStack {
|
|
| 258 |
+ ChargedDeviceIdentityLabelView(chargedDevice: chargedDevice, iconPointSize: 16) |
|
| 259 |
+ .font(.headline) |
|
| 260 |
+ |
|
| 261 |
+ Spacer() |
|
| 262 |
+ |
|
| 263 |
+ Text(session.status.title) |
|
| 264 |
+ .font(.caption.weight(.bold)) |
|
| 265 |
+ .foregroundColor(monitoringStatusColor(for: session)) |
|
| 266 |
+ .padding(.horizontal, 8) |
|
| 267 |
+ .padding(.vertical, 4) |
|
| 268 |
+ .meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
|
| 269 |
+ } |
|
| 270 |
+ |
|
| 271 |
+ if let batteryPrediction {
|
|
| 272 |
+ batteryGaugeSection( |
|
| 273 |
+ prediction: batteryPrediction, |
|
| 274 |
+ session: session, |
|
| 275 |
+ displayedEnergyWh: displayedEnergyWh |
|
| 276 |
+ ) |
|
| 277 |
+ } |
|
| 278 |
+ |
|
| 279 |
+ sessionMetricsGrid( |
|
| 280 |
+ session: session, |
|
| 281 |
+ chargedDevice: chargedDevice, |
|
| 282 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 283 |
+ hasPrediction: batteryPrediction != nil |
|
| 284 |
+ ) |
|
| 285 |
+ |
|
| 286 |
+ if session.stopThresholdAmps > 0 {
|
|
| 287 |
+ Text("Stop threshold: \(session.stopThresholdAmps.format(decimalDigits: 2)) A")
|
|
| 288 |
+ .font(.caption) |
|
| 289 |
+ .foregroundColor(.secondary) |
|
| 290 |
+ } |
|
| 291 |
+ |
|
| 292 |
+ if let sessionWarning = sessionWarning(for: session) {
|
|
| 293 |
+ Label(sessionWarning, systemImage: "exclamationmark.triangle") |
|
| 294 |
+ .font(.caption) |
|
| 295 |
+ .foregroundColor(.orange) |
|
| 296 |
+ } |
|
| 297 |
+ |
|
| 298 |
+ if session.isPaused {
|
|
| 299 |
+ Label( |
|
| 300 |
+ "Paused \(session.pausedAt?.format() ?? session.lastObservedAt.format()). Auto-stops after 10 min.", |
|
| 301 |
+ systemImage: "pause.circle" |
|
| 302 |
+ ) |
|
| 303 |
+ .font(.caption) |
|
| 304 |
+ .foregroundColor(.secondary) |
|
| 305 |
+ } |
|
| 306 |
+ |
|
| 307 |
+ if session.requiresCompletionConfirmation && !showingStopConfirm {
|
|
| 308 |
+ completionConfirmationCard(session) |
|
| 309 |
+ } |
|
| 310 |
+ |
|
| 311 |
+ BatteryCheckpointSectionView( |
|
| 312 |
+ sessionID: session.id, |
|
| 313 |
+ checkpoints: session.checkpoints, |
|
| 314 |
+ message: "Checkpoints are used for capacity estimation and the typical charge curve.", |
|
| 315 |
+ canAddCheckpoint: appData.canAddBatteryCheckpoint(to: session.id), |
|
| 316 |
+ canDeleteCheckpoint: true, |
|
| 317 |
+ requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id), |
|
| 318 |
+ effectiveEnergyWhOverride: displayedEnergyWh, |
|
| 319 |
+ measuredChargeAhOverride: displayedChargeAh, |
|
| 320 |
+ onDelete: { checkpoint in
|
|
| 321 |
+ pendingCheckpointDeletion = checkpoint |
|
| 322 |
+ } |
|
| 323 |
+ ) |
|
| 324 |
+ |
|
| 325 |
+ targetSectionView( |
|
| 326 |
+ session: session, |
|
| 327 |
+ predictedPercent: batteryPrediction?.predictedPercent |
|
| 328 |
+ ) |
|
| 329 |
+ |
|
| 330 |
+ if showingStopConfirm {
|
|
| 331 |
+ stopConfirmPanel( |
|
| 332 |
+ session: session, |
|
| 333 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 334 |
+ displayedChargeAh: displayedChargeAh |
|
| 335 |
+ ) |
|
| 336 |
+ } else {
|
|
| 337 |
+ monitoringActionRow(session) |
|
| 338 |
+ } |
|
| 339 |
+ } |
|
| 340 |
+ .padding(18) |
|
| 341 |
+ .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 342 |
+ } |
|
| 343 |
+ |
|
| 344 |
+ private func overviewCard( |
|
| 345 |
+ _ session: ChargeSessionSummary, |
|
| 346 |
+ chargedDevice: ChargedDeviceSummary |
|
| 347 |
+ ) -> some View {
|
|
| 348 |
+ MeterInfoCardView(title: session.status.isOpen ? "Open Session" : "Overview", tint: statusTint(for: session)) {
|
|
| 349 |
+ MeterInfoRowView(label: "Device", value: chargedDevice.name) |
|
| 350 |
+ MeterInfoRowView(label: "Status", value: session.status.title) |
|
| 351 |
+ MeterInfoRowView(label: "Started", value: session.startedAt.format()) |
|
| 352 |
+ if let endedAt = session.endedAt {
|
|
| 353 |
+ MeterInfoRowView(label: "Ended", value: endedAt.format()) |
|
| 354 |
+ } |
|
| 355 |
+ MeterInfoRowView(label: "Duration", value: sessionDurationText(session)) |
|
| 356 |
+ MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title) |
|
| 357 |
+ MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title) |
|
| 358 |
+ MeterInfoRowView(label: "Source", value: session.sourceMode.title) |
|
| 359 |
+ MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session)) |
|
| 360 |
+ if session.isTrimmed {
|
|
| 361 |
+ MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format()) |
|
| 362 |
+ MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format()) |
|
| 363 |
+ } |
|
| 364 |
+ if let meterName = session.meterName {
|
|
| 365 |
+ MeterInfoRowView(label: "Meter", value: meterName) |
|
| 366 |
+ } else if let meterMACAddress = session.meterMACAddress {
|
|
| 367 |
+ MeterInfoRowView(label: "Meter", value: meterMACAddress) |
|
| 368 |
+ } |
|
| 369 |
+ if let meterModel = session.meterModel {
|
|
| 370 |
+ MeterInfoRowView(label: "Meter Model", value: meterModel) |
|
| 371 |
+ } |
|
| 372 |
+ } |
|
| 373 |
+ } |
|
| 374 |
+ |
|
| 375 |
+ private func energyCard( |
|
| 376 |
+ _ session: ChargeSessionSummary, |
|
| 377 |
+ chargedDevice: ChargedDeviceSummary |
|
| 378 |
+ ) -> some View {
|
|
| 379 |
+ let displayedEnergyWh = displayedSessionEnergyWh(for: session) |
|
| 380 |
+ let displayedChargeAh = displayedSessionChargeAh(for: session) |
|
| 381 |
+ |
|
| 382 |
+ return MeterInfoCardView(title: "Energy", tint: .teal) {
|
|
| 383 |
+ MeterInfoRowView(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 384 |
+ if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
|
|
| 385 |
+ MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 386 |
+ } |
|
| 387 |
+ MeterInfoRowView(label: "Measured Charge", value: "\(displayedChargeAh.format(decimalDigits: 3)) Ah") |
|
| 388 |
+ if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh, |
|
| 389 |
+ abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
|
|
| 390 |
+ MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 391 |
+ } |
|
| 392 |
+ if let capacityEstimateWh = session.capacityEstimateWh {
|
|
| 393 |
+ MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh") |
|
| 394 |
+ } |
|
| 395 |
+ if let chargerID = session.chargerID, |
|
| 396 |
+ let charger = appData.chargedDeviceSummary(id: chargerID) {
|
|
| 397 |
+ MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name) |
|
| 398 |
+ } |
|
| 399 |
+ if let wirelessSessionHint = wirelessSessionHint(for: session) {
|
|
| 400 |
+ Text(wirelessSessionHint) |
|
| 401 |
+ .font(.caption2) |
|
| 402 |
+ .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary) |
|
| 403 |
+ } |
|
| 404 |
+ if let sessionWarning = sessionWarning(for: session) {
|
|
| 405 |
+ Label(sessionWarning, systemImage: "exclamationmark.triangle") |
|
| 406 |
+ .font(.caption2) |
|
| 407 |
+ .foregroundColor(.orange) |
|
| 408 |
+ } |
|
| 409 |
+ } |
|
| 410 |
+ } |
|
| 411 |
+ |
|
| 412 |
+ private func observedMetricsCard( |
|
| 413 |
+ _ session: ChargeSessionSummary, |
|
| 414 |
+ chargedDevice: ChargedDeviceSummary |
|
| 415 |
+ ) -> some View {
|
|
| 416 |
+ MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
|
|
| 417 |
+ if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
|
|
| 418 |
+ MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A") |
|
| 419 |
+ } |
|
| 420 |
+ if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
|
|
| 421 |
+ MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A") |
|
| 422 |
+ } |
|
| 423 |
+ if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
|
|
| 424 |
+ MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W") |
|
| 425 |
+ } |
|
| 426 |
+ if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
|
|
| 427 |
+ MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V") |
|
| 428 |
+ } |
|
| 429 |
+ if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
|
|
| 430 |
+ MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V") |
|
| 431 |
+ } |
|
| 432 |
+ if let completionCurrentAmps = session.completionCurrentAmps {
|
|
| 433 |
+ MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A") |
|
| 434 |
+ } |
|
| 435 |
+ if session.selectedDataGroup != nil {
|
|
| 436 |
+ MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)") |
|
| 437 |
+ } |
|
| 438 |
+ } |
|
| 439 |
+ } |
|
| 440 |
+ |
|
| 441 |
+ private func batteryCard( |
|
| 442 |
+ _ session: ChargeSessionSummary, |
|
| 443 |
+ chargedDevice: ChargedDeviceSummary |
|
| 444 |
+ ) -> some View {
|
|
| 445 |
+ let displayedEnergyWh = displayedSessionEnergyWh(for: session) |
|
| 446 |
+ let displayedChargeAh = displayedSessionChargeAh(for: session) |
|
| 447 |
+ let batteryPrediction = chargedDevice.batteryLevelPrediction( |
|
| 448 |
+ for: session, |
|
| 449 |
+ effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil |
|
| 450 |
+ ) |
|
| 451 |
+ |
|
| 452 |
+ return MeterInfoCardView(title: "Battery", tint: .orange) {
|
|
| 453 |
+ if let startBatteryPercent = session.startBatteryPercent {
|
|
| 454 |
+ MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%") |
|
| 455 |
+ } |
|
| 456 |
+ if let endBatteryPercent = session.endBatteryPercent {
|
|
| 457 |
+ MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%") |
|
| 458 |
+ } |
|
| 459 |
+ if let batteryDeltaPercent = session.batteryDeltaPercent {
|
|
| 460 |
+ MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%") |
|
| 461 |
+ } |
|
| 462 |
+ if let targetBatteryPercent = session.targetBatteryPercent {
|
|
| 463 |
+ MeterInfoRowView(label: "Target Notification", value: "\(targetBatteryPercent.format(decimalDigits: 0))%") |
|
| 464 |
+ } |
|
| 465 |
+ if let batteryPrediction {
|
|
| 466 |
+ MeterInfoRowView( |
|
| 467 |
+ label: "Predicted Battery", |
|
| 468 |
+ value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%" |
|
| 469 |
+ ) |
|
| 470 |
+ Text( |
|
| 471 |
+ "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity." |
|
| 472 |
+ ) |
|
| 473 |
+ .font(.caption2) |
|
| 474 |
+ .foregroundColor(.secondary) |
|
| 475 |
+ } |
|
| 476 |
+ |
|
| 477 |
+ BatteryCheckpointSectionView( |
|
| 478 |
+ sessionID: session.id, |
|
| 479 |
+ checkpoints: session.checkpoints, |
|
| 480 |
+ message: session.status.isOpen |
|
| 481 |
+ ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction." |
|
| 482 |
+ : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.", |
|
| 483 |
+ canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id), |
|
| 484 |
+ canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false, |
|
| 485 |
+ requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil, |
|
| 486 |
+ effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil, |
|
| 487 |
+ measuredChargeAhOverride: hasMonitoringControls ? displayedChargeAh : nil, |
|
| 488 |
+ onDelete: { checkpoint in
|
|
| 489 |
+ pendingCheckpointDeletion = checkpoint |
|
| 490 |
+ } |
|
| 491 |
+ ) |
|
| 492 |
+ } |
|
| 493 |
+ } |
|
| 494 |
+ |
|
| 495 |
+ private func batteryGaugeSection( |
|
| 496 |
+ prediction: BatteryLevelPrediction, |
|
| 497 |
+ session: ChargeSessionSummary, |
|
| 498 |
+ displayedEnergyWh: Double |
|
| 499 |
+ ) -> some View {
|
|
| 500 |
+ let percent = prediction.predictedPercent |
|
| 501 |
+ let color = batteryColor(for: percent) |
|
| 502 |
+ let duration = displayedSessionDuration(for: session) |
|
| 503 |
+ let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01 |
|
| 504 |
+ ? displayedEnergyWh / duration |
|
| 505 |
+ : nil |
|
| 506 |
+ let etaToFull = etaText( |
|
| 507 |
+ rateWhPerSec: rateWhPerSec, |
|
| 508 |
+ remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0), |
|
| 509 |
+ isRelevant: percent < 98 |
|
| 510 |
+ ) |
|
| 511 |
+ let etaToTarget = etaToTargetText( |
|
| 512 |
+ session: session, |
|
| 513 |
+ prediction: prediction, |
|
| 514 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 515 |
+ rateWhPerSec: rateWhPerSec |
|
| 516 |
+ ) |
|
| 517 |
+ |
|
| 518 |
+ return VStack(spacing: 10) {
|
|
| 519 |
+ HStack(alignment: .lastTextBaseline, spacing: 8) {
|
|
| 520 |
+ HStack(alignment: .lastTextBaseline, spacing: 3) {
|
|
| 521 |
+ Text("\(Int(percent.rounded()))")
|
|
| 522 |
+ .font(.system(size: 52, weight: .bold, design: .rounded)) |
|
| 523 |
+ .foregroundColor(color) |
|
| 524 |
+ .monospacedDigit() |
|
| 525 |
+ Text("%")
|
|
| 526 |
+ .font(.title2.weight(.semibold)) |
|
| 527 |
+ .foregroundColor(color.opacity(0.8)) |
|
| 528 |
+ } |
|
| 529 |
+ |
|
| 530 |
+ Spacer() |
|
| 531 |
+ |
|
| 532 |
+ VStack(alignment: .trailing, spacing: 2) {
|
|
| 533 |
+ Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
|
|
| 534 |
+ .font(.callout.weight(.bold)) |
|
| 535 |
+ .foregroundColor(.orange) |
|
| 536 |
+ .monospacedDigit() |
|
| 537 |
+ Text("est. capacity")
|
|
| 538 |
+ .font(.caption2) |
|
| 539 |
+ .foregroundColor(.secondary) |
|
| 540 |
+ } |
|
| 541 |
+ } |
|
| 542 |
+ |
|
| 543 |
+ batteryProgressBar( |
|
| 544 |
+ percent: percent, |
|
| 545 |
+ startPercent: session.startBatteryPercent, |
|
| 546 |
+ targetPercent: session.targetBatteryPercent |
|
| 547 |
+ ) |
|
| 548 |
+ |
|
| 549 |
+ HStack(spacing: 14) {
|
|
| 550 |
+ if let etaToFull {
|
|
| 551 |
+ etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full") |
|
| 552 |
+ } |
|
| 553 |
+ |
|
| 554 |
+ if let etaToTarget, let target = session.targetBatteryPercent {
|
|
| 555 |
+ etaPill( |
|
| 556 |
+ icon: "bell.badge.fill", |
|
| 557 |
+ tint: .indigo, |
|
| 558 |
+ value: etaToTarget, |
|
| 559 |
+ label: "to \(Int(target.rounded()))%" |
|
| 560 |
+ ) |
|
| 561 |
+ } |
|
| 562 |
+ |
|
| 563 |
+ Spacer() |
|
| 564 |
+ |
|
| 565 |
+ Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
|
|
| 566 |
+ .font(.caption2) |
|
| 567 |
+ .foregroundColor(.secondary) |
|
| 568 |
+ .multilineTextAlignment(.trailing) |
|
| 569 |
+ } |
|
| 570 |
+ } |
|
| 571 |
+ .padding(14) |
|
| 572 |
+ .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 573 |
+ } |
|
| 574 |
+ |
|
| 575 |
+ private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
|
|
| 576 |
+ VStack(alignment: .leading, spacing: 1) {
|
|
| 577 |
+ HStack(spacing: 4) {
|
|
| 578 |
+ Image(systemName: icon) |
|
| 579 |
+ .font(.caption) |
|
| 580 |
+ .foregroundColor(tint) |
|
| 581 |
+ Text(value) |
|
| 582 |
+ .font(.caption.weight(.bold)) |
|
| 583 |
+ } |
|
| 584 |
+ Text(label) |
|
| 585 |
+ .font(.caption2) |
|
| 586 |
+ .foregroundColor(.secondary) |
|
| 587 |
+ } |
|
| 588 |
+ } |
|
| 589 |
+ |
|
| 590 |
+ private func batteryProgressBar( |
|
| 591 |
+ percent: Double, |
|
| 592 |
+ startPercent: Double?, |
|
| 593 |
+ targetPercent: Double? |
|
| 594 |
+ ) -> some View {
|
|
| 595 |
+ let color = batteryColor(for: percent) |
|
| 596 |
+ return GeometryReader { geo in
|
|
| 597 |
+ let width = geo.size.width |
|
| 598 |
+ ZStack(alignment: .leading) {
|
|
| 599 |
+ Capsule() |
|
| 600 |
+ .fill(Color.primary.opacity(0.10)) |
|
| 601 |
+ Rectangle() |
|
| 602 |
+ .fill( |
|
| 603 |
+ LinearGradient( |
|
| 604 |
+ colors: [color.opacity(0.6), color], |
|
| 605 |
+ startPoint: .leading, |
|
| 606 |
+ endPoint: .trailing |
|
| 607 |
+ ) |
|
| 608 |
+ ) |
|
| 609 |
+ .frame(width: max(width * CGFloat(percent / 100), 4)) |
|
| 610 |
+ .animation(.easeInOut(duration: 0.4), value: percent) |
|
| 611 |
+ if let start = startPercent, start > 2, start < 98 {
|
|
| 612 |
+ Rectangle() |
|
| 613 |
+ .fill(Color.white.opacity(0.55)) |
|
| 614 |
+ .frame(width: 2, height: 20) |
|
| 615 |
+ .offset(x: width * CGFloat(start / 100) - 1) |
|
| 616 |
+ } |
|
| 617 |
+ if let target = targetPercent {
|
|
| 618 |
+ Rectangle() |
|
| 619 |
+ .fill(Color.indigo.opacity(0.9)) |
|
| 620 |
+ .frame(width: 2.5, height: 20) |
|
| 621 |
+ .offset(x: width * CGFloat(target / 100) - 1.25) |
|
| 622 |
+ } |
|
| 623 |
+ } |
|
| 624 |
+ .clipShape(Capsule()) |
|
| 625 |
+ } |
|
| 626 |
+ .frame(height: 20) |
|
| 627 |
+ } |
|
| 628 |
+ |
|
| 629 |
+ private func sessionMetricsGrid( |
|
| 630 |
+ session: ChargeSessionSummary, |
|
| 631 |
+ chargedDevice: ChargedDeviceSummary, |
|
| 632 |
+ displayedEnergyWh: Double, |
|
| 633 |
+ hasPrediction: Bool |
|
| 634 |
+ ) -> some View {
|
|
| 635 |
+ let capacityFallback: Double? = hasPrediction ? nil : ( |
|
| 636 |
+ session.capacityEstimateWh |
|
| 637 |
+ ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode) |
|
| 638 |
+ ?? chargedDevice.estimatedBatteryCapacityWh |
|
| 639 |
+ ) |
|
| 640 |
+ let columns = [GridItem(.flexible()), GridItem(.flexible())] |
|
| 641 |
+ |
|
| 642 |
+ return LazyVGrid(columns: columns, spacing: 8) {
|
|
| 643 |
+ metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue) |
|
| 644 |
+ metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal) |
|
| 645 |
+ |
|
| 646 |
+ if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
|
|
| 647 |
+ metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange) |
|
| 648 |
+ } |
|
| 649 |
+ if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
|
|
| 650 |
+ metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple) |
|
| 651 |
+ } |
|
| 652 |
+ |
|
| 653 |
+ metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary) |
|
| 654 |
+ |
|
| 655 |
+ if let capacityFallback {
|
|
| 656 |
+ metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange) |
|
| 657 |
+ } |
|
| 658 |
+ } |
|
| 659 |
+ } |
|
| 660 |
+ |
|
| 661 |
+ private func metricCell(label: String, value: String, tint: Color) -> some View {
|
|
| 662 |
+ VStack(alignment: .leading, spacing: 3) {
|
|
| 663 |
+ Text(label) |
|
| 664 |
+ .font(.caption2) |
|
| 665 |
+ .foregroundColor(.secondary) |
|
| 666 |
+ Text(value) |
|
| 667 |
+ .font(.subheadline.weight(.semibold)) |
|
| 668 |
+ .lineLimit(1) |
|
| 669 |
+ .minimumScaleFactor(0.7) |
|
| 670 |
+ .monospacedDigit() |
|
| 671 |
+ } |
|
| 672 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 673 |
+ .padding(.horizontal, 12) |
|
| 674 |
+ .padding(.vertical, 10) |
|
| 675 |
+ .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) |
|
| 676 |
+ } |
|
| 677 |
+ |
|
| 678 |
+ private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 679 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 680 |
+ Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
|
|
| 681 |
+ .font(.subheadline.weight(.semibold)) |
|
| 682 |
+ |
|
| 683 |
+ if let contradictionPercent = session.completionContradictionPercent {
|
|
| 684 |
+ Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
|
|
| 685 |
+ .font(.caption) |
|
| 686 |
+ .foregroundColor(.secondary) |
|
| 687 |
+ } else {
|
|
| 688 |
+ Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
|
|
| 689 |
+ .font(.caption) |
|
| 690 |
+ .foregroundColor(.secondary) |
|
| 691 |
+ } |
|
| 692 |
+ |
|
| 693 |
+ HStack(spacing: 10) {
|
|
| 694 |
+ Button("Finish") {
|
|
| 695 |
+ beginStopConfirmation(for: session) |
|
| 696 |
+ } |
|
| 697 |
+ .frame(maxWidth: .infinity) |
|
| 698 |
+ .padding(.vertical, 9) |
|
| 699 |
+ .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 700 |
+ .buttonStyle(.plain) |
|
| 701 |
+ |
|
| 702 |
+ Button("Keep Monitoring") {
|
|
| 703 |
+ _ = appData.continueChargeSessionMonitoring(sessionID: session.id) |
|
| 704 |
+ } |
|
| 705 |
+ .frame(maxWidth: .infinity) |
|
| 706 |
+ .padding(.vertical, 9) |
|
| 707 |
+ .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 708 |
+ .buttonStyle(.plain) |
|
| 709 |
+ } |
|
| 710 |
+ } |
|
| 711 |
+ .padding(14) |
|
| 712 |
+ .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 713 |
+ } |
|
| 714 |
+ |
|
| 715 |
+ private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
|
|
| 716 |
+ let draftBelowPrediction: Bool = {
|
|
| 717 |
+ guard let draft = parsedDraftTarget, let predictedPercent else { return false }
|
|
| 718 |
+ return draft <= predictedPercent |
|
| 719 |
+ }() |
|
| 720 |
+ let savedBelowPrediction: Bool = {
|
|
| 721 |
+ guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
|
|
| 722 |
+ return saved <= predictedPercent |
|
| 723 |
+ }() |
|
| 724 |
+ |
|
| 725 |
+ return HStack(alignment: .center, spacing: 8) {
|
|
| 726 |
+ Image(systemName: "bell.badge") |
|
| 727 |
+ .foregroundColor(.indigo) |
|
| 728 |
+ .font(.subheadline) |
|
| 729 |
+ |
|
| 730 |
+ Text("Notify at")
|
|
| 731 |
+ .font(.subheadline.weight(.semibold)) |
|
| 732 |
+ |
|
| 733 |
+ Spacer(minLength: 8) |
|
| 734 |
+ |
|
| 735 |
+ if showingInlineTargetEditor {
|
|
| 736 |
+ targetEditorControls( |
|
| 737 |
+ session: session, |
|
| 738 |
+ draftBelowPrediction: draftBelowPrediction, |
|
| 739 |
+ predictedPercent: predictedPercent |
|
| 740 |
+ ) |
|
| 741 |
+ } else {
|
|
| 742 |
+ savedTargetControls( |
|
| 743 |
+ session: session, |
|
| 744 |
+ savedBelowPrediction: savedBelowPrediction, |
|
| 745 |
+ predictedPercent: predictedPercent |
|
| 746 |
+ ) |
|
| 747 |
+ } |
|
| 748 |
+ } |
|
| 749 |
+ } |
|
| 750 |
+ |
|
| 751 |
+ private func targetEditorControls( |
|
| 752 |
+ session: ChargeSessionSummary, |
|
| 753 |
+ draftBelowPrediction: Bool, |
|
| 754 |
+ predictedPercent: Double? |
|
| 755 |
+ ) -> some View {
|
|
| 756 |
+ Group {
|
|
| 757 |
+ Button {
|
|
| 758 |
+ let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80 |
|
| 759 |
+ draftTargetText = max(current - 1, 1).format(decimalDigits: 0) |
|
| 760 |
+ } label: {
|
|
| 761 |
+ Image(systemName: "minus.circle") |
|
| 762 |
+ .font(.title3) |
|
| 763 |
+ } |
|
| 764 |
+ .buttonStyle(.plain) |
|
| 765 |
+ |
|
| 766 |
+ TextField("-", text: $draftTargetText)
|
|
| 767 |
+ .keyboardType(.decimalPad) |
|
| 768 |
+ .textFieldStyle(.roundedBorder) |
|
| 769 |
+ .frame(width: 48) |
|
| 770 |
+ .multilineTextAlignment(.center) |
|
| 771 |
+ .foregroundColor(draftBelowPrediction ? .orange : .primary) |
|
| 772 |
+ |
|
| 773 |
+ Text("%")
|
|
| 774 |
+ .font(.subheadline) |
|
| 775 |
+ .foregroundColor(.secondary) |
|
| 776 |
+ |
|
| 777 |
+ if draftBelowPrediction, let predictedPercent {
|
|
| 778 |
+ predictionWarningButton(predictedPercent: predictedPercent) |
|
| 779 |
+ } |
|
| 780 |
+ |
|
| 781 |
+ Button {
|
|
| 782 |
+ let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80 |
|
| 783 |
+ draftTargetText = min(current + 1, 100).format(decimalDigits: 0) |
|
| 784 |
+ } label: {
|
|
| 785 |
+ Image(systemName: "plus.circle") |
|
| 786 |
+ .font(.title3) |
|
| 787 |
+ } |
|
| 788 |
+ .buttonStyle(.plain) |
|
| 789 |
+ |
|
| 790 |
+ Button {
|
|
| 791 |
+ if let value = parsedDraftTarget {
|
|
| 792 |
+ _ = appData.setTargetBatteryPercent(value, for: session.id) |
|
| 793 |
+ } |
|
| 794 |
+ showingInlineTargetEditor = false |
|
| 795 |
+ } label: {
|
|
| 796 |
+ Image(systemName: "checkmark.circle.fill") |
|
| 797 |
+ .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary) |
|
| 798 |
+ .font(.title3) |
|
| 799 |
+ } |
|
| 800 |
+ .buttonStyle(.plain) |
|
| 801 |
+ .disabled(parsedDraftTarget == nil) |
|
| 802 |
+ |
|
| 803 |
+ Button {
|
|
| 804 |
+ showingInlineTargetEditor = false |
|
| 805 |
+ draftTargetText = "" |
|
| 806 |
+ } label: {
|
|
| 807 |
+ Image(systemName: "xmark.circle") |
|
| 808 |
+ .foregroundColor(.secondary) |
|
| 809 |
+ .font(.title3) |
|
| 810 |
+ } |
|
| 811 |
+ .buttonStyle(.plain) |
|
| 812 |
+ } |
|
| 813 |
+ } |
|
| 814 |
+ |
|
| 815 |
+ private func savedTargetControls( |
|
| 816 |
+ session: ChargeSessionSummary, |
|
| 817 |
+ savedBelowPrediction: Bool, |
|
| 818 |
+ predictedPercent: Double? |
|
| 819 |
+ ) -> some View {
|
|
| 820 |
+ Group {
|
|
| 821 |
+ if let targetPercent = session.targetBatteryPercent {
|
|
| 822 |
+ Text("\(targetPercent.format(decimalDigits: 0))%")
|
|
| 823 |
+ .font(.subheadline.weight(.semibold)) |
|
| 824 |
+ .foregroundColor(savedBelowPrediction ? .orange : .indigo) |
|
| 825 |
+ |
|
| 826 |
+ if savedBelowPrediction, let predictedPercent {
|
|
| 827 |
+ predictionWarningButton(predictedPercent: predictedPercent) |
|
| 828 |
+ } |
|
| 829 |
+ |
|
| 830 |
+ Button {
|
|
| 831 |
+ _ = appData.setTargetBatteryPercent(nil, for: session.id) |
|
| 832 |
+ } label: {
|
|
| 833 |
+ Image(systemName: "xmark.circle.fill") |
|
| 834 |
+ .foregroundColor(.secondary) |
|
| 835 |
+ .font(.callout) |
|
| 836 |
+ } |
|
| 837 |
+ .buttonStyle(.plain) |
|
| 838 |
+ .help("Remove alert")
|
|
| 839 |
+ } |
|
| 840 |
+ |
|
| 841 |
+ Button {
|
|
| 842 |
+ draftTargetText = session.targetBatteryPercent.map {
|
|
| 843 |
+ $0.format(decimalDigits: 0) |
|
| 844 |
+ } ?? "80" |
|
| 845 |
+ showingInlineTargetEditor = true |
|
| 846 |
+ } label: {
|
|
| 847 |
+ Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil") |
|
| 848 |
+ .font(.caption.weight(.semibold)) |
|
| 849 |
+ .frame(width: 30, height: 30) |
|
| 850 |
+ .contentShape(Rectangle()) |
|
| 851 |
+ } |
|
| 852 |
+ .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10) |
|
| 853 |
+ .buttonStyle(.plain) |
|
| 854 |
+ .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert") |
|
| 855 |
+ } |
|
| 856 |
+ } |
|
| 857 |
+ |
|
| 858 |
+ private func predictionWarningButton(predictedPercent: Double) -> some View {
|
|
| 859 |
+ Button {} label: {
|
|
| 860 |
+ Image(systemName: "exclamationmark.triangle.fill") |
|
| 861 |
+ .font(.callout.weight(.semibold)) |
|
| 862 |
+ .foregroundColor(.orange) |
|
| 863 |
+ } |
|
| 864 |
+ .buttonStyle(.plain) |
|
| 865 |
+ .help("Battery is already predicted at \(predictedPercent.format(decimalDigits: 0))% - this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
|
|
| 866 |
+ } |
|
| 867 |
+ |
|
| 868 |
+ private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
|
|
| 869 |
+ HStack(spacing: 10) {
|
|
| 870 |
+ if session.status == .active {
|
|
| 871 |
+ Button("Pause") {
|
|
| 872 |
+ _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter) |
|
| 873 |
+ } |
|
| 874 |
+ .monitoringActionStyle(tint: .orange) |
|
| 875 |
+ } else if session.status == .paused {
|
|
| 876 |
+ Button("Resume") {
|
|
| 877 |
+ _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter) |
|
| 878 |
+ } |
|
| 879 |
+ .monitoringActionStyle(tint: .blue) |
|
| 880 |
+ } |
|
| 881 |
+ |
|
| 882 |
+ Button("Terminate Session") {
|
|
| 883 |
+ beginStopConfirmation(for: session) |
|
| 884 |
+ } |
|
| 885 |
+ .monitoringActionStyle(tint: .red) |
|
| 886 |
+ } |
|
| 887 |
+ } |
|
| 888 |
+ |
|
| 889 |
+ private func stopConfirmPanel( |
|
| 890 |
+ session: ChargeSessionSummary, |
|
| 891 |
+ displayedEnergyWh: Double, |
|
| 892 |
+ displayedChargeAh: Double |
|
| 893 |
+ ) -> some View {
|
|
| 894 |
+ let canSave = hasSavableChargeData( |
|
| 895 |
+ session: session, |
|
| 896 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 897 |
+ displayedChargeAh: displayedChargeAh |
|
| 898 |
+ ) |
|
| 899 |
+ let saveDisabledReason = saveDisabledReason( |
|
| 900 |
+ session: session, |
|
| 901 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 902 |
+ displayedChargeAh: displayedChargeAh |
|
| 903 |
+ ) |
|
| 904 |
+ let isSaveEnabled = saveDisabledReason == nil |
|
| 905 |
+ |
|
| 906 |
+ return VStack(alignment: .leading, spacing: 12) {
|
|
| 907 |
+ HStack {
|
|
| 908 |
+ Text("Final Checkpoint")
|
|
| 909 |
+ .font(.subheadline.weight(.semibold)) |
|
| 910 |
+ Text("optional")
|
|
| 911 |
+ .font(.caption2.weight(.semibold)) |
|
| 912 |
+ .foregroundColor(.secondary) |
|
| 913 |
+ } |
|
| 914 |
+ |
|
| 915 |
+ finalCheckpointPicker(session) |
|
| 916 |
+ |
|
| 917 |
+ if finalCheckpointMode == .custom {
|
|
| 918 |
+ customFinalCheckpointRow |
|
| 919 |
+ } |
|
| 920 |
+ |
|
| 921 |
+ if let saveDisabledReason {
|
|
| 922 |
+ Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill") |
|
| 923 |
+ .font(.caption) |
|
| 924 |
+ .foregroundColor(.red) |
|
| 925 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 926 |
+ } else if let stopFailureMessage {
|
|
| 927 |
+ Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill") |
|
| 928 |
+ .font(.caption) |
|
| 929 |
+ .foregroundColor(.red) |
|
| 930 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 931 |
+ } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
|
|
| 932 |
+ Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
|
|
| 933 |
+ .font(.caption) |
|
| 934 |
+ .foregroundColor(.green) |
|
| 935 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 936 |
+ } |
|
| 937 |
+ |
|
| 938 |
+ HStack(spacing: 8) {
|
|
| 939 |
+ Button("Discard") {
|
|
| 940 |
+ discardSession(session) |
|
| 941 |
+ } |
|
| 942 |
+ .monitoringPanelActionStyle(tint: .secondary) |
|
| 943 |
+ |
|
| 944 |
+ Button {
|
|
| 945 |
+ stopSession( |
|
| 946 |
+ session, |
|
| 947 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 948 |
+ displayedChargeAh: displayedChargeAh |
|
| 949 |
+ ) |
|
| 950 |
+ } label: {
|
|
| 951 |
+ Label("Save Session", systemImage: "checkmark.circle.fill")
|
|
| 952 |
+ .frame(maxWidth: .infinity) |
|
| 953 |
+ } |
|
| 954 |
+ .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled) |
|
| 955 |
+ .disabled(!isSaveEnabled) |
|
| 956 |
+ .help(saveDisabledReason ?? "Close and save this session") |
|
| 957 |
+ |
|
| 958 |
+ Button("Cancel") {
|
|
| 959 |
+ resetStopConfirmation() |
|
| 960 |
+ } |
|
| 961 |
+ .monitoringPanelActionStyle(tint: .secondary) |
|
| 962 |
+ } |
|
| 963 |
+ } |
|
| 964 |
+ .padding(14) |
|
| 965 |
+ .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16) |
|
| 966 |
+ } |
|
| 967 |
+ |
|
| 968 |
+ private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
|
|
| 969 |
+ return HStack(spacing: 8) {
|
|
| 970 |
+ ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
|
|
| 971 |
+ Button {
|
|
| 972 |
+ finalCheckpointMode = mode |
|
| 973 |
+ if mode == .custom {
|
|
| 974 |
+ prefillFinalCheckpointIfNeeded(for: session) |
|
| 975 |
+ } else {
|
|
| 976 |
+ finalCheckpointText = "" |
|
| 977 |
+ } |
|
| 978 |
+ } label: {
|
|
| 979 |
+ VStack(spacing: 5) {
|
|
| 980 |
+ Image(systemName: mode.icon) |
|
| 981 |
+ .font(.title3) |
|
| 982 |
+ .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary) |
|
| 983 |
+ Text(mode.label) |
|
| 984 |
+ .font(.caption.weight(.semibold)) |
|
| 985 |
+ .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary) |
|
| 986 |
+ } |
|
| 987 |
+ .frame(maxWidth: .infinity) |
|
| 988 |
+ .padding(.vertical, 10) |
|
| 989 |
+ .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear) |
|
| 990 |
+ .meterCard( |
|
| 991 |
+ tint: finalCheckpointMode == mode ? .primary : .secondary, |
|
| 992 |
+ fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04, |
|
| 993 |
+ strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10, |
|
| 994 |
+ cornerRadius: 12 |
|
| 995 |
+ ) |
|
| 996 |
+ } |
|
| 997 |
+ .buttonStyle(.plain) |
|
| 998 |
+ } |
|
| 999 |
+ } |
|
| 1000 |
+ } |
|
| 1001 |
+ |
|
| 1002 |
+ private var customFinalCheckpointRow: some View {
|
|
| 1003 |
+ let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
|
| 1004 |
+ || parsedFinalCheckpoint == nil |
|
| 1005 |
+ |
|
| 1006 |
+ return HStack(spacing: 8) {
|
|
| 1007 |
+ Button {
|
|
| 1008 |
+ adjustFinalCheckpoint(by: -1) |
|
| 1009 |
+ } label: {
|
|
| 1010 |
+ Image(systemName: "minus.circle").font(.title3) |
|
| 1011 |
+ } |
|
| 1012 |
+ .buttonStyle(.plain) |
|
| 1013 |
+ |
|
| 1014 |
+ TextField("-", text: $finalCheckpointText)
|
|
| 1015 |
+ .keyboardType(.decimalPad) |
|
| 1016 |
+ .textFieldStyle(.roundedBorder) |
|
| 1017 |
+ .frame(width: 56) |
|
| 1018 |
+ .multilineTextAlignment(.center) |
|
| 1019 |
+ .overlay( |
|
| 1020 |
+ RoundedRectangle(cornerRadius: 6) |
|
| 1021 |
+ .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1) |
|
| 1022 |
+ ) |
|
| 1023 |
+ |
|
| 1024 |
+ Text("%").foregroundColor(.secondary)
|
|
| 1025 |
+ |
|
| 1026 |
+ Text("required")
|
|
| 1027 |
+ .font(.caption2.weight(.semibold)) |
|
| 1028 |
+ .foregroundColor(isInvalid ? .red : .secondary) |
|
| 1029 |
+ |
|
| 1030 |
+ Button {
|
|
| 1031 |
+ adjustFinalCheckpoint(by: 1) |
|
| 1032 |
+ } label: {
|
|
| 1033 |
+ Image(systemName: "plus.circle").font(.title3) |
|
| 1034 |
+ } |
|
| 1035 |
+ .buttonStyle(.plain) |
|
| 1036 |
+ |
|
| 1037 |
+ Spacer() |
|
| 1038 |
+ } |
|
| 1039 |
+ } |
|
| 1040 |
+ |
|
| 1041 |
+ private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 1042 |
+ MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
|
|
| 1043 |
+ if let meterName = session.meterName {
|
|
| 1044 |
+ MeterInfoRowView(label: "Controlled On", value: meterName) |
|
| 1045 |
+ } |
|
| 1046 |
+ Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
|
|
| 1047 |
+ .font(.caption2) |
|
| 1048 |
+ .foregroundColor(.secondary) |
|
| 1049 |
+ } |
|
| 1050 |
+ } |
|
| 1051 |
+ |
|
| 1052 |
+ private func managementCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 1053 |
+ MeterInfoCardView(title: "Administration", tint: .red) {
|
|
| 1054 |
+ Button(role: .destructive) {
|
|
| 1055 |
+ pendingSessionDeletion = session |
|
| 1056 |
+ } label: {
|
|
| 1057 |
+ Label("Delete Session", systemImage: "trash")
|
|
| 1058 |
+ .font(.subheadline.weight(.semibold)) |
|
| 1059 |
+ .frame(maxWidth: .infinity) |
|
| 1060 |
+ .padding(.vertical, 10) |
|
| 1061 |
+ .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14) |
|
| 1062 |
+ } |
|
| 1063 |
+ .buttonStyle(.plain) |
|
| 1064 |
+ } |
|
| 1065 |
+ } |
|
| 1066 |
+ |
|
| 1067 |
+ @ViewBuilder |
|
| 1068 |
+ private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
|
|
| 1069 |
+ if let window = detectedTrimWindow {
|
|
| 1070 |
+ HStack(spacing: 12) {
|
|
| 1071 |
+ Image(systemName: "scissors.circle.fill") |
|
| 1072 |
+ .font(.title3) |
|
| 1073 |
+ .foregroundColor(.blue) |
|
| 1074 |
+ |
|
| 1075 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 1076 |
+ Text("Charging ended early")
|
|
| 1077 |
+ .font(.subheadline.weight(.semibold)) |
|
| 1078 |
+ Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
|
|
| 1079 |
+ .font(.caption) |
|
| 1080 |
+ .foregroundColor(.secondary) |
|
| 1081 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 1082 |
+ } |
|
| 1083 |
+ |
|
| 1084 |
+ Spacer(minLength: 0) |
|
| 1085 |
+ |
|
| 1086 |
+ VStack(spacing: 6) {
|
|
| 1087 |
+ Button("Trim Start") {
|
|
| 1088 |
+ setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd) |
|
| 1089 |
+ trimBannerDismissedForSessionID = session.id |
|
| 1090 |
+ } |
|
| 1091 |
+ .font(.caption.weight(.semibold)) |
|
| 1092 |
+ .buttonStyle(.borderedProminent) |
|
| 1093 |
+ .controlSize(.small) |
|
| 1094 |
+ .tint(.blue) |
|
| 1095 |
+ |
|
| 1096 |
+ Button("End & Finish") {
|
|
| 1097 |
+ requestStop( |
|
| 1098 |
+ session, |
|
| 1099 |
+ applyingTrimStart: session.trimStart ?? window.start, |
|
| 1100 |
+ trimEnd: window.end, |
|
| 1101 |
+ title: "Trim End & Finish", |
|
| 1102 |
+ confirmTitle: "Finish", |
|
| 1103 |
+ explanation: "The detected charging window will be saved before the session is closed." |
|
| 1104 |
+ ) |
|
| 1105 |
+ trimBannerDismissedForSessionID = session.id |
|
| 1106 |
+ } |
|
| 1107 |
+ .font(.caption.weight(.semibold)) |
|
| 1108 |
+ .buttonStyle(.bordered) |
|
| 1109 |
+ .controlSize(.small) |
|
| 1110 |
+ .tint(.red) |
|
| 1111 |
+ } |
|
| 1112 |
+ } |
|
| 1113 |
+ .padding(14) |
|
| 1114 |
+ .background( |
|
| 1115 |
+ RoundedRectangle(cornerRadius: 14) |
|
| 1116 |
+ .fill(Color.blue.opacity(0.10)) |
|
| 1117 |
+ .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1)) |
|
| 1118 |
+ ) |
|
| 1119 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 1120 |
+ } |
|
| 1121 |
+ } |
|
| 1122 |
+ |
|
| 1123 |
+ private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
|
|
| 1124 |
+ !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil |
|
| 1125 |
+ } |
|
| 1126 |
+ |
|
| 1127 |
+ private func chartCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 1128 |
+ ChargeSessionChartCardView( |
|
| 1129 |
+ session: session, |
|
| 1130 |
+ monitoringMeter: liveMonitoringMeter, |
|
| 1131 |
+ controlMode: chartControlMode(for: session), |
|
| 1132 |
+ onSetTrim: { start, end in
|
|
| 1133 |
+ setSessionTrim(sessionID: session.id, start: start, end: end) |
|
| 1134 |
+ }, |
|
| 1135 |
+ onStopWithTrim: { start, end in
|
|
| 1136 |
+ requestStop( |
|
| 1137 |
+ session, |
|
| 1138 |
+ applyingTrimStart: start, |
|
| 1139 |
+ trimEnd: end, |
|
| 1140 |
+ title: "Trim End & Finish", |
|
| 1141 |
+ confirmTitle: "Finish", |
|
| 1142 |
+ explanation: "The selected chart window will be saved as this session's active charging window before the session is closed." |
|
| 1143 |
+ ) |
|
| 1144 |
+ } |
|
| 1145 |
+ ) |
|
| 1146 |
+ } |
|
| 1147 |
+ |
|
| 1148 |
+ private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
|
|
| 1149 |
+ if hasMonitoringControls {
|
|
| 1150 |
+ return .activeMonitoring |
|
| 1151 |
+ } |
|
| 1152 |
+ |
|
| 1153 |
+ if session.status.isOpen == false {
|
|
| 1154 |
+ return .closed |
|
| 1155 |
+ } |
|
| 1156 |
+ |
|
| 1157 |
+ return .none |
|
| 1158 |
+ } |
|
| 1159 |
+ |
|
| 1160 |
+ private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
|
|
| 1161 |
+ _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end) |
|
| 1162 |
+ trimBannerDismissedForSessionID = sessionID |
|
| 1163 |
+ } |
|
| 1164 |
+ |
|
| 1165 |
+ private func requestStop( |
|
| 1166 |
+ _ session: ChargeSessionSummary, |
|
| 1167 |
+ applyingTrimStart trimStart: Date?, |
|
| 1168 |
+ trimEnd: Date?, |
|
| 1169 |
+ title: String, |
|
| 1170 |
+ confirmTitle: String, |
|
| 1171 |
+ explanation: String |
|
| 1172 |
+ ) {
|
|
| 1173 |
+ pendingSessionStopRequest = ChargeSessionStopRequest( |
|
| 1174 |
+ sessionID: session.id, |
|
| 1175 |
+ title: title, |
|
| 1176 |
+ confirmTitle: confirmTitle, |
|
| 1177 |
+ explanation: explanation, |
|
| 1178 |
+ appliesTrim: trimStart != nil || trimEnd != nil, |
|
| 1179 |
+ trimStart: trimStart, |
|
| 1180 |
+ trimEnd: trimEnd |
|
| 1181 |
+ ) |
|
| 1182 |
+ } |
|
| 1183 |
+ |
|
| 1184 |
+ private var parsedDraftTarget: Double? {
|
|
| 1185 |
+ let normalized = draftTargetText |
|
| 1186 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 1187 |
+ .replacingOccurrences(of: ",", with: ".") |
|
| 1188 |
+ guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
|
|
| 1189 |
+ return value |
|
| 1190 |
+ } |
|
| 1191 |
+ |
|
| 1192 |
+ private var parsedFinalCheckpoint: Double? {
|
|
| 1193 |
+ let normalized = finalCheckpointText |
|
| 1194 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 1195 |
+ .replacingOccurrences(of: ",", with: ".") |
|
| 1196 |
+ guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
|
|
| 1197 |
+ return value |
|
| 1198 |
+ } |
|
| 1199 |
+ |
|
| 1200 |
+ private var resolvedFinalCheckpoint: Double? {
|
|
| 1201 |
+ switch finalCheckpointMode {
|
|
| 1202 |
+ case .full: return 100 |
|
| 1203 |
+ case .skip: return nil |
|
| 1204 |
+ case .custom: return parsedFinalCheckpoint |
|
| 1205 |
+ } |
|
| 1206 |
+ } |
|
| 1207 |
+ |
|
| 1208 |
+ private func adjustFinalCheckpoint(by delta: Double) {
|
|
| 1209 |
+ let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0 |
|
| 1210 |
+ let next = min(max(current + delta, 0), 100) |
|
| 1211 |
+ finalCheckpointText = next.format(decimalDigits: 0) |
|
| 1212 |
+ } |
|
| 1213 |
+ |
|
| 1214 |
+ private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
|
|
| 1215 |
+ guard let session else { return nil }
|
|
| 1216 |
+ if let endBatteryPercent = session.endBatteryPercent {
|
|
| 1217 |
+ return endBatteryPercent |
|
| 1218 |
+ } |
|
| 1219 |
+ if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
|
|
| 1220 |
+ return latestCheckpoint.batteryPercent |
|
| 1221 |
+ } |
|
| 1222 |
+ return session.targetBatteryPercent ?? session.completionContradictionPercent |
|
| 1223 |
+ } |
|
| 1224 |
+ |
|
| 1225 |
+ private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
|
|
| 1226 |
+ guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, |
|
| 1227 |
+ let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
|
|
| 1228 |
+ return |
|
| 1229 |
+ } |
|
| 1230 |
+ finalCheckpointText = suggestedPercent.format(decimalDigits: 0) |
|
| 1231 |
+ } |
|
| 1232 |
+ |
|
| 1233 |
+ private func hasSavableChargeData( |
|
| 1234 |
+ session: ChargeSessionSummary, |
|
| 1235 |
+ displayedEnergyWh: Double, |
|
| 1236 |
+ displayedChargeAh: Double |
|
| 1237 |
+ ) -> Bool {
|
|
| 1238 |
+ session.hasSavableChargeData |
|
| 1239 |
+ || displayedEnergyWh > 0 |
|
| 1240 |
+ || displayedChargeAh > 0 |
|
| 1241 |
+ } |
|
| 1242 |
+ |
|
| 1243 |
+ private func saveDisabledReason( |
|
| 1244 |
+ session: ChargeSessionSummary, |
|
| 1245 |
+ displayedEnergyWh: Double, |
|
| 1246 |
+ displayedChargeAh: Double |
|
| 1247 |
+ ) -> String? {
|
|
| 1248 |
+ if finalCheckpointMode == .custom {
|
|
| 1249 |
+ let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 1250 |
+ if trimmed.isEmpty {
|
|
| 1251 |
+ return "Enter the final battery percentage or choose Skip." |
|
| 1252 |
+ } |
|
| 1253 |
+ if parsedFinalCheckpoint == nil {
|
|
| 1254 |
+ return "Final battery percentage must be between 0 and 100." |
|
| 1255 |
+ } |
|
| 1256 |
+ } |
|
| 1257 |
+ |
|
| 1258 |
+ guard hasSavableChargeData( |
|
| 1259 |
+ session: session, |
|
| 1260 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 1261 |
+ displayedChargeAh: displayedChargeAh |
|
| 1262 |
+ ) else {
|
|
| 1263 |
+ return "This session has no charging data to save. Discard it instead." |
|
| 1264 |
+ } |
|
| 1265 |
+ |
|
| 1266 |
+ return nil |
|
| 1267 |
+ } |
|
| 1268 |
+ |
|
| 1269 |
+ private func stopSession( |
|
| 1270 |
+ _ session: ChargeSessionSummary, |
|
| 1271 |
+ displayedEnergyWh: Double, |
|
| 1272 |
+ displayedChargeAh: Double |
|
| 1273 |
+ ) {
|
|
| 1274 |
+ stopFailureMessage = nil |
|
| 1275 |
+ |
|
| 1276 |
+ if let saveDisabledReason = saveDisabledReason( |
|
| 1277 |
+ session: session, |
|
| 1278 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 1279 |
+ displayedChargeAh: displayedChargeAh |
|
| 1280 |
+ ) {
|
|
| 1281 |
+ stopFailureMessage = saveDisabledReason |
|
| 1282 |
+ return |
|
| 1283 |
+ } |
|
| 1284 |
+ |
|
| 1285 |
+ let didSave = appData.stopChargeSession( |
|
| 1286 |
+ sessionID: session.id, |
|
| 1287 |
+ finalBatteryPercent: resolvedFinalCheckpoint, |
|
| 1288 |
+ from: liveMonitoringMeter |
|
| 1289 |
+ ) |
|
| 1290 |
+ if didSave {
|
|
| 1291 |
+ resetStopConfirmation() |
|
| 1292 |
+ } else {
|
|
| 1293 |
+ stopFailureMessage = "The session could not be closed. Live readings were flushed, but the stored session did not accept the save yet. Try again in a moment." |
|
| 1294 |
+ } |
|
| 1295 |
+ } |
|
| 1296 |
+ |
|
| 1297 |
+ private func beginStopConfirmation(for session: ChargeSessionSummary) {
|
|
| 1298 |
+ finalCheckpointMode = .skip |
|
| 1299 |
+ finalCheckpointText = "" |
|
| 1300 |
+ stopFailureMessage = nil |
|
| 1301 |
+ showingStopConfirm = true |
|
| 1302 |
+ } |
|
| 1303 |
+ |
|
| 1304 |
+ private func discardSession(_ session: ChargeSessionSummary) {
|
|
| 1305 |
+ _ = appData.deleteChargeSession(sessionID: session.id) |
|
| 1306 |
+ resetStopConfirmation() |
|
| 1307 |
+ } |
|
| 1308 |
+ |
|
| 1309 |
+ private func resetStopConfirmation() {
|
|
| 1310 |
+ showingStopConfirm = false |
|
| 1311 |
+ finalCheckpointText = "" |
|
| 1312 |
+ finalCheckpointMode = .skip |
|
| 1313 |
+ stopFailureMessage = nil |
|
| 1314 |
+ } |
|
| 1315 |
+ |
|
| 1316 |
+ private func syncMonitoringRestore() {
|
|
| 1317 |
+ guard let session, |
|
| 1318 |
+ session.status.isOpen, |
|
| 1319 |
+ let liveMonitoringMeter, |
|
| 1320 |
+ session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
|
|
| 1321 |
+ return |
|
| 1322 |
+ } |
|
| 1323 |
+ liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session) |
|
| 1324 |
+ } |
|
| 1325 |
+ |
|
| 1326 |
+ private func runTrimDetection() {
|
|
| 1327 |
+ guard hasMonitoringControls, |
|
| 1328 |
+ let session, |
|
| 1329 |
+ session.isTrimmed == false, |
|
| 1330 |
+ !session.aggregatedSamples.isEmpty else {
|
|
| 1331 |
+ detectedTrimWindow = nil |
|
| 1332 |
+ return |
|
| 1333 |
+ } |
|
| 1334 |
+ |
|
| 1335 |
+ let sessionEnd = session.endedAt ?? session.lastObservedAt |
|
| 1336 |
+ detectedTrimWindow = ChargingWindowDetector.detect( |
|
| 1337 |
+ samples: session.aggregatedSamples, |
|
| 1338 |
+ sessionStart: session.startedAt, |
|
| 1339 |
+ sessionEnd: sessionEnd |
|
| 1340 |
+ ) |
|
| 1341 |
+ } |
|
| 1342 |
+ |
|
| 1343 |
+ private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
|
|
| 1344 |
+ let storedEnergyWh = session.effectiveOrMeasuredEnergyWh |
|
| 1345 |
+ guard session.isTrimmed == false else { return storedEnergyWh }
|
|
| 1346 |
+ guard session.status.isOpen else { return storedEnergyWh }
|
|
| 1347 |
+ guard let liveMonitoringMeter else { return storedEnergyWh }
|
|
| 1348 |
+ guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
|
|
| 1349 |
+ if let baselineEnergyWh = session.meterEnergyBaselineWh {
|
|
| 1350 |
+ return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0)) |
|
| 1351 |
+ } |
|
| 1352 |
+ return storedEnergyWh |
|
| 1353 |
+ } |
|
| 1354 |
+ |
|
| 1355 |
+ private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
|
|
| 1356 |
+ let storedChargeAh = session.measuredChargeAh |
|
| 1357 |
+ guard session.isTrimmed == false else { return storedChargeAh }
|
|
| 1358 |
+ guard session.status.isOpen else { return storedChargeAh }
|
|
| 1359 |
+ guard let liveMonitoringMeter else { return storedChargeAh }
|
|
| 1360 |
+ guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
|
|
| 1361 |
+ if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
| 1362 |
+ return max(storedChargeAh, max(liveMonitoringMeter.recordedAH - baselineChargeAh, 0)) |
|
| 1363 |
+ } |
|
| 1364 |
+ return storedChargeAh |
|
| 1365 |
+ } |
|
| 1366 |
+ |
|
| 1367 |
+ private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
|
|
| 1368 |
+ let storedDuration = max(session.effectiveDuration, 0) |
|
| 1369 |
+ guard session.isTrimmed == false else { return storedDuration }
|
|
| 1370 |
+ guard session.status.isOpen else { return storedDuration }
|
|
| 1371 |
+ guard let liveMonitoringMeter else { return storedDuration }
|
|
| 1372 |
+ guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
|
|
| 1373 |
+ return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0)) |
|
| 1374 |
+ } |
|
| 1375 |
+ |
|
| 1376 |
+ private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
|
|
| 1377 |
+ let displayedDuration = displayedSessionDuration(for: session) |
|
| 1378 |
+ let formatter = DateComponentsFormatter() |
|
| 1379 |
+ formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 1380 |
+ formatter.unitsStyle = .abbreviated |
|
| 1381 |
+ formatter.zeroFormattingBehavior = .dropAll |
|
| 1382 |
+ return formatter.string(from: displayedDuration) ?? "0m" |
|
| 1383 |
+ } |
|
| 1384 |
+ |
|
| 1385 |
+ private func formatDuration(_ duration: TimeInterval) -> String {
|
|
| 1386 |
+ let totalSeconds = Int(duration.rounded(.down)) |
|
| 1387 |
+ let hours = totalSeconds / 3600 |
|
| 1388 |
+ let minutes = (totalSeconds % 3600) / 60 |
|
| 1389 |
+ let seconds = totalSeconds % 60 |
|
| 1390 |
+ if hours > 0 {
|
|
| 1391 |
+ return String(format: "%d:%02d:%02d", hours, minutes, seconds) |
|
| 1392 |
+ } |
|
| 1393 |
+ return String(format: "%02d:%02d", minutes, seconds) |
|
| 1394 |
+ } |
|
| 1395 |
+ |
|
| 1396 |
+ private func autoStopDescription(for session: ChargeSessionSummary) -> String {
|
|
| 1397 |
+ if session.autoStopEnabled == false {
|
|
| 1398 |
+ return "Manual" |
|
| 1399 |
+ } |
|
| 1400 |
+ |
|
| 1401 |
+ if let sessionWarning = sessionWarning(for: session), |
|
| 1402 |
+ sessionWarning.contains("idle-current") {
|
|
| 1403 |
+ return "Blocked by charger setup" |
|
| 1404 |
+ } |
|
| 1405 |
+ |
|
| 1406 |
+ if session.stopThresholdAmps > 0 {
|
|
| 1407 |
+ return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A" |
|
| 1408 |
+ } |
|
| 1409 |
+ |
|
| 1410 |
+ return "Learning" |
|
| 1411 |
+ } |
|
| 1412 |
+ |
|
| 1413 |
+ private func autoStopLabel(for session: ChargeSessionSummary) -> String {
|
|
| 1414 |
+ if session.autoStopEnabled == false {
|
|
| 1415 |
+ return "Manual" |
|
| 1416 |
+ } |
|
| 1417 |
+ if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
|
|
| 1418 |
+ return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
|
|
| 1419 |
+ } |
|
| 1420 |
+ if session.stopThresholdAmps > 0 {
|
|
| 1421 |
+ return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A" |
|
| 1422 |
+ } |
|
| 1423 |
+ return "Learning" |
|
| 1424 |
+ } |
|
| 1425 |
+ |
|
| 1426 |
+ private func shouldShowChargingTransport( |
|
| 1427 |
+ for session: ChargeSessionSummary, |
|
| 1428 |
+ chargedDevice: ChargedDeviceSummary |
|
| 1429 |
+ ) -> Bool {
|
|
| 1430 |
+ chargedDevice.supportedChargingModes.count > 1 |
|
| 1431 |
+ || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false |
|
| 1432 |
+ } |
|
| 1433 |
+ |
|
| 1434 |
+ private func shouldShowChargingState( |
|
| 1435 |
+ for session: ChargeSessionSummary, |
|
| 1436 |
+ chargedDevice: ChargedDeviceSummary |
|
| 1437 |
+ ) -> Bool {
|
|
| 1438 |
+ chargedDevice.supportedChargingStateModes.count > 1 |
|
| 1439 |
+ || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false |
|
| 1440 |
+ } |
|
| 1441 |
+ |
|
| 1442 |
+ private func batteryColor(for percent: Double) -> Color {
|
|
| 1443 |
+ if percent >= 75 { return .green }
|
|
| 1444 |
+ if percent >= 35 { return .orange }
|
|
| 1445 |
+ return .red |
|
| 1446 |
+ } |
|
| 1447 |
+ |
|
| 1448 |
+ private func etaText( |
|
| 1449 |
+ rateWhPerSec: Double?, |
|
| 1450 |
+ remainingWh: Double, |
|
| 1451 |
+ isRelevant: Bool |
|
| 1452 |
+ ) -> String? {
|
|
| 1453 |
+ guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
|
|
| 1454 |
+ let seconds = remainingWh / rateWhPerSec |
|
| 1455 |
+ return seconds > 120 ? formatETA(seconds) : nil |
|
| 1456 |
+ } |
|
| 1457 |
+ |
|
| 1458 |
+ private func etaToTargetText( |
|
| 1459 |
+ session: ChargeSessionSummary, |
|
| 1460 |
+ prediction: BatteryLevelPrediction, |
|
| 1461 |
+ displayedEnergyWh: Double, |
|
| 1462 |
+ rateWhPerSec: Double? |
|
| 1463 |
+ ) -> String? {
|
|
| 1464 |
+ guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
|
|
| 1465 |
+ return nil |
|
| 1466 |
+ } |
|
| 1467 |
+ let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh |
|
| 1468 |
+ return etaText( |
|
| 1469 |
+ rateWhPerSec: rateWhPerSec, |
|
| 1470 |
+ remainingWh: max(targetEnergyWh - displayedEnergyWh, 0), |
|
| 1471 |
+ isRelevant: true |
|
| 1472 |
+ ) |
|
| 1473 |
+ } |
|
| 1474 |
+ |
|
| 1475 |
+ private func formatETA(_ seconds: TimeInterval) -> String {
|
|
| 1476 |
+ let totalMinutes = Int(seconds / 60) |
|
| 1477 |
+ if totalMinutes < 60 { return "\(totalMinutes)m" }
|
|
| 1478 |
+ let hours = totalMinutes / 60 |
|
| 1479 |
+ let minutes = totalMinutes % 60 |
|
| 1480 |
+ return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m" |
|
| 1481 |
+ } |
|
| 1482 |
+ |
|
| 1483 |
+ private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
|
|
| 1484 |
+ switch session.status {
|
|
| 1485 |
+ case .active: |
|
| 1486 |
+ return .red |
|
| 1487 |
+ case .paused: |
|
| 1488 |
+ return .orange |
|
| 1489 |
+ case .completed: |
|
| 1490 |
+ return .green |
|
| 1491 |
+ case .abandoned: |
|
| 1492 |
+ return .secondary |
|
| 1493 |
+ } |
|
| 1494 |
+ } |
|
| 1495 |
+ |
|
| 1496 |
+ private func sessionWarning(for session: ChargeSessionSummary) -> String? {
|
|
| 1497 |
+ nil |
|
| 1498 |
+ } |
|
| 1499 |
+ |
|
| 1500 |
+ private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
|
|
| 1501 |
+ guard session.chargingTransportMode == .wireless else {
|
|
| 1502 |
+ return nil |
|
| 1503 |
+ } |
|
| 1504 |
+ |
|
| 1505 |
+ var components: [String] = [] |
|
| 1506 |
+ if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
|
|
| 1507 |
+ components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
|
|
| 1508 |
+ } |
|
| 1509 |
+ if session.usesEstimatedWirelessEfficiency {
|
|
| 1510 |
+ components.append("Estimated from wired baseline and checkpoints")
|
|
| 1511 |
+ } |
|
| 1512 |
+ if session.shouldWarnAboutLowWirelessEfficiency {
|
|
| 1513 |
+ components.append("Low wireless efficiency, so capacity confidence is reduced")
|
|
| 1514 |
+ } |
|
| 1515 |
+ |
|
| 1516 |
+ return components.isEmpty ? nil : components.joined(separator: " - ") |
|
| 1517 |
+ } |
|
| 1518 |
+ |
|
| 1519 |
+ private func statusTint(for session: ChargeSessionSummary) -> Color {
|
|
| 1520 |
+ switch session.status {
|
|
| 1521 |
+ case .active: |
|
| 1522 |
+ return .green |
|
| 1523 |
+ case .paused: |
|
| 1524 |
+ return .orange |
|
| 1525 |
+ case .completed: |
|
| 1526 |
+ return .teal |
|
| 1527 |
+ case .abandoned: |
|
| 1528 |
+ return .secondary |
|
| 1529 |
+ } |
|
| 1530 |
+ } |
|
| 1531 |
+} |
|
| 1532 |
+ |
|
| 1533 |
+enum ChargeSessionChartControlMode {
|
|
| 1534 |
+ case none |
|
| 1535 |
+ case activeMonitoring |
|
| 1536 |
+ case closed |
|
| 1537 |
+} |
|
| 1538 |
+ |
|
| 1539 |
+struct ChargeSessionChartCardView: View {
|
|
| 1540 |
+ let session: ChargeSessionSummary |
|
| 1541 |
+ let monitoringMeter: Meter? |
|
| 1542 |
+ let controlMode: ChargeSessionChartControlMode |
|
| 1543 |
+ let onSetTrim: (Date?, Date?) -> Void |
|
| 1544 |
+ let onStopWithTrim: (Date?, Date?) -> Void |
|
| 1545 |
+ |
|
| 1546 |
+ @StateObject private var storedMeasurements = Measurements() |
|
| 1547 |
+ |
|
| 1548 |
+ private var chartMeasurements: Measurements {
|
|
| 1549 |
+ if let monitoringMeter, |
|
| 1550 |
+ session.status.isOpen, |
|
| 1551 |
+ session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
|
|
| 1552 |
+ return monitoringMeter.chargeRecordMeasurements |
|
| 1553 |
+ } |
|
| 1554 |
+ return storedMeasurements |
|
| 1555 |
+ } |
|
| 1556 |
+ |
|
| 1557 |
+ private var fullTimeRange: ClosedRange<Date> {
|
|
| 1558 |
+ let start = session.startedAt |
|
| 1559 |
+ let end = max(session.endedAt ?? session.lastObservedAt, start) |
|
| 1560 |
+ return start...end |
|
| 1561 |
+ } |
|
| 1562 |
+ |
|
| 1563 |
+ private var fixedTimeRange: ClosedRange<Date>? {
|
|
| 1564 |
+ if monitoringMeter != nil && session.status.isOpen {
|
|
| 1565 |
+ return nil |
|
| 1566 |
+ } |
|
| 1567 |
+ return session.effectiveTimeRange |
|
| 1568 |
+ } |
|
| 1569 |
+ |
|
| 1570 |
+ private var liveTrimBounds: (lower: Date?, upper: Date?) {
|
|
| 1571 |
+ guard monitoringMeter != nil && session.status.isOpen else {
|
|
| 1572 |
+ return (nil, nil) |
|
| 1573 |
+ } |
|
| 1574 |
+ return (session.trimStart, session.trimEnd) |
|
| 1575 |
+ } |
|
| 1576 |
+ |
|
| 1577 |
+ private var showsRangeSelector: Bool {
|
|
| 1578 |
+ controlMode != .none && !session.aggregatedSamples.isEmpty |
|
| 1579 |
+ } |
|
| 1580 |
+ |
|
| 1581 |
+ var body: some View {
|
|
| 1582 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 1583 |
+ HStack(spacing: 8) {
|
|
| 1584 |
+ Image(systemName: "chart.xyaxis.line") |
|
| 1585 |
+ .foregroundColor(.blue) |
|
| 1586 |
+ Text("Session Chart")
|
|
| 1587 |
+ .font(.headline) |
|
| 1588 |
+ ContextInfoButton( |
|
| 1589 |
+ title: "Session Chart", |
|
| 1590 |
+ message: chartInfoMessage |
|
| 1591 |
+ ) |
|
| 1592 |
+ Spacer(minLength: 0) |
|
| 1593 |
+ } |
|
| 1594 |
+ |
|
| 1595 |
+ MeasurementChartView( |
|
| 1596 |
+ timeRange: fixedTimeRange, |
|
| 1597 |
+ timeRangeLowerBound: liveTrimBounds.lower, |
|
| 1598 |
+ timeRangeUpperBound: liveTrimBounds.upper, |
|
| 1599 |
+ showsRangeSelector: showsRangeSelector, |
|
| 1600 |
+ rebasesEnergyToVisibleRangeStart: true, |
|
| 1601 |
+ extendsTimelineToPresent: false, |
|
| 1602 |
+ showsTemperatureSeries: false, |
|
| 1603 |
+ rangeSelectorConfiguration: rangeSelectorConfiguration |
|
| 1604 |
+ ) |
|
| 1605 |
+ .environmentObject(chartMeasurements) |
|
| 1606 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 1607 |
+ } |
|
| 1608 |
+ .padding(18) |
|
| 1609 |
+ .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 1610 |
+ .onAppear(perform: restoreStoredMeasurementsIfNeeded) |
|
| 1611 |
+ .onChange(of: session.id) { _ in
|
|
| 1612 |
+ restoreStoredMeasurementsIfNeeded() |
|
| 1613 |
+ } |
|
| 1614 |
+ .onChange(of: session.aggregatedSamples.count) { _ in
|
|
| 1615 |
+ restoreStoredMeasurementsIfNeeded() |
|
| 1616 |
+ } |
|
| 1617 |
+ } |
|
| 1618 |
+ |
|
| 1619 |
+ private var chartInfoMessage: String {
|
|
| 1620 |
+ if monitoringMeter != nil && session.status.isOpen {
|
|
| 1621 |
+ return "This chart combines the persisted session curve with current live data from this meter." |
|
| 1622 |
+ } |
|
| 1623 |
+ |
|
| 1624 |
+ return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging." |
|
| 1625 |
+ } |
|
| 1626 |
+ |
|
| 1627 |
+ private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
|
|
| 1628 |
+ switch controlMode {
|
|
| 1629 |
+ case .none: |
|
| 1630 |
+ return nil |
|
| 1631 |
+ case .activeMonitoring: |
|
| 1632 |
+ return MeasurementChartRangeSelectorConfiguration( |
|
| 1633 |
+ keepAction: MeasurementChartSelectionAction( |
|
| 1634 |
+ title: "Trim Start", |
|
| 1635 |
+ shortTitle: "Start", |
|
| 1636 |
+ systemName: "arrow.right.to.line", |
|
| 1637 |
+ tone: .destructive, |
|
| 1638 |
+ handler: applyActiveStartTrim |
|
| 1639 |
+ ), |
|
| 1640 |
+ removeAction: MeasurementChartSelectionAction( |
|
| 1641 |
+ title: "Trim End & Finish", |
|
| 1642 |
+ shortTitle: "End", |
|
| 1643 |
+ systemName: "arrow.left.to.line", |
|
| 1644 |
+ tone: .destructiveProminent, |
|
| 1645 |
+ handler: requestActiveEndTrim |
|
| 1646 |
+ ), |
|
| 1647 |
+ resetAction: MeasurementChartResetAction( |
|
| 1648 |
+ title: "Reset Trim", |
|
| 1649 |
+ shortTitle: "Reset", |
|
| 1650 |
+ systemName: "arrow.counterclockwise", |
|
| 1651 |
+ tone: .reversible, |
|
| 1652 |
+ confirmationTitle: "Reset session trim?", |
|
| 1653 |
+ confirmationButtonTitle: "Reset trim", |
|
| 1654 |
+ handler: {
|
|
| 1655 |
+ onSetTrim(nil, nil) |
|
| 1656 |
+ } |
|
| 1657 |
+ ) |
|
| 1658 |
+ ) |
|
| 1659 |
+ case .closed: |
|
| 1660 |
+ return MeasurementChartRangeSelectorConfiguration( |
|
| 1661 |
+ keepAction: MeasurementChartSelectionAction( |
|
| 1662 |
+ title: "Trim Window", |
|
| 1663 |
+ shortTitle: "Trim", |
|
| 1664 |
+ systemName: "scissors", |
|
| 1665 |
+ tone: .destructive, |
|
| 1666 |
+ handler: applyClosedTrim |
|
| 1667 |
+ ), |
|
| 1668 |
+ removeAction: nil, |
|
| 1669 |
+ resetAction: MeasurementChartResetAction( |
|
| 1670 |
+ title: "Reset Trim", |
|
| 1671 |
+ shortTitle: "Reset", |
|
| 1672 |
+ systemName: "arrow.counterclockwise", |
|
| 1673 |
+ tone: .reversible, |
|
| 1674 |
+ confirmationTitle: "Reset session trim?", |
|
| 1675 |
+ confirmationButtonTitle: "Reset trim", |
|
| 1676 |
+ handler: {
|
|
| 1677 |
+ onSetTrim(nil, nil) |
|
| 1678 |
+ } |
|
| 1679 |
+ ) |
|
| 1680 |
+ ) |
|
| 1681 |
+ } |
|
| 1682 |
+ } |
|
| 1683 |
+ |
|
| 1684 |
+ private func restoreStoredMeasurementsIfNeeded() {
|
|
| 1685 |
+ guard monitoringMeter == nil || session.status.isOpen == false else {
|
|
| 1686 |
+ return |
|
| 1687 |
+ } |
|
| 1688 |
+ storedMeasurements.resetSeries() |
|
| 1689 |
+ _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded( |
|
| 1690 |
+ from: session, |
|
| 1691 |
+ replacingLiveBufferIfNeeded: true |
|
| 1692 |
+ ) |
|
| 1693 |
+ } |
|
| 1694 |
+ |
|
| 1695 |
+ private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
|
|
| 1696 |
+ onSetTrim(normalizedStart(range.lowerBound), session.trimEnd) |
|
| 1697 |
+ } |
|
| 1698 |
+ |
|
| 1699 |
+ private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
|
|
| 1700 |
+ let start = session.trimStart ?? normalizedStart(range.lowerBound) |
|
| 1701 |
+ let end = normalizedEnd(range.upperBound) |
|
| 1702 |
+ onStopWithTrim(start, end) |
|
| 1703 |
+ } |
|
| 1704 |
+ |
|
| 1705 |
+ private func applyClosedTrim(_ range: ClosedRange<Date>) {
|
|
| 1706 |
+ onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound)) |
|
| 1707 |
+ } |
|
| 1708 |
+ |
|
| 1709 |
+ private func normalizedStart(_ date: Date) -> Date? {
|
|
| 1710 |
+ date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date |
|
| 1711 |
+ } |
|
| 1712 |
+ |
|
| 1713 |
+ private func normalizedEnd(_ date: Date) -> Date? {
|
|
| 1714 |
+ fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date |
|
| 1715 |
+ } |
|
| 1716 |
+} |
|
| 1717 |
+ |
|
| 1718 |
+private struct ChargeSessionStopRequest: Identifiable {
|
|
| 1719 |
+ let sessionID: UUID |
|
| 1720 |
+ let title: String |
|
| 1721 |
+ let confirmTitle: String |
|
| 1722 |
+ let explanation: String |
|
| 1723 |
+ let appliesTrim: Bool |
|
| 1724 |
+ let trimStart: Date? |
|
| 1725 |
+ let trimEnd: Date? |
|
| 1726 |
+ |
|
| 1727 |
+ var id: String {
|
|
| 1728 |
+ [ |
|
| 1729 |
+ sessionID.uuidString, |
|
| 1730 |
+ title, |
|
| 1731 |
+ trimStart?.timeIntervalSince1970.description ?? "nil", |
|
| 1732 |
+ trimEnd?.timeIntervalSince1970.description ?? "nil" |
|
| 1733 |
+ ].joined(separator: "-") |
|
| 1734 |
+ } |
|
| 1735 |
+} |
|
| 1736 |
+ |
|
| 1737 |
+private extension View {
|
|
| 1738 |
+ func monitoringActionStyle(tint: Color) -> some View {
|
|
| 1739 |
+ frame(maxWidth: .infinity) |
|
| 1740 |
+ .padding(.vertical, 10) |
|
| 1741 |
+ .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 1742 |
+ .buttonStyle(.plain) |
|
| 1743 |
+ } |
|
| 1744 |
+ |
|
| 1745 |
+ func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
|
|
| 1746 |
+ frame(maxWidth: .infinity) |
|
| 1747 |
+ .padding(.vertical, 9) |
|
| 1748 |
+ .meterCard( |
|
| 1749 |
+ tint: tint, |
|
| 1750 |
+ fillOpacity: isProminent ? 0.22 : 0.10, |
|
| 1751 |
+ strokeOpacity: isProminent ? 0.32 : 0.14, |
|
| 1752 |
+ cornerRadius: 14 |
|
| 1753 |
+ ) |
|
| 1754 |
+ .buttonStyle(.plain) |
|
| 1755 |
+ } |
|
| 1756 |
+} |
|
@@ -0,0 +1,410 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargedDeviceSessionsView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 22/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+struct ChargedDeviceSessionsView: View {
|
|
| 11 |
+ @EnvironmentObject private var appData: AppData |
|
| 12 |
+ @State private var pendingSessionDeletion: ChargeSessionSummary? |
|
| 13 |
+ |
|
| 14 |
+ let chargedDeviceID: UUID |
|
| 15 |
+ |
|
| 16 |
+ private var chargedDevice: ChargedDeviceSummary? {
|
|
| 17 |
+ appData.chargedDeviceSummary(id: chargedDeviceID) |
|
| 18 |
+ } |
|
| 19 |
+ |
|
| 20 |
+ private var sessions: [ChargeSessionSummary] {
|
|
| 21 |
+ chargedDevice?.sessions.filter { !$0.status.isOpen } ?? []
|
|
| 22 |
+ } |
|
| 23 |
+ |
|
| 24 |
+ /// Maps session ID → capacity delta vs the closest preceding session that has an estimate. |
|
| 25 |
+ private var capacityDeltas: [UUID: Double] {
|
|
| 26 |
+ let sorted = sessions.sorted { $0.startedAt < $1.startedAt }
|
|
| 27 |
+ var result: [UUID: Double] = [:] |
|
| 28 |
+ var previousCapacity: Double? = nil |
|
| 29 |
+ for session in sorted {
|
|
| 30 |
+ if let current = session.capacityEstimateWh {
|
|
| 31 |
+ if let prev = previousCapacity {
|
|
| 32 |
+ result[session.id] = current - prev |
|
| 33 |
+ } |
|
| 34 |
+ previousCapacity = current |
|
| 35 |
+ } |
|
| 36 |
+ } |
|
| 37 |
+ return result |
|
| 38 |
+ } |
|
| 39 |
+ |
|
| 40 |
+ var body: some View {
|
|
| 41 |
+ Group {
|
|
| 42 |
+ if let chargedDevice {
|
|
| 43 |
+ ScrollView {
|
|
| 44 |
+ VStack(spacing: 14) {
|
|
| 45 |
+ if sessions.isEmpty {
|
|
| 46 |
+ emptyState |
|
| 47 |
+ } else {
|
|
| 48 |
+ summaryHeader(chargedDevice) |
|
| 49 |
+ |
|
| 50 |
+ let deltas = capacityDeltas |
|
| 51 |
+ ForEach(sessions, id: \.id) { session in
|
|
| 52 |
+ sessionCard(session, chargedDevice: chargedDevice, capacityDelta: deltas[session.id]) |
|
| 53 |
+ } |
|
| 54 |
+ } |
|
| 55 |
+ } |
|
| 56 |
+ .padding() |
|
| 57 |
+ } |
|
| 58 |
+ .background( |
|
| 59 |
+ LinearGradient( |
|
| 60 |
+ colors: [tint(for: chargedDevice).opacity(0.14), Color.clear], |
|
| 61 |
+ startPoint: .topLeading, |
|
| 62 |
+ endPoint: .bottomTrailing |
|
| 63 |
+ ) |
|
| 64 |
+ .ignoresSafeArea() |
|
| 65 |
+ ) |
|
| 66 |
+ .navigationTitle("Sessions")
|
|
| 67 |
+ } else {
|
|
| 68 |
+ Text("This device is no longer available.")
|
|
| 69 |
+ .foregroundColor(.secondary) |
|
| 70 |
+ .navigationTitle("Sessions")
|
|
| 71 |
+ } |
|
| 72 |
+ } |
|
| 73 |
+ .alert(item: $pendingSessionDeletion) { session in
|
|
| 74 |
+ Alert( |
|
| 75 |
+ title: Text("Delete Session?"),
|
|
| 76 |
+ message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
|
|
| 77 |
+ primaryButton: .destructive(Text("Delete")) {
|
|
| 78 |
+ _ = appData.deleteChargeSession(sessionID: session.id) |
|
| 79 |
+ }, |
|
| 80 |
+ secondaryButton: .cancel() |
|
| 81 |
+ ) |
|
| 82 |
+ } |
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 85 |
+ private var emptyState: some View {
|
|
| 86 |
+ VStack(spacing: 10) {
|
|
| 87 |
+ Image(systemName: "clock") |
|
| 88 |
+ .font(.system(size: 34, weight: .semibold)) |
|
| 89 |
+ .foregroundColor(.secondary) |
|
| 90 |
+ Text("No Closed Sessions")
|
|
| 91 |
+ .font(.headline) |
|
| 92 |
+ Text("Completed and abandoned sessions will appear here after they are closed.")
|
|
| 93 |
+ .font(.footnote) |
|
| 94 |
+ .foregroundColor(.secondary) |
|
| 95 |
+ .multilineTextAlignment(.center) |
|
| 96 |
+ } |
|
| 97 |
+ .frame(maxWidth: .infinity) |
|
| 98 |
+ .padding(24) |
|
| 99 |
+ .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 18) |
|
| 100 |
+ } |
|
| 101 |
+ |
|
| 102 |
+ private func summaryHeader(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 103 |
+ let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
|
|
| 104 |
+ let completedCount = sessions.filter { $0.status == .completed }.count
|
|
| 105 |
+ |
|
| 106 |
+ return MeterInfoCardView(title: chargedDevice.name, tint: tint(for: chargedDevice)) {
|
|
| 107 |
+ MeterInfoRowView(label: "Closed Sessions", value: "\(sessions.count)") |
|
| 108 |
+ MeterInfoRowView(label: "Completed", value: "\(completedCount)") |
|
| 109 |
+ MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh") |
|
| 110 |
+ } |
|
| 111 |
+ } |
|
| 112 |
+ |
|
| 113 |
+ // MARK: - Session Card |
|
| 114 |
+ |
|
| 115 |
+ private func sessionCard( |
|
| 116 |
+ _ session: ChargeSessionSummary, |
|
| 117 |
+ chargedDevice: ChargedDeviceSummary, |
|
| 118 |
+ capacityDelta: Double? |
|
| 119 |
+ ) -> some View {
|
|
| 120 |
+ let sessionTint = statusTint(for: session) |
|
| 121 |
+ |
|
| 122 |
+ return VStack(alignment: .leading, spacing: 10) {
|
|
| 123 |
+ NavigationLink( |
|
| 124 |
+ destination: ChargeSessionDetailView( |
|
| 125 |
+ chargedDeviceID: chargedDevice.id, |
|
| 126 |
+ sessionID: session.id |
|
| 127 |
+ ) |
|
| 128 |
+ ) {
|
|
| 129 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 130 |
+ // Header: date + status badge |
|
| 131 |
+ HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
| 132 |
+ Text(session.startedAt.format()) |
|
| 133 |
+ .font(.subheadline.weight(.semibold)) |
|
| 134 |
+ .foregroundColor(.primary) |
|
| 135 |
+ |
|
| 136 |
+ Text(session.status.title) |
|
| 137 |
+ .font(.caption2.weight(.semibold)) |
|
| 138 |
+ .foregroundColor(sessionTint) |
|
| 139 |
+ .padding(.horizontal, 8) |
|
| 140 |
+ .padding(.vertical, 4) |
|
| 141 |
+ .background(Capsule().fill(sessionTint.opacity(0.16))) |
|
| 142 |
+ |
|
| 143 |
+ if session.wasConflictHealed {
|
|
| 144 |
+ Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90") |
|
| 145 |
+ .font(.caption2.weight(.semibold)) |
|
| 146 |
+ .foregroundColor(.orange) |
|
| 147 |
+ .help("This session was automatically closed because a newer session was started on another device while offline.")
|
|
| 148 |
+ } |
|
| 149 |
+ |
|
| 150 |
+ Spacer() |
|
| 151 |
+ |
|
| 152 |
+ Image(systemName: "chevron.right") |
|
| 153 |
+ .font(.caption.weight(.semibold)) |
|
| 154 |
+ .foregroundColor(.secondary) |
|
| 155 |
+ } |
|
| 156 |
+ |
|
| 157 |
+ // Primary metrics: Energy + Duration |
|
| 158 |
+ HStack(spacing: 8) {
|
|
| 159 |
+ primaryMetricCell( |
|
| 160 |
+ label: "Energy", |
|
| 161 |
+ value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", |
|
| 162 |
+ tint: .teal |
|
| 163 |
+ ) |
|
| 164 |
+ primaryMetricCell( |
|
| 165 |
+ label: "Duration", |
|
| 166 |
+ value: sessionDurationText(session), |
|
| 167 |
+ tint: .orange |
|
| 168 |
+ ) |
|
| 169 |
+ } |
|
| 170 |
+ |
|
| 171 |
+ // Charge bar (if start/end battery % known) |
|
| 172 |
+ if let chargeRange = chargeBarRange(for: session) {
|
|
| 173 |
+ chargeBarView(range: chargeRange, tint: sessionTint) |
|
| 174 |
+ } |
|
| 175 |
+ |
|
| 176 |
+ // Capacity estimate + battery delta chips |
|
| 177 |
+ let chips = chipContent(session: session, capacityDelta: capacityDelta) |
|
| 178 |
+ if !chips.isEmpty {
|
|
| 179 |
+ chipsRow(chips) |
|
| 180 |
+ } |
|
| 181 |
+ |
|
| 182 |
+ // Secondary info line |
|
| 183 |
+ let secondary = secondaryInfoLine(session, chargedDevice: chargedDevice) |
|
| 184 |
+ if !secondary.isEmpty {
|
|
| 185 |
+ Text(secondary) |
|
| 186 |
+ .font(.caption) |
|
| 187 |
+ .foregroundColor(.secondary) |
|
| 188 |
+ } |
|
| 189 |
+ } |
|
| 190 |
+ } |
|
| 191 |
+ .buttonStyle(.plain) |
|
| 192 |
+ |
|
| 193 |
+ Divider() |
|
| 194 |
+ |
|
| 195 |
+ HStack {
|
|
| 196 |
+ if !session.displayedAggregatedSamples.isEmpty {
|
|
| 197 |
+ Label("\(session.displayedAggregatedSamples.count) curve points", systemImage: "chart.xyaxis.line")
|
|
| 198 |
+ .font(.caption2) |
|
| 199 |
+ .foregroundColor(.secondary) |
|
| 200 |
+ } |
|
| 201 |
+ |
|
| 202 |
+ Spacer() |
|
| 203 |
+ |
|
| 204 |
+ Button(role: .destructive) {
|
|
| 205 |
+ pendingSessionDeletion = session |
|
| 206 |
+ } label: {
|
|
| 207 |
+ Image(systemName: "trash") |
|
| 208 |
+ .font(.caption.weight(.semibold)) |
|
| 209 |
+ .foregroundColor(.red) |
|
| 210 |
+ .frame(width: 30, height: 30) |
|
| 211 |
+ .background(Circle().fill(Color.red.opacity(0.10))) |
|
| 212 |
+ } |
|
| 213 |
+ .buttonStyle(.plain) |
|
| 214 |
+ .help("Delete session")
|
|
| 215 |
+ } |
|
| 216 |
+ } |
|
| 217 |
+ .padding(14) |
|
| 218 |
+ .meterCard(tint: sessionTint, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 219 |
+ } |
|
| 220 |
+ |
|
| 221 |
+ // MARK: - Primary metric cell |
|
| 222 |
+ |
|
| 223 |
+ private func primaryMetricCell(label: String, value: String, tint: Color) -> some View {
|
|
| 224 |
+ VStack(alignment: .leading, spacing: 3) {
|
|
| 225 |
+ Text(label) |
|
| 226 |
+ .font(.caption2) |
|
| 227 |
+ .foregroundColor(.secondary) |
|
| 228 |
+ Text(value) |
|
| 229 |
+ .font(.subheadline.weight(.bold)) |
|
| 230 |
+ .foregroundColor(.primary) |
|
| 231 |
+ .monospacedDigit() |
|
| 232 |
+ .lineLimit(1) |
|
| 233 |
+ .minimumScaleFactor(0.8) |
|
| 234 |
+ } |
|
| 235 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 236 |
+ .padding(10) |
|
| 237 |
+ .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) |
|
| 238 |
+ } |
|
| 239 |
+ |
|
| 240 |
+ // MARK: - Charge bar |
|
| 241 |
+ |
|
| 242 |
+ /// Returns (startPercent, endPercent) if we have enough data to render a charge bar. |
|
| 243 |
+ private func chargeBarRange(for session: ChargeSessionSummary) -> (start: Double, end: Double)? {
|
|
| 244 |
+ let start = session.startBatteryPercent |
|
| 245 |
+ let end = session.endBatteryPercent |
|
| 246 |
+ |
|
| 247 |
+ if let s = start, let e = end, e > s {
|
|
| 248 |
+ return (s, e) |
|
| 249 |
+ } |
|
| 250 |
+ |
|
| 251 |
+ // Fall back to first / last checkpoint |
|
| 252 |
+ let sorted = session.checkpoints.sorted { $0.timestamp < $1.timestamp }
|
|
| 253 |
+ if sorted.count >= 2, |
|
| 254 |
+ let first = sorted.first, |
|
| 255 |
+ let last = sorted.last, |
|
| 256 |
+ last.batteryPercent > first.batteryPercent {
|
|
| 257 |
+ return (first.batteryPercent, last.batteryPercent) |
|
| 258 |
+ } |
|
| 259 |
+ |
|
| 260 |
+ return nil |
|
| 261 |
+ } |
|
| 262 |
+ |
|
| 263 |
+ private func chargeBarView(range: (start: Double, end: Double), tint: Color) -> some View {
|
|
| 264 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 265 |
+ GeometryReader { geo in
|
|
| 266 |
+ let w = geo.size.width |
|
| 267 |
+ let startX = w * CGFloat(range.start / 100) |
|
| 268 |
+ let fillWidth = max(w * CGFloat((range.end - range.start) / 100), 4) |
|
| 269 |
+ ZStack(alignment: .leading) {
|
|
| 270 |
+ Capsule() |
|
| 271 |
+ .fill(Color.primary.opacity(0.08)) |
|
| 272 |
+ // Filled charged portion |
|
| 273 |
+ Rectangle() |
|
| 274 |
+ .fill(LinearGradient( |
|
| 275 |
+ colors: [tint.opacity(0.6), tint], |
|
| 276 |
+ startPoint: .leading, |
|
| 277 |
+ endPoint: .trailing |
|
| 278 |
+ )) |
|
| 279 |
+ .frame(width: fillWidth) |
|
| 280 |
+ .offset(x: startX) |
|
| 281 |
+ // Start marker line |
|
| 282 |
+ if range.start > 3 {
|
|
| 283 |
+ Rectangle() |
|
| 284 |
+ .fill(Color.white.opacity(0.5)) |
|
| 285 |
+ .frame(width: 1.5, height: 14) |
|
| 286 |
+ .offset(x: startX - 0.75) |
|
| 287 |
+ } |
|
| 288 |
+ } |
|
| 289 |
+ .clipShape(Capsule()) |
|
| 290 |
+ } |
|
| 291 |
+ .frame(height: 14) |
|
| 292 |
+ |
|
| 293 |
+ // Labels |
|
| 294 |
+ HStack {
|
|
| 295 |
+ Text("\(Int(range.start.rounded()))%")
|
|
| 296 |
+ .font(.caption2) |
|
| 297 |
+ .foregroundColor(.secondary) |
|
| 298 |
+ Spacer() |
|
| 299 |
+ Text("+\(Int((range.end - range.start).rounded()))%")
|
|
| 300 |
+ .font(.caption2.weight(.semibold)) |
|
| 301 |
+ .foregroundColor(tint) |
|
| 302 |
+ Spacer() |
|
| 303 |
+ Text("\(Int(range.end.rounded()))%")
|
|
| 304 |
+ .font(.caption2) |
|
| 305 |
+ .foregroundColor(.secondary) |
|
| 306 |
+ } |
|
| 307 |
+ } |
|
| 308 |
+ } |
|
| 309 |
+ |
|
| 310 |
+ // MARK: - Chips |
|
| 311 |
+ |
|
| 312 |
+ private struct ChipContent {
|
|
| 313 |
+ let label: String |
|
| 314 |
+ let tint: Color |
|
| 315 |
+ } |
|
| 316 |
+ |
|
| 317 |
+ private func chipContent(session: ChargeSessionSummary, capacityDelta: Double?) -> [ChipContent] {
|
|
| 318 |
+ var chips: [ChipContent] = [] |
|
| 319 |
+ |
|
| 320 |
+ if let capacityWh = session.capacityEstimateWh {
|
|
| 321 |
+ var label = "\(capacityWh.format(decimalDigits: 1)) Wh" |
|
| 322 |
+ if let delta = capacityDelta {
|
|
| 323 |
+ let sign = delta >= 0 ? "+" : "" |
|
| 324 |
+ label += " (\(sign)\(delta.format(decimalDigits: 1)))" |
|
| 325 |
+ } |
|
| 326 |
+ chips.append(ChipContent(label: label, tint: .orange)) |
|
| 327 |
+ } |
|
| 328 |
+ |
|
| 329 |
+ if let batteryDelta = session.batteryDeltaPercent {
|
|
| 330 |
+ let sign = batteryDelta >= 0 ? "+" : "" |
|
| 331 |
+ chips.append(ChipContent(label: "\(sign)\(Int(batteryDelta.rounded()))% charged", tint: .teal)) |
|
| 332 |
+ } |
|
| 333 |
+ |
|
| 334 |
+ return chips |
|
| 335 |
+ } |
|
| 336 |
+ |
|
| 337 |
+ private func chipsRow(_ chips: [ChipContent]) -> some View {
|
|
| 338 |
+ HStack(spacing: 6) {
|
|
| 339 |
+ ForEach(chips.indices, id: \.self) { i in
|
|
| 340 |
+ let chip = chips[i] |
|
| 341 |
+ Text(chip.label) |
|
| 342 |
+ .font(.caption2.weight(.semibold)) |
|
| 343 |
+ .foregroundColor(chip.tint) |
|
| 344 |
+ .padding(.horizontal, 8) |
|
| 345 |
+ .padding(.vertical, 4) |
|
| 346 |
+ .background( |
|
| 347 |
+ RoundedRectangle(cornerRadius: 8) |
|
| 348 |
+ .fill(chip.tint.opacity(0.14)) |
|
| 349 |
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(chip.tint.opacity(0.22), lineWidth: 1)) |
|
| 350 |
+ ) |
|
| 351 |
+ } |
|
| 352 |
+ Spacer() |
|
| 353 |
+ } |
|
| 354 |
+ } |
|
| 355 |
+ |
|
| 356 |
+ // MARK: - Secondary info line |
|
| 357 |
+ |
|
| 358 |
+ private func secondaryInfoLine( |
|
| 359 |
+ _ session: ChargeSessionSummary, |
|
| 360 |
+ chargedDevice: ChargedDeviceSummary |
|
| 361 |
+ ) -> String {
|
|
| 362 |
+ var components: [String] = [] |
|
| 363 |
+ |
|
| 364 |
+ if chargedDevice.shouldShowChargingTransport(session.chargingTransportMode) {
|
|
| 365 |
+ components.append(session.chargingTransportMode.title) |
|
| 366 |
+ } |
|
| 367 |
+ if chargedDevice.shouldShowChargingStateMode(session.chargingStateMode) {
|
|
| 368 |
+ components.append(session.chargingStateMode.title) |
|
| 369 |
+ } |
|
| 370 |
+ if session.isTrimmed {
|
|
| 371 |
+ components.append("Trimmed")
|
|
| 372 |
+ } |
|
| 373 |
+ if session.wasConflictHealed {
|
|
| 374 |
+ components.append("Auto-closed (sync conflict)")
|
|
| 375 |
+ } |
|
| 376 |
+ components.append(session.sourceMode.title) |
|
| 377 |
+ |
|
| 378 |
+ return components.joined(separator: " · ") |
|
| 379 |
+ } |
|
| 380 |
+ |
|
| 381 |
+ // MARK: - Helpers |
|
| 382 |
+ |
|
| 383 |
+ private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
|
|
| 384 |
+ let formatter = DateComponentsFormatter() |
|
| 385 |
+ let effectiveDuration = max(session.effectiveDuration, 0) |
|
| 386 |
+ formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 387 |
+ formatter.unitsStyle = .abbreviated |
|
| 388 |
+ formatter.zeroFormattingBehavior = .dropAll |
|
| 389 |
+ return formatter.string(from: effectiveDuration) ?? "0m" |
|
| 390 |
+ } |
|
| 391 |
+ |
|
| 392 |
+ private func statusTint(for session: ChargeSessionSummary) -> Color {
|
|
| 393 |
+ switch session.status {
|
|
| 394 |
+ case .active: return .green |
|
| 395 |
+ case .paused: return .orange |
|
| 396 |
+ case .completed: return .teal |
|
| 397 |
+ case .abandoned: return .secondary |
|
| 398 |
+ } |
|
| 399 |
+ } |
|
| 400 |
+ |
|
| 401 |
+ private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
|
|
| 402 |
+ switch chargedDevice.deviceClass {
|
|
| 403 |
+ case .iphone: return .blue |
|
| 404 |
+ case .watch: return .green |
|
| 405 |
+ case .powerbank: return .orange |
|
| 406 |
+ case .charger: return .pink |
|
| 407 |
+ case .other: return .secondary |
|
| 408 |
+ } |
|
| 409 |
+ } |
|
| 410 |
+} |
|
@@ -0,0 +1,323 @@ |
||
| 1 |
+// |
|
| 2 |
+// BatteryCheckpointEditorSheetView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 10/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+struct BatteryCheckpointEditorContentView: View {
|
|
| 11 |
+ @EnvironmentObject private var appData: AppData |
|
| 12 |
+ |
|
| 13 |
+ let sessionID: UUID |
|
| 14 |
+ let message: String |
|
| 15 |
+ let effectiveEnergyWhOverride: Double? |
|
| 16 |
+ let measuredChargeAhOverride: Double? |
|
| 17 |
+ let onCancel: (() -> Void)? |
|
| 18 |
+ let onSaved: (() -> Void)? |
|
| 19 |
+ let showsHeader: Bool |
|
| 20 |
+ |
|
| 21 |
+ @State private var batteryPercent = "" |
|
| 22 |
+ @State private var showsWarningPopover = false |
|
| 23 |
+ |
|
| 24 |
+ init( |
|
| 25 |
+ sessionID: UUID, |
|
| 26 |
+ message: String, |
|
| 27 |
+ effectiveEnergyWhOverride: Double?, |
|
| 28 |
+ measuredChargeAhOverride: Double?, |
|
| 29 |
+ onCancel: (() -> Void)?, |
|
| 30 |
+ onSaved: (() -> Void)?, |
|
| 31 |
+ showsHeader: Bool = true |
|
| 32 |
+ ) {
|
|
| 33 |
+ self.sessionID = sessionID |
|
| 34 |
+ self.message = message |
|
| 35 |
+ self.effectiveEnergyWhOverride = effectiveEnergyWhOverride |
|
| 36 |
+ self.measuredChargeAhOverride = measuredChargeAhOverride |
|
| 37 |
+ self.onCancel = onCancel |
|
| 38 |
+ self.onSaved = onSaved |
|
| 39 |
+ self.showsHeader = showsHeader |
|
| 40 |
+ } |
|
| 41 |
+ |
|
| 42 |
+ private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
|
|
| 43 |
+ guard let percent = normalizedBatteryPercent else {
|
|
| 44 |
+ return nil |
|
| 45 |
+ } |
|
| 46 |
+ return appData.batteryCheckpointPlausibilityWarning( |
|
| 47 |
+ percent: percent, |
|
| 48 |
+ for: sessionID, |
|
| 49 |
+ effectiveEnergyWhOverride: effectiveEnergyWhOverride |
|
| 50 |
+ ) |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 53 |
+ private var normalizedBatteryPercent: Double? {
|
|
| 54 |
+ let normalized = batteryPercent |
|
| 55 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 56 |
+ .replacingOccurrences(of: ",", with: ".") |
|
| 57 |
+ return Double(normalized) |
|
| 58 |
+ } |
|
| 59 |
+ |
|
| 60 |
+ private var canSave: Bool {
|
|
| 61 |
+ guard let percent = normalizedBatteryPercent else {
|
|
| 62 |
+ return false |
|
| 63 |
+ } |
|
| 64 |
+ return percent >= 0 && percent <= 100 |
|
| 65 |
+ } |
|
| 66 |
+ |
|
| 67 |
+ var body: some View {
|
|
| 68 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 69 |
+ if showsHeader {
|
|
| 70 |
+ HStack(spacing: 8) {
|
|
| 71 |
+ Text("Checkpoint")
|
|
| 72 |
+ Spacer(minLength: 0) |
|
| 73 |
+ ContextInfoButton( |
|
| 74 |
+ title: "Checkpoint", |
|
| 75 |
+ message: message |
|
| 76 |
+ ) |
|
| 77 |
+ } |
|
| 78 |
+ } |
|
| 79 |
+ |
|
| 80 |
+ compactEditorRow |
|
| 81 |
+ } |
|
| 82 |
+ } |
|
| 83 |
+ |
|
| 84 |
+ private var compactEditorRow: some View {
|
|
| 85 |
+ HStack(spacing: 8) {
|
|
| 86 |
+ TextField("Battery %", text: $batteryPercent)
|
|
| 87 |
+ .keyboardType(.decimalPad) |
|
| 88 |
+ .textFieldStyle(.roundedBorder) |
|
| 89 |
+ .frame(width: 104) |
|
| 90 |
+ .onSubmit(saveCheckpoint) |
|
| 91 |
+ |
|
| 92 |
+ if let plausibilityWarning {
|
|
| 93 |
+ Button {
|
|
| 94 |
+ showsWarningPopover.toggle() |
|
| 95 |
+ } label: {
|
|
| 96 |
+ Image(systemName: "exclamationmark.triangle.fill") |
|
| 97 |
+ .font(.body.weight(.semibold)) |
|
| 98 |
+ .foregroundColor(.orange) |
|
| 99 |
+ } |
|
| 100 |
+ .buttonStyle(.plain) |
|
| 101 |
+ .accessibilityLabel(plausibilityWarning.title) |
|
| 102 |
+ .popover(isPresented: $showsWarningPopover, arrowEdge: .top) {
|
|
| 103 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 104 |
+ Text(plausibilityWarning.title) |
|
| 105 |
+ .font(.headline) |
|
| 106 |
+ Text(plausibilityWarning.message) |
|
| 107 |
+ .font(.body) |
|
| 108 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 109 |
+ } |
|
| 110 |
+ .padding(16) |
|
| 111 |
+ .frame(width: 320, alignment: .leading) |
|
| 112 |
+ } |
|
| 113 |
+ } |
|
| 114 |
+ |
|
| 115 |
+ if let onCancel {
|
|
| 116 |
+ inlineActionButton( |
|
| 117 |
+ systemName: "xmark", |
|
| 118 |
+ tint: .secondary, |
|
| 119 |
+ fillOpacity: 0.12, |
|
| 120 |
+ strokeOpacity: 0.18, |
|
| 121 |
+ isEnabled: true, |
|
| 122 |
+ action: onCancel |
|
| 123 |
+ ) |
|
| 124 |
+ } |
|
| 125 |
+ |
|
| 126 |
+ inlineActionButton( |
|
| 127 |
+ systemName: "checkmark", |
|
| 128 |
+ tint: .green, |
|
| 129 |
+ fillOpacity: 0.16, |
|
| 130 |
+ strokeOpacity: 0.22, |
|
| 131 |
+ isEnabled: canSave, |
|
| 132 |
+ action: saveCheckpoint |
|
| 133 |
+ ) |
|
| 134 |
+ } |
|
| 135 |
+ } |
|
| 136 |
+ |
|
| 137 |
+ private func inlineActionButton( |
|
| 138 |
+ systemName: String, |
|
| 139 |
+ tint: Color, |
|
| 140 |
+ fillOpacity: Double, |
|
| 141 |
+ strokeOpacity: Double, |
|
| 142 |
+ isEnabled: Bool, |
|
| 143 |
+ action: @escaping () -> Void |
|
| 144 |
+ ) -> some View {
|
|
| 145 |
+ Button(action: action) {
|
|
| 146 |
+ Image(systemName: systemName) |
|
| 147 |
+ .font(.caption.weight(.semibold)) |
|
| 148 |
+ .frame(width: 30, height: 30) |
|
| 149 |
+ .contentShape(Rectangle()) |
|
| 150 |
+ } |
|
| 151 |
+ .meterCard( |
|
| 152 |
+ tint: tint, |
|
| 153 |
+ fillOpacity: fillOpacity, |
|
| 154 |
+ strokeOpacity: strokeOpacity, |
|
| 155 |
+ cornerRadius: 10 |
|
| 156 |
+ ) |
|
| 157 |
+ .buttonStyle(.plain) |
|
| 158 |
+ .disabled(!isEnabled) |
|
| 159 |
+ .opacity(isEnabled ? 1 : 0.6) |
|
| 160 |
+ } |
|
| 161 |
+ |
|
| 162 |
+ private func saveCheckpoint() {
|
|
| 163 |
+ guard let percent = normalizedBatteryPercent else {
|
|
| 164 |
+ return |
|
| 165 |
+ } |
|
| 166 |
+ |
|
| 167 |
+ if appData.addBatteryCheckpoint( |
|
| 168 |
+ percent: percent, |
|
| 169 |
+ for: sessionID, |
|
| 170 |
+ measuredEnergyWh: effectiveEnergyWhOverride, |
|
| 171 |
+ measuredChargeAh: measuredChargeAhOverride |
|
| 172 |
+ ) {
|
|
| 173 |
+ onSaved?() |
|
| 174 |
+ } |
|
| 175 |
+ } |
|
| 176 |
+} |
|
| 177 |
+ |
|
| 178 |
+struct BatteryCheckpointSectionView: View {
|
|
| 179 |
+ let sessionID: UUID |
|
| 180 |
+ let checkpoints: [ChargeCheckpointSummary] |
|
| 181 |
+ let message: String |
|
| 182 |
+ let canAddCheckpoint: Bool |
|
| 183 |
+ let canDeleteCheckpoint: Bool |
|
| 184 |
+ let requirementMessage: String? |
|
| 185 |
+ let effectiveEnergyWhOverride: Double? |
|
| 186 |
+ let measuredChargeAhOverride: Double? |
|
| 187 |
+ let onDelete: (ChargeCheckpointSummary) -> Void |
|
| 188 |
+ |
|
| 189 |
+ @State private var showsInlineCheckpointEditor = false |
|
| 190 |
+ |
|
| 191 |
+ private var displayedCheckpoints: [ChargeCheckpointSummary] {
|
|
| 192 |
+ Array(checkpoints.suffix(6).reversed()) |
|
| 193 |
+ } |
|
| 194 |
+ |
|
| 195 |
+ var body: some View {
|
|
| 196 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 197 |
+ HStack(alignment: .center, spacing: 8) {
|
|
| 198 |
+ Text("Battery Checkpoints")
|
|
| 199 |
+ .font(.subheadline.weight(.semibold)) |
|
| 200 |
+ |
|
| 201 |
+ ContextInfoButton( |
|
| 202 |
+ title: "Battery Checkpoints", |
|
| 203 |
+ message: message |
|
| 204 |
+ ) |
|
| 205 |
+ |
|
| 206 |
+ Spacer(minLength: 12) |
|
| 207 |
+ |
|
| 208 |
+ if canAddCheckpoint {
|
|
| 209 |
+ if showsInlineCheckpointEditor {
|
|
| 210 |
+ BatteryCheckpointEditorContentView( |
|
| 211 |
+ sessionID: sessionID, |
|
| 212 |
+ message: message, |
|
| 213 |
+ effectiveEnergyWhOverride: effectiveEnergyWhOverride, |
|
| 214 |
+ measuredChargeAhOverride: measuredChargeAhOverride, |
|
| 215 |
+ onCancel: { showsInlineCheckpointEditor = false },
|
|
| 216 |
+ onSaved: { showsInlineCheckpointEditor = false },
|
|
| 217 |
+ showsHeader: false |
|
| 218 |
+ ) |
|
| 219 |
+ } else {
|
|
| 220 |
+ Button {
|
|
| 221 |
+ showsInlineCheckpointEditor = true |
|
| 222 |
+ } label: {
|
|
| 223 |
+ Image(systemName: "plus") |
|
| 224 |
+ .font(.caption.weight(.semibold)) |
|
| 225 |
+ .frame(width: 30, height: 30) |
|
| 226 |
+ .contentShape(Rectangle()) |
|
| 227 |
+ } |
|
| 228 |
+ .meterCard( |
|
| 229 |
+ tint: .green, |
|
| 230 |
+ fillOpacity: 0.12, |
|
| 231 |
+ strokeOpacity: 0.18, |
|
| 232 |
+ cornerRadius: 10 |
|
| 233 |
+ ) |
|
| 234 |
+ .buttonStyle(.plain) |
|
| 235 |
+ .help("Add checkpoint")
|
|
| 236 |
+ } |
|
| 237 |
+ } |
|
| 238 |
+ } |
|
| 239 |
+ |
|
| 240 |
+ ForEach(displayedCheckpoints, id: \.id) { checkpoint in
|
|
| 241 |
+ HStack {
|
|
| 242 |
+ Text(checkpoint.timestamp.format()) |
|
| 243 |
+ .font(.caption2) |
|
| 244 |
+ .foregroundColor(.secondary) |
|
| 245 |
+ Text(checkpoint.flag.title) |
|
| 246 |
+ .font(.caption2.weight(.semibold)) |
|
| 247 |
+ .foregroundColor(.secondary) |
|
| 248 |
+ Spacer() |
|
| 249 |
+ Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
|
|
| 250 |
+ .font(.caption.weight(.semibold)) |
|
| 251 |
+ Text("•")
|
|
| 252 |
+ .foregroundColor(.secondary) |
|
| 253 |
+ Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
|
|
| 254 |
+ .font(.caption2) |
|
| 255 |
+ .foregroundColor(.secondary) |
|
| 256 |
+ if canDeleteCheckpoint {
|
|
| 257 |
+ Button {
|
|
| 258 |
+ onDelete(checkpoint) |
|
| 259 |
+ } label: {
|
|
| 260 |
+ Image(systemName: "trash") |
|
| 261 |
+ .font(.caption.weight(.semibold)) |
|
| 262 |
+ .foregroundColor(.red) |
|
| 263 |
+ } |
|
| 264 |
+ .buttonStyle(.plain) |
|
| 265 |
+ .help("Delete checkpoint")
|
|
| 266 |
+ } |
|
| 267 |
+ } |
|
| 268 |
+ } |
|
| 269 |
+ |
|
| 270 |
+ if !canAddCheckpoint, let requirementMessage {
|
|
| 271 |
+ Text(requirementMessage) |
|
| 272 |
+ .font(.caption2) |
|
| 273 |
+ .foregroundColor(.secondary) |
|
| 274 |
+ } |
|
| 275 |
+ } |
|
| 276 |
+ } |
|
| 277 |
+} |
|
| 278 |
+ |
|
| 279 |
+struct BatteryCheckpointEditorSheetView: View {
|
|
| 280 |
+ @EnvironmentObject private var appData: AppData |
|
| 281 |
+ @EnvironmentObject private var meter: Meter |
|
| 282 |
+ @Environment(\.dismiss) private var dismiss |
|
| 283 |
+ |
|
| 284 |
+ private var activeSession: ChargeSessionSummary? {
|
|
| 285 |
+ appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description) |
|
| 286 |
+ } |
|
| 287 |
+ |
|
| 288 |
+ var body: some View {
|
|
| 289 |
+ NavigationView {
|
|
| 290 |
+ Group {
|
|
| 291 |
+ if let activeSession {
|
|
| 292 |
+ Form {
|
|
| 293 |
+ BatteryCheckpointEditorContentView( |
|
| 294 |
+ sessionID: activeSession.id, |
|
| 295 |
+ message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.", |
|
| 296 |
+ effectiveEnergyWhOverride: nil, |
|
| 297 |
+ measuredChargeAhOverride: nil, |
|
| 298 |
+ onCancel: { dismiss() },
|
|
| 299 |
+ onSaved: { dismiss() },
|
|
| 300 |
+ showsHeader: true |
|
| 301 |
+ ) |
|
| 302 |
+ } |
|
| 303 |
+ } else {
|
|
| 304 |
+ VStack(spacing: 12) {
|
|
| 305 |
+ Image(systemName: "bolt.slash") |
|
| 306 |
+ .font(.title2) |
|
| 307 |
+ .foregroundColor(.secondary) |
|
| 308 |
+ Text("No Active Session")
|
|
| 309 |
+ .font(.headline) |
|
| 310 |
+ Text("Start a charging session before adding a battery checkpoint.")
|
|
| 311 |
+ .font(.footnote) |
|
| 312 |
+ .foregroundColor(.secondary) |
|
| 313 |
+ .multilineTextAlignment(.center) |
|
| 314 |
+ } |
|
| 315 |
+ .padding(24) |
|
| 316 |
+ } |
|
| 317 |
+ } |
|
| 318 |
+ .navigationTitle("Battery Checkpoint")
|
|
| 319 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 320 |
+ } |
|
| 321 |
+ .navigationViewStyle(StackNavigationViewStyle()) |
|
| 322 |
+ } |
|
| 323 |
+} |
|
@@ -0,0 +1,266 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargeSessionCompletionSheetView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct ChargeSessionCompletionSheetView: View {
|
|
| 9 |
+ private enum FinalCheckpoint: String, CaseIterable, Identifiable {
|
|
| 10 |
+ case full |
|
| 11 |
+ case skip |
|
| 12 |
+ case custom |
|
| 13 |
+ |
|
| 14 |
+ var id: String { rawValue }
|
|
| 15 |
+ |
|
| 16 |
+ var label: String {
|
|
| 17 |
+ switch self {
|
|
| 18 |
+ case .full: return "Full" |
|
| 19 |
+ case .skip: return "Skip" |
|
| 20 |
+ case .custom: return "Other %" |
|
| 21 |
+ } |
|
| 22 |
+ } |
|
| 23 |
+ } |
|
| 24 |
+ |
|
| 25 |
+ @EnvironmentObject private var appData: AppData |
|
| 26 |
+ @Environment(\.dismiss) private var dismiss |
|
| 27 |
+ |
|
| 28 |
+ let sessionID: UUID |
|
| 29 |
+ let title: String |
|
| 30 |
+ let confirmTitle: String |
|
| 31 |
+ let explanation: String |
|
| 32 |
+ let monitoringMeter: Meter? |
|
| 33 |
+ let appliesTrim: Bool |
|
| 34 |
+ let trimStart: Date? |
|
| 35 |
+ let trimEnd: Date? |
|
| 36 |
+ |
|
| 37 |
+ @State private var batteryPercent = "" |
|
| 38 |
+ @State private var finalCheckpoint: FinalCheckpoint = .skip |
|
| 39 |
+ @State private var saveFailureMessage: String? |
|
| 40 |
+ |
|
| 41 |
+ init( |
|
| 42 |
+ sessionID: UUID, |
|
| 43 |
+ title: String, |
|
| 44 |
+ confirmTitle: String, |
|
| 45 |
+ explanation: String, |
|
| 46 |
+ monitoringMeter: Meter? = nil, |
|
| 47 |
+ appliesTrim: Bool = false, |
|
| 48 |
+ trimStart: Date? = nil, |
|
| 49 |
+ trimEnd: Date? = nil |
|
| 50 |
+ ) {
|
|
| 51 |
+ self.sessionID = sessionID |
|
| 52 |
+ self.title = title |
|
| 53 |
+ self.confirmTitle = confirmTitle |
|
| 54 |
+ self.explanation = explanation |
|
| 55 |
+ self.monitoringMeter = monitoringMeter |
|
| 56 |
+ self.appliesTrim = appliesTrim |
|
| 57 |
+ self.trimStart = trimStart |
|
| 58 |
+ self.trimEnd = trimEnd |
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 61 |
+ var body: some View {
|
|
| 62 |
+ NavigationView {
|
|
| 63 |
+ Form {
|
|
| 64 |
+ Section( |
|
| 65 |
+ header: ContextInfoHeader( |
|
| 66 |
+ title: "Final Checkpoint", |
|
| 67 |
+ message: explanation |
|
| 68 |
+ ) |
|
| 69 |
+ ) {
|
|
| 70 |
+ Picker("Final Battery", selection: $finalCheckpoint) {
|
|
| 71 |
+ ForEach(FinalCheckpoint.allCases) { mode in
|
|
| 72 |
+ Text(mode.label).tag(mode) |
|
| 73 |
+ } |
|
| 74 |
+ } |
|
| 75 |
+ .pickerStyle(.segmented) |
|
| 76 |
+ |
|
| 77 |
+ if finalCheckpoint == .custom {
|
|
| 78 |
+ TextField("Battery %", text: $batteryPercent)
|
|
| 79 |
+ .keyboardType(.decimalPad) |
|
| 80 |
+ } |
|
| 81 |
+ } |
|
| 82 |
+ |
|
| 83 |
+ Section {
|
|
| 84 |
+ if appliesTrim {
|
|
| 85 |
+ Label("The selected trim window will be applied before the session is closed.", systemImage: "scissors")
|
|
| 86 |
+ .font(.footnote) |
|
| 87 |
+ .foregroundColor(.blue) |
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ if let saveFailureMessage {
|
|
| 91 |
+ Label(saveFailureMessage, systemImage: "exclamationmark.triangle.fill") |
|
| 92 |
+ .font(.footnote) |
|
| 93 |
+ .foregroundColor(.red) |
|
| 94 |
+ } else if let saveDisabledReason {
|
|
| 95 |
+ Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill") |
|
| 96 |
+ .font(.footnote) |
|
| 97 |
+ .foregroundColor(.red) |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ if hasChargeDataToSave == false {
|
|
| 101 |
+ Button(role: .destructive) {
|
|
| 102 |
+ _ = appData.deleteChargeSession(sessionID: sessionID) |
|
| 103 |
+ dismiss() |
|
| 104 |
+ } label: {
|
|
| 105 |
+ Label("Discard Session", systemImage: "trash")
|
|
| 106 |
+ } |
|
| 107 |
+ } else if saveDisabledReason == nil, let sessionWarning {
|
|
| 108 |
+ Text(sessionWarning) |
|
| 109 |
+ .font(.footnote) |
|
| 110 |
+ .foregroundColor(.orange) |
|
| 111 |
+ } else if saveDisabledReason == nil, resolvedFinalBatteryPercent == 100 {
|
|
| 112 |
+ Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.")
|
|
| 113 |
+ .font(.footnote) |
|
| 114 |
+ .foregroundColor(.secondary) |
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 117 |
+ } |
|
| 118 |
+ .navigationTitle(title) |
|
| 119 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 120 |
+ .toolbar {
|
|
| 121 |
+ ToolbarItem(placement: .cancellationAction) {
|
|
| 122 |
+ Button("Cancel") {
|
|
| 123 |
+ dismiss() |
|
| 124 |
+ } |
|
| 125 |
+ } |
|
| 126 |
+ ToolbarItem(placement: .confirmationAction) {
|
|
| 127 |
+ Button(confirmTitle) {
|
|
| 128 |
+ guard canSave else {
|
|
| 129 |
+ saveFailureMessage = saveDisabledReason |
|
| 130 |
+ return |
|
| 131 |
+ } |
|
| 132 |
+ if appliesTrim {
|
|
| 133 |
+ _ = appData.setSessionTrim( |
|
| 134 |
+ sessionID: sessionID, |
|
| 135 |
+ start: trimStart, |
|
| 136 |
+ end: trimEnd |
|
| 137 |
+ ) |
|
| 138 |
+ } |
|
| 139 |
+ |
|
| 140 |
+ if appData.stopChargeSession( |
|
| 141 |
+ sessionID: sessionID, |
|
| 142 |
+ finalBatteryPercent: resolvedFinalBatteryPercent, |
|
| 143 |
+ from: monitoringMeter |
|
| 144 |
+ ) {
|
|
| 145 |
+ dismiss() |
|
| 146 |
+ } else {
|
|
| 147 |
+ saveFailureMessage = "The session could not be closed. Live readings were flushed, but the stored session did not accept the save yet. Try again in a moment." |
|
| 148 |
+ } |
|
| 149 |
+ } |
|
| 150 |
+ .disabled(!canSave) |
|
| 151 |
+ .opacity(canSave ? 1 : 0.45) |
|
| 152 |
+ } |
|
| 153 |
+ } |
|
| 154 |
+ } |
|
| 155 |
+ .navigationViewStyle(StackNavigationViewStyle()) |
|
| 156 |
+ .onChange(of: finalCheckpoint) { mode in
|
|
| 157 |
+ saveFailureMessage = nil |
|
| 158 |
+ if mode == .custom {
|
|
| 159 |
+ prefillFinalCheckpointIfNeeded() |
|
| 160 |
+ } else {
|
|
| 161 |
+ batteryPercent = "" |
|
| 162 |
+ } |
|
| 163 |
+ } |
|
| 164 |
+ .onChange(of: batteryPercent) { _ in
|
|
| 165 |
+ saveFailureMessage = nil |
|
| 166 |
+ } |
|
| 167 |
+ } |
|
| 168 |
+ |
|
| 169 |
+ private var session: ChargeSessionSummary? {
|
|
| 170 |
+ appData.chargedDevices |
|
| 171 |
+ .flatMap(\.sessions) |
|
| 172 |
+ .first(where: { $0.id == sessionID })
|
|
| 173 |
+ } |
|
| 174 |
+ |
|
| 175 |
+ private var canSave: Bool {
|
|
| 176 |
+ saveDisabledReason == nil |
|
| 177 |
+ } |
|
| 178 |
+ |
|
| 179 |
+ private var hasChargeDataToSave: Bool {
|
|
| 180 |
+ guard let session else { return false }
|
|
| 181 |
+ return session.hasSavableChargeData |
|
| 182 |
+ || displayedSessionEnergyWh(for: session) > 0 |
|
| 183 |
+ || displayedSessionChargeAh(for: session) > 0 |
|
| 184 |
+ } |
|
| 185 |
+ |
|
| 186 |
+ private var saveDisabledReason: String? {
|
|
| 187 |
+ if finalCheckpoint == .custom {
|
|
| 188 |
+ let trimmed = batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 189 |
+ if trimmed.isEmpty {
|
|
| 190 |
+ return "Enter the final battery percentage or choose Skip." |
|
| 191 |
+ } |
|
| 192 |
+ if parsedBatteryPercent == nil {
|
|
| 193 |
+ return "Final battery percentage must be between 0 and 100." |
|
| 194 |
+ } |
|
| 195 |
+ } |
|
| 196 |
+ |
|
| 197 |
+ guard hasChargeDataToSave else {
|
|
| 198 |
+ return "This session has no charging data to save. Discard it instead." |
|
| 199 |
+ } |
|
| 200 |
+ |
|
| 201 |
+ return nil |
|
| 202 |
+ } |
|
| 203 |
+ |
|
| 204 |
+ private var parsedBatteryPercent: Double? {
|
|
| 205 |
+ let normalized = batteryPercent |
|
| 206 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 207 |
+ .replacingOccurrences(of: ",", with: ".") |
|
| 208 |
+ guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
|
|
| 209 |
+ return value |
|
| 210 |
+ } |
|
| 211 |
+ |
|
| 212 |
+ private var resolvedFinalBatteryPercent: Double? {
|
|
| 213 |
+ switch finalCheckpoint {
|
|
| 214 |
+ case .full: return 100 |
|
| 215 |
+ case .skip: return nil |
|
| 216 |
+ case .custom: return parsedBatteryPercent |
|
| 217 |
+ } |
|
| 218 |
+ } |
|
| 219 |
+ |
|
| 220 |
+ private var suggestedFinalBatteryPercent: Double? {
|
|
| 221 |
+ guard let session else { return nil }
|
|
| 222 |
+ if let endBatteryPercent = session.endBatteryPercent {
|
|
| 223 |
+ return endBatteryPercent |
|
| 224 |
+ } |
|
| 225 |
+ if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
|
|
| 226 |
+ return latestCheckpoint.batteryPercent |
|
| 227 |
+ } |
|
| 228 |
+ return session.targetBatteryPercent ?? session.completionContradictionPercent |
|
| 229 |
+ } |
|
| 230 |
+ |
|
| 231 |
+ private func prefillFinalCheckpointIfNeeded() {
|
|
| 232 |
+ guard batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, |
|
| 233 |
+ let suggestedFinalBatteryPercent else {
|
|
| 234 |
+ return |
|
| 235 |
+ } |
|
| 236 |
+ batteryPercent = suggestedFinalBatteryPercent.format(decimalDigits: 0) |
|
| 237 |
+ } |
|
| 238 |
+ |
|
| 239 |
+ private var sessionWarning: String? {
|
|
| 240 |
+ nil |
|
| 241 |
+ } |
|
| 242 |
+ |
|
| 243 |
+ private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
|
|
| 244 |
+ let storedEnergyWh = session.effectiveOrMeasuredEnergyWh |
|
| 245 |
+ guard session.isTrimmed == false else { return storedEnergyWh }
|
|
| 246 |
+ guard session.status.isOpen else { return storedEnergyWh }
|
|
| 247 |
+ guard let monitoringMeter else { return storedEnergyWh }
|
|
| 248 |
+ guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
|
|
| 249 |
+ if let baselineEnergyWh = session.meterEnergyBaselineWh {
|
|
| 250 |
+ return max(storedEnergyWh, max(monitoringMeter.recordedWH - baselineEnergyWh, 0)) |
|
| 251 |
+ } |
|
| 252 |
+ return storedEnergyWh |
|
| 253 |
+ } |
|
| 254 |
+ |
|
| 255 |
+ private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
|
|
| 256 |
+ let storedChargeAh = session.measuredChargeAh |
|
| 257 |
+ guard session.isTrimmed == false else { return storedChargeAh }
|
|
| 258 |
+ guard session.status.isOpen else { return storedChargeAh }
|
|
| 259 |
+ guard let monitoringMeter else { return storedChargeAh }
|
|
| 260 |
+ guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
|
|
| 261 |
+ if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
| 262 |
+ return max(storedChargeAh, max(monitoringMeter.recordedAH - baselineChargeAh, 0)) |
|
| 263 |
+ } |
|
| 264 |
+ return storedChargeAh |
|
| 265 |
+ } |
|
| 266 |
+} |
|
@@ -0,0 +1,500 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargedDeviceEditorSheetView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 10/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+struct ChargedDeviceEditorSheetView: View {
|
|
| 11 |
+ @EnvironmentObject private var appData: AppData |
|
| 12 |
+ @Environment(\.dismiss) private var dismiss |
|
| 13 |
+ |
|
| 14 |
+ let meterMACAddress: String? |
|
| 15 |
+ let chargedDevice: ChargedDeviceSummary? |
|
| 16 |
+ |
|
| 17 |
+ @State private var name: String |
|
| 18 |
+ @State private var notes: String |
|
| 19 |
+ @State private var deviceClass: ChargedDeviceClass |
|
| 20 |
+ @State private var selectedTemplateID: String? |
|
| 21 |
+ @State private var lastAppliedTemplateID: String? |
|
| 22 |
+ @State private var chargingStateAvailability: ChargingStateAvailability |
|
| 23 |
+ @State private var supportsWiredCharging: Bool |
|
| 24 |
+ @State private var supportsWirelessCharging: Bool |
|
| 25 |
+ @State private var wirelessChargingProfile: WirelessChargingProfile |
|
| 26 |
+ @State private var completionCurrentTexts: [ChargeSessionKind: String] |
|
| 27 |
+ |
|
| 28 |
+ let standalone: Bool |
|
| 29 |
+ |
|
| 30 |
+ init( |
|
| 31 |
+ meterMACAddress: String?, |
|
| 32 |
+ chargedDevice: ChargedDeviceSummary? = nil, |
|
| 33 |
+ standalone: Bool = true |
|
| 34 |
+ ) {
|
|
| 35 |
+ self.meterMACAddress = meterMACAddress |
|
| 36 |
+ self.chargedDevice = chargedDevice |
|
| 37 |
+ self.standalone = standalone |
|
| 38 |
+ |
|
| 39 |
+ _name = State(initialValue: chargedDevice?.name ?? "") |
|
| 40 |
+ _notes = State(initialValue: chargedDevice?.notes ?? "") |
|
| 41 |
+ |
|
| 42 |
+ let initialDeviceClass = chargedDevice?.deviceClass ?? .iphone |
|
| 43 |
+ let initialChargingStateAvailability = initialDeviceClass.normalizedChargingStateAvailability( |
|
| 44 |
+ chargedDevice?.chargingStateAvailability ?? initialDeviceClass.defaultChargingStateAvailability |
|
| 45 |
+ ) |
|
| 46 |
+ let defaultChargingSupport = initialDeviceClass.defaultChargingSupport |
|
| 47 |
+ let initialChargingSupport = initialDeviceClass.normalizedChargingSupport( |
|
| 48 |
+ supportsWiredCharging: chargedDevice?.supportsWiredCharging ?? defaultChargingSupport.wired, |
|
| 49 |
+ supportsWirelessCharging: chargedDevice?.supportsWirelessCharging ?? defaultChargingSupport.wireless |
|
| 50 |
+ ) |
|
| 51 |
+ let initialTemplateID = chargedDevice?.deviceTemplateID |
|
| 52 |
+ _deviceClass = State(initialValue: initialDeviceClass) |
|
| 53 |
+ _selectedTemplateID = State(initialValue: initialTemplateID) |
|
| 54 |
+ _lastAppliedTemplateID = State(initialValue: initialTemplateID) |
|
| 55 |
+ _chargingStateAvailability = State(initialValue: initialChargingStateAvailability) |
|
| 56 |
+ _supportsWiredCharging = State(initialValue: initialChargingSupport.wired) |
|
| 57 |
+ _supportsWirelessCharging = State(initialValue: initialChargingSupport.wireless) |
|
| 58 |
+ _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi) |
|
| 59 |
+ _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice)) |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 62 |
+ var body: some View {
|
|
| 63 |
+ ChargedDeviceEditorScaffoldView( |
|
| 64 |
+ title: editorTitle, |
|
| 65 |
+ saveButtonTitle: saveButtonTitle, |
|
| 66 |
+ canSave: canSave, |
|
| 67 |
+ standalone: standalone, |
|
| 68 |
+ save: save |
|
| 69 |
+ ) {
|
|
| 70 |
+ identitySection |
|
| 71 |
+ templateSection |
|
| 72 |
+ deviceChargeBehaviourSection |
|
| 73 |
+ deviceChargingSupportSection |
|
| 74 |
+ deviceCompletionSection |
|
| 75 |
+ notesSection |
|
| 76 |
+ } |
|
| 77 |
+ .onChange(of: deviceClass) { newValue in
|
|
| 78 |
+ applyDeviceClassRules(for: newValue) |
|
| 79 |
+ } |
|
| 80 |
+ .onChange(of: selectedTemplateID) { newValue in
|
|
| 81 |
+ applyTemplateSelection( |
|
| 82 |
+ previousTemplateID: lastAppliedTemplateID, |
|
| 83 |
+ newTemplateID: newValue |
|
| 84 |
+ ) |
|
| 85 |
+ lastAppliedTemplateID = newValue |
|
| 86 |
+ } |
|
| 87 |
+ .onAppear {
|
|
| 88 |
+ applyDeviceClassRules(for: deviceClass) |
|
| 89 |
+ } |
|
| 90 |
+ } |
|
| 91 |
+ |
|
| 92 |
+ private var identitySection: some View {
|
|
| 93 |
+ Section(header: Text("Identity")) {
|
|
| 94 |
+ TextField("Name", text: $name)
|
|
| 95 |
+ |
|
| 96 |
+ Picker("Class", selection: $deviceClass) {
|
|
| 97 |
+ ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
|
|
| 98 |
+ Label(deviceClass.title, systemImage: deviceClass.symbolName) |
|
| 99 |
+ .tag(deviceClass) |
|
| 100 |
+ } |
|
| 101 |
+ } |
|
| 102 |
+ |
|
| 103 |
+ if let chargedDevice {
|
|
| 104 |
+ Text(chargedDevice.qrIdentifier) |
|
| 105 |
+ .font(.caption.monospaced()) |
|
| 106 |
+ .foregroundColor(.secondary) |
|
| 107 |
+ .textSelection(.enabled) |
|
| 108 |
+ } |
|
| 109 |
+ } |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ private var templateSection: some View {
|
|
| 113 |
+ Section( |
|
| 114 |
+ header: ContextInfoHeader( |
|
| 115 |
+ title: "Template", |
|
| 116 |
+ message: "Templates load from a JSON catalog and provide the starting icon and charging profile for common devices and chargers." |
|
| 117 |
+ ) |
|
| 118 |
+ ) {
|
|
| 119 |
+ Picker("Template", selection: $selectedTemplateID) {
|
|
| 120 |
+ Text("Custom")
|
|
| 121 |
+ .tag(String?.none) |
|
| 122 |
+ |
|
| 123 |
+ ForEach(groupedTemplates, id: \.group) { group in
|
|
| 124 |
+ Section(group.group) {
|
|
| 125 |
+ ForEach(group.templates) { template in
|
|
| 126 |
+ Text(template.name) |
|
| 127 |
+ .tag(template.id as String?) |
|
| 128 |
+ } |
|
| 129 |
+ } |
|
| 130 |
+ } |
|
| 131 |
+ } |
|
| 132 |
+ |
|
| 133 |
+ if let selectedTemplate {
|
|
| 134 |
+ ChargedDeviceTemplateLabelView( |
|
| 135 |
+ template: selectedTemplate, |
|
| 136 |
+ iconPointSize: 18 |
|
| 137 |
+ ) |
|
| 138 |
+ .font(.subheadline.weight(.semibold)) |
|
| 139 |
+ |
|
| 140 |
+ Text(selectedTemplate.capabilitySummary) |
|
| 141 |
+ .font(.caption) |
|
| 142 |
+ .foregroundColor(.secondary) |
|
| 143 |
+ } else {
|
|
| 144 |
+ Text("Choose a template when you want a predefined icon and a starting charging setup.")
|
|
| 145 |
+ .font(.caption) |
|
| 146 |
+ .foregroundColor(.secondary) |
|
| 147 |
+ } |
|
| 148 |
+ } |
|
| 149 |
+ } |
|
| 150 |
+ |
|
| 151 |
+ private var deviceChargeBehaviourSection: some View {
|
|
| 152 |
+ Section( |
|
| 153 |
+ header: ContextInfoHeader( |
|
| 154 |
+ title: "Charge Behaviour", |
|
| 155 |
+ message: "Choose whether sessions for this device are recorded only while it is on, only while it is off, or explicitly in either state." |
|
| 156 |
+ ) |
|
| 157 |
+ ) {
|
|
| 158 |
+ if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
|
|
| 159 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 160 |
+ Label(enforcedChargingStateAvailability.title, systemImage: "lock.fill") |
|
| 161 |
+ .font(.subheadline.weight(.semibold)) |
|
| 162 |
+ Text(enforcedChargingStateAvailability.description) |
|
| 163 |
+ .font(.caption) |
|
| 164 |
+ .foregroundColor(.secondary) |
|
| 165 |
+ } |
|
| 166 |
+ } else {
|
|
| 167 |
+ Picker("Session Modes", selection: $chargingStateAvailability) {
|
|
| 168 |
+ ForEach(ChargingStateAvailability.allCases) { availability in
|
|
| 169 |
+ Text(availability.title) |
|
| 170 |
+ .tag(availability) |
|
| 171 |
+ } |
|
| 172 |
+ } |
|
| 173 |
+ } |
|
| 174 |
+ } |
|
| 175 |
+ } |
|
| 176 |
+ |
|
| 177 |
+ private var deviceChargingSupportSection: some View {
|
|
| 178 |
+ Section( |
|
| 179 |
+ header: ContextInfoHeader( |
|
| 180 |
+ title: "Charging Support", |
|
| 181 |
+ message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate." |
|
| 182 |
+ ) |
|
| 183 |
+ ) {
|
|
| 184 |
+ if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
|
|
| 185 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 186 |
+ Label( |
|
| 187 |
+ Self.chargingSupportDescription( |
|
| 188 |
+ supportsWiredCharging: enforcedChargingSupport.wired, |
|
| 189 |
+ supportsWirelessCharging: enforcedChargingSupport.wireless |
|
| 190 |
+ ), |
|
| 191 |
+ systemImage: "lock.fill" |
|
| 192 |
+ ) |
|
| 193 |
+ .font(.subheadline.weight(.semibold)) |
|
| 194 |
+ |
|
| 195 |
+ Text("This device class is fixed so sessions cannot be recorded with an impossible charging transport.")
|
|
| 196 |
+ .font(.caption) |
|
| 197 |
+ .foregroundColor(.secondary) |
|
| 198 |
+ } |
|
| 199 |
+ } else {
|
|
| 200 |
+ Toggle("Supports wired charging", isOn: $supportsWiredCharging)
|
|
| 201 |
+ Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
|
|
| 202 |
+ } |
|
| 203 |
+ |
|
| 204 |
+ if showsWirelessProfilePicker {
|
|
| 205 |
+ Picker("Wireless profile", selection: $wirelessChargingProfile) {
|
|
| 206 |
+ ForEach(WirelessChargingProfile.allCases) { profile in
|
|
| 207 |
+ Text(profile.title) |
|
| 208 |
+ .tag(profile) |
|
| 209 |
+ } |
|
| 210 |
+ } |
|
| 211 |
+ |
|
| 212 |
+ } |
|
| 213 |
+ |
|
| 214 |
+ if supportedChargingModes.isEmpty {
|
|
| 215 |
+ Text("Enable at least one charging method.")
|
|
| 216 |
+ .font(.footnote) |
|
| 217 |
+ .foregroundColor(.secondary) |
|
| 218 |
+ } |
|
| 219 |
+ } |
|
| 220 |
+ } |
|
| 221 |
+ |
|
| 222 |
+ private var deviceCompletionSection: some View {
|
|
| 223 |
+ Section( |
|
| 224 |
+ header: ContextInfoHeader( |
|
| 225 |
+ title: "Charge Completion", |
|
| 226 |
+ message: "Completion currents can be set per session type. Leave a value empty to keep learning that threshold automatically from sessions of the same type." |
|
| 227 |
+ ) |
|
| 228 |
+ ) {
|
|
| 229 |
+ if applicableSessionKinds.isEmpty {
|
|
| 230 |
+ Text("Enable at least one charging method to configure stop currents.")
|
|
| 231 |
+ .font(.footnote) |
|
| 232 |
+ .foregroundColor(.secondary) |
|
| 233 |
+ } else {
|
|
| 234 |
+ ForEach(applicableSessionKinds) { sessionKind in
|
|
| 235 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 236 |
+ TextField( |
|
| 237 |
+ completionCurrentFieldLabel(for: sessionKind), |
|
| 238 |
+ text: completionCurrentTextBinding(for: sessionKind) |
|
| 239 |
+ ) |
|
| 240 |
+ .keyboardType(.decimalPad) |
|
| 241 |
+ } |
|
| 242 |
+ .padding(.vertical, 2) |
|
| 243 |
+ } |
|
| 244 |
+ } |
|
| 245 |
+ } |
|
| 246 |
+ } |
|
| 247 |
+ |
|
| 248 |
+ private var notesSection: some View {
|
|
| 249 |
+ Section(header: Text("Notes")) {
|
|
| 250 |
+ TextField("Optional notes", text: $notes)
|
|
| 251 |
+ } |
|
| 252 |
+ } |
|
| 253 |
+ |
|
| 254 |
+ private var editorTitle: String {
|
|
| 255 |
+ chargedDevice == nil ? "New Device" : "Edit Device" |
|
| 256 |
+ } |
|
| 257 |
+ |
|
| 258 |
+ private var saveButtonTitle: String {
|
|
| 259 |
+ chargedDevice == nil ? "Save" : "Update" |
|
| 260 |
+ } |
|
| 261 |
+ |
|
| 262 |
+ private var canSave: Bool {
|
|
| 263 |
+ !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
|
| 264 |
+ && (supportsWiredCharging || supportsWirelessCharging) |
|
| 265 |
+ && !hasInvalidCompletionCurrentEntry |
|
| 266 |
+ } |
|
| 267 |
+ |
|
| 268 |
+ private var availableTemplates: [ChargedDeviceTemplateDefinition] {
|
|
| 269 |
+ ChargedDeviceTemplateCatalog.shared.templates(for: .device) |
|
| 270 |
+ } |
|
| 271 |
+ |
|
| 272 |
+ private var groupedTemplates: [(group: String, templates: [ChargedDeviceTemplateDefinition])] {
|
|
| 273 |
+ Dictionary(grouping: availableTemplates, by: \.group) |
|
| 274 |
+ .keys |
|
| 275 |
+ .sorted() |
|
| 276 |
+ .map { group in
|
|
| 277 |
+ ( |
|
| 278 |
+ group: group, |
|
| 279 |
+ templates: availableTemplates.filter { $0.group == group }
|
|
| 280 |
+ ) |
|
| 281 |
+ } |
|
| 282 |
+ } |
|
| 283 |
+ |
|
| 284 |
+ private var selectedTemplate: ChargedDeviceTemplateDefinition? {
|
|
| 285 |
+ ChargedDeviceTemplateCatalog.shared.template(id: selectedTemplateID) |
|
| 286 |
+ } |
|
| 287 |
+ |
|
| 288 |
+ private var supportedChargingModes: [ChargingTransportMode] {
|
|
| 289 |
+ var modes: [ChargingTransportMode] = [] |
|
| 290 |
+ if supportsWiredCharging {
|
|
| 291 |
+ modes.append(.wired) |
|
| 292 |
+ } |
|
| 293 |
+ if supportsWirelessCharging {
|
|
| 294 |
+ modes.append(.wireless) |
|
| 295 |
+ } |
|
| 296 |
+ return modes |
|
| 297 |
+ } |
|
| 298 |
+ |
|
| 299 |
+ private var applicableSessionKinds: [ChargeSessionKind] {
|
|
| 300 |
+ supportedChargingModes.flatMap { chargingTransportMode in
|
|
| 301 |
+ chargingStateAvailability.supportedModes.map { chargingStateMode in
|
|
| 302 |
+ ChargeSessionKind( |
|
| 303 |
+ chargingTransportMode: chargingTransportMode, |
|
| 304 |
+ chargingStateMode: chargingStateMode |
|
| 305 |
+ ) |
|
| 306 |
+ } |
|
| 307 |
+ } |
|
| 308 |
+ } |
|
| 309 |
+ |
|
| 310 |
+ private var showsWirelessProfilePicker: Bool {
|
|
| 311 |
+ supportsWirelessCharging |
|
| 312 |
+ && deviceClass != .watch |
|
| 313 |
+ && supportedChargingModes.count > 1 |
|
| 314 |
+ } |
|
| 315 |
+ |
|
| 316 |
+ private var parsedCompletionCurrents: [ChargeSessionKind: Double] {
|
|
| 317 |
+ applicableSessionKinds.reduce(into: [ChargeSessionKind: Double]()) { result, sessionKind in
|
|
| 318 |
+ guard let value = parsedOptionalCurrent(completionCurrentTexts[sessionKind] ?? "") else {
|
|
| 319 |
+ return |
|
| 320 |
+ } |
|
| 321 |
+ result[sessionKind] = value |
|
| 322 |
+ } |
|
| 323 |
+ } |
|
| 324 |
+ |
|
| 325 |
+ private var hasInvalidCompletionCurrentEntry: Bool {
|
|
| 326 |
+ applicableSessionKinds.contains { sessionKind in
|
|
| 327 |
+ let text = completionCurrentTexts[sessionKind] ?? "" |
|
| 328 |
+ let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 329 |
+ return !normalized.isEmpty && parsedOptionalCurrent(text) == nil |
|
| 330 |
+ } |
|
| 331 |
+ } |
|
| 332 |
+ |
|
| 333 |
+ private func completionCurrentTextBinding(for sessionKind: ChargeSessionKind) -> Binding<String> {
|
|
| 334 |
+ Binding( |
|
| 335 |
+ get: { completionCurrentTexts[sessionKind] ?? "" },
|
|
| 336 |
+ set: { completionCurrentTexts[sessionKind] = $0 }
|
|
| 337 |
+ ) |
|
| 338 |
+ } |
|
| 339 |
+ |
|
| 340 |
+ private func save() {
|
|
| 341 |
+ let configuredCompletionCurrents = parsedCompletionCurrents |
|
| 342 |
+ let didSave: Bool |
|
| 343 |
+ |
|
| 344 |
+ if let chargedDevice {
|
|
| 345 |
+ didSave = appData.updateDevice( |
|
| 346 |
+ id: chargedDevice.id, |
|
| 347 |
+ name: name, |
|
| 348 |
+ deviceClass: deviceClass, |
|
| 349 |
+ templateID: selectedTemplateID, |
|
| 350 |
+ chargingStateAvailability: chargingStateAvailability, |
|
| 351 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 352 |
+ supportsWirelessCharging: supportsWirelessCharging, |
|
| 353 |
+ wirelessChargingProfile: wirelessChargingProfile, |
|
| 354 |
+ configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 355 |
+ notes: notes |
|
| 356 |
+ ) |
|
| 357 |
+ } else {
|
|
| 358 |
+ didSave = appData.createDevice( |
|
| 359 |
+ name: name, |
|
| 360 |
+ deviceClass: deviceClass, |
|
| 361 |
+ templateID: selectedTemplateID, |
|
| 362 |
+ chargingStateAvailability: chargingStateAvailability, |
|
| 363 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 364 |
+ supportsWirelessCharging: supportsWirelessCharging, |
|
| 365 |
+ wirelessChargingProfile: wirelessChargingProfile, |
|
| 366 |
+ configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 367 |
+ notes: notes, |
|
| 368 |
+ meterMACAddress: meterMACAddress |
|
| 369 |
+ ) |
|
| 370 |
+ } |
|
| 371 |
+ |
|
| 372 |
+ if didSave {
|
|
| 373 |
+ dismiss() |
|
| 374 |
+ } |
|
| 375 |
+ } |
|
| 376 |
+ |
|
| 377 |
+ private func applyTemplateSelection( |
|
| 378 |
+ previousTemplateID: String?, |
|
| 379 |
+ newTemplateID: String? |
|
| 380 |
+ ) {
|
|
| 381 |
+ guard let newTemplate = ChargedDeviceTemplateCatalog.shared.template(id: newTemplateID) else {
|
|
| 382 |
+ return |
|
| 383 |
+ } |
|
| 384 |
+ |
|
| 385 |
+ let previousTemplate = ChargedDeviceTemplateCatalog.shared.template(id: previousTemplateID) |
|
| 386 |
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 387 |
+ if trimmedName.isEmpty || trimmedName == previousTemplate?.name {
|
|
| 388 |
+ name = newTemplate.name |
|
| 389 |
+ } |
|
| 390 |
+ |
|
| 391 |
+ deviceClass = newTemplate.deviceClass |
|
| 392 |
+ chargingStateAvailability = newTemplate.deviceClass.normalizedChargingStateAvailability( |
|
| 393 |
+ newTemplate.chargingStateAvailability |
|
| 394 |
+ ) |
|
| 395 |
+ |
|
| 396 |
+ let normalizedChargingSupport = newTemplate.deviceClass.normalizedChargingSupport( |
|
| 397 |
+ supportsWiredCharging: newTemplate.supportsWiredCharging, |
|
| 398 |
+ supportsWirelessCharging: newTemplate.supportsWirelessCharging |
|
| 399 |
+ ) |
|
| 400 |
+ supportsWiredCharging = normalizedChargingSupport.wired |
|
| 401 |
+ supportsWirelessCharging = normalizedChargingSupport.wireless |
|
| 402 |
+ wirelessChargingProfile = newTemplate.wirelessChargingProfile |
|
| 403 |
+ } |
|
| 404 |
+ |
|
| 405 |
+ private func applyDeviceClassRules(for deviceClass: ChargedDeviceClass) {
|
|
| 406 |
+ if let selectedTemplate {
|
|
| 407 |
+ chargingStateAvailability = deviceClass.normalizedChargingStateAvailability( |
|
| 408 |
+ selectedTemplate.chargingStateAvailability |
|
| 409 |
+ ) |
|
| 410 |
+ let normalizedChargingSupport = deviceClass.normalizedChargingSupport( |
|
| 411 |
+ supportsWiredCharging: selectedTemplate.supportsWiredCharging, |
|
| 412 |
+ supportsWirelessCharging: selectedTemplate.supportsWirelessCharging |
|
| 413 |
+ ) |
|
| 414 |
+ supportsWiredCharging = normalizedChargingSupport.wired |
|
| 415 |
+ supportsWirelessCharging = normalizedChargingSupport.wireless |
|
| 416 |
+ wirelessChargingProfile = selectedTemplate.wirelessChargingProfile |
|
| 417 |
+ return |
|
| 418 |
+ } |
|
| 419 |
+ |
|
| 420 |
+ if let enforcedChargingStateAvailability = deviceClass.enforcedChargingStateAvailability {
|
|
| 421 |
+ chargingStateAvailability = enforcedChargingStateAvailability |
|
| 422 |
+ } else if chargedDevice == nil {
|
|
| 423 |
+ chargingStateAvailability = deviceClass.defaultChargingStateAvailability |
|
| 424 |
+ } |
|
| 425 |
+ |
|
| 426 |
+ if let enforcedChargingSupport = deviceClass.enforcedChargingSupport {
|
|
| 427 |
+ supportsWiredCharging = enforcedChargingSupport.wired |
|
| 428 |
+ supportsWirelessCharging = enforcedChargingSupport.wireless |
|
| 429 |
+ } else if chargedDevice == nil {
|
|
| 430 |
+ let defaultChargingSupport = deviceClass.defaultChargingSupport |
|
| 431 |
+ supportsWiredCharging = defaultChargingSupport.wired |
|
| 432 |
+ supportsWirelessCharging = defaultChargingSupport.wireless |
|
| 433 |
+ } |
|
| 434 |
+ } |
|
| 435 |
+ |
|
| 436 |
+ private func parsedOptionalCurrent(_ text: String) -> Double? {
|
|
| 437 |
+ let normalized = text |
|
| 438 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 439 |
+ .replacingOccurrences(of: ",", with: ".") |
|
| 440 |
+ guard !normalized.isEmpty else {
|
|
| 441 |
+ return nil |
|
| 442 |
+ } |
|
| 443 |
+ guard let value = Double(normalized), value > 0 else {
|
|
| 444 |
+ return nil |
|
| 445 |
+ } |
|
| 446 |
+ return value |
|
| 447 |
+ } |
|
| 448 |
+ |
|
| 449 |
+ private func completionCurrentFieldLabel(for sessionKind: ChargeSessionKind) -> String {
|
|
| 450 |
+ let showsTransport = supportedChargingModes.count > 1 |
|
| 451 |
+ let showsState = chargingStateAvailability.supportedModes.count > 1 |
|
| 452 |
+ |
|
| 453 |
+ switch (showsTransport, showsState) {
|
|
| 454 |
+ case (true, true): |
|
| 455 |
+ return "\(sessionKind.shortTitle) completion current (A)" |
|
| 456 |
+ case (true, false): |
|
| 457 |
+ return "\(sessionKind.chargingTransportMode.title) completion current (A)" |
|
| 458 |
+ case (false, true): |
|
| 459 |
+ return "\(sessionKind.chargingStateMode.title) completion current (A)" |
|
| 460 |
+ case (false, false): |
|
| 461 |
+ return "Stop current (A)" |
|
| 462 |
+ } |
|
| 463 |
+ } |
|
| 464 |
+ |
|
| 465 |
+ private static func makeCompletionCurrentTexts(for chargedDevice: ChargedDeviceSummary?) -> [ChargeSessionKind: String] {
|
|
| 466 |
+ guard let chargedDevice else {
|
|
| 467 |
+ return [:] |
|
| 468 |
+ } |
|
| 469 |
+ |
|
| 470 |
+ return ChargeSessionKind.allCases.reduce(into: [ChargeSessionKind: String]()) { result, sessionKind in
|
|
| 471 |
+ result[sessionKind] = optionalCurrentText( |
|
| 472 |
+ chargedDevice.configuredCompletionCurrentAmps(for: sessionKind) |
|
| 473 |
+ ) |
|
| 474 |
+ } |
|
| 475 |
+ } |
|
| 476 |
+ |
|
| 477 |
+ private static func optionalCurrentText(_ value: Double?) -> String {
|
|
| 478 |
+ guard let value else {
|
|
| 479 |
+ return "" |
|
| 480 |
+ } |
|
| 481 |
+ return value.format(decimalDigits: 2) |
|
| 482 |
+ } |
|
| 483 |
+ |
|
| 484 |
+ private static func chargingSupportDescription( |
|
| 485 |
+ supportsWiredCharging: Bool, |
|
| 486 |
+ supportsWirelessCharging: Bool |
|
| 487 |
+ ) -> String {
|
|
| 488 |
+ switch (supportsWiredCharging, supportsWirelessCharging) {
|
|
| 489 |
+ case (true, true): |
|
| 490 |
+ return "Supports wired and wireless charging" |
|
| 491 |
+ case (true, false): |
|
| 492 |
+ return "Supports wired charging only" |
|
| 493 |
+ case (false, true): |
|
| 494 |
+ return "Supports wireless charging only" |
|
| 495 |
+ case (false, false): |
|
| 496 |
+ return "No charging method configured" |
|
| 497 |
+ } |
|
| 498 |
+ } |
|
| 499 |
+ |
|
| 500 |
+} |
|
@@ -0,0 +1,111 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargerEditorSheetView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct ChargerEditorSheetView: View {
|
|
| 9 |
+ @EnvironmentObject private var appData: AppData |
|
| 10 |
+ @Environment(\.dismiss) private var dismiss |
|
| 11 |
+ |
|
| 12 |
+ let chargedDevice: ChargedDeviceSummary? |
|
| 13 |
+ let meterMACAddress: String? |
|
| 14 |
+ /// When false the view omits its own NavigationView (used as a push destination). |
|
| 15 |
+ let standalone: Bool |
|
| 16 |
+ |
|
| 17 |
+ @State private var name: String |
|
| 18 |
+ @State private var chargerType: ChargerType |
|
| 19 |
+ @State private var notes: String |
|
| 20 |
+ |
|
| 21 |
+ init( |
|
| 22 |
+ chargedDevice: ChargedDeviceSummary? = nil, |
|
| 23 |
+ meterMACAddress: String? = nil, |
|
| 24 |
+ standalone: Bool = true |
|
| 25 |
+ ) {
|
|
| 26 |
+ self.chargedDevice = chargedDevice |
|
| 27 |
+ self.meterMACAddress = meterMACAddress |
|
| 28 |
+ self.standalone = standalone |
|
| 29 |
+ _name = State(initialValue: chargedDevice?.name ?? "") |
|
| 30 |
+ _chargerType = State(initialValue: chargedDevice?.chargerType ?? .genericQi) |
|
| 31 |
+ _notes = State(initialValue: chargedDevice?.notes ?? "") |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 34 |
+ var body: some View {
|
|
| 35 |
+ ChargedDeviceEditorScaffoldView( |
|
| 36 |
+ title: editorTitle, |
|
| 37 |
+ saveButtonTitle: saveButtonTitle, |
|
| 38 |
+ canSave: canSave, |
|
| 39 |
+ standalone: standalone, |
|
| 40 |
+ save: save |
|
| 41 |
+ ) {
|
|
| 42 |
+ Section(header: Text("Identity")) {
|
|
| 43 |
+ TextField("Charger name", text: $name)
|
|
| 44 |
+ |
|
| 45 |
+ if let chargedDevice {
|
|
| 46 |
+ Text(chargedDevice.qrIdentifier) |
|
| 47 |
+ .font(.caption.monospaced()) |
|
| 48 |
+ .foregroundColor(.secondary) |
|
| 49 |
+ .textSelection(.enabled) |
|
| 50 |
+ } |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 53 |
+ Section( |
|
| 54 |
+ header: ContextInfoHeader( |
|
| 55 |
+ title: "Charger Type", |
|
| 56 |
+ message: "MagSafe and Watch chargers use magnetic alignment, enabling accurate efficiency calibration. Standby current and efficiency are learned automatically from sessions." |
|
| 57 |
+ ) |
|
| 58 |
+ ) {
|
|
| 59 |
+ Picker("Type", selection: $chargerType) {
|
|
| 60 |
+ ForEach(ChargerType.allCases) { type in
|
|
| 61 |
+ Label(type.title, systemImage: type.symbolName) |
|
| 62 |
+ .tag(type) |
|
| 63 |
+ } |
|
| 64 |
+ } |
|
| 65 |
+ .pickerStyle(.menu) |
|
| 66 |
+ } |
|
| 67 |
+ |
|
| 68 |
+ Section(header: Text("Notes")) {
|
|
| 69 |
+ TextField("Optional notes", text: $notes)
|
|
| 70 |
+ } |
|
| 71 |
+ } |
|
| 72 |
+ } |
|
| 73 |
+ |
|
| 74 |
+ private var editorTitle: String {
|
|
| 75 |
+ chargedDevice == nil ? "New Charger" : "Edit Charger" |
|
| 76 |
+ } |
|
| 77 |
+ |
|
| 78 |
+ private var saveButtonTitle: String {
|
|
| 79 |
+ chargedDevice == nil ? "Save" : "Update" |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 82 |
+ private var canSave: Bool {
|
|
| 83 |
+ !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
|
| 84 |
+ } |
|
| 85 |
+ |
|
| 86 |
+ private func save() {
|
|
| 87 |
+ let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 88 |
+ let notesValue: String? = trimmedNotes.isEmpty ? nil : trimmedNotes |
|
| 89 |
+ |
|
| 90 |
+ let didSave: Bool |
|
| 91 |
+ if let chargedDevice {
|
|
| 92 |
+ didSave = appData.updateCharger( |
|
| 93 |
+ id: chargedDevice.id, |
|
| 94 |
+ name: name, |
|
| 95 |
+ chargerType: chargerType, |
|
| 96 |
+ notes: notesValue |
|
| 97 |
+ ) |
|
| 98 |
+ } else {
|
|
| 99 |
+ didSave = appData.createCharger( |
|
| 100 |
+ name: name, |
|
| 101 |
+ chargerType: chargerType, |
|
| 102 |
+ notes: notesValue, |
|
| 103 |
+ meterMACAddress: meterMACAddress |
|
| 104 |
+ ) |
|
| 105 |
+ } |
|
| 106 |
+ |
|
| 107 |
+ if didSave {
|
|
| 108 |
+ dismiss() |
|
| 109 |
+ } |
|
| 110 |
+ } |
|
| 111 |
+} |
|
@@ -0,0 +1,229 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargedDeviceLibrarySheetView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 10/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+enum ChargedDeviceLibraryMode {
|
|
| 11 |
+ case device |
|
| 12 |
+ case charger |
|
| 13 |
+ |
|
| 14 |
+ var kind: ChargedDeviceKind {
|
|
| 15 |
+ switch self {
|
|
| 16 |
+ case .device: |
|
| 17 |
+ return .device |
|
| 18 |
+ case .charger: |
|
| 19 |
+ return .charger |
|
| 20 |
+ } |
|
| 21 |
+ } |
|
| 22 |
+ |
|
| 23 |
+ var title: String {
|
|
| 24 |
+ kind.pluralTitle |
|
| 25 |
+ } |
|
| 26 |
+ |
|
| 27 |
+ var singularTitle: String {
|
|
| 28 |
+ kind.title |
|
| 29 |
+ } |
|
| 30 |
+} |
|
| 31 |
+ |
|
| 32 |
+struct ChargedDeviceLibrarySheetView: View {
|
|
| 33 |
+ @EnvironmentObject private var appData: AppData |
|
| 34 |
+ @Environment(\.dismiss) private var dismiss |
|
| 35 |
+ |
|
| 36 |
+ let meterMACAddress: String |
|
| 37 |
+ let meterTint: Color |
|
| 38 |
+ let mode: ChargedDeviceLibraryMode |
|
| 39 |
+ /// true = standalone sheet with own NavigationView; false = pushed into parent nav stack |
|
| 40 |
+ let standalone: Bool |
|
| 41 |
+ |
|
| 42 |
+ @State private var showingNewEditor = false |
|
| 43 |
+ @State private var editingChargedDevice: ChargedDeviceSummary? |
|
| 44 |
+ @State private var pendingDeletion: ChargedDeviceSummary? |
|
| 45 |
+ |
|
| 46 |
+ init( |
|
| 47 |
+ meterMACAddress: String, |
|
| 48 |
+ meterTint: Color, |
|
| 49 |
+ mode: ChargedDeviceLibraryMode, |
|
| 50 |
+ standalone: Bool = true |
|
| 51 |
+ ) {
|
|
| 52 |
+ self.meterMACAddress = meterMACAddress |
|
| 53 |
+ self.meterTint = meterTint |
|
| 54 |
+ self.mode = mode |
|
| 55 |
+ self.standalone = standalone |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ var body: some View {
|
|
| 59 |
+ if standalone {
|
|
| 60 |
+ NavigationView { listContent }
|
|
| 61 |
+ .navigationViewStyle(StackNavigationViewStyle()) |
|
| 62 |
+ } else {
|
|
| 63 |
+ listContent |
|
| 64 |
+ } |
|
| 65 |
+ } |
|
| 66 |
+ |
|
| 67 |
+ private var listContent: some View {
|
|
| 68 |
+ List {
|
|
| 69 |
+ if displayedChargedDevices.isEmpty {
|
|
| 70 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 71 |
+ HStack(spacing: 8) {
|
|
| 72 |
+ Text("No \(mode.title.lowercased()) yet.")
|
|
| 73 |
+ .font(.headline) |
|
| 74 |
+ ContextInfoButton( |
|
| 75 |
+ title: mode.title, |
|
| 76 |
+ message: emptyStateDescription |
|
| 77 |
+ ) |
|
| 78 |
+ } |
|
| 79 |
+ } |
|
| 80 |
+ .padding(.vertical, 10) |
|
| 81 |
+ .listRowBackground(Color.clear) |
|
| 82 |
+ } else {
|
|
| 83 |
+ ForEach(displayedChargedDevices) { chargedDevice in
|
|
| 84 |
+ Button {
|
|
| 85 |
+ select(chargedDevice) |
|
| 86 |
+ dismiss() |
|
| 87 |
+ } label: {
|
|
| 88 |
+ ChargedDeviceLibraryRowView( |
|
| 89 |
+ chargedDevice: chargedDevice, |
|
| 90 |
+ isSelected: chargedDevice.id == selectedDeviceID |
|
| 91 |
+ ) |
|
| 92 |
+ } |
|
| 93 |
+ .buttonStyle(.plain) |
|
| 94 |
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
| 95 |
+ Button(role: .destructive) {
|
|
| 96 |
+ pendingDeletion = chargedDevice |
|
| 97 |
+ } label: {
|
|
| 98 |
+ Label("Delete", systemImage: "trash")
|
|
| 99 |
+ } |
|
| 100 |
+ Button {
|
|
| 101 |
+ editingChargedDevice = chargedDevice |
|
| 102 |
+ } label: {
|
|
| 103 |
+ Label("Edit", systemImage: "pencil")
|
|
| 104 |
+ } |
|
| 105 |
+ .tint(.blue) |
|
| 106 |
+ } |
|
| 107 |
+ .contextMenu {
|
|
| 108 |
+ Button {
|
|
| 109 |
+ editingChargedDevice = chargedDevice |
|
| 110 |
+ } label: {
|
|
| 111 |
+ Label("Edit \(mode.singularTitle)", systemImage: "pencil")
|
|
| 112 |
+ } |
|
| 113 |
+ Button(role: .destructive) {
|
|
| 114 |
+ pendingDeletion = chargedDevice |
|
| 115 |
+ } label: {
|
|
| 116 |
+ Label("Delete \(mode.singularTitle)", systemImage: "trash")
|
|
| 117 |
+ } |
|
| 118 |
+ } |
|
| 119 |
+ } |
|
| 120 |
+ } |
|
| 121 |
+ } |
|
| 122 |
+ .listStyle(InsetGroupedListStyle()) |
|
| 123 |
+ .background( |
|
| 124 |
+ LinearGradient( |
|
| 125 |
+ colors: [meterTint.opacity(0.14), Color.clear], |
|
| 126 |
+ startPoint: .topLeading, |
|
| 127 |
+ endPoint: .bottomTrailing |
|
| 128 |
+ ) |
|
| 129 |
+ .ignoresSafeArea() |
|
| 130 |
+ ) |
|
| 131 |
+ .navigationTitle(mode.title) |
|
| 132 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 133 |
+ .toolbar {
|
|
| 134 |
+ ToolbarItem(placement: .cancellationAction) {
|
|
| 135 |
+ if standalone {
|
|
| 136 |
+ Button("Done") { dismiss() }
|
|
| 137 |
+ } |
|
| 138 |
+ } |
|
| 139 |
+ ToolbarItem(placement: .confirmationAction) {
|
|
| 140 |
+ Button("New") { showingNewEditor = true }
|
|
| 141 |
+ } |
|
| 142 |
+ } |
|
| 143 |
+ .sheet(isPresented: $showingNewEditor) {
|
|
| 144 |
+ newEditorSheet |
|
| 145 |
+ } |
|
| 146 |
+ .sheet(item: $editingChargedDevice) { device in
|
|
| 147 |
+ editEditorSheet(device) |
|
| 148 |
+ } |
|
| 149 |
+ .confirmationDialog( |
|
| 150 |
+ "Delete \(pendingDeletion?.name ?? mode.singularTitle)?", |
|
| 151 |
+ isPresented: Binding( |
|
| 152 |
+ get: { pendingDeletion != nil },
|
|
| 153 |
+ set: { if !$0 { pendingDeletion = nil } }
|
|
| 154 |
+ ), |
|
| 155 |
+ titleVisibility: .visible |
|
| 156 |
+ ) {
|
|
| 157 |
+ Button("Delete", role: .destructive) {
|
|
| 158 |
+ if let device = pendingDeletion {
|
|
| 159 |
+ _ = appData.deleteChargedDevice(id: device.id) |
|
| 160 |
+ pendingDeletion = nil |
|
| 161 |
+ } |
|
| 162 |
+ } |
|
| 163 |
+ Button("Cancel", role: .cancel) { pendingDeletion = nil }
|
|
| 164 |
+ } message: {
|
|
| 165 |
+ Text("This will permanently remove the \(mode.singularTitle.lowercased()) and all associated data.")
|
|
| 166 |
+ } |
|
| 167 |
+ } |
|
| 168 |
+ |
|
| 169 |
+ @ViewBuilder |
|
| 170 |
+ private var newEditorSheet: some View {
|
|
| 171 |
+ if mode == .charger {
|
|
| 172 |
+ ChargerEditorSheetView(meterMACAddress: meterMACAddress) |
|
| 173 |
+ .environmentObject(appData) |
|
| 174 |
+ } else {
|
|
| 175 |
+ ChargedDeviceEditorSheetView(meterMACAddress: meterMACAddress) |
|
| 176 |
+ .environmentObject(appData) |
|
| 177 |
+ } |
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ @ViewBuilder |
|
| 181 |
+ private func editEditorSheet(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 182 |
+ if chargedDevice.isCharger {
|
|
| 183 |
+ ChargerEditorSheetView(chargedDevice: chargedDevice) |
|
| 184 |
+ .environmentObject(appData) |
|
| 185 |
+ } else {
|
|
| 186 |
+ ChargedDeviceEditorSheetView( |
|
| 187 |
+ meterMACAddress: nil, |
|
| 188 |
+ chargedDevice: chargedDevice |
|
| 189 |
+ ) |
|
| 190 |
+ .environmentObject(appData) |
|
| 191 |
+ } |
|
| 192 |
+ } |
|
| 193 |
+ |
|
| 194 |
+ private var displayedChargedDevices: [ChargedDeviceSummary] {
|
|
| 195 |
+ switch mode {
|
|
| 196 |
+ case .device: |
|
| 197 |
+ return appData.deviceSummaries |
|
| 198 |
+ case .charger: |
|
| 199 |
+ return appData.chargerSummaries |
|
| 200 |
+ } |
|
| 201 |
+ } |
|
| 202 |
+ |
|
| 203 |
+ private var selectedDeviceID: UUID? {
|
|
| 204 |
+ switch mode {
|
|
| 205 |
+ case .device: |
|
| 206 |
+ return appData.currentChargedDeviceSummary(for: meterMACAddress)?.id |
|
| 207 |
+ case .charger: |
|
| 208 |
+ return appData.currentChargerSummary(for: meterMACAddress)?.id |
|
| 209 |
+ } |
|
| 210 |
+ } |
|
| 211 |
+ |
|
| 212 |
+ private var emptyStateDescription: String {
|
|
| 213 |
+ switch mode {
|
|
| 214 |
+ case .device: |
|
| 215 |
+ return "Create one here, then select it before or during a charging session. The selected device becomes the default for this meter." |
|
| 216 |
+ case .charger: |
|
| 217 |
+ return "Create one here, then select it for wireless charging sessions. The selected charger becomes the default wireless source for this meter." |
|
| 218 |
+ } |
|
| 219 |
+ } |
|
| 220 |
+ |
|
| 221 |
+ private func select(_ chargedDevice: ChargedDeviceSummary) {
|
|
| 222 |
+ switch mode {
|
|
| 223 |
+ case .device: |
|
| 224 |
+ appData.assignChargedDevice(chargedDevice.id, to: meterMACAddress) |
|
| 225 |
+ case .charger: |
|
| 226 |
+ appData.assignCharger(chargedDevice.id, to: meterMACAddress) |
|
| 227 |
+ } |
|
| 228 |
+ } |
|
| 229 |
+} |
|
@@ -0,0 +1,158 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarChargedDeviceLibraryView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 22/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+/// Full-management library for the sidebar — navigates into detail instead of select-and-dismiss. |
|
| 11 |
+struct SidebarChargedDeviceLibraryView: View {
|
|
| 12 |
+ @EnvironmentObject private var appData: AppData |
|
| 13 |
+ |
|
| 14 |
+ let mode: ChargedDeviceLibraryMode |
|
| 15 |
+ |
|
| 16 |
+ @State private var showingNewEditor = false |
|
| 17 |
+ @State private var editingChargedDevice: ChargedDeviceSummary? |
|
| 18 |
+ @State private var pendingDeletion: ChargedDeviceSummary? |
|
| 19 |
+ |
|
| 20 |
+ private var tint: Color {
|
|
| 21 |
+ mode == .device ? .orange : .pink |
|
| 22 |
+ } |
|
| 23 |
+ |
|
| 24 |
+ var body: some View {
|
|
| 25 |
+ List {
|
|
| 26 |
+ if displayedDevices.isEmpty {
|
|
| 27 |
+ emptyStateView |
|
| 28 |
+ } else {
|
|
| 29 |
+ deviceRows |
|
| 30 |
+ } |
|
| 31 |
+ } |
|
| 32 |
+ .listStyle(InsetGroupedListStyle()) |
|
| 33 |
+ .background(backgroundGradient) |
|
| 34 |
+ .navigationTitle(mode.title) |
|
| 35 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 36 |
+ .toolbar {
|
|
| 37 |
+ ToolbarItem(placement: .primaryAction) {
|
|
| 38 |
+ Button("New") { showingNewEditor = true }
|
|
| 39 |
+ } |
|
| 40 |
+ } |
|
| 41 |
+ .sheet(isPresented: $showingNewEditor) { newEditorSheet }
|
|
| 42 |
+ .sheet(item: $editingChargedDevice) { device in editEditorSheet(device) }
|
|
| 43 |
+ .confirmationDialog( |
|
| 44 |
+ "Delete \(pendingDeletion?.name ?? mode.singularTitle)?", |
|
| 45 |
+ isPresented: Binding( |
|
| 46 |
+ get: { pendingDeletion != nil },
|
|
| 47 |
+ set: { if !$0 { pendingDeletion = nil } }
|
|
| 48 |
+ ), |
|
| 49 |
+ titleVisibility: .visible |
|
| 50 |
+ ) {
|
|
| 51 |
+ Button("Delete", role: .destructive, action: deletePendingDevice)
|
|
| 52 |
+ Button("Cancel", role: .cancel) { pendingDeletion = nil }
|
|
| 53 |
+ } message: {
|
|
| 54 |
+ Text("This will permanently remove the \(mode.singularTitle.lowercased()) and all associated data.")
|
|
| 55 |
+ } |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ private var emptyStateView: some View {
|
|
| 59 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 60 |
+ HStack(spacing: 8) {
|
|
| 61 |
+ Text("No \(mode.title.lowercased()) yet.")
|
|
| 62 |
+ .font(.headline) |
|
| 63 |
+ ContextInfoButton( |
|
| 64 |
+ title: mode.title, |
|
| 65 |
+ message: emptyStateDescription |
|
| 66 |
+ ) |
|
| 67 |
+ } |
|
| 68 |
+ } |
|
| 69 |
+ .padding(.vertical, 10) |
|
| 70 |
+ .listRowBackground(Color.clear) |
|
| 71 |
+ } |
|
| 72 |
+ |
|
| 73 |
+ private var deviceRows: some View {
|
|
| 74 |
+ ForEach(displayedDevices) { device in
|
|
| 75 |
+ NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: device.id)) {
|
|
| 76 |
+ ChargedDeviceLibraryRowView(chargedDevice: device, isSelected: false) |
|
| 77 |
+ } |
|
| 78 |
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
| 79 |
+ rowActions(for: device) |
|
| 80 |
+ } |
|
| 81 |
+ .contextMenu {
|
|
| 82 |
+ Button {
|
|
| 83 |
+ editingChargedDevice = device |
|
| 84 |
+ } label: {
|
|
| 85 |
+ Label("Edit \(mode.singularTitle)", systemImage: "pencil")
|
|
| 86 |
+ } |
|
| 87 |
+ Button(role: .destructive) {
|
|
| 88 |
+ pendingDeletion = device |
|
| 89 |
+ } label: {
|
|
| 90 |
+ Label("Delete \(mode.singularTitle)", systemImage: "trash")
|
|
| 91 |
+ } |
|
| 92 |
+ } |
|
| 93 |
+ } |
|
| 94 |
+ } |
|
| 95 |
+ |
|
| 96 |
+ private var backgroundGradient: some View {
|
|
| 97 |
+ LinearGradient( |
|
| 98 |
+ colors: [tint.opacity(0.14), Color.clear], |
|
| 99 |
+ startPoint: .topLeading, |
|
| 100 |
+ endPoint: .bottomTrailing |
|
| 101 |
+ ) |
|
| 102 |
+ .ignoresSafeArea() |
|
| 103 |
+ } |
|
| 104 |
+ |
|
| 105 |
+ private var displayedDevices: [ChargedDeviceSummary] {
|
|
| 106 |
+ mode == .device ? appData.deviceSummaries : appData.chargerSummaries |
|
| 107 |
+ } |
|
| 108 |
+ |
|
| 109 |
+ @ViewBuilder |
|
| 110 |
+ private var newEditorSheet: some View {
|
|
| 111 |
+ if mode == .charger {
|
|
| 112 |
+ ChargerEditorSheetView(meterMACAddress: nil) |
|
| 113 |
+ .environmentObject(appData) |
|
| 114 |
+ } else {
|
|
| 115 |
+ ChargedDeviceEditorSheetView(meterMACAddress: nil) |
|
| 116 |
+ .environmentObject(appData) |
|
| 117 |
+ } |
|
| 118 |
+ } |
|
| 119 |
+ |
|
| 120 |
+ @ViewBuilder |
|
| 121 |
+ private func editEditorSheet(_ device: ChargedDeviceSummary) -> some View {
|
|
| 122 |
+ if device.isCharger {
|
|
| 123 |
+ ChargerEditorSheetView(chargedDevice: device) |
|
| 124 |
+ .environmentObject(appData) |
|
| 125 |
+ } else {
|
|
| 126 |
+ ChargedDeviceEditorSheetView(meterMACAddress: nil, chargedDevice: device) |
|
| 127 |
+ .environmentObject(appData) |
|
| 128 |
+ } |
|
| 129 |
+ } |
|
| 130 |
+ |
|
| 131 |
+ @ViewBuilder |
|
| 132 |
+ private func rowActions(for device: ChargedDeviceSummary) -> some View {
|
|
| 133 |
+ Button(role: .destructive) {
|
|
| 134 |
+ pendingDeletion = device |
|
| 135 |
+ } label: {
|
|
| 136 |
+ Label("Delete", systemImage: "trash")
|
|
| 137 |
+ } |
|
| 138 |
+ Button {
|
|
| 139 |
+ editingChargedDevice = device |
|
| 140 |
+ } label: {
|
|
| 141 |
+ Label("Edit", systemImage: "pencil")
|
|
| 142 |
+ } |
|
| 143 |
+ .tint(.blue) |
|
| 144 |
+ } |
|
| 145 |
+ |
|
| 146 |
+ private var emptyStateDescription: String {
|
|
| 147 |
+ mode == .device |
|
| 148 |
+ ? "Create one here, then select it before or during a charging session. The selected device becomes the default for this meter." |
|
| 149 |
+ : "Create one here, then select it for wireless charging sessions. The selected charger becomes the default wireless source for this meter." |
|
| 150 |
+ } |
|
| 151 |
+ |
|
| 152 |
+ private func deletePendingDevice() {
|
|
| 153 |
+ if let device = pendingDeletion {
|
|
| 154 |
+ _ = appData.deleteChargedDevice(id: device.id) |
|
| 155 |
+ pendingDeletion = nil |
|
| 156 |
+ } |
|
| 157 |
+ } |
|
| 158 |
+} |
|
@@ -0,0 +1,93 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarChargedDevicesSectionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 10/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+struct SidebarChargedDevicesSectionView: View {
|
|
| 11 |
+ let title: String |
|
| 12 |
+ let mode: ChargedDeviceLibraryMode |
|
| 13 |
+ let chargedDevices: [ChargedDeviceSummary] |
|
| 14 |
+ let emptyStateText: String |
|
| 15 |
+ let tint: Color |
|
| 16 |
+ let isExpanded: Bool |
|
| 17 |
+ let onToggle: () -> Void |
|
| 18 |
+ let onAdd: () -> Void |
|
| 19 |
+ |
|
| 20 |
+ var body: some View {
|
|
| 21 |
+ Section(header: headerView) {
|
|
| 22 |
+ if isExpanded {
|
|
| 23 |
+ // Library overview row — navigates to the full management library |
|
| 24 |
+ NavigationLink(destination: SidebarChargedDeviceLibraryView(mode: mode)) {
|
|
| 25 |
+ libraryRow |
|
| 26 |
+ } |
|
| 27 |
+ .buttonStyle(.plain) |
|
| 28 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 29 |
+ |
|
| 30 |
+ ForEach(chargedDevices) { chargedDevice in
|
|
| 31 |
+ NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
|
|
| 32 |
+ ChargedDeviceSidebarCardView(chargedDevice: chargedDevice) |
|
| 33 |
+ } |
|
| 34 |
+ .buttonStyle(.plain) |
|
| 35 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 36 |
+ } |
|
| 37 |
+ } |
|
| 38 |
+ } |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ private var libraryRow: some View {
|
|
| 42 |
+ HStack(spacing: 12) {
|
|
| 43 |
+ Image(systemName: mode == .device ? "square.grid.2x2" : "bolt.circle") |
|
| 44 |
+ .font(.system(size: 16, weight: .semibold)) |
|
| 45 |
+ .foregroundColor(tint) |
|
| 46 |
+ .frame(width: 36, height: 36) |
|
| 47 |
+ .background(tint.opacity(0.14)) |
|
| 48 |
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) |
|
| 49 |
+ |
|
| 50 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 51 |
+ Text("All \(title)")
|
|
| 52 |
+ .font(.subheadline.weight(.semibold)) |
|
| 53 |
+ .foregroundColor(.primary) |
|
| 54 |
+ let count = chargedDevices.count |
|
| 55 |
+ Text("\(count) \(count == 1 ? mode.singularTitle.lowercased() : mode.title.lowercased())")
|
|
| 56 |
+ .font(.caption) |
|
| 57 |
+ .foregroundColor(.secondary) |
|
| 58 |
+ } |
|
| 59 |
+ |
|
| 60 |
+ Spacer() |
|
| 61 |
+ } |
|
| 62 |
+ .padding(.vertical, 4) |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ private var headerView: some View {
|
|
| 66 |
+ HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
| 67 |
+ Button(action: onToggle) {
|
|
| 68 |
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
|
|
| 69 |
+ Image(systemName: "chevron.right") |
|
| 70 |
+ .font(.caption.weight(.semibold)) |
|
| 71 |
+ .foregroundColor(.secondary) |
|
| 72 |
+ .rotationEffect(.degrees(isExpanded ? 90 : 0)) |
|
| 73 |
+ .animation(.easeInOut(duration: 0.22), value: isExpanded) |
|
| 74 |
+ Text(title) |
|
| 75 |
+ .font(.headline) |
|
| 76 |
+ } |
|
| 77 |
+ } |
|
| 78 |
+ .buttonStyle(.plain) |
|
| 79 |
+ Spacer() |
|
| 80 |
+ Button(action: onAdd) {
|
|
| 81 |
+ Image(systemName: "plus.circle.fill") |
|
| 82 |
+ .font(.body.weight(.semibold)) |
|
| 83 |
+ .foregroundColor(tint) |
|
| 84 |
+ } |
|
| 85 |
+ .buttonStyle(.plain) |
|
| 86 |
+ Text("\(chargedDevices.count)")
|
|
| 87 |
+ .font(.caption.weight(.bold)) |
|
| 88 |
+ .padding(.horizontal, 10) |
|
| 89 |
+ .padding(.vertical, 6) |
|
| 90 |
+ .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
|
| 91 |
+ } |
|
| 92 |
+ } |
|
| 93 |
+} |
|
@@ -0,0 +1,422 @@ |
||
| 1 |
+// |
|
| 2 |
+// TimeSeriesChart.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import CoreGraphics |
|
| 7 |
+import Foundation |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+enum TimeSeriesChartPointKind: Hashable {
|
|
| 11 |
+ case sample |
|
| 12 |
+ case discontinuity |
|
| 13 |
+} |
|
| 14 |
+ |
|
| 15 |
+protocol TimeSeriesChartPointRepresentable {
|
|
| 16 |
+ var chartPointID: Int { get }
|
|
| 17 |
+ var chartTimestamp: Date { get }
|
|
| 18 |
+ var chartValue: Double { get }
|
|
| 19 |
+ var chartPointKind: TimeSeriesChartPointKind { get }
|
|
| 20 |
+} |
|
| 21 |
+ |
|
| 22 |
+extension TimeSeriesChartPointRepresentable {
|
|
| 23 |
+ var isChartSample: Bool {
|
|
| 24 |
+ chartPointKind == .sample |
|
| 25 |
+ } |
|
| 26 |
+ |
|
| 27 |
+ var isChartDiscontinuity: Bool {
|
|
| 28 |
+ chartPointKind == .discontinuity |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ func chartCGPoint() -> CGPoint {
|
|
| 32 |
+ CGPoint(x: chartTimestamp.timeIntervalSince1970, y: chartValue) |
|
| 33 |
+ } |
|
| 34 |
+} |
|
| 35 |
+ |
|
| 36 |
+struct TimeSeriesChartStyle {
|
|
| 37 |
+ var drawsArea: Bool |
|
| 38 |
+ var strokeColor: Color |
|
| 39 |
+ var areaFillColor: Color? |
|
| 40 |
+ var lineWidth: CGFloat |
|
| 41 |
+ |
|
| 42 |
+ static func line( |
|
| 43 |
+ strokeColor: Color = .black, |
|
| 44 |
+ lineWidth: CGFloat = 2 |
|
| 45 |
+ ) -> TimeSeriesChartStyle {
|
|
| 46 |
+ TimeSeriesChartStyle( |
|
| 47 |
+ drawsArea: false, |
|
| 48 |
+ strokeColor: strokeColor, |
|
| 49 |
+ areaFillColor: nil, |
|
| 50 |
+ lineWidth: lineWidth |
|
| 51 |
+ ) |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ static func area( |
|
| 55 |
+ strokeColor: Color = .black, |
|
| 56 |
+ areaFillColor: Color? = nil, |
|
| 57 |
+ lineWidth: CGFloat = 2 |
|
| 58 |
+ ) -> TimeSeriesChartStyle {
|
|
| 59 |
+ TimeSeriesChartStyle( |
|
| 60 |
+ drawsArea: true, |
|
| 61 |
+ strokeColor: strokeColor, |
|
| 62 |
+ areaFillColor: areaFillColor, |
|
| 63 |
+ lineWidth: lineWidth |
|
| 64 |
+ ) |
|
| 65 |
+ } |
|
| 66 |
+} |
|
| 67 |
+ |
|
| 68 |
+struct TimeSeriesChart<Point: TimeSeriesChartPointRepresentable>: View {
|
|
| 69 |
+ @Environment(\.displayScale) private var displayScale |
|
| 70 |
+ |
|
| 71 |
+ let points: [Point] |
|
| 72 |
+ let context: ChartContext |
|
| 73 |
+ let style: TimeSeriesChartStyle |
|
| 74 |
+ |
|
| 75 |
+ init( |
|
| 76 |
+ points: [Point], |
|
| 77 |
+ context: ChartContext, |
|
| 78 |
+ style: TimeSeriesChartStyle |
|
| 79 |
+ ) {
|
|
| 80 |
+ self.points = points |
|
| 81 |
+ self.context = context |
|
| 82 |
+ self.style = style |
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 85 |
+ init( |
|
| 86 |
+ points: [Point], |
|
| 87 |
+ context: ChartContext, |
|
| 88 |
+ areaChart: Bool = false, |
|
| 89 |
+ strokeColor: Color = .black, |
|
| 90 |
+ areaFillColor: Color? = nil |
|
| 91 |
+ ) {
|
|
| 92 |
+ self.points = points |
|
| 93 |
+ self.context = context |
|
| 94 |
+ self.style = areaChart |
|
| 95 |
+ ? .area(strokeColor: strokeColor, areaFillColor: areaFillColor) |
|
| 96 |
+ : .line(strokeColor: strokeColor) |
|
| 97 |
+ } |
|
| 98 |
+ |
|
| 99 |
+ var body: some View {
|
|
| 100 |
+ GeometryReader { geometry in
|
|
| 101 |
+ if style.drawsArea {
|
|
| 102 |
+ let fillColor = style.areaFillColor ?? style.strokeColor.opacity(0.2) |
|
| 103 |
+ path(geometry: geometry) |
|
| 104 |
+ .fill( |
|
| 105 |
+ LinearGradient( |
|
| 106 |
+ gradient: .init( |
|
| 107 |
+ colors: [ |
|
| 108 |
+ fillColor.opacity(0.72), |
|
| 109 |
+ fillColor.opacity(0.18) |
|
| 110 |
+ ] |
|
| 111 |
+ ), |
|
| 112 |
+ startPoint: .init(x: 0.5, y: 0.08), |
|
| 113 |
+ endPoint: .init(x: 0.5, y: 0.92) |
|
| 114 |
+ ) |
|
| 115 |
+ ) |
|
| 116 |
+ } else {
|
|
| 117 |
+ path(geometry: geometry) |
|
| 118 |
+ .stroke( |
|
| 119 |
+ style.strokeColor, |
|
| 120 |
+ style: StrokeStyle( |
|
| 121 |
+ lineWidth: style.lineWidth, |
|
| 122 |
+ lineCap: .round, |
|
| 123 |
+ lineJoin: .round |
|
| 124 |
+ ) |
|
| 125 |
+ ) |
|
| 126 |
+ } |
|
| 127 |
+ } |
|
| 128 |
+ } |
|
| 129 |
+ |
|
| 130 |
+ private func path(geometry: GeometryProxy) -> Path {
|
|
| 131 |
+ let displayedPoints = scaledPoints(for: geometry.size.width) |
|
| 132 |
+ let baselineY = context.placeInRect( |
|
| 133 |
+ point: CGPoint(x: context.origin.x, y: context.origin.y) |
|
| 134 |
+ ).y * geometry.size.height |
|
| 135 |
+ |
|
| 136 |
+ return Path { path in
|
|
| 137 |
+ var firstRenderedPoint: CGPoint? |
|
| 138 |
+ var lastRenderedPoint: CGPoint? |
|
| 139 |
+ var needsMove = true |
|
| 140 |
+ |
|
| 141 |
+ for point in displayedPoints {
|
|
| 142 |
+ if point.isDiscontinuity {
|
|
| 143 |
+ closeAreaSegment( |
|
| 144 |
+ in: &path, |
|
| 145 |
+ firstPoint: firstRenderedPoint, |
|
| 146 |
+ lastPoint: lastRenderedPoint, |
|
| 147 |
+ baselineY: baselineY |
|
| 148 |
+ ) |
|
| 149 |
+ firstRenderedPoint = nil |
|
| 150 |
+ lastRenderedPoint = nil |
|
| 151 |
+ needsMove = true |
|
| 152 |
+ continue |
|
| 153 |
+ } |
|
| 154 |
+ |
|
| 155 |
+ let item = context.placeInRect(point: point.cgPoint) |
|
| 156 |
+ let renderedPoint = CGPoint( |
|
| 157 |
+ x: item.x * geometry.size.width, |
|
| 158 |
+ y: item.y * geometry.size.height |
|
| 159 |
+ ) |
|
| 160 |
+ |
|
| 161 |
+ if needsMove {
|
|
| 162 |
+ path.move(to: renderedPoint) |
|
| 163 |
+ firstRenderedPoint = renderedPoint |
|
| 164 |
+ needsMove = false |
|
| 165 |
+ } else {
|
|
| 166 |
+ path.addLine(to: renderedPoint) |
|
| 167 |
+ } |
|
| 168 |
+ |
|
| 169 |
+ lastRenderedPoint = renderedPoint |
|
| 170 |
+ } |
|
| 171 |
+ |
|
| 172 |
+ closeAreaSegment( |
|
| 173 |
+ in: &path, |
|
| 174 |
+ firstPoint: firstRenderedPoint, |
|
| 175 |
+ lastPoint: lastRenderedPoint, |
|
| 176 |
+ baselineY: baselineY |
|
| 177 |
+ ) |
|
| 178 |
+ } |
|
| 179 |
+ } |
|
| 180 |
+ |
|
| 181 |
+ private func closeAreaSegment( |
|
| 182 |
+ in path: inout Path, |
|
| 183 |
+ firstPoint: CGPoint?, |
|
| 184 |
+ lastPoint: CGPoint?, |
|
| 185 |
+ baselineY: CGFloat |
|
| 186 |
+ ) {
|
|
| 187 |
+ guard style.drawsArea, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
|
|
| 188 |
+ |
|
| 189 |
+ path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY)) |
|
| 190 |
+ path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY)) |
|
| 191 |
+ path.closeSubpath() |
|
| 192 |
+ } |
|
| 193 |
+ |
|
| 194 |
+ private func scaledPoints(for width: CGFloat) -> [TimeSeriesChartRenderPoint] {
|
|
| 195 |
+ let renderPoints = points.map(TimeSeriesChartRenderPoint.init) |
|
| 196 |
+ let sampleCount = renderPoints.reduce(into: 0) { partialResult, point in
|
|
| 197 |
+ if point.isSample {
|
|
| 198 |
+ partialResult += 1 |
|
| 199 |
+ } |
|
| 200 |
+ } |
|
| 201 |
+ |
|
| 202 |
+ let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1) |
|
| 203 |
+ let maximumSamplesToRender = max(displayColumns * (style.drawsArea ? 3 : 4), 240) |
|
| 204 |
+ |
|
| 205 |
+ guard sampleCount > maximumSamplesToRender, context.isValid else {
|
|
| 206 |
+ return renderPoints |
|
| 207 |
+ } |
|
| 208 |
+ |
|
| 209 |
+ var scaledPoints: [TimeSeriesChartRenderPoint] = [] |
|
| 210 |
+ var currentSegment: [TimeSeriesChartRenderPoint] = [] |
|
| 211 |
+ |
|
| 212 |
+ for point in renderPoints {
|
|
| 213 |
+ if point.isDiscontinuity {
|
|
| 214 |
+ appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns) |
|
| 215 |
+ currentSegment.removeAll(keepingCapacity: true) |
|
| 216 |
+ |
|
| 217 |
+ if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
|
|
| 218 |
+ appendScaledPoint(point, to: &scaledPoints) |
|
| 219 |
+ } |
|
| 220 |
+ } else {
|
|
| 221 |
+ currentSegment.append(point) |
|
| 222 |
+ } |
|
| 223 |
+ } |
|
| 224 |
+ |
|
| 225 |
+ appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns) |
|
| 226 |
+ return scaledPoints.isEmpty ? renderPoints : scaledPoints |
|
| 227 |
+ } |
|
| 228 |
+ |
|
| 229 |
+ private func appendScaledSegment( |
|
| 230 |
+ _ segment: [TimeSeriesChartRenderPoint], |
|
| 231 |
+ to scaledPoints: inout [TimeSeriesChartRenderPoint], |
|
| 232 |
+ displayColumns: Int |
|
| 233 |
+ ) {
|
|
| 234 |
+ guard !segment.isEmpty else { return }
|
|
| 235 |
+ |
|
| 236 |
+ if segment.count <= max(displayColumns * 2, 120) {
|
|
| 237 |
+ for point in segment {
|
|
| 238 |
+ appendScaledPoint(point, to: &scaledPoints) |
|
| 239 |
+ } |
|
| 240 |
+ return |
|
| 241 |
+ } |
|
| 242 |
+ |
|
| 243 |
+ var bucket: [TimeSeriesChartRenderPoint] = [] |
|
| 244 |
+ var currentColumn: Int? |
|
| 245 |
+ |
|
| 246 |
+ for point in segment {
|
|
| 247 |
+ let column = displayColumn(for: point, totalColumns: displayColumns) |
|
| 248 |
+ |
|
| 249 |
+ if let currentColumn, currentColumn != column {
|
|
| 250 |
+ appendBucket(bucket, to: &scaledPoints) |
|
| 251 |
+ bucket.removeAll(keepingCapacity: true) |
|
| 252 |
+ } |
|
| 253 |
+ |
|
| 254 |
+ bucket.append(point) |
|
| 255 |
+ currentColumn = column |
|
| 256 |
+ } |
|
| 257 |
+ |
|
| 258 |
+ appendBucket(bucket, to: &scaledPoints) |
|
| 259 |
+ } |
|
| 260 |
+ |
|
| 261 |
+ private func appendBucket( |
|
| 262 |
+ _ bucket: [TimeSeriesChartRenderPoint], |
|
| 263 |
+ to scaledPoints: inout [TimeSeriesChartRenderPoint] |
|
| 264 |
+ ) {
|
|
| 265 |
+ guard !bucket.isEmpty else { return }
|
|
| 266 |
+ |
|
| 267 |
+ if bucket.count <= 2 {
|
|
| 268 |
+ for point in bucket {
|
|
| 269 |
+ appendScaledPoint(point, to: &scaledPoints) |
|
| 270 |
+ } |
|
| 271 |
+ return |
|
| 272 |
+ } |
|
| 273 |
+ |
|
| 274 |
+ let firstPoint = bucket.first! |
|
| 275 |
+ let lastPoint = bucket.last! |
|
| 276 |
+ let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
|
|
| 277 |
+ let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
|
|
| 278 |
+ |
|
| 279 |
+ let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint] |
|
| 280 |
+ .sorted { lhs, rhs in
|
|
| 281 |
+ if lhs.timestamp == rhs.timestamp {
|
|
| 282 |
+ return lhs.sourceID < rhs.sourceID |
|
| 283 |
+ } |
|
| 284 |
+ return lhs.timestamp < rhs.timestamp |
|
| 285 |
+ } |
|
| 286 |
+ |
|
| 287 |
+ var emittedPointIDs: Set<Int> = [] |
|
| 288 |
+ for point in orderedPoints where emittedPointIDs.insert(point.sourceID).inserted {
|
|
| 289 |
+ appendScaledPoint(point, to: &scaledPoints) |
|
| 290 |
+ } |
|
| 291 |
+ } |
|
| 292 |
+ |
|
| 293 |
+ private func appendScaledPoint( |
|
| 294 |
+ _ point: TimeSeriesChartRenderPoint, |
|
| 295 |
+ to scaledPoints: inout [TimeSeriesChartRenderPoint] |
|
| 296 |
+ ) {
|
|
| 297 |
+ guard !(scaledPoints.last?.timestamp == point.timestamp && |
|
| 298 |
+ scaledPoints.last?.value == point.value && |
|
| 299 |
+ scaledPoints.last?.kind == point.kind) else {
|
|
| 300 |
+ return |
|
| 301 |
+ } |
|
| 302 |
+ |
|
| 303 |
+ scaledPoints.append(point) |
|
| 304 |
+ } |
|
| 305 |
+ |
|
| 306 |
+ private func displayColumn( |
|
| 307 |
+ for point: TimeSeriesChartRenderPoint, |
|
| 308 |
+ totalColumns: Int |
|
| 309 |
+ ) -> Int {
|
|
| 310 |
+ let totalColumns = max(totalColumns, 1) |
|
| 311 |
+ let timeSpan = max(Double(context.size.width), 1) |
|
| 312 |
+ let normalizedOffset = min( |
|
| 313 |
+ max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0), |
|
| 314 |
+ 1 |
|
| 315 |
+ ) |
|
| 316 |
+ |
|
| 317 |
+ return min( |
|
| 318 |
+ Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)), |
|
| 319 |
+ totalColumns - 1 |
|
| 320 |
+ ) |
|
| 321 |
+ } |
|
| 322 |
+} |
|
| 323 |
+ |
|
| 324 |
+struct TimeSeriesChartHorizontalGuides: View {
|
|
| 325 |
+ let context: ChartContext |
|
| 326 |
+ let labelCount: Int |
|
| 327 |
+ var strokeColor: Color = Color.secondary.opacity(0.38) |
|
| 328 |
+ var lineWidth: CGFloat = 0.85 |
|
| 329 |
+ |
|
| 330 |
+ var body: some View {
|
|
| 331 |
+ GeometryReader { geometry in
|
|
| 332 |
+ Path { path in
|
|
| 333 |
+ for labelIndex in 1...max(labelCount, 1) {
|
|
| 334 |
+ let y = context.yGuidePosition( |
|
| 335 |
+ for: labelIndex, |
|
| 336 |
+ of: labelCount, |
|
| 337 |
+ height: geometry.size.height |
|
| 338 |
+ ) |
|
| 339 |
+ path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y)) |
|
| 340 |
+ } |
|
| 341 |
+ } |
|
| 342 |
+ .stroke(strokeColor, lineWidth: lineWidth) |
|
| 343 |
+ } |
|
| 344 |
+ } |
|
| 345 |
+} |
|
| 346 |
+ |
|
| 347 |
+struct TimeSeriesChartVerticalGuides: View {
|
|
| 348 |
+ let context: ChartContext |
|
| 349 |
+ let labelCount: Int |
|
| 350 |
+ var visibleLabelRange: Range<Int>? = nil |
|
| 351 |
+ var strokeColor: Color = Color.secondary.opacity(0.34) |
|
| 352 |
+ var strokeStyle: StrokeStyle = StrokeStyle(lineWidth: 0.8, dash: [4, 4]) |
|
| 353 |
+ |
|
| 354 |
+ var body: some View {
|
|
| 355 |
+ GeometryReader { geometry in
|
|
| 356 |
+ Path { path in
|
|
| 357 |
+ for labelIndex in resolvedLabelRange {
|
|
| 358 |
+ let x = context.xGuidePosition( |
|
| 359 |
+ for: labelIndex, |
|
| 360 |
+ of: labelCount, |
|
| 361 |
+ width: geometry.size.width |
|
| 362 |
+ ) |
|
| 363 |
+ path.move(to: CGPoint(x: x, y: 0)) |
|
| 364 |
+ path.addLine(to: CGPoint(x: x, y: geometry.size.height)) |
|
| 365 |
+ } |
|
| 366 |
+ } |
|
| 367 |
+ .stroke(strokeColor, style: strokeStyle) |
|
| 368 |
+ } |
|
| 369 |
+ } |
|
| 370 |
+ |
|
| 371 |
+ private var resolvedLabelRange: Range<Int> {
|
|
| 372 |
+ visibleLabelRange ?? 2..<max(labelCount, 2) |
|
| 373 |
+ } |
|
| 374 |
+} |
|
| 375 |
+ |
|
| 376 |
+private struct TimeSeriesChartRenderPoint: Hashable {
|
|
| 377 |
+ let sourceID: Int |
|
| 378 |
+ let timestamp: Date |
|
| 379 |
+ let value: Double |
|
| 380 |
+ let kind: TimeSeriesChartPointKind |
|
| 381 |
+ |
|
| 382 |
+ init<Point: TimeSeriesChartPointRepresentable>(_ point: Point) {
|
|
| 383 |
+ self.sourceID = point.chartPointID |
|
| 384 |
+ self.timestamp = point.chartTimestamp |
|
| 385 |
+ self.value = point.chartValue |
|
| 386 |
+ self.kind = point.chartPointKind |
|
| 387 |
+ } |
|
| 388 |
+ |
|
| 389 |
+ var isSample: Bool {
|
|
| 390 |
+ kind == .sample |
|
| 391 |
+ } |
|
| 392 |
+ |
|
| 393 |
+ var isDiscontinuity: Bool {
|
|
| 394 |
+ kind == .discontinuity |
|
| 395 |
+ } |
|
| 396 |
+ |
|
| 397 |
+ var cgPoint: CGPoint {
|
|
| 398 |
+ CGPoint(x: timestamp.timeIntervalSince1970, y: value) |
|
| 399 |
+ } |
|
| 400 |
+} |
|
| 401 |
+ |
|
| 402 |
+extension ChartContext {
|
|
| 403 |
+ func yGuidePosition( |
|
| 404 |
+ for labelIndex: Int, |
|
| 405 |
+ of labelCount: Int, |
|
| 406 |
+ height: CGFloat |
|
| 407 |
+ ) -> CGFloat {
|
|
| 408 |
+ let value = yAxisLabel(for: labelIndex, of: max(labelCount, 2)) |
|
| 409 |
+ let anchorPoint = CGPoint(x: origin.x, y: CGFloat(value)) |
|
| 410 |
+ return placeInRect(point: anchorPoint).y * height |
|
| 411 |
+ } |
|
| 412 |
+ |
|
| 413 |
+ func xGuidePosition( |
|
| 414 |
+ for labelIndex: Int, |
|
| 415 |
+ of labelCount: Int, |
|
| 416 |
+ width: CGFloat |
|
| 417 |
+ ) -> CGFloat {
|
|
| 418 |
+ let value = xAxisLabel(for: labelIndex, of: max(labelCount, 2)) |
|
| 419 |
+ let anchorPoint = CGPoint(x: CGFloat(value), y: origin.y) |
|
| 420 |
+ return placeInRect(point: anchorPoint).x * width |
|
| 421 |
+ } |
|
| 422 |
+} |
|
@@ -0,0 +1,29 @@ |
||
| 1 |
+// |
|
| 2 |
+// AdaptiveTabBarPresentation.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 22/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+struct AdaptiveTabBarPresentation: Equatable {
|
|
| 11 |
+ let showsTitles: Bool |
|
| 12 |
+ let maxWidth: CGFloat |
|
| 13 |
+ |
|
| 14 |
+ static func standard(for size: CGSize) -> AdaptiveTabBarPresentation {
|
|
| 15 |
+ let compact = min(size.width, size.height) |
|
| 16 |
+ |
|
| 17 |
+ if compact < 700 {
|
|
| 18 |
+ return AdaptiveTabBarPresentation( |
|
| 19 |
+ showsTitles: false, |
|
| 20 |
+ maxWidth: 340 |
|
| 21 |
+ ) |
|
| 22 |
+ } |
|
| 23 |
+ |
|
| 24 |
+ return AdaptiveTabBarPresentation( |
|
| 25 |
+ showsTitles: true, |
|
| 26 |
+ maxWidth: min(size.width - 32, 680) |
|
| 27 |
+ ) |
|
| 28 |
+ } |
|
| 29 |
+} |
|
@@ -0,0 +1,80 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChevronView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 02/05/2020. |
|
| 6 |
+// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
+// |
|
| 8 |
+ |
|
| 9 |
+import SwiftUI |
|
| 10 |
+ |
|
| 11 |
+struct ChevronView: View {
|
|
| 12 |
+ |
|
| 13 |
+ @Binding var rotate: Bool |
|
| 14 |
+ |
|
| 15 |
+ var body: some View {
|
|
| 16 |
+ Button(action: {
|
|
| 17 |
+ self.rotate.toggle() |
|
| 18 |
+ }) {
|
|
| 19 |
+ Image(systemName: "chevron.right.circle") |
|
| 20 |
+ .imageScale(.large) |
|
| 21 |
+ .rotationEffect(.degrees(rotate ? 270 : 90)) |
|
| 22 |
+ .animation(.easeInOut, value: rotate) |
|
| 23 |
+ .padding(.vertical) |
|
| 24 |
+ } |
|
| 25 |
+ } |
|
| 26 |
+} |
|
| 27 |
+ |
|
| 28 |
+struct ContextInfoButton: View {
|
|
| 29 |
+ let title: String |
|
| 30 |
+ let message: String |
|
| 31 |
+ let popoverWidth: CGFloat |
|
| 32 |
+ |
|
| 33 |
+ @State private var showsPopover = false |
|
| 34 |
+ |
|
| 35 |
+ init( |
|
| 36 |
+ title: String, |
|
| 37 |
+ message: String, |
|
| 38 |
+ popoverWidth: CGFloat = 280 |
|
| 39 |
+ ) {
|
|
| 40 |
+ self.title = title |
|
| 41 |
+ self.message = message |
|
| 42 |
+ self.popoverWidth = popoverWidth |
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ var body: some View {
|
|
| 46 |
+ Button {
|
|
| 47 |
+ showsPopover.toggle() |
|
| 48 |
+ } label: {
|
|
| 49 |
+ Image(systemName: "info.circle") |
|
| 50 |
+ .font(.body.weight(.semibold)) |
|
| 51 |
+ .foregroundColor(.secondary) |
|
| 52 |
+ } |
|
| 53 |
+ .buttonStyle(.plain) |
|
| 54 |
+ .accessibilityLabel("\(title) info")
|
|
| 55 |
+ .popover(isPresented: $showsPopover, arrowEdge: .top) {
|
|
| 56 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 57 |
+ Text(title) |
|
| 58 |
+ .font(.headline) |
|
| 59 |
+ Text(message) |
|
| 60 |
+ .font(.body) |
|
| 61 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 62 |
+ } |
|
| 63 |
+ .padding(16) |
|
| 64 |
+ .frame(width: popoverWidth, alignment: .leading) |
|
| 65 |
+ } |
|
| 66 |
+ } |
|
| 67 |
+} |
|
| 68 |
+ |
|
| 69 |
+struct ContextInfoHeader: View {
|
|
| 70 |
+ let title: String |
|
| 71 |
+ let message: String |
|
| 72 |
+ |
|
| 73 |
+ var body: some View {
|
|
| 74 |
+ HStack(spacing: 8) {
|
|
| 75 |
+ Text(title) |
|
| 76 |
+ Spacer(minLength: 0) |
|
| 77 |
+ ContextInfoButton(title: title, message: message) |
|
| 78 |
+ } |
|
| 79 |
+ } |
|
| 80 |
+} |
|
@@ -1,5 +1,5 @@ |
||
| 1 | 1 |
// |
| 2 |
-// SwiftUIView.swift |
|
| 2 |
+// RSSIView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 | 5 |
// Created by Bogdan Timofte on 14/03/2020. |
@@ -54,7 +54,7 @@ struct RSSIView: View {
|
||
| 54 | 54 |
} |
| 55 | 55 |
|
| 56 | 56 |
|
| 57 |
-struct SwiftUIView_Previews: PreviewProvider {
|
|
| 57 |
+struct RSSIView_Previews: PreviewProvider {
|
|
| 58 | 58 |
static var previews: some View {
|
| 59 | 59 |
RSSIView(RSSI: -80).frame(width: 20, height: 20, alignment: .center) |
| 60 | 60 |
} |
@@ -9,384 +9,37 @@ |
||
| 9 | 9 |
//MARK: Bluetooth Icon: https://upload.wikimedia.org/wikipedia/commons/d/da/Bluetooth.svg |
| 10 | 10 |
|
| 11 | 11 |
import SwiftUI |
| 12 |
-import Combine |
|
| 13 | 12 |
|
| 14 | 13 |
struct ContentView: View {
|
| 15 |
- private enum HelpAutoReason: String {
|
|
| 16 |
- case bluetoothPermission |
|
| 17 |
- case noDevicesDetected |
|
| 14 |
+ #if os(iOS) |
|
| 15 |
+ private static let isPhone: Bool = UIDevice.current.userInterfaceIdiom == .phone |
|
| 16 |
+ #else |
|
| 17 |
+ private static let isPhone: Bool = false |
|
| 18 |
+ #endif |
|
| 18 | 19 |
|
| 19 |
- var tint: Color {
|
|
| 20 |
- switch self {
|
|
| 21 |
- case .bluetoothPermission: |
|
| 22 |
- return .orange |
|
| 23 |
- case .noDevicesDetected: |
|
| 24 |
- return .yellow |
|
| 25 |
- } |
|
| 26 |
- } |
|
| 27 |
- |
|
| 28 |
- var symbol: String {
|
|
| 29 |
- switch self {
|
|
| 30 |
- case .bluetoothPermission: |
|
| 31 |
- return "bolt.horizontal.circle.fill" |
|
| 32 |
- case .noDevicesDetected: |
|
| 33 |
- return "magnifyingglass.circle.fill" |
|
| 34 |
- } |
|
| 35 |
- } |
|
| 36 |
- |
|
| 37 |
- var badgeTitle: String {
|
|
| 38 |
- switch self {
|
|
| 39 |
- case .bluetoothPermission: |
|
| 40 |
- return "Required" |
|
| 41 |
- case .noDevicesDetected: |
|
| 42 |
- return "Suggested" |
|
| 43 |
- } |
|
| 44 |
- } |
|
| 45 |
- } |
|
| 46 |
- |
|
| 47 |
- @EnvironmentObject private var appData: AppData |
|
| 48 |
- @State private var isHelpExpanded = false |
|
| 49 |
- @State private var dismissedAutoHelpReason: HelpAutoReason? |
|
| 50 |
- @State private var now = Date() |
|
| 51 |
- private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() |
|
| 52 |
- private let noDevicesHelpDelay: TimeInterval = 12 |
|
| 53 |
- |
|
| 54 | 20 |
var body: some View {
|
| 55 |
- NavigationView {
|
|
| 56 |
- ScrollView {
|
|
| 57 |
- VStack(alignment: .leading, spacing: 18) {
|
|
| 58 |
- headerCard |
|
| 59 |
- helpSection |
|
| 60 |
- devicesSection |
|
| 61 |
- } |
|
| 62 |
- .padding() |
|
| 63 |
- } |
|
| 64 |
- .background( |
|
| 65 |
- LinearGradient( |
|
| 66 |
- colors: [ |
|
| 67 |
- appData.bluetoothManager.managerState.color.opacity(0.18), |
|
| 68 |
- Color.clear |
|
| 69 |
- ], |
|
| 70 |
- startPoint: .topLeading, |
|
| 71 |
- endPoint: .bottomTrailing |
|
| 72 |
- ) |
|
| 73 |
- .ignoresSafeArea() |
|
| 74 |
- ) |
|
| 75 |
- .navigationBarTitle(Text("USB Meters"), displayMode: .inline)
|
|
| 76 |
- } |
|
| 77 |
- .onAppear {
|
|
| 78 |
- appData.bluetoothManager.start() |
|
| 79 |
- now = Date() |
|
| 80 |
- } |
|
| 81 |
- .onReceive(helpRefreshTimer) { currentDate in
|
|
| 82 |
- now = currentDate |
|
| 83 |
- } |
|
| 84 |
- .onChange(of: activeHelpAutoReason) { newReason in
|
|
| 85 |
- if newReason == nil {
|
|
| 86 |
- dismissedAutoHelpReason = nil |
|
| 87 |
- } |
|
| 88 |
- } |
|
| 89 |
- } |
|
| 90 |
- |
|
| 91 |
- private var headerCard: some View {
|
|
| 92 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 93 |
- Text("USB Meters")
|
|
| 94 |
- .font(.system(.title2, design: .rounded).weight(.bold)) |
|
| 95 |
- Text("Browse nearby supported meters and jump into live diagnostics, charge records, and device controls.")
|
|
| 96 |
- .font(.footnote) |
|
| 97 |
- .foregroundColor(.secondary) |
|
| 98 |
- HStack {
|
|
| 99 |
- Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
|
|
| 100 |
- .font(.footnote.weight(.semibold)) |
|
| 101 |
- .foregroundColor(appData.bluetoothManager.managerState.color) |
|
| 102 |
- Spacer() |
|
| 103 |
- Text(bluetoothStatusText) |
|
| 104 |
- .font(.caption.weight(.semibold)) |
|
| 105 |
- .foregroundColor(.secondary) |
|
| 106 |
- } |
|
| 107 |
- } |
|
| 108 |
- .padding(18) |
|
| 109 |
- .meterCard(tint: appData.bluetoothManager.managerState.color, fillOpacity: 0.22, strokeOpacity: 0.26) |
|
| 110 |
- } |
|
| 111 |
- |
|
| 112 |
- private var helpSection: some View {
|
|
| 113 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 114 |
- Button(action: toggleHelpSection) {
|
|
| 115 |
- HStack(spacing: 14) {
|
|
| 116 |
- Image(systemName: helpSectionSymbol) |
|
| 117 |
- .font(.system(size: 18, weight: .semibold)) |
|
| 118 |
- .foregroundColor(helpSectionTint) |
|
| 119 |
- .frame(width: 42, height: 42) |
|
| 120 |
- .background(Circle().fill(helpSectionTint.opacity(0.18))) |
|
| 121 |
- |
|
| 122 |
- VStack(alignment: .leading, spacing: 4) {
|
|
| 123 |
- Text("Help")
|
|
| 124 |
- .font(.headline) |
|
| 125 |
- Text(helpSectionSummary) |
|
| 126 |
- .font(.caption) |
|
| 127 |
- .foregroundColor(.secondary) |
|
| 128 |
- } |
|
| 129 |
- |
|
| 130 |
- Spacer() |
|
| 131 |
- |
|
| 132 |
- if let activeHelpAutoReason {
|
|
| 133 |
- Text(activeHelpAutoReason.badgeTitle) |
|
| 134 |
- .font(.caption2.weight(.bold)) |
|
| 135 |
- .foregroundColor(activeHelpAutoReason.tint) |
|
| 136 |
- .padding(.horizontal, 10) |
|
| 137 |
- .padding(.vertical, 6) |
|
| 138 |
- .background( |
|
| 139 |
- Capsule(style: .continuous) |
|
| 140 |
- .fill(activeHelpAutoReason.tint.opacity(0.12)) |
|
| 141 |
- ) |
|
| 142 |
- .overlay( |
|
| 143 |
- Capsule(style: .continuous) |
|
| 144 |
- .stroke(activeHelpAutoReason.tint.opacity(0.22), lineWidth: 1) |
|
| 145 |
- ) |
|
| 146 |
- } |
|
| 147 |
- |
|
| 148 |
- Image(systemName: helpIsExpanded ? "chevron.up" : "chevron.down") |
|
| 149 |
- .font(.footnote.weight(.bold)) |
|
| 150 |
- .foregroundColor(.secondary) |
|
| 151 |
- } |
|
| 152 |
- .padding(14) |
|
| 153 |
- .meterCard(tint: helpSectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) |
|
| 154 |
- } |
|
| 155 |
- .buttonStyle(.plain) |
|
| 156 |
- |
|
| 157 |
- if helpIsExpanded {
|
|
| 158 |
- if let activeHelpAutoReason {
|
|
| 159 |
- helpNoticeCard(for: activeHelpAutoReason) |
|
| 160 |
- } |
|
| 161 |
- |
|
| 162 |
- NavigationLink(destination: appData.bluetoothManager.managerState.helpView) {
|
|
| 163 |
- sidebarLinkCard( |
|
| 164 |
- title: "Bluetooth", |
|
| 165 |
- subtitle: "Permissions, adapter state, and connection tips.", |
|
| 166 |
- symbol: "bolt.horizontal.circle.fill", |
|
| 167 |
- tint: appData.bluetoothManager.managerState.color |
|
| 168 |
- ) |
|
| 169 |
- } |
|
| 170 |
- .buttonStyle(.plain) |
|
| 171 |
- |
|
| 172 |
- NavigationLink(destination: DeviceHelpView()) {
|
|
| 173 |
- sidebarLinkCard( |
|
| 174 |
- title: "Device", |
|
| 175 |
- subtitle: "Quick checks when a meter is not responding as expected.", |
|
| 176 |
- symbol: "questionmark.circle.fill", |
|
| 177 |
- tint: .orange |
|
| 178 |
- ) |
|
| 179 |
- } |
|
| 180 |
- .buttonStyle(.plain) |
|
| 181 |
- } |
|
| 182 |
- } |
|
| 183 |
- .animation(.easeInOut(duration: 0.22), value: helpIsExpanded) |
|
| 184 |
- } |
|
| 185 |
- |
|
| 186 |
- private var devicesSection: some View {
|
|
| 187 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 188 |
- HStack {
|
|
| 189 |
- Text("Discovered Devices")
|
|
| 190 |
- .font(.headline) |
|
| 191 |
- Spacer() |
|
| 192 |
- Text("\(appData.meters.count)")
|
|
| 193 |
- .font(.caption.weight(.bold)) |
|
| 194 |
- .padding(.horizontal, 10) |
|
| 195 |
- .padding(.vertical, 6) |
|
| 196 |
- .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
|
| 197 |
- } |
|
| 198 |
- |
|
| 199 |
- if appData.meters.isEmpty {
|
|
| 200 |
- Text(devicesEmptyStateText) |
|
| 201 |
- .font(.footnote) |
|
| 202 |
- .foregroundColor(.secondary) |
|
| 203 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 204 |
- .padding(18) |
|
| 205 |
- .meterCard( |
|
| 206 |
- tint: isWaitingForFirstDiscovery ? .blue : .secondary, |
|
| 207 |
- fillOpacity: 0.14, |
|
| 208 |
- strokeOpacity: 0.20 |
|
| 209 |
- ) |
|
| 210 |
- } else {
|
|
| 211 |
- ForEach(discoveredMeters, id: \.self) { meter in
|
|
| 212 |
- NavigationLink(destination: MeterView().environmentObject(meter)) {
|
|
| 213 |
- MeterRowView() |
|
| 214 |
- .environmentObject(meter) |
|
| 215 |
- } |
|
| 216 |
- .buttonStyle(.plain) |
|
| 217 |
- } |
|
| 218 |
- } |
|
| 219 |
- } |
|
| 220 |
- } |
|
| 221 |
- |
|
| 222 |
- private var discoveredMeters: [Meter] {
|
|
| 223 |
- Array(appData.meters.values).sorted { lhs, rhs in
|
|
| 224 |
- lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending |
|
| 225 |
- } |
|
| 226 |
- } |
|
| 227 |
- |
|
| 228 |
- private var bluetoothStatusText: String {
|
|
| 229 |
- switch appData.bluetoothManager.managerState {
|
|
| 230 |
- case .poweredOff: |
|
| 231 |
- return "Off" |
|
| 232 |
- case .poweredOn: |
|
| 233 |
- return "On" |
|
| 234 |
- case .resetting: |
|
| 235 |
- return "Resetting" |
|
| 236 |
- case .unauthorized: |
|
| 237 |
- return "Unauthorized" |
|
| 238 |
- case .unknown: |
|
| 239 |
- return "Unknown" |
|
| 240 |
- case .unsupported: |
|
| 241 |
- return "Unsupported" |
|
| 242 |
- @unknown default: |
|
| 243 |
- return "Other" |
|
| 244 |
- } |
|
| 245 |
- } |
|
| 246 |
- |
|
| 247 |
- private var helpIsExpanded: Bool {
|
|
| 248 |
- isHelpExpanded || shouldAutoExpandHelp |
|
| 249 |
- } |
|
| 250 |
- |
|
| 251 |
- private var shouldAutoExpandHelp: Bool {
|
|
| 252 |
- guard let activeHelpAutoReason else {
|
|
| 253 |
- return false |
|
| 21 |
+ if Self.isPhone {
|
|
| 22 |
+ navigationViewPhone |
|
| 23 |
+ } else {
|
|
| 24 |
+ navigationViewPad |
|
| 254 | 25 |
} |
| 255 |
- return dismissedAutoHelpReason != activeHelpAutoReason |
|
| 256 | 26 |
} |
| 257 | 27 |
|
| 258 |
- private var activeHelpAutoReason: HelpAutoReason? {
|
|
| 259 |
- if appData.bluetoothManager.managerState == .unauthorized {
|
|
| 260 |
- return .bluetoothPermission |
|
| 261 |
- } |
|
| 262 |
- if hasWaitedLongEnoughForDevices {
|
|
| 263 |
- return .noDevicesDetected |
|
| 264 |
- } |
|
| 265 |
- return nil |
|
| 266 |
- } |
|
| 267 |
- |
|
| 268 |
- private var hasWaitedLongEnoughForDevices: Bool {
|
|
| 269 |
- guard appData.bluetoothManager.managerState == .poweredOn else {
|
|
| 270 |
- return false |
|
| 271 |
- } |
|
| 272 |
- guard appData.meters.isEmpty else {
|
|
| 273 |
- return false |
|
| 274 |
- } |
|
| 275 |
- guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
|
|
| 276 |
- return false |
|
| 277 |
- } |
|
| 278 |
- return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay |
|
| 279 |
- } |
|
| 280 |
- |
|
| 281 |
- private var isWaitingForFirstDiscovery: Bool {
|
|
| 282 |
- guard appData.bluetoothManager.managerState == .poweredOn else {
|
|
| 283 |
- return false |
|
| 284 |
- } |
|
| 285 |
- guard appData.meters.isEmpty else {
|
|
| 286 |
- return false |
|
| 287 |
- } |
|
| 288 |
- guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
|
|
| 289 |
- return false |
|
| 290 |
- } |
|
| 291 |
- return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay |
|
| 292 |
- } |
|
| 293 |
- |
|
| 294 |
- private var devicesEmptyStateText: String {
|
|
| 295 |
- if isWaitingForFirstDiscovery {
|
|
| 296 |
- return "Scanning for nearby supported meters..." |
|
| 297 |
- } |
|
| 298 |
- return "No supported meters are visible right now." |
|
| 299 |
- } |
|
| 300 |
- |
|
| 301 |
- private var helpSectionTint: Color {
|
|
| 302 |
- activeHelpAutoReason?.tint ?? .secondary |
|
| 303 |
- } |
|
| 304 |
- |
|
| 305 |
- private var helpSectionSymbol: String {
|
|
| 306 |
- activeHelpAutoReason?.symbol ?? "questionmark.circle.fill" |
|
| 307 |
- } |
|
| 308 |
- |
|
| 309 |
- private var helpSectionSummary: String {
|
|
| 310 |
- switch activeHelpAutoReason {
|
|
| 311 |
- case .bluetoothPermission: |
|
| 312 |
- return "Bluetooth permission is needed before scanning can begin." |
|
| 313 |
- case .noDevicesDetected: |
|
| 314 |
- return "No supported devices were found after \(Int(noDevicesHelpDelay)) seconds." |
|
| 315 |
- case nil: |
|
| 316 |
- return "Connection tips and quick checks when discovery needs help." |
|
| 317 |
- } |
|
| 318 |
- } |
|
| 319 |
- |
|
| 320 |
- private func toggleHelpSection() {
|
|
| 321 |
- withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 322 |
- if shouldAutoExpandHelp {
|
|
| 323 |
- dismissedAutoHelpReason = activeHelpAutoReason |
|
| 324 |
- isHelpExpanded = false |
|
| 325 |
- } else {
|
|
| 326 |
- isHelpExpanded.toggle() |
|
| 327 |
- } |
|
| 328 |
- } |
|
| 329 |
- } |
|
| 330 |
- |
|
| 331 |
- private func helpNoticeCard(for reason: HelpAutoReason) -> some View {
|
|
| 332 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 333 |
- Text(helpNoticeTitle(for: reason)) |
|
| 334 |
- .font(.subheadline.weight(.semibold)) |
|
| 335 |
- Text(helpNoticeDetail(for: reason)) |
|
| 336 |
- .font(.caption) |
|
| 337 |
- .foregroundColor(.secondary) |
|
| 338 |
- } |
|
| 339 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 340 |
- .padding(14) |
|
| 341 |
- .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) |
|
| 342 |
- } |
|
| 343 |
- |
|
| 344 |
- private func helpNoticeTitle(for reason: HelpAutoReason) -> String {
|
|
| 345 |
- switch reason {
|
|
| 346 |
- case .bluetoothPermission: |
|
| 347 |
- return "Bluetooth access needs attention" |
|
| 348 |
- case .noDevicesDetected: |
|
| 349 |
- return "No supported meters found yet" |
|
| 350 |
- } |
|
| 351 |
- } |
|
| 352 |
- |
|
| 353 |
- private func helpNoticeDetail(for reason: HelpAutoReason) -> String {
|
|
| 354 |
- switch reason {
|
|
| 355 |
- case .bluetoothPermission: |
|
| 356 |
- return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked." |
|
| 357 |
- case .noDevicesDetected: |
|
| 358 |
- return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone." |
|
| 28 |
+ @ViewBuilder |
|
| 29 |
+ private var navigationViewPhone: some View {
|
|
| 30 |
+ NavigationView {
|
|
| 31 |
+ SidebarView() |
|
| 32 |
+ .navigationBarTitle(Text("USB Meters"), displayMode: .inline)
|
|
| 359 | 33 |
} |
| 34 |
+ .navigationViewStyle(.stack) |
|
| 360 | 35 |
} |
| 361 | 36 |
|
| 362 |
- private func sidebarLinkCard( |
|
| 363 |
- title: String, |
|
| 364 |
- subtitle: String, |
|
| 365 |
- symbol: String, |
|
| 366 |
- tint: Color |
|
| 367 |
- ) -> some View {
|
|
| 368 |
- HStack(spacing: 14) {
|
|
| 369 |
- Image(systemName: symbol) |
|
| 370 |
- .font(.system(size: 18, weight: .semibold)) |
|
| 371 |
- .foregroundColor(tint) |
|
| 372 |
- .frame(width: 42, height: 42) |
|
| 373 |
- .background(Circle().fill(tint.opacity(0.18))) |
|
| 374 |
- |
|
| 375 |
- VStack(alignment: .leading, spacing: 4) {
|
|
| 376 |
- Text(title) |
|
| 377 |
- .font(.headline) |
|
| 378 |
- Text(subtitle) |
|
| 379 |
- .font(.caption) |
|
| 380 |
- .foregroundColor(.secondary) |
|
| 381 |
- } |
|
| 382 |
- |
|
| 383 |
- Spacer() |
|
| 384 |
- |
|
| 385 |
- Image(systemName: "chevron.right") |
|
| 386 |
- .font(.footnote.weight(.bold)) |
|
| 387 |
- .foregroundColor(.secondary) |
|
| 37 |
+ @ViewBuilder |
|
| 38 |
+ private var navigationViewPad: some View {
|
|
| 39 |
+ NavigationView {
|
|
| 40 |
+ SidebarView() |
|
| 41 |
+ .navigationBarTitle(Text("USB Meters"), displayMode: .inline)
|
|
| 388 | 42 |
} |
| 389 |
- .padding(14) |
|
| 390 |
- .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) |
|
| 43 |
+ .navigationViewStyle(.columns) |
|
| 391 | 44 |
} |
| 392 | 45 |
} |
@@ -1,26 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// SwiftUIView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Bogdan Timofte on 02/05/2020. |
|
| 6 |
-// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
-// |
|
| 8 |
- |
|
| 9 |
-import SwiftUI |
|
| 10 |
- |
|
| 11 |
-struct ChevronView: View {
|
|
| 12 |
- |
|
| 13 |
- @Binding var rotate: Bool |
|
| 14 |
- |
|
| 15 |
- var body: some View {
|
|
| 16 |
- Button(action: {
|
|
| 17 |
- self.rotate.toggle() |
|
| 18 |
- }) {
|
|
| 19 |
- Image(systemName: "chevron.right.circle") |
|
| 20 |
- .imageScale(.large) |
|
| 21 |
- .rotationEffect(.degrees(rotate ? 270 : 90)) |
|
| 22 |
- .animation(.easeInOut, value: rotate) |
|
| 23 |
- .padding(.vertical) |
|
| 24 |
- } |
|
| 25 |
- } |
|
| 26 |
-} |
|
@@ -0,0 +1,2795 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeasurementChartView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 06/05/2020. |
|
| 6 |
+// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
+// |
|
| 8 |
+ |
|
| 9 |
+import SwiftUI |
|
| 10 |
+ |
|
| 11 |
+private enum PresentTrackingMode: CaseIterable, Hashable {
|
|
| 12 |
+ case keepDuration |
|
| 13 |
+ case keepStartTimestamp |
|
| 14 |
+} |
|
| 15 |
+ |
|
| 16 |
+enum MeasurementChartSizing {
|
|
| 17 |
+ case provided(size: CGSize, compact: Bool) |
|
| 18 |
+ case embedded |
|
| 19 |
+} |
|
| 20 |
+ |
|
| 21 |
+enum MeasurementChartSelectorActionTone {
|
|
| 22 |
+ case reversible |
|
| 23 |
+ case destructive |
|
| 24 |
+ case destructiveProminent |
|
| 25 |
+} |
|
| 26 |
+ |
|
| 27 |
+struct MeasurementChartSelectionAction {
|
|
| 28 |
+ let title: String |
|
| 29 |
+ let shortTitle: String? |
|
| 30 |
+ let systemName: String |
|
| 31 |
+ let tone: MeasurementChartSelectorActionTone |
|
| 32 |
+ let handler: (ClosedRange<Date>) -> Void |
|
| 33 |
+ |
|
| 34 |
+ init( |
|
| 35 |
+ title: String, |
|
| 36 |
+ shortTitle: String? = nil, |
|
| 37 |
+ systemName: String, |
|
| 38 |
+ tone: MeasurementChartSelectorActionTone, |
|
| 39 |
+ handler: @escaping (ClosedRange<Date>) -> Void |
|
| 40 |
+ ) {
|
|
| 41 |
+ self.title = title |
|
| 42 |
+ self.shortTitle = shortTitle |
|
| 43 |
+ self.systemName = systemName |
|
| 44 |
+ self.tone = tone |
|
| 45 |
+ self.handler = handler |
|
| 46 |
+ } |
|
| 47 |
+} |
|
| 48 |
+ |
|
| 49 |
+struct MeasurementChartResetAction {
|
|
| 50 |
+ let title: String |
|
| 51 |
+ let shortTitle: String? |
|
| 52 |
+ let systemName: String |
|
| 53 |
+ let tone: MeasurementChartSelectorActionTone |
|
| 54 |
+ let confirmationTitle: String |
|
| 55 |
+ let confirmationButtonTitle: String |
|
| 56 |
+ let handler: () -> Void |
|
| 57 |
+ |
|
| 58 |
+ init( |
|
| 59 |
+ title: String, |
|
| 60 |
+ shortTitle: String? = nil, |
|
| 61 |
+ systemName: String, |
|
| 62 |
+ tone: MeasurementChartSelectorActionTone, |
|
| 63 |
+ confirmationTitle: String, |
|
| 64 |
+ confirmationButtonTitle: String, |
|
| 65 |
+ handler: @escaping () -> Void |
|
| 66 |
+ ) {
|
|
| 67 |
+ self.title = title |
|
| 68 |
+ self.shortTitle = shortTitle |
|
| 69 |
+ self.systemName = systemName |
|
| 70 |
+ self.tone = tone |
|
| 71 |
+ self.confirmationTitle = confirmationTitle |
|
| 72 |
+ self.confirmationButtonTitle = confirmationButtonTitle |
|
| 73 |
+ self.handler = handler |
|
| 74 |
+ } |
|
| 75 |
+} |
|
| 76 |
+ |
|
| 77 |
+struct MeasurementChartRangeSelectorConfiguration {
|
|
| 78 |
+ let keepAction: MeasurementChartSelectionAction |
|
| 79 |
+ let removeAction: MeasurementChartSelectionAction? |
|
| 80 |
+ let resetAction: MeasurementChartResetAction |
|
| 81 |
+} |
|
| 82 |
+ |
|
| 83 |
+struct MeasurementChartView: View {
|
|
| 84 |
+ private enum SmoothingLevel: CaseIterable, Hashable {
|
|
| 85 |
+ case off |
|
| 86 |
+ case light |
|
| 87 |
+ case medium |
|
| 88 |
+ case strong |
|
| 89 |
+ |
|
| 90 |
+ var label: String {
|
|
| 91 |
+ switch self {
|
|
| 92 |
+ case .off: return "Off" |
|
| 93 |
+ case .light: return "Light" |
|
| 94 |
+ case .medium: return "Medium" |
|
| 95 |
+ case .strong: return "Strong" |
|
| 96 |
+ } |
|
| 97 |
+ } |
|
| 98 |
+ |
|
| 99 |
+ var shortLabel: String {
|
|
| 100 |
+ switch self {
|
|
| 101 |
+ case .off: return "Off" |
|
| 102 |
+ case .light: return "Low" |
|
| 103 |
+ case .medium: return "Med" |
|
| 104 |
+ case .strong: return "High" |
|
| 105 |
+ } |
|
| 106 |
+ } |
|
| 107 |
+ |
|
| 108 |
+ var movingAverageWindowSize: Int {
|
|
| 109 |
+ switch self {
|
|
| 110 |
+ case .off: return 1 |
|
| 111 |
+ case .light: return 5 |
|
| 112 |
+ case .medium: return 11 |
|
| 113 |
+ case .strong: return 21 |
|
| 114 |
+ } |
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 117 |
+ |
|
| 118 |
+ private enum SeriesKind {
|
|
| 119 |
+ case power |
|
| 120 |
+ case energy |
|
| 121 |
+ case voltage |
|
| 122 |
+ case current |
|
| 123 |
+ case temperature |
|
| 124 |
+ |
|
| 125 |
+ var unit: String {
|
|
| 126 |
+ switch self {
|
|
| 127 |
+ case .power: return "W" |
|
| 128 |
+ case .energy: return "Wh" |
|
| 129 |
+ case .voltage: return "V" |
|
| 130 |
+ case .current: return "A" |
|
| 131 |
+ case .temperature: return "" |
|
| 132 |
+ } |
|
| 133 |
+ } |
|
| 134 |
+ |
|
| 135 |
+ var tint: Color {
|
|
| 136 |
+ switch self {
|
|
| 137 |
+ case .power: return .red |
|
| 138 |
+ case .energy: return .teal |
|
| 139 |
+ case .voltage: return .green |
|
| 140 |
+ case .current: return .blue |
|
| 141 |
+ case .temperature: return .orange |
|
| 142 |
+ } |
|
| 143 |
+ } |
|
| 144 |
+ } |
|
| 145 |
+ |
|
| 146 |
+ private struct SeriesData {
|
|
| 147 |
+ let kind: SeriesKind |
|
| 148 |
+ let points: [Measurements.Measurement.Point] |
|
| 149 |
+ let samplePoints: [Measurements.Measurement.Point] |
|
| 150 |
+ let context: ChartContext |
|
| 151 |
+ let autoLowerBound: Double |
|
| 152 |
+ let autoUpperBound: Double |
|
| 153 |
+ let maximumSampleValue: Double? |
|
| 154 |
+ } |
|
| 155 |
+ |
|
| 156 |
+ private let minimumTimeSpan: TimeInterval = 1 |
|
| 157 |
+ private let minimumVoltageSpan = 0.5 |
|
| 158 |
+ private let minimumCurrentSpan = 0.5 |
|
| 159 |
+ private let minimumPowerSpan = 0.5 |
|
| 160 |
+ private let minimumEnergySpan = 0.1 |
|
| 161 |
+ private let minimumTemperatureSpan = 1.0 |
|
| 162 |
+ private let defaultEmptyChartTimeSpan: TimeInterval = 60 |
|
| 163 |
+ private let selectorTint: Color = .blue |
|
| 164 |
+ |
|
| 165 |
+ let sizing: MeasurementChartSizing |
|
| 166 |
+ let showsRangeSelector: Bool |
|
| 167 |
+ let rebasesEnergyToVisibleRangeStart: Bool |
|
| 168 |
+ let extendsTimelineToPresent: Bool |
|
| 169 |
+ let showsTemperatureSeries: Bool |
|
| 170 |
+ let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? |
|
| 171 |
+ |
|
| 172 |
+ @EnvironmentObject private var measurements: Measurements |
|
| 173 |
+ @Environment(\.horizontalSizeClass) private var horizontalSizeClass |
|
| 174 |
+ @Environment(\.verticalSizeClass) private var verticalSizeClass |
|
| 175 |
+ var timeRange: ClosedRange<Date>? = nil |
|
| 176 |
+ let timeRangeLowerBound: Date? |
|
| 177 |
+ let timeRangeUpperBound: Date? |
|
| 178 |
+ |
|
| 179 |
+ @State private var embeddedWidth: CGFloat = 760 |
|
| 180 |
+ |
|
| 181 |
+ private var compactLayout: Bool {
|
|
| 182 |
+ switch sizing {
|
|
| 183 |
+ case .provided(_, let compact): return compact |
|
| 184 |
+ case .embedded: return embeddedWidth < 760 |
|
| 185 |
+ } |
|
| 186 |
+ } |
|
| 187 |
+ |
|
| 188 |
+ private var availableSize: CGSize {
|
|
| 189 |
+ switch sizing {
|
|
| 190 |
+ case .provided(let size, _): return size |
|
| 191 |
+ case .embedded: |
|
| 192 |
+ let h = compactLayout ? 290 : 350 |
|
| 193 |
+ return CGSize(width: embeddedWidth, height: CGFloat(h)) |
|
| 194 |
+ } |
|
| 195 |
+ } |
|
| 196 |
+ |
|
| 197 |
+ @State var displayVoltage: Bool = false |
|
| 198 |
+ @State var displayCurrent: Bool = false |
|
| 199 |
+ @State var displayPower: Bool = true |
|
| 200 |
+ @State var displayEnergy: Bool = false |
|
| 201 |
+ @State var displayTemperature: Bool = false |
|
| 202 |
+ @State private var smoothingLevel: SmoothingLevel = .off |
|
| 203 |
+ @State private var chartNow: Date = Date() |
|
| 204 |
+ @State private var selectedVisibleTimeRange: ClosedRange<Date>? |
|
| 205 |
+ @State private var isPinnedToPresent: Bool = false |
|
| 206 |
+ @State private var presentTrackingMode: PresentTrackingMode = .keepStartTimestamp |
|
| 207 |
+ @State private var pinOrigin: Bool = false |
|
| 208 |
+ @State private var useSharedOrigin: Bool = false |
|
| 209 |
+ @State private var sharedAxisOrigin: Double = 0 |
|
| 210 |
+ @State private var sharedAxisUpperBound: Double = 1 |
|
| 211 |
+ @State private var powerAxisOrigin: Double = 0 |
|
| 212 |
+ @State private var energyAxisOrigin: Double = 0 |
|
| 213 |
+ @State private var voltageAxisOrigin: Double = 0 |
|
| 214 |
+ @State private var currentAxisOrigin: Double = 0 |
|
| 215 |
+ @State private var temperatureAxisOrigin: Double = 0 |
|
| 216 |
+ let xLabels: Int = 4 |
|
| 217 |
+ let yLabels: Int = 4 |
|
| 218 |
+ |
|
| 219 |
+ init( |
|
| 220 |
+ sizing: MeasurementChartSizing = .embedded, |
|
| 221 |
+ timeRange: ClosedRange<Date>? = nil, |
|
| 222 |
+ timeRangeLowerBound: Date? = nil, |
|
| 223 |
+ timeRangeUpperBound: Date? = nil, |
|
| 224 |
+ showsRangeSelector: Bool = true, |
|
| 225 |
+ rebasesEnergyToVisibleRangeStart: Bool = false, |
|
| 226 |
+ extendsTimelineToPresent: Bool = true, |
|
| 227 |
+ showsTemperatureSeries: Bool = true, |
|
| 228 |
+ rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil |
|
| 229 |
+ ) {
|
|
| 230 |
+ self.sizing = sizing |
|
| 231 |
+ self.timeRange = timeRange |
|
| 232 |
+ self.timeRangeLowerBound = timeRangeLowerBound |
|
| 233 |
+ self.timeRangeUpperBound = timeRangeUpperBound |
|
| 234 |
+ self.showsRangeSelector = showsRangeSelector |
|
| 235 |
+ self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart |
|
| 236 |
+ self.extendsTimelineToPresent = extendsTimelineToPresent |
|
| 237 |
+ self.showsTemperatureSeries = showsTemperatureSeries |
|
| 238 |
+ self.rangeSelectorConfiguration = rangeSelectorConfiguration |
|
| 239 |
+ } |
|
| 240 |
+ |
|
| 241 |
+ private static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
|
|
| 242 |
+ let compact = width < 760 |
|
| 243 |
+ let plotHeight: CGFloat = compact ? 290 : 350 |
|
| 244 |
+ guard showsRangeSelector else { return plotHeight }
|
|
| 245 |
+ return plotHeight + TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact) |
|
| 246 |
+ } |
|
| 247 |
+ |
|
| 248 |
+ private var axisColumnWidth: CGFloat {
|
|
| 249 |
+ if compactLayout {
|
|
| 250 |
+ return 38 |
|
| 251 |
+ } |
|
| 252 |
+ return isLargeDisplay ? 62 : 46 |
|
| 253 |
+ } |
|
| 254 |
+ |
|
| 255 |
+ private var chartSectionSpacing: CGFloat {
|
|
| 256 |
+ compactLayout ? 6 : 8 |
|
| 257 |
+ } |
|
| 258 |
+ |
|
| 259 |
+ private var xAxisHeight: CGFloat {
|
|
| 260 |
+ if compactLayout {
|
|
| 261 |
+ return 24 |
|
| 262 |
+ } |
|
| 263 |
+ return isLargeDisplay ? 36 : 28 |
|
| 264 |
+ } |
|
| 265 |
+ |
|
| 266 |
+ private var belowXAxisControlsHeight: CGFloat {
|
|
| 267 |
+ if usesCompactLandscapeOriginControls {
|
|
| 268 |
+ return 40 |
|
| 269 |
+ } |
|
| 270 |
+ if compactLayout {
|
|
| 271 |
+ return 46 |
|
| 272 |
+ } |
|
| 273 |
+ return isLargeDisplay ? 58 : 50 |
|
| 274 |
+ } |
|
| 275 |
+ |
|
| 276 |
+ private var isPortraitLayout: Bool {
|
|
| 277 |
+ guard availableSize != .zero else { return verticalSizeClass != .compact }
|
|
| 278 |
+ return availableSize.height >= availableSize.width |
|
| 279 |
+ } |
|
| 280 |
+ |
|
| 281 |
+ private var isIPhone: Bool {
|
|
| 282 |
+ #if os(iOS) |
|
| 283 |
+ return UIDevice.current.userInterfaceIdiom == .phone |
|
| 284 |
+ #else |
|
| 285 |
+ return false |
|
| 286 |
+ #endif |
|
| 287 |
+ } |
|
| 288 |
+ |
|
| 289 |
+ private enum OriginControlsPlacement {
|
|
| 290 |
+ case aboveXAxisLegend |
|
| 291 |
+ case overXAxisLegend |
|
| 292 |
+ case belowXAxisLegend |
|
| 293 |
+ } |
|
| 294 |
+ |
|
| 295 |
+ private var originControlsPlacement: OriginControlsPlacement {
|
|
| 296 |
+ if isIPhone {
|
|
| 297 |
+ return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend |
|
| 298 |
+ } |
|
| 299 |
+ return .belowXAxisLegend |
|
| 300 |
+ } |
|
| 301 |
+ |
|
| 302 |
+ private var plotSectionHeight: CGFloat {
|
|
| 303 |
+ if availableSize == .zero {
|
|
| 304 |
+ return compactLayout ? 300 : 380 |
|
| 305 |
+ } |
|
| 306 |
+ |
|
| 307 |
+ if isPortraitLayout {
|
|
| 308 |
+ // Keep the rendered plot area (plot section minus X axis) above half of the display height. |
|
| 309 |
+ let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320) |
|
| 310 |
+ return minimumPlotHeight + xAxisHeight |
|
| 311 |
+ } |
|
| 312 |
+ |
|
| 313 |
+ if compactLayout {
|
|
| 314 |
+ return min(max(availableSize.height * 0.36, 240), 300) |
|
| 315 |
+ } |
|
| 316 |
+ |
|
| 317 |
+ return min(max(availableSize.height * 0.5, 300), 440) |
|
| 318 |
+ } |
|
| 319 |
+ |
|
| 320 |
+ private var stackedToolbarLayout: Bool {
|
|
| 321 |
+ if availableSize.width > 0 {
|
|
| 322 |
+ return availableSize.width < 640 |
|
| 323 |
+ } |
|
| 324 |
+ |
|
| 325 |
+ return horizontalSizeClass == .compact && verticalSizeClass != .compact |
|
| 326 |
+ } |
|
| 327 |
+ |
|
| 328 |
+ private var showsLabeledOriginControls: Bool {
|
|
| 329 |
+ !compactLayout && !stackedToolbarLayout |
|
| 330 |
+ } |
|
| 331 |
+ |
|
| 332 |
+ private var isLargeDisplay: Bool {
|
|
| 333 |
+ #if os(iOS) |
|
| 334 |
+ if UIDevice.current.userInterfaceIdiom == .phone {
|
|
| 335 |
+ return false |
|
| 336 |
+ } |
|
| 337 |
+ #endif |
|
| 338 |
+ |
|
| 339 |
+ if availableSize.width > 0 {
|
|
| 340 |
+ return availableSize.width >= 900 || availableSize.height >= 700 |
|
| 341 |
+ } |
|
| 342 |
+ return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular |
|
| 343 |
+ } |
|
| 344 |
+ |
|
| 345 |
+ private var chartBaseFont: Font {
|
|
| 346 |
+ if isIPhone && isPortraitLayout {
|
|
| 347 |
+ return .caption |
|
| 348 |
+ } |
|
| 349 |
+ return isLargeDisplay ? .callout : .footnote |
|
| 350 |
+ } |
|
| 351 |
+ |
|
| 352 |
+ private var usesCompactLandscapeOriginControls: Bool {
|
|
| 353 |
+ isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740 |
|
| 354 |
+ } |
|
| 355 |
+ |
|
| 356 |
+ var body: some View {
|
|
| 357 |
+ Group {
|
|
| 358 |
+ switch sizing {
|
|
| 359 |
+ case .provided: |
|
| 360 |
+ chartBody |
|
| 361 |
+ case .embedded: |
|
| 362 |
+ let chartWidth = max(embeddedWidth, 1) |
|
| 363 |
+ chartBody |
|
| 364 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 365 |
+ .frame(height: Self.embeddedContentHeight(width: chartWidth, showsRangeSelector: showsRangeSelector)) |
|
| 366 |
+ .background( |
|
| 367 |
+ GeometryReader { geometry in
|
|
| 368 |
+ Color.clear.preference(key: EmbeddedWidthKey.self, value: geometry.size.width) |
|
| 369 |
+ } |
|
| 370 |
+ ) |
|
| 371 |
+ .onPreferenceChange(EmbeddedWidthKey.self) { width in
|
|
| 372 |
+ guard width > 0, abs(width - embeddedWidth) > 0.5 else { return }
|
|
| 373 |
+ embeddedWidth = width |
|
| 374 |
+ } |
|
| 375 |
+ } |
|
| 376 |
+ } |
|
| 377 |
+ .onAppear(perform: resetHiddenTemperatureDisplay) |
|
| 378 |
+ .onChange(of: showsTemperatureSeries) { _ in
|
|
| 379 |
+ resetHiddenTemperatureDisplay() |
|
| 380 |
+ } |
|
| 381 |
+ } |
|
| 382 |
+ |
|
| 383 |
+ private func resetHiddenTemperatureDisplay() {
|
|
| 384 |
+ guard !showsTemperatureSeries, displayTemperature else { return }
|
|
| 385 |
+ displayTemperature = false |
|
| 386 |
+ } |
|
| 387 |
+ |
|
| 388 |
+ @ViewBuilder |
|
| 389 |
+ private var chartBody: some View {
|
|
| 390 |
+ let availableTimeRange = availableSelectionTimeRange() |
|
| 391 |
+ let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange) |
|
| 392 |
+ let powerSeries = series( |
|
| 393 |
+ for: measurements.power, |
|
| 394 |
+ kind: .power, |
|
| 395 |
+ minimumYSpan: minimumPowerSpan, |
|
| 396 |
+ visibleTimeRange: visibleTimeRange |
|
| 397 |
+ ) |
|
| 398 |
+ let energySeries = series( |
|
| 399 |
+ for: measurements.energy, |
|
| 400 |
+ kind: .energy, |
|
| 401 |
+ minimumYSpan: minimumEnergySpan, |
|
| 402 |
+ visibleTimeRange: visibleTimeRange |
|
| 403 |
+ ) |
|
| 404 |
+ let voltageSeries = series( |
|
| 405 |
+ for: measurements.voltage, |
|
| 406 |
+ kind: .voltage, |
|
| 407 |
+ minimumYSpan: minimumVoltageSpan, |
|
| 408 |
+ visibleTimeRange: visibleTimeRange |
|
| 409 |
+ ) |
|
| 410 |
+ let currentSeries = series( |
|
| 411 |
+ for: measurements.current, |
|
| 412 |
+ kind: .current, |
|
| 413 |
+ minimumYSpan: minimumCurrentSpan, |
|
| 414 |
+ visibleTimeRange: visibleTimeRange |
|
| 415 |
+ ) |
|
| 416 |
+ let temperatureSeries = series( |
|
| 417 |
+ for: measurements.temperature, |
|
| 418 |
+ kind: .temperature, |
|
| 419 |
+ minimumYSpan: minimumTemperatureSpan, |
|
| 420 |
+ visibleTimeRange: visibleTimeRange |
|
| 421 |
+ ) |
|
| 422 |
+ let primarySeries = displayedPrimarySeries( |
|
| 423 |
+ powerSeries: powerSeries, |
|
| 424 |
+ energySeries: energySeries, |
|
| 425 |
+ voltageSeries: voltageSeries, |
|
| 426 |
+ currentSeries: currentSeries |
|
| 427 |
+ ) |
|
| 428 |
+ let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
|
|
| 429 |
+ |
|
| 430 |
+ Group {
|
|
| 431 |
+ if let primarySeries {
|
|
| 432 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 433 |
+ chartToggleBar() |
|
| 434 |
+ |
|
| 435 |
+ VStack(spacing: compactLayout ? 8 : 10) {
|
|
| 436 |
+ GeometryReader { geometry in
|
|
| 437 |
+ let reservedBottomHeight = |
|
| 438 |
+ xAxisHeight |
|
| 439 |
+ + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0) |
|
| 440 |
+ let plotHeight = max( |
|
| 441 |
+ geometry.size.height - reservedBottomHeight, |
|
| 442 |
+ compactLayout ? 180 : 220 |
|
| 443 |
+ ) |
|
| 444 |
+ |
|
| 445 |
+ VStack(spacing: 6) {
|
|
| 446 |
+ HStack(spacing: chartSectionSpacing) {
|
|
| 447 |
+ primaryAxisView( |
|
| 448 |
+ height: plotHeight, |
|
| 449 |
+ powerSeries: powerSeries, |
|
| 450 |
+ energySeries: energySeries, |
|
| 451 |
+ voltageSeries: voltageSeries, |
|
| 452 |
+ currentSeries: currentSeries |
|
| 453 |
+ ) |
|
| 454 |
+ .frame(width: axisColumnWidth, height: plotHeight) |
|
| 455 |
+ |
|
| 456 |
+ ZStack {
|
|
| 457 |
+ RoundedRectangle(cornerRadius: 18, style: .continuous) |
|
| 458 |
+ .fill(Color.primary.opacity(0.05)) |
|
| 459 |
+ |
|
| 460 |
+ RoundedRectangle(cornerRadius: 18, style: .continuous) |
|
| 461 |
+ .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
|
| 462 |
+ |
|
| 463 |
+ horizontalGuides(context: primarySeries.context) |
|
| 464 |
+ verticalGuides(context: primarySeries.context) |
|
| 465 |
+ discontinuityMarkers(points: primarySeries.points, context: primarySeries.context) |
|
| 466 |
+ renderedChart( |
|
| 467 |
+ powerSeries: powerSeries, |
|
| 468 |
+ energySeries: energySeries, |
|
| 469 |
+ voltageSeries: voltageSeries, |
|
| 470 |
+ currentSeries: currentSeries, |
|
| 471 |
+ temperatureSeries: temperatureSeries |
|
| 472 |
+ ) |
|
| 473 |
+ } |
|
| 474 |
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
|
| 475 |
+ .frame(maxWidth: .infinity) |
|
| 476 |
+ .frame(height: plotHeight) |
|
| 477 |
+ |
|
| 478 |
+ secondaryAxisView( |
|
| 479 |
+ height: plotHeight, |
|
| 480 |
+ powerSeries: powerSeries, |
|
| 481 |
+ energySeries: energySeries, |
|
| 482 |
+ voltageSeries: voltageSeries, |
|
| 483 |
+ currentSeries: currentSeries, |
|
| 484 |
+ temperatureSeries: temperatureSeries |
|
| 485 |
+ ) |
|
| 486 |
+ .frame(width: axisColumnWidth, height: plotHeight) |
|
| 487 |
+ } |
|
| 488 |
+ .overlay(alignment: .bottom) {
|
|
| 489 |
+ if originControlsPlacement == .aboveXAxisLegend {
|
|
| 490 |
+ scaleControlsPill( |
|
| 491 |
+ voltageSeries: voltageSeries, |
|
| 492 |
+ currentSeries: currentSeries |
|
| 493 |
+ ) |
|
| 494 |
+ .padding(.bottom, compactLayout ? 6 : 10) |
|
| 495 |
+ } |
|
| 496 |
+ } |
|
| 497 |
+ |
|
| 498 |
+ switch originControlsPlacement {
|
|
| 499 |
+ case .aboveXAxisLegend: |
|
| 500 |
+ xAxisLabelsView(context: primarySeries.context) |
|
| 501 |
+ .frame(height: xAxisHeight) |
|
| 502 |
+ case .overXAxisLegend: |
|
| 503 |
+ xAxisLabelsView(context: primarySeries.context) |
|
| 504 |
+ .frame(height: xAxisHeight) |
|
| 505 |
+ .overlay(alignment: .center) {
|
|
| 506 |
+ scaleControlsPill( |
|
| 507 |
+ voltageSeries: voltageSeries, |
|
| 508 |
+ currentSeries: currentSeries |
|
| 509 |
+ ) |
|
| 510 |
+ .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10)) |
|
| 511 |
+ } |
|
| 512 |
+ case .belowXAxisLegend: |
|
| 513 |
+ xAxisLabelsView(context: primarySeries.context) |
|
| 514 |
+ .frame(height: xAxisHeight) |
|
| 515 |
+ |
|
| 516 |
+ HStack {
|
|
| 517 |
+ Spacer(minLength: 0) |
|
| 518 |
+ scaleControlsPill( |
|
| 519 |
+ voltageSeries: voltageSeries, |
|
| 520 |
+ currentSeries: currentSeries |
|
| 521 |
+ ) |
|
| 522 |
+ Spacer(minLength: 0) |
|
| 523 |
+ } |
|
| 524 |
+ } |
|
| 525 |
+ } |
|
| 526 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
|
| 527 |
+ } |
|
| 528 |
+ .frame(height: plotSectionHeight) |
|
| 529 |
+ |
|
| 530 |
+ if showsRangeSelector, |
|
| 531 |
+ let availableTimeRange, |
|
| 532 |
+ let selectorSeries, |
|
| 533 |
+ shouldShowRangeSelector( |
|
| 534 |
+ availableTimeRange: availableTimeRange, |
|
| 535 |
+ series: selectorSeries |
|
| 536 |
+ ) {
|
|
| 537 |
+ TimeRangeSelectorView( |
|
| 538 |
+ points: selectorSeries.points, |
|
| 539 |
+ context: selectorSeries.context, |
|
| 540 |
+ availableTimeRange: availableTimeRange, |
|
| 541 |
+ selectorTint: selectorTint, |
|
| 542 |
+ compactLayout: compactLayout, |
|
| 543 |
+ xAxisLabelCount: xLabels, |
|
| 544 |
+ minimumSelectionSpan: minimumTimeSpan, |
|
| 545 |
+ configuration: resolvedRangeSelectorConfiguration(), |
|
| 546 |
+ selectedTimeRange: $selectedVisibleTimeRange, |
|
| 547 |
+ isPinnedToPresent: $isPinnedToPresent, |
|
| 548 |
+ presentTrackingMode: $presentTrackingMode |
|
| 549 |
+ ) |
|
| 550 |
+ } |
|
| 551 |
+ } |
|
| 552 |
+ } |
|
| 553 |
+ } else {
|
|
| 554 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 555 |
+ chartToggleBar() |
|
| 556 |
+ Text("Select at least one measurement series.")
|
|
| 557 |
+ .foregroundColor(.secondary) |
|
| 558 |
+ } |
|
| 559 |
+ } |
|
| 560 |
+ } |
|
| 561 |
+ .font(chartBaseFont) |
|
| 562 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 563 |
+ .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
|
|
| 564 |
+ guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
|
|
| 565 |
+ chartNow = now |
|
| 566 |
+ } |
|
| 567 |
+ } |
|
| 568 |
+ |
|
| 569 |
+ private func chartToggleBar() -> some View {
|
|
| 570 |
+ let condensedLayout = compactLayout || verticalSizeClass == .compact |
|
| 571 |
+ let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10) |
|
| 572 |
+ |
|
| 573 |
+ let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
|
|
| 574 |
+ seriesToggleRow(condensedLayout: condensedLayout) |
|
| 575 |
+ } |
|
| 576 |
+ .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12)) |
|
| 577 |
+ .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)) |
|
| 578 |
+ .background( |
|
| 579 |
+ RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous) |
|
| 580 |
+ .fill(Color.primary.opacity(0.045)) |
|
| 581 |
+ ) |
|
| 582 |
+ .overlay( |
|
| 583 |
+ RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous) |
|
| 584 |
+ .stroke(Color.secondary.opacity(0.14), lineWidth: 1) |
|
| 585 |
+ ) |
|
| 586 |
+ |
|
| 587 |
+ return Group {
|
|
| 588 |
+ if stackedToolbarLayout {
|
|
| 589 |
+ controlsPanel |
|
| 590 |
+ } else {
|
|
| 591 |
+ HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
|
|
| 592 |
+ controlsPanel |
|
| 593 |
+ } |
|
| 594 |
+ } |
|
| 595 |
+ } |
|
| 596 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 597 |
+ } |
|
| 598 |
+ |
|
| 599 |
+ private var shouldFloatScaleControlsOverChart: Bool {
|
|
| 600 |
+ #if os(iOS) |
|
| 601 |
+ if availableSize.width > 0, availableSize.height > 0 {
|
|
| 602 |
+ return availableSize.width > availableSize.height |
|
| 603 |
+ } |
|
| 604 |
+ return horizontalSizeClass != .compact && verticalSizeClass == .compact |
|
| 605 |
+ #else |
|
| 606 |
+ return false |
|
| 607 |
+ #endif |
|
| 608 |
+ } |
|
| 609 |
+ |
|
| 610 |
+ private func scaleControlsPill( |
|
| 611 |
+ voltageSeries: SeriesData, |
|
| 612 |
+ currentSeries: SeriesData |
|
| 613 |
+ ) -> some View {
|
|
| 614 |
+ let condensedLayout = compactLayout || verticalSizeClass == .compact |
|
| 615 |
+ let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart |
|
| 616 |
+ let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10)) |
|
| 617 |
+ let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8)) |
|
| 618 |
+ |
|
| 619 |
+ return originControlsRow( |
|
| 620 |
+ voltageSeries: voltageSeries, |
|
| 621 |
+ currentSeries: currentSeries, |
|
| 622 |
+ condensedLayout: condensedLayout, |
|
| 623 |
+ showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls |
|
| 624 |
+ ) |
|
| 625 |
+ .padding(.horizontal, horizontalPadding) |
|
| 626 |
+ .padding(.vertical, verticalPadding) |
|
| 627 |
+ .background( |
|
| 628 |
+ Capsule(style: .continuous) |
|
| 629 |
+ .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear) |
|
| 630 |
+ ) |
|
| 631 |
+ .overlay( |
|
| 632 |
+ Capsule(style: .continuous) |
|
| 633 |
+ .stroke( |
|
| 634 |
+ showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear, |
|
| 635 |
+ lineWidth: 1 |
|
| 636 |
+ ) |
|
| 637 |
+ ) |
|
| 638 |
+ } |
|
| 639 |
+ |
|
| 640 |
+ private func seriesToggleRow(condensedLayout: Bool) -> some View {
|
|
| 641 |
+ HStack(spacing: condensedLayout ? 6 : 8) {
|
|
| 642 |
+ seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
|
|
| 643 |
+ displayVoltage.toggle() |
|
| 644 |
+ if displayVoltage {
|
|
| 645 |
+ displayPower = false |
|
| 646 |
+ displayEnergy = false |
|
| 647 |
+ if displayTemperature && displayCurrent {
|
|
| 648 |
+ displayCurrent = false |
|
| 649 |
+ } |
|
| 650 |
+ } |
|
| 651 |
+ } |
|
| 652 |
+ |
|
| 653 |
+ seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
|
|
| 654 |
+ displayCurrent.toggle() |
|
| 655 |
+ if displayCurrent {
|
|
| 656 |
+ displayPower = false |
|
| 657 |
+ displayEnergy = false |
|
| 658 |
+ if displayTemperature && displayVoltage {
|
|
| 659 |
+ displayVoltage = false |
|
| 660 |
+ } |
|
| 661 |
+ } |
|
| 662 |
+ } |
|
| 663 |
+ |
|
| 664 |
+ seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
|
|
| 665 |
+ displayPower.toggle() |
|
| 666 |
+ if displayPower {
|
|
| 667 |
+ displayEnergy = false |
|
| 668 |
+ displayCurrent = false |
|
| 669 |
+ displayVoltage = false |
|
| 670 |
+ } |
|
| 671 |
+ } |
|
| 672 |
+ |
|
| 673 |
+ seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
|
|
| 674 |
+ displayEnergy.toggle() |
|
| 675 |
+ if displayEnergy {
|
|
| 676 |
+ displayPower = false |
|
| 677 |
+ displayCurrent = false |
|
| 678 |
+ displayVoltage = false |
|
| 679 |
+ } |
|
| 680 |
+ } |
|
| 681 |
+ |
|
| 682 |
+ if showsTemperatureSeries {
|
|
| 683 |
+ seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
|
|
| 684 |
+ displayTemperature.toggle() |
|
| 685 |
+ if displayTemperature && displayVoltage && displayCurrent {
|
|
| 686 |
+ displayCurrent = false |
|
| 687 |
+ } |
|
| 688 |
+ } |
|
| 689 |
+ } |
|
| 690 |
+ } |
|
| 691 |
+ } |
|
| 692 |
+ |
|
| 693 |
+ private func originControlsRow( |
|
| 694 |
+ voltageSeries: SeriesData, |
|
| 695 |
+ currentSeries: SeriesData, |
|
| 696 |
+ condensedLayout: Bool, |
|
| 697 |
+ showsLabel: Bool |
|
| 698 |
+ ) -> some View {
|
|
| 699 |
+ HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
|
|
| 700 |
+ if supportsSharedOrigin {
|
|
| 701 |
+ symbolControlChip( |
|
| 702 |
+ systemImage: "equal.circle", |
|
| 703 |
+ enabled: true, |
|
| 704 |
+ active: useSharedOrigin, |
|
| 705 |
+ condensedLayout: condensedLayout, |
|
| 706 |
+ showsLabel: showsLabel, |
|
| 707 |
+ label: "Match Y Scale", |
|
| 708 |
+ accessibilityLabel: "Match Y scale" |
|
| 709 |
+ ) {
|
|
| 710 |
+ toggleSharedOrigin( |
|
| 711 |
+ voltageSeries: voltageSeries, |
|
| 712 |
+ currentSeries: currentSeries |
|
| 713 |
+ ) |
|
| 714 |
+ } |
|
| 715 |
+ } |
|
| 716 |
+ |
|
| 717 |
+ symbolControlChip( |
|
| 718 |
+ systemImage: pinOrigin ? "pin.fill" : "pin.slash", |
|
| 719 |
+ enabled: true, |
|
| 720 |
+ active: pinOrigin, |
|
| 721 |
+ condensedLayout: condensedLayout, |
|
| 722 |
+ showsLabel: showsLabel, |
|
| 723 |
+ label: pinOrigin ? "Origin Locked" : "Origin Auto", |
|
| 724 |
+ accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin" |
|
| 725 |
+ ) {
|
|
| 726 |
+ togglePinnedOrigin( |
|
| 727 |
+ voltageSeries: voltageSeries, |
|
| 728 |
+ currentSeries: currentSeries |
|
| 729 |
+ ) |
|
| 730 |
+ } |
|
| 731 |
+ |
|
| 732 |
+ if !pinnedOriginIsZero {
|
|
| 733 |
+ symbolControlChip( |
|
| 734 |
+ systemImage: "0.circle", |
|
| 735 |
+ enabled: true, |
|
| 736 |
+ active: false, |
|
| 737 |
+ condensedLayout: condensedLayout, |
|
| 738 |
+ showsLabel: showsLabel, |
|
| 739 |
+ label: "Origin 0", |
|
| 740 |
+ accessibilityLabel: "Set origin to zero" |
|
| 741 |
+ ) {
|
|
| 742 |
+ setVisibleOriginsToZero() |
|
| 743 |
+ } |
|
| 744 |
+ } |
|
| 745 |
+ |
|
| 746 |
+ smoothingControlChip( |
|
| 747 |
+ condensedLayout: condensedLayout, |
|
| 748 |
+ showsLabel: showsLabel |
|
| 749 |
+ ) |
|
| 750 |
+ |
|
| 751 |
+ } |
|
| 752 |
+ } |
|
| 753 |
+ |
|
| 754 |
+ private func smoothingControlChip( |
|
| 755 |
+ condensedLayout: Bool, |
|
| 756 |
+ showsLabel: Bool |
|
| 757 |
+ ) -> some View {
|
|
| 758 |
+ Menu {
|
|
| 759 |
+ ForEach(SmoothingLevel.allCases, id: \.self) { level in
|
|
| 760 |
+ Button {
|
|
| 761 |
+ smoothingLevel = level |
|
| 762 |
+ } label: {
|
|
| 763 |
+ if smoothingLevel == level {
|
|
| 764 |
+ Label(level.label, systemImage: "checkmark") |
|
| 765 |
+ } else {
|
|
| 766 |
+ Text(level.label) |
|
| 767 |
+ } |
|
| 768 |
+ } |
|
| 769 |
+ } |
|
| 770 |
+ } label: {
|
|
| 771 |
+ Group {
|
|
| 772 |
+ if showsLabel {
|
|
| 773 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 774 |
+ Label("Smoothing", systemImage: "waveform.path")
|
|
| 775 |
+ .font(controlChipFont(condensedLayout: condensedLayout)) |
|
| 776 |
+ |
|
| 777 |
+ Text( |
|
| 778 |
+ smoothingLevel == .off |
|
| 779 |
+ ? "Off" |
|
| 780 |
+ : "MA \(smoothingLevel.movingAverageWindowSize)" |
|
| 781 |
+ ) |
|
| 782 |
+ .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold)) |
|
| 783 |
+ .foregroundColor(.secondary) |
|
| 784 |
+ .monospacedDigit() |
|
| 785 |
+ } |
|
| 786 |
+ .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12)) |
|
| 787 |
+ .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8))) |
|
| 788 |
+ } else {
|
|
| 789 |
+ VStack(spacing: 1) {
|
|
| 790 |
+ Image(systemName: "waveform.path") |
|
| 791 |
+ .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold)) |
|
| 792 |
+ |
|
| 793 |
+ Text(smoothingLevel.shortLabel) |
|
| 794 |
+ .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold)) |
|
| 795 |
+ .monospacedDigit() |
|
| 796 |
+ } |
|
| 797 |
+ .frame( |
|
| 798 |
+ width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)), |
|
| 799 |
+ height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42)) |
|
| 800 |
+ ) |
|
| 801 |
+ } |
|
| 802 |
+ } |
|
| 803 |
+ .background( |
|
| 804 |
+ Capsule(style: .continuous) |
|
| 805 |
+ .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14)) |
|
| 806 |
+ ) |
|
| 807 |
+ .overlay( |
|
| 808 |
+ Capsule(style: .continuous) |
|
| 809 |
+ .stroke( |
|
| 810 |
+ smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24), |
|
| 811 |
+ lineWidth: 1 |
|
| 812 |
+ ) |
|
| 813 |
+ ) |
|
| 814 |
+ } |
|
| 815 |
+ .buttonStyle(.plain) |
|
| 816 |
+ .foregroundColor(smoothingLevel == .off ? .primary : .blue) |
|
| 817 |
+ } |
|
| 818 |
+ |
|
| 819 |
+ private func seriesToggleButton( |
|
| 820 |
+ title: String, |
|
| 821 |
+ isOn: Bool, |
|
| 822 |
+ condensedLayout: Bool, |
|
| 823 |
+ action: @escaping () -> Void |
|
| 824 |
+ ) -> some View {
|
|
| 825 |
+ Button(action: action) {
|
|
| 826 |
+ Text(title) |
|
| 827 |
+ .font(seriesToggleFont(condensedLayout: condensedLayout)) |
|
| 828 |
+ .lineLimit(1) |
|
| 829 |
+ .minimumScaleFactor(0.82) |
|
| 830 |
+ .foregroundColor(isOn ? .white : .blue) |
|
| 831 |
+ .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12)) |
|
| 832 |
+ .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8)) |
|
| 833 |
+ .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84)) |
|
| 834 |
+ .frame(maxWidth: stackedToolbarLayout ? .infinity : nil) |
|
| 835 |
+ .background( |
|
| 836 |
+ RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous) |
|
| 837 |
+ .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12)) |
|
| 838 |
+ ) |
|
| 839 |
+ .overlay( |
|
| 840 |
+ RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous) |
|
| 841 |
+ .stroke(Color.blue, lineWidth: 1.5) |
|
| 842 |
+ ) |
|
| 843 |
+ } |
|
| 844 |
+ .buttonStyle(.plain) |
|
| 845 |
+ } |
|
| 846 |
+ |
|
| 847 |
+ private func symbolControlChip( |
|
| 848 |
+ systemImage: String, |
|
| 849 |
+ enabled: Bool, |
|
| 850 |
+ active: Bool, |
|
| 851 |
+ condensedLayout: Bool, |
|
| 852 |
+ showsLabel: Bool, |
|
| 853 |
+ label: String, |
|
| 854 |
+ accessibilityLabel: String, |
|
| 855 |
+ action: @escaping () -> Void |
|
| 856 |
+ ) -> some View {
|
|
| 857 |
+ Button(action: {
|
|
| 858 |
+ action() |
|
| 859 |
+ }) {
|
|
| 860 |
+ Group {
|
|
| 861 |
+ if showsLabel {
|
|
| 862 |
+ Label(label, systemImage: systemImage) |
|
| 863 |
+ .font(controlChipFont(condensedLayout: condensedLayout)) |
|
| 864 |
+ .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12)) |
|
| 865 |
+ .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8))) |
|
| 866 |
+ } else {
|
|
| 867 |
+ Image(systemName: systemImage) |
|
| 868 |
+ .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold)) |
|
| 869 |
+ .frame( |
|
| 870 |
+ width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)), |
|
| 871 |
+ height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)) |
|
| 872 |
+ ) |
|
| 873 |
+ } |
|
| 874 |
+ } |
|
| 875 |
+ .background( |
|
| 876 |
+ Capsule(style: .continuous) |
|
| 877 |
+ .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10)) |
|
| 878 |
+ ) |
|
| 879 |
+ } |
|
| 880 |
+ .buttonStyle(.plain) |
|
| 881 |
+ .foregroundColor(enabled ? .primary : .secondary) |
|
| 882 |
+ .opacity(enabled ? 1 : 0.55) |
|
| 883 |
+ .accessibilityLabel(accessibilityLabel) |
|
| 884 |
+ } |
|
| 885 |
+ |
|
| 886 |
+ private func resetBuffer() {
|
|
| 887 |
+ measurements.resetSeries() |
|
| 888 |
+ } |
|
| 889 |
+ |
|
| 890 |
+ private func resolvedRangeSelectorConfiguration() -> MeasurementChartRangeSelectorConfiguration {
|
|
| 891 |
+ if let rangeSelectorConfiguration {
|
|
| 892 |
+ return rangeSelectorConfiguration |
|
| 893 |
+ } |
|
| 894 |
+ |
|
| 895 |
+ return MeasurementChartRangeSelectorConfiguration( |
|
| 896 |
+ keepAction: MeasurementChartSelectionAction( |
|
| 897 |
+ title: "Keep Selection", |
|
| 898 |
+ shortTitle: "Keep", |
|
| 899 |
+ systemName: "scissors", |
|
| 900 |
+ tone: .destructive, |
|
| 901 |
+ handler: trimBufferToSelection |
|
| 902 |
+ ), |
|
| 903 |
+ removeAction: MeasurementChartSelectionAction( |
|
| 904 |
+ title: "Remove Selection", |
|
| 905 |
+ shortTitle: "Cut", |
|
| 906 |
+ systemName: "minus.circle", |
|
| 907 |
+ tone: .destructive, |
|
| 908 |
+ handler: removeSelectionFromBuffer |
|
| 909 |
+ ), |
|
| 910 |
+ resetAction: MeasurementChartResetAction( |
|
| 911 |
+ title: "Reset Buffer", |
|
| 912 |
+ shortTitle: "Reset", |
|
| 913 |
+ systemName: "trash", |
|
| 914 |
+ tone: .destructiveProminent, |
|
| 915 |
+ confirmationTitle: "Reset captured measurements?", |
|
| 916 |
+ confirmationButtonTitle: "Reset buffer", |
|
| 917 |
+ handler: resetBuffer |
|
| 918 |
+ ) |
|
| 919 |
+ ) |
|
| 920 |
+ } |
|
| 921 |
+ |
|
| 922 |
+ private func seriesToggleFont(condensedLayout: Bool) -> Font {
|
|
| 923 |
+ if isLargeDisplay {
|
|
| 924 |
+ return .body.weight(.semibold) |
|
| 925 |
+ } |
|
| 926 |
+ return (condensedLayout ? Font.callout : .body).weight(.semibold) |
|
| 927 |
+ } |
|
| 928 |
+ |
|
| 929 |
+ private func controlChipFont(condensedLayout: Bool) -> Font {
|
|
| 930 |
+ if isLargeDisplay {
|
|
| 931 |
+ return .callout.weight(.semibold) |
|
| 932 |
+ } |
|
| 933 |
+ return (condensedLayout ? Font.callout : .footnote).weight(.semibold) |
|
| 934 |
+ } |
|
| 935 |
+ |
|
| 936 |
+ @ViewBuilder |
|
| 937 |
+ private func primaryAxisView( |
|
| 938 |
+ height: CGFloat, |
|
| 939 |
+ powerSeries: SeriesData, |
|
| 940 |
+ energySeries: SeriesData, |
|
| 941 |
+ voltageSeries: SeriesData, |
|
| 942 |
+ currentSeries: SeriesData |
|
| 943 |
+ ) -> some View {
|
|
| 944 |
+ if displayPower {
|
|
| 945 |
+ yAxisLabelsView( |
|
| 946 |
+ height: height, |
|
| 947 |
+ context: powerSeries.context, |
|
| 948 |
+ seriesKind: .power, |
|
| 949 |
+ measurementUnit: powerSeries.kind.unit, |
|
| 950 |
+ tint: powerSeries.kind.tint |
|
| 951 |
+ ) |
|
| 952 |
+ } else if displayEnergy {
|
|
| 953 |
+ yAxisLabelsView( |
|
| 954 |
+ height: height, |
|
| 955 |
+ context: energySeries.context, |
|
| 956 |
+ seriesKind: .energy, |
|
| 957 |
+ measurementUnit: energySeries.kind.unit, |
|
| 958 |
+ tint: energySeries.kind.tint |
|
| 959 |
+ ) |
|
| 960 |
+ } else if displayVoltage {
|
|
| 961 |
+ yAxisLabelsView( |
|
| 962 |
+ height: height, |
|
| 963 |
+ context: voltageSeries.context, |
|
| 964 |
+ seriesKind: .voltage, |
|
| 965 |
+ measurementUnit: voltageSeries.kind.unit, |
|
| 966 |
+ tint: voltageSeries.kind.tint |
|
| 967 |
+ ) |
|
| 968 |
+ } else if displayCurrent {
|
|
| 969 |
+ yAxisLabelsView( |
|
| 970 |
+ height: height, |
|
| 971 |
+ context: currentSeries.context, |
|
| 972 |
+ seriesKind: .current, |
|
| 973 |
+ measurementUnit: currentSeries.kind.unit, |
|
| 974 |
+ tint: currentSeries.kind.tint |
|
| 975 |
+ ) |
|
| 976 |
+ } |
|
| 977 |
+ } |
|
| 978 |
+ |
|
| 979 |
+ @ViewBuilder |
|
| 980 |
+ private func renderedChart( |
|
| 981 |
+ powerSeries: SeriesData, |
|
| 982 |
+ energySeries: SeriesData, |
|
| 983 |
+ voltageSeries: SeriesData, |
|
| 984 |
+ currentSeries: SeriesData, |
|
| 985 |
+ temperatureSeries: SeriesData |
|
| 986 |
+ ) -> some View {
|
|
| 987 |
+ if self.displayPower {
|
|
| 988 |
+ TimeSeriesChart(points: powerSeries.points, context: powerSeries.context, strokeColor: powerSeries.kind.tint) |
|
| 989 |
+ .opacity(0.72) |
|
| 990 |
+ } else if self.displayEnergy {
|
|
| 991 |
+ TimeSeriesChart(points: energySeries.points, context: energySeries.context, strokeColor: energySeries.kind.tint) |
|
| 992 |
+ .opacity(0.78) |
|
| 993 |
+ } else {
|
|
| 994 |
+ if self.displayVoltage {
|
|
| 995 |
+ TimeSeriesChart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: voltageSeries.kind.tint) |
|
| 996 |
+ .opacity(0.78) |
|
| 997 |
+ } |
|
| 998 |
+ if self.displayCurrent {
|
|
| 999 |
+ TimeSeriesChart(points: currentSeries.points, context: currentSeries.context, strokeColor: currentSeries.kind.tint) |
|
| 1000 |
+ .opacity(0.78) |
|
| 1001 |
+ } |
|
| 1002 |
+ } |
|
| 1003 |
+ |
|
| 1004 |
+ if displayTemperature {
|
|
| 1005 |
+ TimeSeriesChart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: temperatureSeries.kind.tint) |
|
| 1006 |
+ .opacity(0.86) |
|
| 1007 |
+ } |
|
| 1008 |
+ } |
|
| 1009 |
+ |
|
| 1010 |
+ @ViewBuilder |
|
| 1011 |
+ private func secondaryAxisView( |
|
| 1012 |
+ height: CGFloat, |
|
| 1013 |
+ powerSeries: SeriesData, |
|
| 1014 |
+ energySeries: SeriesData, |
|
| 1015 |
+ voltageSeries: SeriesData, |
|
| 1016 |
+ currentSeries: SeriesData, |
|
| 1017 |
+ temperatureSeries: SeriesData |
|
| 1018 |
+ ) -> some View {
|
|
| 1019 |
+ if displayTemperature {
|
|
| 1020 |
+ yAxisLabelsView( |
|
| 1021 |
+ height: height, |
|
| 1022 |
+ context: temperatureSeries.context, |
|
| 1023 |
+ seriesKind: .temperature, |
|
| 1024 |
+ measurementUnit: measurementUnit(for: .temperature), |
|
| 1025 |
+ tint: temperatureSeries.kind.tint |
|
| 1026 |
+ ) |
|
| 1027 |
+ } else if displayVoltage && displayCurrent {
|
|
| 1028 |
+ yAxisLabelsView( |
|
| 1029 |
+ height: height, |
|
| 1030 |
+ context: currentSeries.context, |
|
| 1031 |
+ seriesKind: .current, |
|
| 1032 |
+ measurementUnit: currentSeries.kind.unit, |
|
| 1033 |
+ tint: currentSeries.kind.tint |
|
| 1034 |
+ ) |
|
| 1035 |
+ } else {
|
|
| 1036 |
+ primaryAxisView( |
|
| 1037 |
+ height: height, |
|
| 1038 |
+ powerSeries: powerSeries, |
|
| 1039 |
+ energySeries: energySeries, |
|
| 1040 |
+ voltageSeries: voltageSeries, |
|
| 1041 |
+ currentSeries: currentSeries |
|
| 1042 |
+ ) |
|
| 1043 |
+ } |
|
| 1044 |
+ } |
|
| 1045 |
+ |
|
| 1046 |
+ private func displayedPrimarySeries( |
|
| 1047 |
+ powerSeries: SeriesData, |
|
| 1048 |
+ energySeries: SeriesData, |
|
| 1049 |
+ voltageSeries: SeriesData, |
|
| 1050 |
+ currentSeries: SeriesData |
|
| 1051 |
+ ) -> SeriesData? {
|
|
| 1052 |
+ if displayPower {
|
|
| 1053 |
+ return powerSeries |
|
| 1054 |
+ } |
|
| 1055 |
+ if displayEnergy {
|
|
| 1056 |
+ return energySeries |
|
| 1057 |
+ } |
|
| 1058 |
+ if displayVoltage {
|
|
| 1059 |
+ return voltageSeries |
|
| 1060 |
+ } |
|
| 1061 |
+ if displayCurrent {
|
|
| 1062 |
+ return currentSeries |
|
| 1063 |
+ } |
|
| 1064 |
+ return nil |
|
| 1065 |
+ } |
|
| 1066 |
+ |
|
| 1067 |
+ private func series( |
|
| 1068 |
+ for measurement: Measurements.Measurement, |
|
| 1069 |
+ kind: SeriesKind, |
|
| 1070 |
+ minimumYSpan: Double, |
|
| 1071 |
+ visibleTimeRange: ClosedRange<Date>? = nil |
|
| 1072 |
+ ) -> SeriesData {
|
|
| 1073 |
+ let rawPoints = filteredPoints( |
|
| 1074 |
+ measurement, |
|
| 1075 |
+ visibleTimeRange: visibleTimeRange |
|
| 1076 |
+ ) |
|
| 1077 |
+ let normalizedRawPoints = normalizedPoints(rawPoints, for: kind) |
|
| 1078 |
+ let points = smoothedPoints(from: normalizedRawPoints) |
|
| 1079 |
+ let samplePoints = points.filter { $0.isSample }
|
|
| 1080 |
+ let context = ChartContext() |
|
| 1081 |
+ |
|
| 1082 |
+ let autoBounds = automaticYBounds( |
|
| 1083 |
+ for: samplePoints, |
|
| 1084 |
+ minimumYSpan: minimumYSpan |
|
| 1085 |
+ ) |
|
| 1086 |
+ let xBounds = xBounds( |
|
| 1087 |
+ for: samplePoints, |
|
| 1088 |
+ visibleTimeRange: visibleTimeRange |
|
| 1089 |
+ ) |
|
| 1090 |
+ let lowerBound = resolvedLowerBound( |
|
| 1091 |
+ for: kind, |
|
| 1092 |
+ autoLowerBound: autoBounds.lowerBound |
|
| 1093 |
+ ) |
|
| 1094 |
+ let upperBound = resolvedUpperBound( |
|
| 1095 |
+ for: kind, |
|
| 1096 |
+ lowerBound: lowerBound, |
|
| 1097 |
+ autoUpperBound: autoBounds.upperBound, |
|
| 1098 |
+ maximumSampleValue: samplePoints.map(\.value).max(), |
|
| 1099 |
+ minimumYSpan: minimumYSpan |
|
| 1100 |
+ ) |
|
| 1101 |
+ |
|
| 1102 |
+ context.setBounds( |
|
| 1103 |
+ xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970), |
|
| 1104 |
+ xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970), |
|
| 1105 |
+ yMin: CGFloat(lowerBound), |
|
| 1106 |
+ yMax: CGFloat(upperBound) |
|
| 1107 |
+ ) |
|
| 1108 |
+ |
|
| 1109 |
+ return SeriesData( |
|
| 1110 |
+ kind: kind, |
|
| 1111 |
+ points: points, |
|
| 1112 |
+ samplePoints: samplePoints, |
|
| 1113 |
+ context: context, |
|
| 1114 |
+ autoLowerBound: autoBounds.lowerBound, |
|
| 1115 |
+ autoUpperBound: autoBounds.upperBound, |
|
| 1116 |
+ maximumSampleValue: samplePoints.map(\.value).max() |
|
| 1117 |
+ ) |
|
| 1118 |
+ } |
|
| 1119 |
+ |
|
| 1120 |
+ private func normalizedPoints( |
|
| 1121 |
+ _ points: [Measurements.Measurement.Point], |
|
| 1122 |
+ for kind: SeriesKind |
|
| 1123 |
+ ) -> [Measurements.Measurement.Point] {
|
|
| 1124 |
+ guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
|
|
| 1125 |
+ return points |
|
| 1126 |
+ } |
|
| 1127 |
+ |
|
| 1128 |
+ guard let baseline = points.first(where: \.isSample)?.value else {
|
|
| 1129 |
+ return points |
|
| 1130 |
+ } |
|
| 1131 |
+ |
|
| 1132 |
+ return points.enumerated().map { index, point in
|
|
| 1133 |
+ Measurements.Measurement.Point( |
|
| 1134 |
+ id: point.id == index ? point.id : index, |
|
| 1135 |
+ timestamp: point.timestamp, |
|
| 1136 |
+ value: point.value - baseline, |
|
| 1137 |
+ kind: point.kind |
|
| 1138 |
+ ) |
|
| 1139 |
+ } |
|
| 1140 |
+ } |
|
| 1141 |
+ |
|
| 1142 |
+ private func overviewSeries(for kind: SeriesKind) -> SeriesData {
|
|
| 1143 |
+ series( |
|
| 1144 |
+ for: measurement(for: kind), |
|
| 1145 |
+ kind: kind, |
|
| 1146 |
+ minimumYSpan: minimumYSpan(for: kind) |
|
| 1147 |
+ ) |
|
| 1148 |
+ } |
|
| 1149 |
+ |
|
| 1150 |
+ private func smoothedPoints( |
|
| 1151 |
+ from points: [Measurements.Measurement.Point] |
|
| 1152 |
+ ) -> [Measurements.Measurement.Point] {
|
|
| 1153 |
+ guard smoothingLevel != .off else { return points }
|
|
| 1154 |
+ |
|
| 1155 |
+ var smoothedPoints: [Measurements.Measurement.Point] = [] |
|
| 1156 |
+ var currentSegment: [Measurements.Measurement.Point] = [] |
|
| 1157 |
+ |
|
| 1158 |
+ func flushCurrentSegment() {
|
|
| 1159 |
+ guard !currentSegment.isEmpty else { return }
|
|
| 1160 |
+ |
|
| 1161 |
+ for point in smoothedSegment(currentSegment) {
|
|
| 1162 |
+ smoothedPoints.append( |
|
| 1163 |
+ Measurements.Measurement.Point( |
|
| 1164 |
+ id: smoothedPoints.count, |
|
| 1165 |
+ timestamp: point.timestamp, |
|
| 1166 |
+ value: point.value, |
|
| 1167 |
+ kind: .sample |
|
| 1168 |
+ ) |
|
| 1169 |
+ ) |
|
| 1170 |
+ } |
|
| 1171 |
+ |
|
| 1172 |
+ currentSegment.removeAll(keepingCapacity: true) |
|
| 1173 |
+ } |
|
| 1174 |
+ |
|
| 1175 |
+ for point in points {
|
|
| 1176 |
+ if point.isDiscontinuity {
|
|
| 1177 |
+ flushCurrentSegment() |
|
| 1178 |
+ |
|
| 1179 |
+ if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
|
|
| 1180 |
+ smoothedPoints.append( |
|
| 1181 |
+ Measurements.Measurement.Point( |
|
| 1182 |
+ id: smoothedPoints.count, |
|
| 1183 |
+ timestamp: point.timestamp, |
|
| 1184 |
+ value: smoothedPoints.last?.value ?? point.value, |
|
| 1185 |
+ kind: .discontinuity |
|
| 1186 |
+ ) |
|
| 1187 |
+ ) |
|
| 1188 |
+ } |
|
| 1189 |
+ } else {
|
|
| 1190 |
+ currentSegment.append(point) |
|
| 1191 |
+ } |
|
| 1192 |
+ } |
|
| 1193 |
+ |
|
| 1194 |
+ flushCurrentSegment() |
|
| 1195 |
+ return smoothedPoints |
|
| 1196 |
+ } |
|
| 1197 |
+ |
|
| 1198 |
+ private func smoothedSegment( |
|
| 1199 |
+ _ segment: [Measurements.Measurement.Point] |
|
| 1200 |
+ ) -> [Measurements.Measurement.Point] {
|
|
| 1201 |
+ let windowSize = smoothingLevel.movingAverageWindowSize |
|
| 1202 |
+ guard windowSize > 1, segment.count > 2 else { return segment }
|
|
| 1203 |
+ |
|
| 1204 |
+ let radius = windowSize / 2 |
|
| 1205 |
+ var prefixSums: [Double] = [0] |
|
| 1206 |
+ prefixSums.reserveCapacity(segment.count + 1) |
|
| 1207 |
+ |
|
| 1208 |
+ for point in segment {
|
|
| 1209 |
+ prefixSums.append(prefixSums[prefixSums.count - 1] + point.value) |
|
| 1210 |
+ } |
|
| 1211 |
+ |
|
| 1212 |
+ return segment.enumerated().map { index, point in
|
|
| 1213 |
+ let lowerBound = max(0, index - radius) |
|
| 1214 |
+ let upperBound = min(segment.count - 1, index + radius) |
|
| 1215 |
+ let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound] |
|
| 1216 |
+ let average = sum / Double(upperBound - lowerBound + 1) |
|
| 1217 |
+ |
|
| 1218 |
+ return Measurements.Measurement.Point( |
|
| 1219 |
+ id: point.id, |
|
| 1220 |
+ timestamp: point.timestamp, |
|
| 1221 |
+ value: average, |
|
| 1222 |
+ kind: .sample |
|
| 1223 |
+ ) |
|
| 1224 |
+ } |
|
| 1225 |
+ } |
|
| 1226 |
+ |
|
| 1227 |
+ private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
|
|
| 1228 |
+ switch kind {
|
|
| 1229 |
+ case .power: |
|
| 1230 |
+ return measurements.power |
|
| 1231 |
+ case .energy: |
|
| 1232 |
+ return measurements.energy |
|
| 1233 |
+ case .voltage: |
|
| 1234 |
+ return measurements.voltage |
|
| 1235 |
+ case .current: |
|
| 1236 |
+ return measurements.current |
|
| 1237 |
+ case .temperature: |
|
| 1238 |
+ return measurements.temperature |
|
| 1239 |
+ } |
|
| 1240 |
+ } |
|
| 1241 |
+ |
|
| 1242 |
+ private func minimumYSpan(for kind: SeriesKind) -> Double {
|
|
| 1243 |
+ switch kind {
|
|
| 1244 |
+ case .power: |
|
| 1245 |
+ return minimumPowerSpan |
|
| 1246 |
+ case .energy: |
|
| 1247 |
+ return minimumEnergySpan |
|
| 1248 |
+ case .voltage: |
|
| 1249 |
+ return minimumVoltageSpan |
|
| 1250 |
+ case .current: |
|
| 1251 |
+ return minimumCurrentSpan |
|
| 1252 |
+ case .temperature: |
|
| 1253 |
+ return minimumTemperatureSpan |
|
| 1254 |
+ } |
|
| 1255 |
+ } |
|
| 1256 |
+ |
|
| 1257 |
+ private var supportsSharedOrigin: Bool {
|
|
| 1258 |
+ displayVoltage && displayCurrent && !displayPower && !displayEnergy |
|
| 1259 |
+ } |
|
| 1260 |
+ |
|
| 1261 |
+ private var minimumSharedScaleSpan: Double {
|
|
| 1262 |
+ max(minimumVoltageSpan, minimumCurrentSpan) |
|
| 1263 |
+ } |
|
| 1264 |
+ |
|
| 1265 |
+ private var pinnedOriginIsZero: Bool {
|
|
| 1266 |
+ if useSharedOrigin && supportsSharedOrigin {
|
|
| 1267 |
+ return pinOrigin && sharedAxisOrigin == 0 |
|
| 1268 |
+ } |
|
| 1269 |
+ |
|
| 1270 |
+ if displayPower {
|
|
| 1271 |
+ return pinOrigin && powerAxisOrigin == 0 |
|
| 1272 |
+ } |
|
| 1273 |
+ |
|
| 1274 |
+ if displayEnergy {
|
|
| 1275 |
+ return pinOrigin && energyAxisOrigin == 0 |
|
| 1276 |
+ } |
|
| 1277 |
+ |
|
| 1278 |
+ let visibleOrigins = [ |
|
| 1279 |
+ displayVoltage ? voltageAxisOrigin : nil, |
|
| 1280 |
+ displayCurrent ? currentAxisOrigin : nil |
|
| 1281 |
+ ] |
|
| 1282 |
+ .compactMap { $0 }
|
|
| 1283 |
+ |
|
| 1284 |
+ guard !visibleOrigins.isEmpty else { return false }
|
|
| 1285 |
+ return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
|
|
| 1286 |
+ } |
|
| 1287 |
+ |
|
| 1288 |
+ private func toggleSharedOrigin( |
|
| 1289 |
+ voltageSeries: SeriesData, |
|
| 1290 |
+ currentSeries: SeriesData |
|
| 1291 |
+ ) {
|
|
| 1292 |
+ guard supportsSharedOrigin else { return }
|
|
| 1293 |
+ |
|
| 1294 |
+ if useSharedOrigin {
|
|
| 1295 |
+ useSharedOrigin = false |
|
| 1296 |
+ return |
|
| 1297 |
+ } |
|
| 1298 |
+ |
|
| 1299 |
+ captureCurrentOrigins( |
|
| 1300 |
+ voltageSeries: voltageSeries, |
|
| 1301 |
+ currentSeries: currentSeries |
|
| 1302 |
+ ) |
|
| 1303 |
+ sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin) |
|
| 1304 |
+ sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound) |
|
| 1305 |
+ ensureSharedScaleSpan() |
|
| 1306 |
+ useSharedOrigin = true |
|
| 1307 |
+ pinOrigin = true |
|
| 1308 |
+ } |
|
| 1309 |
+ |
|
| 1310 |
+ private func togglePinnedOrigin( |
|
| 1311 |
+ voltageSeries: SeriesData, |
|
| 1312 |
+ currentSeries: SeriesData |
|
| 1313 |
+ ) {
|
|
| 1314 |
+ if pinOrigin {
|
|
| 1315 |
+ pinOrigin = false |
|
| 1316 |
+ return |
|
| 1317 |
+ } |
|
| 1318 |
+ |
|
| 1319 |
+ captureCurrentOrigins( |
|
| 1320 |
+ voltageSeries: voltageSeries, |
|
| 1321 |
+ currentSeries: currentSeries |
|
| 1322 |
+ ) |
|
| 1323 |
+ pinOrigin = true |
|
| 1324 |
+ } |
|
| 1325 |
+ |
|
| 1326 |
+ private func setVisibleOriginsToZero() {
|
|
| 1327 |
+ if useSharedOrigin && supportsSharedOrigin {
|
|
| 1328 |
+ let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan) |
|
| 1329 |
+ sharedAxisOrigin = 0 |
|
| 1330 |
+ sharedAxisUpperBound = currentSpan |
|
| 1331 |
+ voltageAxisOrigin = 0 |
|
| 1332 |
+ currentAxisOrigin = 0 |
|
| 1333 |
+ ensureSharedScaleSpan() |
|
| 1334 |
+ } else {
|
|
| 1335 |
+ if displayPower {
|
|
| 1336 |
+ powerAxisOrigin = 0 |
|
| 1337 |
+ } |
|
| 1338 |
+ if displayEnergy {
|
|
| 1339 |
+ energyAxisOrigin = 0 |
|
| 1340 |
+ } |
|
| 1341 |
+ if displayVoltage {
|
|
| 1342 |
+ voltageAxisOrigin = 0 |
|
| 1343 |
+ } |
|
| 1344 |
+ if displayCurrent {
|
|
| 1345 |
+ currentAxisOrigin = 0 |
|
| 1346 |
+ } |
|
| 1347 |
+ if displayTemperature {
|
|
| 1348 |
+ temperatureAxisOrigin = 0 |
|
| 1349 |
+ } |
|
| 1350 |
+ } |
|
| 1351 |
+ |
|
| 1352 |
+ pinOrigin = true |
|
| 1353 |
+ } |
|
| 1354 |
+ |
|
| 1355 |
+ private func captureCurrentOrigins( |
|
| 1356 |
+ voltageSeries: SeriesData, |
|
| 1357 |
+ currentSeries: SeriesData |
|
| 1358 |
+ ) {
|
|
| 1359 |
+ powerAxisOrigin = displayedLowerBoundForSeries(.power) |
|
| 1360 |
+ energyAxisOrigin = displayedLowerBoundForSeries(.energy) |
|
| 1361 |
+ voltageAxisOrigin = voltageSeries.autoLowerBound |
|
| 1362 |
+ currentAxisOrigin = currentSeries.autoLowerBound |
|
| 1363 |
+ temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature) |
|
| 1364 |
+ sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin) |
|
| 1365 |
+ sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound) |
|
| 1366 |
+ ensureSharedScaleSpan() |
|
| 1367 |
+ } |
|
| 1368 |
+ |
|
| 1369 |
+ private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
|
|
| 1370 |
+ let visibleTimeRange = activeVisibleTimeRange |
|
| 1371 |
+ |
|
| 1372 |
+ switch kind {
|
|
| 1373 |
+ case .power: |
|
| 1374 |
+ return pinOrigin |
|
| 1375 |
+ ? powerAxisOrigin |
|
| 1376 |
+ : automaticYBounds( |
|
| 1377 |
+ for: filteredSamplePoints( |
|
| 1378 |
+ measurements.power, |
|
| 1379 |
+ visibleTimeRange: visibleTimeRange |
|
| 1380 |
+ ), |
|
| 1381 |
+ minimumYSpan: minimumPowerSpan |
|
| 1382 |
+ ).lowerBound |
|
| 1383 |
+ case .energy: |
|
| 1384 |
+ return pinOrigin |
|
| 1385 |
+ ? energyAxisOrigin |
|
| 1386 |
+ : automaticYBounds( |
|
| 1387 |
+ for: filteredSamplePoints( |
|
| 1388 |
+ measurements.energy, |
|
| 1389 |
+ visibleTimeRange: visibleTimeRange |
|
| 1390 |
+ ), |
|
| 1391 |
+ minimumYSpan: minimumEnergySpan |
|
| 1392 |
+ ).lowerBound |
|
| 1393 |
+ case .voltage: |
|
| 1394 |
+ if pinOrigin && useSharedOrigin && supportsSharedOrigin {
|
|
| 1395 |
+ return sharedAxisOrigin |
|
| 1396 |
+ } |
|
| 1397 |
+ return pinOrigin |
|
| 1398 |
+ ? voltageAxisOrigin |
|
| 1399 |
+ : automaticYBounds( |
|
| 1400 |
+ for: filteredSamplePoints( |
|
| 1401 |
+ measurements.voltage, |
|
| 1402 |
+ visibleTimeRange: visibleTimeRange |
|
| 1403 |
+ ), |
|
| 1404 |
+ minimumYSpan: minimumVoltageSpan |
|
| 1405 |
+ ).lowerBound |
|
| 1406 |
+ case .current: |
|
| 1407 |
+ if pinOrigin && useSharedOrigin && supportsSharedOrigin {
|
|
| 1408 |
+ return sharedAxisOrigin |
|
| 1409 |
+ } |
|
| 1410 |
+ return pinOrigin |
|
| 1411 |
+ ? currentAxisOrigin |
|
| 1412 |
+ : automaticYBounds( |
|
| 1413 |
+ for: filteredSamplePoints( |
|
| 1414 |
+ measurements.current, |
|
| 1415 |
+ visibleTimeRange: visibleTimeRange |
|
| 1416 |
+ ), |
|
| 1417 |
+ minimumYSpan: minimumCurrentSpan |
|
| 1418 |
+ ).lowerBound |
|
| 1419 |
+ case .temperature: |
|
| 1420 |
+ return pinOrigin |
|
| 1421 |
+ ? temperatureAxisOrigin |
|
| 1422 |
+ : automaticYBounds( |
|
| 1423 |
+ for: filteredSamplePoints( |
|
| 1424 |
+ measurements.temperature, |
|
| 1425 |
+ visibleTimeRange: visibleTimeRange |
|
| 1426 |
+ ), |
|
| 1427 |
+ minimumYSpan: minimumTemperatureSpan |
|
| 1428 |
+ ).lowerBound |
|
| 1429 |
+ } |
|
| 1430 |
+ } |
|
| 1431 |
+ |
|
| 1432 |
+ private var activeVisibleTimeRange: ClosedRange<Date>? {
|
|
| 1433 |
+ resolvedVisibleTimeRange(within: availableSelectionTimeRange()) |
|
| 1434 |
+ } |
|
| 1435 |
+ |
|
| 1436 |
+ private func filteredPoints( |
|
| 1437 |
+ _ measurement: Measurements.Measurement, |
|
| 1438 |
+ visibleTimeRange: ClosedRange<Date>? = nil |
|
| 1439 |
+ ) -> [Measurements.Measurement.Point] {
|
|
| 1440 |
+ let resolvedRange: ClosedRange<Date>? |
|
| 1441 |
+ |
|
| 1442 |
+ switch (timeRange, visibleTimeRange) {
|
|
| 1443 |
+ case let (baseRange?, visibleRange?): |
|
| 1444 |
+ let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound) |
|
| 1445 |
+ let upperBound = min(baseRange.upperBound, visibleRange.upperBound) |
|
| 1446 |
+ resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil |
|
| 1447 |
+ case let (baseRange?, nil): |
|
| 1448 |
+ resolvedRange = baseRange |
|
| 1449 |
+ case let (nil, visibleRange?): |
|
| 1450 |
+ resolvedRange = visibleRange |
|
| 1451 |
+ case (nil, nil): |
|
| 1452 |
+ resolvedRange = nil |
|
| 1453 |
+ } |
|
| 1454 |
+ |
|
| 1455 |
+ guard let resolvedRange else {
|
|
| 1456 |
+ return timeRange == nil && visibleTimeRange == nil ? measurement.points : [] |
|
| 1457 |
+ } |
|
| 1458 |
+ |
|
| 1459 |
+ return measurement.points(in: resolvedRange) |
|
| 1460 |
+ } |
|
| 1461 |
+ |
|
| 1462 |
+ private func filteredSamplePoints( |
|
| 1463 |
+ _ measurement: Measurements.Measurement, |
|
| 1464 |
+ visibleTimeRange: ClosedRange<Date>? = nil |
|
| 1465 |
+ ) -> [Measurements.Measurement.Point] {
|
|
| 1466 |
+ filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
|
|
| 1467 |
+ point.isSample |
|
| 1468 |
+ } |
|
| 1469 |
+ } |
|
| 1470 |
+ |
|
| 1471 |
+ private func xBounds( |
|
| 1472 |
+ for samplePoints: [Measurements.Measurement.Point], |
|
| 1473 |
+ visibleTimeRange: ClosedRange<Date>? = nil |
|
| 1474 |
+ ) -> ClosedRange<Date> {
|
|
| 1475 |
+ if let visibleTimeRange {
|
|
| 1476 |
+ return normalizedTimeRange(visibleTimeRange) |
|
| 1477 |
+ } |
|
| 1478 |
+ |
|
| 1479 |
+ if let timeRange {
|
|
| 1480 |
+ return normalizedTimeRange(timeRange) |
|
| 1481 |
+ } |
|
| 1482 |
+ |
|
| 1483 |
+ let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow) |
|
| 1484 |
+ let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan) |
|
| 1485 |
+ |
|
| 1486 |
+ return normalizedTimeRange(lowerBound...upperBound) |
|
| 1487 |
+ } |
|
| 1488 |
+ |
|
| 1489 |
+ private func availableSelectionTimeRange() -> ClosedRange<Date>? {
|
|
| 1490 |
+ if let timeRange {
|
|
| 1491 |
+ return normalizedTimeRange(timeRange) |
|
| 1492 |
+ } |
|
| 1493 |
+ |
|
| 1494 |
+ let samplePoints = timelineSamplePoints() |
|
| 1495 |
+ let lowerBound = timeRangeLowerBound ?? samplePoints.first?.timestamp |
|
| 1496 |
+ guard let lowerBound else {
|
|
| 1497 |
+ return nil |
|
| 1498 |
+ } |
|
| 1499 |
+ |
|
| 1500 |
+ let latestSampleTimestamp = samplePoints.last?.timestamp |
|
| 1501 |
+ let resolvedUpperBound = timeRangeUpperBound ?? {
|
|
| 1502 |
+ guard extendsTimelineToPresent else {
|
|
| 1503 |
+ return latestSampleTimestamp ?? lowerBound |
|
| 1504 |
+ } |
|
| 1505 |
+ return max(latestSampleTimestamp ?? chartNow, chartNow) |
|
| 1506 |
+ }() |
|
| 1507 |
+ let upperBound = max(resolvedUpperBound, lowerBound) |
|
| 1508 |
+ return normalizedTimeRange(lowerBound...upperBound) |
|
| 1509 |
+ } |
|
| 1510 |
+ |
|
| 1511 |
+ private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
|
|
| 1512 |
+ let candidates = [ |
|
| 1513 |
+ filteredSamplePoints(measurements.power), |
|
| 1514 |
+ filteredSamplePoints(measurements.energy), |
|
| 1515 |
+ filteredSamplePoints(measurements.voltage), |
|
| 1516 |
+ filteredSamplePoints(measurements.current), |
|
| 1517 |
+ filteredSamplePoints(measurements.temperature) |
|
| 1518 |
+ ] |
|
| 1519 |
+ |
|
| 1520 |
+ return candidates.first(where: { !$0.isEmpty }) ?? []
|
|
| 1521 |
+ } |
|
| 1522 |
+ |
|
| 1523 |
+ private func resolvedVisibleTimeRange( |
|
| 1524 |
+ within availableTimeRange: ClosedRange<Date>? |
|
| 1525 |
+ ) -> ClosedRange<Date>? {
|
|
| 1526 |
+ guard let availableTimeRange else { return nil }
|
|
| 1527 |
+ guard let selectedVisibleTimeRange else { return availableTimeRange }
|
|
| 1528 |
+ |
|
| 1529 |
+ if isPinnedToPresent {
|
|
| 1530 |
+ let pinnedRange: ClosedRange<Date> |
|
| 1531 |
+ |
|
| 1532 |
+ switch presentTrackingMode {
|
|
| 1533 |
+ case .keepDuration: |
|
| 1534 |
+ let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound) |
|
| 1535 |
+ pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound |
|
| 1536 |
+ case .keepStartTimestamp: |
|
| 1537 |
+ pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound |
|
| 1538 |
+ } |
|
| 1539 |
+ |
|
| 1540 |
+ return clampedTimeRange(pinnedRange, within: availableTimeRange) |
|
| 1541 |
+ } |
|
| 1542 |
+ |
|
| 1543 |
+ return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange) |
|
| 1544 |
+ } |
|
| 1545 |
+ |
|
| 1546 |
+ private func clampedTimeRange( |
|
| 1547 |
+ _ candidateRange: ClosedRange<Date>, |
|
| 1548 |
+ within bounds: ClosedRange<Date> |
|
| 1549 |
+ ) -> ClosedRange<Date> {
|
|
| 1550 |
+ let normalizedBounds = normalizedTimeRange(bounds) |
|
| 1551 |
+ let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound) |
|
| 1552 |
+ |
|
| 1553 |
+ guard boundsSpan > 0 else {
|
|
| 1554 |
+ return normalizedBounds |
|
| 1555 |
+ } |
|
| 1556 |
+ |
|
| 1557 |
+ let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan) |
|
| 1558 |
+ let requestedSpan = min( |
|
| 1559 |
+ max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan), |
|
| 1560 |
+ boundsSpan |
|
| 1561 |
+ ) |
|
| 1562 |
+ |
|
| 1563 |
+ if requestedSpan >= boundsSpan {
|
|
| 1564 |
+ return normalizedBounds |
|
| 1565 |
+ } |
|
| 1566 |
+ |
|
| 1567 |
+ var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound) |
|
| 1568 |
+ var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound) |
|
| 1569 |
+ |
|
| 1570 |
+ if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
|
|
| 1571 |
+ if lowerBound == normalizedBounds.lowerBound {
|
|
| 1572 |
+ upperBound = lowerBound.addingTimeInterval(requestedSpan) |
|
| 1573 |
+ } else {
|
|
| 1574 |
+ lowerBound = upperBound.addingTimeInterval(-requestedSpan) |
|
| 1575 |
+ } |
|
| 1576 |
+ } |
|
| 1577 |
+ |
|
| 1578 |
+ if upperBound > normalizedBounds.upperBound {
|
|
| 1579 |
+ let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound) |
|
| 1580 |
+ upperBound = normalizedBounds.upperBound |
|
| 1581 |
+ lowerBound = lowerBound.addingTimeInterval(-delta) |
|
| 1582 |
+ } |
|
| 1583 |
+ |
|
| 1584 |
+ if lowerBound < normalizedBounds.lowerBound {
|
|
| 1585 |
+ let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound) |
|
| 1586 |
+ lowerBound = normalizedBounds.lowerBound |
|
| 1587 |
+ upperBound = upperBound.addingTimeInterval(delta) |
|
| 1588 |
+ } |
|
| 1589 |
+ |
|
| 1590 |
+ return lowerBound...upperBound |
|
| 1591 |
+ } |
|
| 1592 |
+ |
|
| 1593 |
+ private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
|
|
| 1594 |
+ let span = range.upperBound.timeIntervalSince(range.lowerBound) |
|
| 1595 |
+ guard span < minimumTimeSpan else { return range }
|
|
| 1596 |
+ |
|
| 1597 |
+ let expansion = (minimumTimeSpan - span) / 2 |
|
| 1598 |
+ return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion) |
|
| 1599 |
+ } |
|
| 1600 |
+ |
|
| 1601 |
+ private func shouldShowRangeSelector( |
|
| 1602 |
+ availableTimeRange: ClosedRange<Date>, |
|
| 1603 |
+ series: SeriesData |
|
| 1604 |
+ ) -> Bool {
|
|
| 1605 |
+ series.samplePoints.count > 1 && |
|
| 1606 |
+ availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan |
|
| 1607 |
+ } |
|
| 1608 |
+ |
|
| 1609 |
+ private func automaticYBounds( |
|
| 1610 |
+ for samplePoints: [Measurements.Measurement.Point], |
|
| 1611 |
+ minimumYSpan: Double |
|
| 1612 |
+ ) -> (lowerBound: Double, upperBound: Double) {
|
|
| 1613 |
+ let negativeAllowance = max(0.05, minimumYSpan * 0.08) |
|
| 1614 |
+ |
|
| 1615 |
+ guard |
|
| 1616 |
+ let minimumSampleValue = samplePoints.map(\.value).min(), |
|
| 1617 |
+ let maximumSampleValue = samplePoints.map(\.value).max() |
|
| 1618 |
+ else {
|
|
| 1619 |
+ return (0, minimumYSpan) |
|
| 1620 |
+ } |
|
| 1621 |
+ |
|
| 1622 |
+ var lowerBound = minimumSampleValue |
|
| 1623 |
+ var upperBound = maximumSampleValue |
|
| 1624 |
+ let currentSpan = upperBound - lowerBound |
|
| 1625 |
+ |
|
| 1626 |
+ if currentSpan < minimumYSpan {
|
|
| 1627 |
+ let expansion = (minimumYSpan - currentSpan) / 2 |
|
| 1628 |
+ lowerBound -= expansion |
|
| 1629 |
+ upperBound += expansion |
|
| 1630 |
+ } |
|
| 1631 |
+ |
|
| 1632 |
+ if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
|
|
| 1633 |
+ let shift = -negativeAllowance - lowerBound |
|
| 1634 |
+ lowerBound += shift |
|
| 1635 |
+ upperBound += shift |
|
| 1636 |
+ } |
|
| 1637 |
+ |
|
| 1638 |
+ let snappedLowerBound = snappedOriginValue(lowerBound) |
|
| 1639 |
+ let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan) |
|
| 1640 |
+ return (snappedLowerBound, resolvedUpperBound) |
|
| 1641 |
+ } |
|
| 1642 |
+ |
|
| 1643 |
+ private func resolvedLowerBound( |
|
| 1644 |
+ for kind: SeriesKind, |
|
| 1645 |
+ autoLowerBound: Double |
|
| 1646 |
+ ) -> Double {
|
|
| 1647 |
+ guard pinOrigin else { return autoLowerBound }
|
|
| 1648 |
+ |
|
| 1649 |
+ if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
|
|
| 1650 |
+ return sharedAxisOrigin |
|
| 1651 |
+ } |
|
| 1652 |
+ |
|
| 1653 |
+ switch kind {
|
|
| 1654 |
+ case .power: |
|
| 1655 |
+ return powerAxisOrigin |
|
| 1656 |
+ case .energy: |
|
| 1657 |
+ return energyAxisOrigin |
|
| 1658 |
+ case .voltage: |
|
| 1659 |
+ return voltageAxisOrigin |
|
| 1660 |
+ case .current: |
|
| 1661 |
+ return currentAxisOrigin |
|
| 1662 |
+ case .temperature: |
|
| 1663 |
+ return temperatureAxisOrigin |
|
| 1664 |
+ } |
|
| 1665 |
+ } |
|
| 1666 |
+ |
|
| 1667 |
+ private func resolvedUpperBound( |
|
| 1668 |
+ for kind: SeriesKind, |
|
| 1669 |
+ lowerBound: Double, |
|
| 1670 |
+ autoUpperBound: Double, |
|
| 1671 |
+ maximumSampleValue: Double?, |
|
| 1672 |
+ minimumYSpan: Double |
|
| 1673 |
+ ) -> Double {
|
|
| 1674 |
+ guard pinOrigin else {
|
|
| 1675 |
+ return autoUpperBound |
|
| 1676 |
+ } |
|
| 1677 |
+ |
|
| 1678 |
+ if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
|
|
| 1679 |
+ return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan) |
|
| 1680 |
+ } |
|
| 1681 |
+ |
|
| 1682 |
+ if kind == .temperature {
|
|
| 1683 |
+ return autoUpperBound |
|
| 1684 |
+ } |
|
| 1685 |
+ |
|
| 1686 |
+ return max( |
|
| 1687 |
+ maximumSampleValue ?? lowerBound, |
|
| 1688 |
+ lowerBound + minimumYSpan, |
|
| 1689 |
+ autoUpperBound |
|
| 1690 |
+ ) |
|
| 1691 |
+ } |
|
| 1692 |
+ |
|
| 1693 |
+ private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
|
|
| 1694 |
+ let baseline = displayedLowerBoundForSeries(kind) |
|
| 1695 |
+ let proposedOrigin = snappedOriginValue(baseline + delta) |
|
| 1696 |
+ |
|
| 1697 |
+ if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
|
|
| 1698 |
+ let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan) |
|
| 1699 |
+ sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin()) |
|
| 1700 |
+ sharedAxisUpperBound = sharedAxisOrigin + currentSpan |
|
| 1701 |
+ ensureSharedScaleSpan() |
|
| 1702 |
+ } else {
|
|
| 1703 |
+ switch kind {
|
|
| 1704 |
+ case .power: |
|
| 1705 |
+ powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power)) |
|
| 1706 |
+ case .energy: |
|
| 1707 |
+ energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy)) |
|
| 1708 |
+ case .voltage: |
|
| 1709 |
+ voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage)) |
|
| 1710 |
+ case .current: |
|
| 1711 |
+ currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current)) |
|
| 1712 |
+ case .temperature: |
|
| 1713 |
+ temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature)) |
|
| 1714 |
+ } |
|
| 1715 |
+ } |
|
| 1716 |
+ |
|
| 1717 |
+ pinOrigin = true |
|
| 1718 |
+ } |
|
| 1719 |
+ |
|
| 1720 |
+ private func clearOriginOffset(for kind: SeriesKind) {
|
|
| 1721 |
+ if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
|
|
| 1722 |
+ let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan) |
|
| 1723 |
+ sharedAxisOrigin = 0 |
|
| 1724 |
+ sharedAxisUpperBound = currentSpan |
|
| 1725 |
+ ensureSharedScaleSpan() |
|
| 1726 |
+ voltageAxisOrigin = 0 |
|
| 1727 |
+ currentAxisOrigin = 0 |
|
| 1728 |
+ } else {
|
|
| 1729 |
+ switch kind {
|
|
| 1730 |
+ case .power: |
|
| 1731 |
+ powerAxisOrigin = 0 |
|
| 1732 |
+ case .energy: |
|
| 1733 |
+ energyAxisOrigin = 0 |
|
| 1734 |
+ case .voltage: |
|
| 1735 |
+ voltageAxisOrigin = 0 |
|
| 1736 |
+ case .current: |
|
| 1737 |
+ currentAxisOrigin = 0 |
|
| 1738 |
+ case .temperature: |
|
| 1739 |
+ temperatureAxisOrigin = 0 |
|
| 1740 |
+ } |
|
| 1741 |
+ } |
|
| 1742 |
+ |
|
| 1743 |
+ pinOrigin = true |
|
| 1744 |
+ } |
|
| 1745 |
+ |
|
| 1746 |
+ private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
|
|
| 1747 |
+ guard totalHeight > 1 else { return }
|
|
| 1748 |
+ |
|
| 1749 |
+ let normalized = max(0, min(1, locationY / totalHeight)) |
|
| 1750 |
+ if normalized < (1.0 / 3.0) {
|
|
| 1751 |
+ applyOriginDelta(-1, kind: kind) |
|
| 1752 |
+ } else if normalized < (2.0 / 3.0) {
|
|
| 1753 |
+ clearOriginOffset(for: kind) |
|
| 1754 |
+ } else {
|
|
| 1755 |
+ applyOriginDelta(1, kind: kind) |
|
| 1756 |
+ } |
|
| 1757 |
+ } |
|
| 1758 |
+ |
|
| 1759 |
+ private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
|
|
| 1760 |
+ let visibleTimeRange = activeVisibleTimeRange |
|
| 1761 |
+ |
|
| 1762 |
+ switch kind {
|
|
| 1763 |
+ case .power: |
|
| 1764 |
+ return snappedOriginValue( |
|
| 1765 |
+ filteredSamplePoints( |
|
| 1766 |
+ measurements.power, |
|
| 1767 |
+ visibleTimeRange: visibleTimeRange |
|
| 1768 |
+ ).map(\.value).min() ?? 0 |
|
| 1769 |
+ ) |
|
| 1770 |
+ case .energy: |
|
| 1771 |
+ return snappedOriginValue( |
|
| 1772 |
+ filteredSamplePoints( |
|
| 1773 |
+ measurements.energy, |
|
| 1774 |
+ visibleTimeRange: visibleTimeRange |
|
| 1775 |
+ ).map(\.value).min() ?? 0 |
|
| 1776 |
+ ) |
|
| 1777 |
+ case .voltage: |
|
| 1778 |
+ return snappedOriginValue( |
|
| 1779 |
+ filteredSamplePoints( |
|
| 1780 |
+ measurements.voltage, |
|
| 1781 |
+ visibleTimeRange: visibleTimeRange |
|
| 1782 |
+ ).map(\.value).min() ?? 0 |
|
| 1783 |
+ ) |
|
| 1784 |
+ case .current: |
|
| 1785 |
+ return snappedOriginValue( |
|
| 1786 |
+ filteredSamplePoints( |
|
| 1787 |
+ measurements.current, |
|
| 1788 |
+ visibleTimeRange: visibleTimeRange |
|
| 1789 |
+ ).map(\.value).min() ?? 0 |
|
| 1790 |
+ ) |
|
| 1791 |
+ case .temperature: |
|
| 1792 |
+ return snappedOriginValue( |
|
| 1793 |
+ filteredSamplePoints( |
|
| 1794 |
+ measurements.temperature, |
|
| 1795 |
+ visibleTimeRange: visibleTimeRange |
|
| 1796 |
+ ).map(\.value).min() ?? 0 |
|
| 1797 |
+ ) |
|
| 1798 |
+ } |
|
| 1799 |
+ } |
|
| 1800 |
+ |
|
| 1801 |
+ private func maximumVisibleSharedOrigin() -> Double {
|
|
| 1802 |
+ min( |
|
| 1803 |
+ maximumVisibleOrigin(for: .voltage), |
|
| 1804 |
+ maximumVisibleOrigin(for: .current) |
|
| 1805 |
+ ) |
|
| 1806 |
+ } |
|
| 1807 |
+ |
|
| 1808 |
+ private func measurementUnit(for kind: SeriesKind) -> String {
|
|
| 1809 |
+ switch kind {
|
|
| 1810 |
+ case .temperature: |
|
| 1811 |
+ let locale = Locale.autoupdatingCurrent |
|
| 1812 |
+ if #available(iOS 16.0, *) {
|
|
| 1813 |
+ switch locale.measurementSystem {
|
|
| 1814 |
+ case .us: |
|
| 1815 |
+ return "°F" |
|
| 1816 |
+ default: |
|
| 1817 |
+ return "°C" |
|
| 1818 |
+ } |
|
| 1819 |
+ } |
|
| 1820 |
+ |
|
| 1821 |
+ let regionCode = locale.regionCode ?? "" |
|
| 1822 |
+ let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"] |
|
| 1823 |
+ return fahrenheitRegions.contains(regionCode) ? "°F" : "°C" |
|
| 1824 |
+ default: |
|
| 1825 |
+ return kind.unit |
|
| 1826 |
+ } |
|
| 1827 |
+ } |
|
| 1828 |
+ |
|
| 1829 |
+ private func ensureSharedScaleSpan() {
|
|
| 1830 |
+ sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan) |
|
| 1831 |
+ } |
|
| 1832 |
+ |
|
| 1833 |
+ private func snappedOriginValue(_ value: Double) -> Double {
|
|
| 1834 |
+ if value >= 0 {
|
|
| 1835 |
+ return value.rounded(.down) |
|
| 1836 |
+ } |
|
| 1837 |
+ |
|
| 1838 |
+ return value.rounded(.up) |
|
| 1839 |
+ } |
|
| 1840 |
+ |
|
| 1841 |
+ private func trimBufferToSelection(_ range: ClosedRange<Date>) {
|
|
| 1842 |
+ measurements.keepOnly(in: range) |
|
| 1843 |
+ selectedVisibleTimeRange = nil |
|
| 1844 |
+ isPinnedToPresent = false |
|
| 1845 |
+ } |
|
| 1846 |
+ |
|
| 1847 |
+ private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
|
|
| 1848 |
+ measurements.removeValues(in: range) |
|
| 1849 |
+ selectedVisibleTimeRange = nil |
|
| 1850 |
+ isPinnedToPresent = false |
|
| 1851 |
+ } |
|
| 1852 |
+ |
|
| 1853 |
+ private func yGuidePosition( |
|
| 1854 |
+ for labelIndex: Int, |
|
| 1855 |
+ context: ChartContext, |
|
| 1856 |
+ height: CGFloat |
|
| 1857 |
+ ) -> CGFloat {
|
|
| 1858 |
+ context.yGuidePosition(for: labelIndex, of: yLabels, height: height) |
|
| 1859 |
+ } |
|
| 1860 |
+ |
|
| 1861 |
+ private func xGuidePosition( |
|
| 1862 |
+ for labelIndex: Int, |
|
| 1863 |
+ context: ChartContext, |
|
| 1864 |
+ width: CGFloat |
|
| 1865 |
+ ) -> CGFloat {
|
|
| 1866 |
+ context.xGuidePosition(for: labelIndex, of: xLabels, width: width) |
|
| 1867 |
+ } |
|
| 1868 |
+ |
|
| 1869 |
+ // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat |
|
| 1870 |
+ fileprivate func xAxisLabelsView( |
|
| 1871 |
+ context: ChartContext |
|
| 1872 |
+ ) -> some View {
|
|
| 1873 |
+ var timeFormat: String? |
|
| 1874 |
+ switch context.size.width {
|
|
| 1875 |
+ case 0..<3600: timeFormat = "HH:mm:ss" |
|
| 1876 |
+ case 3600...86400: timeFormat = "HH:mm" |
|
| 1877 |
+ default: timeFormat = "E HH:mm" |
|
| 1878 |
+ } |
|
| 1879 |
+ let labels = (1...xLabels).map {
|
|
| 1880 |
+ Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!) |
|
| 1881 |
+ } |
|
| 1882 |
+ let axisLabelFont: Font = {
|
|
| 1883 |
+ if isIPhone && isPortraitLayout {
|
|
| 1884 |
+ return .caption2.weight(.semibold) |
|
| 1885 |
+ } |
|
| 1886 |
+ return (isLargeDisplay ? Font.callout : .caption).weight(.semibold) |
|
| 1887 |
+ }() |
|
| 1888 |
+ |
|
| 1889 |
+ return HStack(spacing: chartSectionSpacing) {
|
|
| 1890 |
+ Color.clear |
|
| 1891 |
+ .frame(width: axisColumnWidth) |
|
| 1892 |
+ |
|
| 1893 |
+ GeometryReader { geometry in
|
|
| 1894 |
+ let labelWidth = max( |
|
| 1895 |
+ geometry.size.width / CGFloat(max(xLabels - 1, 1)), |
|
| 1896 |
+ 1 |
|
| 1897 |
+ ) |
|
| 1898 |
+ |
|
| 1899 |
+ ZStack(alignment: .topLeading) {
|
|
| 1900 |
+ Path { path in
|
|
| 1901 |
+ for labelIndex in 1...self.xLabels {
|
|
| 1902 |
+ let x = xGuidePosition( |
|
| 1903 |
+ for: labelIndex, |
|
| 1904 |
+ context: context, |
|
| 1905 |
+ width: geometry.size.width |
|
| 1906 |
+ ) |
|
| 1907 |
+ path.move(to: CGPoint(x: x, y: 0)) |
|
| 1908 |
+ path.addLine(to: CGPoint(x: x, y: 6)) |
|
| 1909 |
+ } |
|
| 1910 |
+ } |
|
| 1911 |
+ .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75) |
|
| 1912 |
+ |
|
| 1913 |
+ ForEach(Array(labels.enumerated()), id: \.offset) { item in
|
|
| 1914 |
+ let labelIndex = item.offset + 1 |
|
| 1915 |
+ let centerX = xGuidePosition( |
|
| 1916 |
+ for: labelIndex, |
|
| 1917 |
+ context: context, |
|
| 1918 |
+ width: geometry.size.width |
|
| 1919 |
+ ) |
|
| 1920 |
+ |
|
| 1921 |
+ Text(item.element) |
|
| 1922 |
+ .font(axisLabelFont) |
|
| 1923 |
+ .monospacedDigit() |
|
| 1924 |
+ .lineLimit(1) |
|
| 1925 |
+ .minimumScaleFactor(0.74) |
|
| 1926 |
+ .frame(width: labelWidth) |
|
| 1927 |
+ .position( |
|
| 1928 |
+ x: centerX, |
|
| 1929 |
+ y: geometry.size.height * 0.7 |
|
| 1930 |
+ ) |
|
| 1931 |
+ } |
|
| 1932 |
+ } |
|
| 1933 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 1934 |
+ } |
|
| 1935 |
+ |
|
| 1936 |
+ Color.clear |
|
| 1937 |
+ .frame(width: axisColumnWidth) |
|
| 1938 |
+ } |
|
| 1939 |
+ } |
|
| 1940 |
+ |
|
| 1941 |
+ private func yAxisLabelsView( |
|
| 1942 |
+ height: CGFloat, |
|
| 1943 |
+ context: ChartContext, |
|
| 1944 |
+ seriesKind: SeriesKind, |
|
| 1945 |
+ measurementUnit: String, |
|
| 1946 |
+ tint: Color |
|
| 1947 |
+ ) -> some View {
|
|
| 1948 |
+ let yAxisFont: Font = {
|
|
| 1949 |
+ if isIPhone && isPortraitLayout {
|
|
| 1950 |
+ return .caption2.weight(.semibold) |
|
| 1951 |
+ } |
|
| 1952 |
+ return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold) |
|
| 1953 |
+ }() |
|
| 1954 |
+ |
|
| 1955 |
+ let unitFont: Font = {
|
|
| 1956 |
+ if isIPhone && isPortraitLayout {
|
|
| 1957 |
+ return .caption2.weight(.bold) |
|
| 1958 |
+ } |
|
| 1959 |
+ return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold) |
|
| 1960 |
+ }() |
|
| 1961 |
+ |
|
| 1962 |
+ return GeometryReader { geometry in
|
|
| 1963 |
+ let footerHeight: CGFloat = isLargeDisplay ? 30 : 24 |
|
| 1964 |
+ let topInset: CGFloat = isLargeDisplay ? 34 : 28 |
|
| 1965 |
+ let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1) |
|
| 1966 |
+ |
|
| 1967 |
+ ZStack(alignment: .top) {
|
|
| 1968 |
+ ForEach(0..<yLabels, id: \.self) { row in
|
|
| 1969 |
+ let labelIndex = yLabels - row |
|
| 1970 |
+ |
|
| 1971 |
+ Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
|
|
| 1972 |
+ .font(yAxisFont) |
|
| 1973 |
+ .monospacedDigit() |
|
| 1974 |
+ .lineLimit(1) |
|
| 1975 |
+ .minimumScaleFactor(0.8) |
|
| 1976 |
+ .frame(width: max(geometry.size.width - 10, 0)) |
|
| 1977 |
+ .position( |
|
| 1978 |
+ x: geometry.size.width / 2, |
|
| 1979 |
+ y: topInset + yGuidePosition( |
|
| 1980 |
+ for: labelIndex, |
|
| 1981 |
+ context: context, |
|
| 1982 |
+ height: labelAreaHeight |
|
| 1983 |
+ ) |
|
| 1984 |
+ ) |
|
| 1985 |
+ } |
|
| 1986 |
+ |
|
| 1987 |
+ Text(measurementUnit) |
|
| 1988 |
+ .font(unitFont) |
|
| 1989 |
+ .foregroundColor(tint) |
|
| 1990 |
+ .padding(.horizontal, isLargeDisplay ? 8 : 6) |
|
| 1991 |
+ .padding(.vertical, isLargeDisplay ? 5 : 4) |
|
| 1992 |
+ .background( |
|
| 1993 |
+ Capsule(style: .continuous) |
|
| 1994 |
+ .fill(tint.opacity(0.14)) |
|
| 1995 |
+ ) |
|
| 1996 |
+ .padding(.top, 8) |
|
| 1997 |
+ |
|
| 1998 |
+ } |
|
| 1999 |
+ } |
|
| 2000 |
+ .frame(height: height) |
|
| 2001 |
+ .background( |
|
| 2002 |
+ RoundedRectangle(cornerRadius: 16, style: .continuous) |
|
| 2003 |
+ .fill(tint.opacity(0.12)) |
|
| 2004 |
+ ) |
|
| 2005 |
+ .overlay( |
|
| 2006 |
+ RoundedRectangle(cornerRadius: 16, style: .continuous) |
|
| 2007 |
+ .stroke(tint.opacity(0.20), lineWidth: 1) |
|
| 2008 |
+ ) |
|
| 2009 |
+ .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) |
|
| 2010 |
+ .gesture( |
|
| 2011 |
+ DragGesture(minimumDistance: 0) |
|
| 2012 |
+ .onEnded { value in
|
|
| 2013 |
+ handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind) |
|
| 2014 |
+ } |
|
| 2015 |
+ ) |
|
| 2016 |
+ } |
|
| 2017 |
+ |
|
| 2018 |
+ fileprivate func horizontalGuides(context: ChartContext) -> some View {
|
|
| 2019 |
+ TimeSeriesChartHorizontalGuides(context: context, labelCount: yLabels) |
|
| 2020 |
+ } |
|
| 2021 |
+ |
|
| 2022 |
+ fileprivate func verticalGuides(context: ChartContext) -> some View {
|
|
| 2023 |
+ TimeSeriesChartVerticalGuides(context: context, labelCount: xLabels) |
|
| 2024 |
+ } |
|
| 2025 |
+ |
|
| 2026 |
+ fileprivate func discontinuityMarkers( |
|
| 2027 |
+ points: [Measurements.Measurement.Point], |
|
| 2028 |
+ context: ChartContext |
|
| 2029 |
+ ) -> some View {
|
|
| 2030 |
+ GeometryReader { geometry in
|
|
| 2031 |
+ Path { path in
|
|
| 2032 |
+ for point in points where point.isDiscontinuity {
|
|
| 2033 |
+ let markerX = context.placeInRect( |
|
| 2034 |
+ point: CGPoint( |
|
| 2035 |
+ x: point.timestamp.timeIntervalSince1970, |
|
| 2036 |
+ y: context.origin.y |
|
| 2037 |
+ ) |
|
| 2038 |
+ ).x * geometry.size.width |
|
| 2039 |
+ path.move(to: CGPoint(x: markerX, y: 0)) |
|
| 2040 |
+ path.addLine(to: CGPoint(x: markerX, y: geometry.size.height)) |
|
| 2041 |
+ } |
|
| 2042 |
+ } |
|
| 2043 |
+ .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4])) |
|
| 2044 |
+ } |
|
| 2045 |
+ } |
|
| 2046 |
+ |
|
| 2047 |
+} |
|
| 2048 |
+ |
|
| 2049 |
+private struct EmbeddedWidthKey: PreferenceKey {
|
|
| 2050 |
+ static let defaultValue: CGFloat = 760 |
|
| 2051 |
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
| 2052 |
+ let next = nextValue() |
|
| 2053 |
+ if next > 0 { value = next }
|
|
| 2054 |
+ } |
|
| 2055 |
+} |
|
| 2056 |
+ |
|
| 2057 |
+private struct TimeRangeSelectorView: View {
|
|
| 2058 |
+ private enum DragTarget {
|
|
| 2059 |
+ case lowerBound |
|
| 2060 |
+ case upperBound |
|
| 2061 |
+ case window |
|
| 2062 |
+ } |
|
| 2063 |
+ |
|
| 2064 |
+ private struct DragState {
|
|
| 2065 |
+ let target: DragTarget |
|
| 2066 |
+ let initialRange: ClosedRange<Date> |
|
| 2067 |
+ } |
|
| 2068 |
+ |
|
| 2069 |
+ let points: [Measurements.Measurement.Point] |
|
| 2070 |
+ let context: ChartContext |
|
| 2071 |
+ let availableTimeRange: ClosedRange<Date> |
|
| 2072 |
+ let selectorTint: Color |
|
| 2073 |
+ let compactLayout: Bool |
|
| 2074 |
+ let xAxisLabelCount: Int |
|
| 2075 |
+ let minimumSelectionSpan: TimeInterval |
|
| 2076 |
+ let configuration: MeasurementChartRangeSelectorConfiguration |
|
| 2077 |
+ |
|
| 2078 |
+ @Binding var selectedTimeRange: ClosedRange<Date>? |
|
| 2079 |
+ @Binding var isPinnedToPresent: Bool |
|
| 2080 |
+ @Binding var presentTrackingMode: PresentTrackingMode |
|
| 2081 |
+ @State private var dragState: DragState? |
|
| 2082 |
+ @State private var showResetConfirmation: Bool = false |
|
| 2083 |
+ |
|
| 2084 |
+ private var totalSpan: TimeInterval {
|
|
| 2085 |
+ availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) |
|
| 2086 |
+ } |
|
| 2087 |
+ |
|
| 2088 |
+ private var currentRange: ClosedRange<Date> {
|
|
| 2089 |
+ resolvedSelectionRange() |
|
| 2090 |
+ } |
|
| 2091 |
+ |
|
| 2092 |
+ private var trackHeight: CGFloat {
|
|
| 2093 |
+ Self.trackHeight(compactLayout: compactLayout) |
|
| 2094 |
+ } |
|
| 2095 |
+ |
|
| 2096 |
+ private static func trackHeight(compactLayout: Bool) -> CGFloat {
|
|
| 2097 |
+ compactLayout ? 42 : 50 |
|
| 2098 |
+ } |
|
| 2099 |
+ |
|
| 2100 |
+ static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
|
|
| 2101 |
+ let rowHeight: CGFloat = compactLayout ? 28 : 32 |
|
| 2102 |
+ let trackHeight = Self.trackHeight(compactLayout: compactLayout) |
|
| 2103 |
+ let axisLabelsHeight: CGFloat = compactLayout ? 18 : 20 |
|
| 2104 |
+ let spacing: CGFloat = compactLayout ? 6 : 8 |
|
| 2105 |
+ return rowHeight + spacing + rowHeight + spacing + trackHeight + spacing + axisLabelsHeight |
|
| 2106 |
+ } |
|
| 2107 |
+ |
|
| 2108 |
+ private var cornerRadius: CGFloat {
|
|
| 2109 |
+ compactLayout ? 14 : 16 |
|
| 2110 |
+ } |
|
| 2111 |
+ |
|
| 2112 |
+ private var symbolButtonSize: CGFloat {
|
|
| 2113 |
+ compactLayout ? 28 : 32 |
|
| 2114 |
+ } |
|
| 2115 |
+ |
|
| 2116 |
+ var body: some View {
|
|
| 2117 |
+ let coversFullRange = selectionCoversFullRange(currentRange) |
|
| 2118 |
+ |
|
| 2119 |
+ VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
|
|
| 2120 |
+ if !coversFullRange || isPinnedToPresent {
|
|
| 2121 |
+ HStack(spacing: 8) {
|
|
| 2122 |
+ alignmentButton( |
|
| 2123 |
+ systemName: "arrow.left.to.line.compact", |
|
| 2124 |
+ isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent, |
|
| 2125 |
+ action: alignSelectionToLeadingEdge, |
|
| 2126 |
+ accessibilityLabel: "Align selection to start" |
|
| 2127 |
+ ) |
|
| 2128 |
+ |
|
| 2129 |
+ alignmentButton( |
|
| 2130 |
+ systemName: "arrow.right.to.line.compact", |
|
| 2131 |
+ isActive: isPinnedToPresent || selectionTouchesPresent(currentRange), |
|
| 2132 |
+ action: alignSelectionToTrailingEdge, |
|
| 2133 |
+ accessibilityLabel: "Align selection to present" |
|
| 2134 |
+ ) |
|
| 2135 |
+ |
|
| 2136 |
+ Spacer(minLength: 0) |
|
| 2137 |
+ |
|
| 2138 |
+ if isPinnedToPresent {
|
|
| 2139 |
+ trackingModeToggleButton() |
|
| 2140 |
+ } |
|
| 2141 |
+ } |
|
| 2142 |
+ } |
|
| 2143 |
+ |
|
| 2144 |
+ HStack(spacing: 8) {
|
|
| 2145 |
+ if !coversFullRange {
|
|
| 2146 |
+ actionButton( |
|
| 2147 |
+ title: configuration.keepAction.title, |
|
| 2148 |
+ shortTitle: configuration.keepAction.shortTitle, |
|
| 2149 |
+ systemName: configuration.keepAction.systemName, |
|
| 2150 |
+ tone: configuration.keepAction.tone, |
|
| 2151 |
+ action: {
|
|
| 2152 |
+ configuration.keepAction.handler(currentRange) |
|
| 2153 |
+ resetSelectionState() |
|
| 2154 |
+ } |
|
| 2155 |
+ ) |
|
| 2156 |
+ |
|
| 2157 |
+ if let removeAction = configuration.removeAction {
|
|
| 2158 |
+ actionButton( |
|
| 2159 |
+ title: removeAction.title, |
|
| 2160 |
+ shortTitle: removeAction.shortTitle, |
|
| 2161 |
+ systemName: removeAction.systemName, |
|
| 2162 |
+ tone: removeAction.tone, |
|
| 2163 |
+ action: {
|
|
| 2164 |
+ removeAction.handler(currentRange) |
|
| 2165 |
+ resetSelectionState() |
|
| 2166 |
+ } |
|
| 2167 |
+ ) |
|
| 2168 |
+ } |
|
| 2169 |
+ } |
|
| 2170 |
+ |
|
| 2171 |
+ Spacer(minLength: 0) |
|
| 2172 |
+ |
|
| 2173 |
+ actionButton( |
|
| 2174 |
+ title: configuration.resetAction.title, |
|
| 2175 |
+ shortTitle: configuration.resetAction.shortTitle, |
|
| 2176 |
+ systemName: configuration.resetAction.systemName, |
|
| 2177 |
+ tone: configuration.resetAction.tone, |
|
| 2178 |
+ action: {
|
|
| 2179 |
+ showResetConfirmation = true |
|
| 2180 |
+ } |
|
| 2181 |
+ ) |
|
| 2182 |
+ } |
|
| 2183 |
+ .confirmationDialog(configuration.resetAction.confirmationTitle, isPresented: $showResetConfirmation, titleVisibility: .visible) {
|
|
| 2184 |
+ Button(configuration.resetAction.confirmationButtonTitle, role: .destructive) {
|
|
| 2185 |
+ configuration.resetAction.handler() |
|
| 2186 |
+ resetSelectionState() |
|
| 2187 |
+ } |
|
| 2188 |
+ Button("Cancel", role: .cancel) {}
|
|
| 2189 |
+ } |
|
| 2190 |
+ |
|
| 2191 |
+ GeometryReader { geometry in
|
|
| 2192 |
+ let selectionFrame = selectionFrame(in: geometry.size) |
|
| 2193 |
+ let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58) |
|
| 2194 |
+ |
|
| 2195 |
+ ZStack(alignment: .topLeading) {
|
|
| 2196 |
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) |
|
| 2197 |
+ .fill(Color.primary.opacity(0.05)) |
|
| 2198 |
+ |
|
| 2199 |
+ TimeSeriesChart( |
|
| 2200 |
+ points: points, |
|
| 2201 |
+ context: context, |
|
| 2202 |
+ areaChart: true, |
|
| 2203 |
+ strokeColor: selectorTint, |
|
| 2204 |
+ areaFillColor: selectorTint.opacity(0.22) |
|
| 2205 |
+ ) |
|
| 2206 |
+ .opacity(0.94) |
|
| 2207 |
+ .allowsHitTesting(false) |
|
| 2208 |
+ |
|
| 2209 |
+ TimeSeriesChart( |
|
| 2210 |
+ points: points, |
|
| 2211 |
+ context: context, |
|
| 2212 |
+ strokeColor: selectorTint.opacity(0.56) |
|
| 2213 |
+ ) |
|
| 2214 |
+ .opacity(0.82) |
|
| 2215 |
+ .allowsHitTesting(false) |
|
| 2216 |
+ |
|
| 2217 |
+ if selectionFrame.minX > 0 {
|
|
| 2218 |
+ Rectangle() |
|
| 2219 |
+ .fill(dimmingColor) |
|
| 2220 |
+ .frame(width: selectionFrame.minX, height: geometry.size.height) |
|
| 2221 |
+ .allowsHitTesting(false) |
|
| 2222 |
+ } |
|
| 2223 |
+ |
|
| 2224 |
+ if selectionFrame.maxX < geometry.size.width {
|
|
| 2225 |
+ Rectangle() |
|
| 2226 |
+ .fill(dimmingColor) |
|
| 2227 |
+ .frame( |
|
| 2228 |
+ width: max(geometry.size.width - selectionFrame.maxX, 0), |
|
| 2229 |
+ height: geometry.size.height |
|
| 2230 |
+ ) |
|
| 2231 |
+ .offset(x: selectionFrame.maxX) |
|
| 2232 |
+ .allowsHitTesting(false) |
|
| 2233 |
+ } |
|
| 2234 |
+ |
|
| 2235 |
+ RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous) |
|
| 2236 |
+ .fill(selectorTint.opacity(0.18)) |
|
| 2237 |
+ .frame(width: max(selectionFrame.width, 2), height: geometry.size.height) |
|
| 2238 |
+ .offset(x: selectionFrame.minX) |
|
| 2239 |
+ .allowsHitTesting(false) |
|
| 2240 |
+ |
|
| 2241 |
+ RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous) |
|
| 2242 |
+ .stroke(selectorTint.opacity(0.52), lineWidth: 1.2) |
|
| 2243 |
+ .frame(width: max(selectionFrame.width, 2), height: geometry.size.height) |
|
| 2244 |
+ .offset(x: selectionFrame.minX) |
|
| 2245 |
+ .allowsHitTesting(false) |
|
| 2246 |
+ |
|
| 2247 |
+ handleView(height: max(geometry.size.height - 18, 16)) |
|
| 2248 |
+ .offset(x: max(selectionFrame.minX + 6, 6), y: 9) |
|
| 2249 |
+ .allowsHitTesting(false) |
|
| 2250 |
+ |
|
| 2251 |
+ handleView(height: max(geometry.size.height - 18, 16)) |
|
| 2252 |
+ .offset(x: max(selectionFrame.maxX - 12, 6), y: 9) |
|
| 2253 |
+ .allowsHitTesting(false) |
|
| 2254 |
+ } |
|
| 2255 |
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) |
|
| 2256 |
+ .overlay( |
|
| 2257 |
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) |
|
| 2258 |
+ .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
|
| 2259 |
+ ) |
|
| 2260 |
+ .contentShape(Rectangle()) |
|
| 2261 |
+ .gesture(selectionGesture(totalWidth: geometry.size.width)) |
|
| 2262 |
+ } |
|
| 2263 |
+ .frame(height: trackHeight) |
|
| 2264 |
+ |
|
| 2265 |
+ xAxisLabelsView |
|
| 2266 |
+ } |
|
| 2267 |
+ } |
|
| 2268 |
+ |
|
| 2269 |
+ private func handleView(height: CGFloat) -> some View {
|
|
| 2270 |
+ Capsule(style: .continuous) |
|
| 2271 |
+ .fill(Color.white.opacity(0.95)) |
|
| 2272 |
+ .frame(width: 6, height: height) |
|
| 2273 |
+ .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1) |
|
| 2274 |
+ } |
|
| 2275 |
+ |
|
| 2276 |
+ private func alignmentButton( |
|
| 2277 |
+ systemName: String, |
|
| 2278 |
+ isActive: Bool, |
|
| 2279 |
+ action: @escaping () -> Void, |
|
| 2280 |
+ accessibilityLabel: String |
|
| 2281 |
+ ) -> some View {
|
|
| 2282 |
+ Button(action: action) {
|
|
| 2283 |
+ Image(systemName: systemName) |
|
| 2284 |
+ .font(.system(size: compactLayout ? 12 : 13, weight: .semibold)) |
|
| 2285 |
+ .frame(width: symbolButtonSize, height: symbolButtonSize) |
|
| 2286 |
+ } |
|
| 2287 |
+ .buttonStyle(.plain) |
|
| 2288 |
+ .foregroundColor(isActive ? .white : selectorTint) |
|
| 2289 |
+ .background( |
|
| 2290 |
+ RoundedRectangle(cornerRadius: 9, style: .continuous) |
|
| 2291 |
+ .fill(isActive ? selectorTint : selectorTint.opacity(0.14)) |
|
| 2292 |
+ ) |
|
| 2293 |
+ .overlay( |
|
| 2294 |
+ RoundedRectangle(cornerRadius: 9, style: .continuous) |
|
| 2295 |
+ .stroke(selectorTint.opacity(0.28), lineWidth: 1) |
|
| 2296 |
+ ) |
|
| 2297 |
+ .accessibilityLabel(accessibilityLabel) |
|
| 2298 |
+ } |
|
| 2299 |
+ |
|
| 2300 |
+ private func trackingModeToggleButton() -> some View {
|
|
| 2301 |
+ Button {
|
|
| 2302 |
+ presentTrackingMode = presentTrackingMode == .keepDuration |
|
| 2303 |
+ ? .keepStartTimestamp |
|
| 2304 |
+ : .keepDuration |
|
| 2305 |
+ } label: {
|
|
| 2306 |
+ Image(systemName: trackingModeSymbolName) |
|
| 2307 |
+ .font(.system(size: compactLayout ? 12 : 13, weight: .semibold)) |
|
| 2308 |
+ .frame(width: symbolButtonSize, height: symbolButtonSize) |
|
| 2309 |
+ } |
|
| 2310 |
+ .buttonStyle(.plain) |
|
| 2311 |
+ .foregroundColor(.white) |
|
| 2312 |
+ .background( |
|
| 2313 |
+ RoundedRectangle(cornerRadius: 9, style: .continuous) |
|
| 2314 |
+ .fill(selectorTint) |
|
| 2315 |
+ ) |
|
| 2316 |
+ .overlay( |
|
| 2317 |
+ RoundedRectangle(cornerRadius: 9, style: .continuous) |
|
| 2318 |
+ .stroke(selectorTint.opacity(0.28), lineWidth: 1) |
|
| 2319 |
+ ) |
|
| 2320 |
+ .accessibilityLabel(trackingModeAccessibilityLabel) |
|
| 2321 |
+ .accessibilityHint("Toggles how the interval follows the present")
|
|
| 2322 |
+ } |
|
| 2323 |
+ |
|
| 2324 |
+ private func actionButton( |
|
| 2325 |
+ title: String, |
|
| 2326 |
+ shortTitle: String? = nil, |
|
| 2327 |
+ systemName: String, |
|
| 2328 |
+ tone: MeasurementChartSelectorActionTone, |
|
| 2329 |
+ action: @escaping () -> Void |
|
| 2330 |
+ ) -> some View {
|
|
| 2331 |
+ let foregroundColor: Color = {
|
|
| 2332 |
+ switch tone {
|
|
| 2333 |
+ case .reversible, .destructive: |
|
| 2334 |
+ return toneColor(for: tone) |
|
| 2335 |
+ case .destructiveProminent: |
|
| 2336 |
+ return .white |
|
| 2337 |
+ } |
|
| 2338 |
+ }() |
|
| 2339 |
+ let displayTitle = (compactLayout ? shortTitle : nil) ?? title |
|
| 2340 |
+ |
|
| 2341 |
+ return Button(action: action) {
|
|
| 2342 |
+ Label(displayTitle, systemImage: systemName) |
|
| 2343 |
+ .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)) |
|
| 2344 |
+ .padding(.horizontal, compactLayout ? 10 : 12) |
|
| 2345 |
+ .padding(.vertical, compactLayout ? 7 : 8) |
|
| 2346 |
+ } |
|
| 2347 |
+ .buttonStyle(.plain) |
|
| 2348 |
+ .foregroundColor(foregroundColor) |
|
| 2349 |
+ .background( |
|
| 2350 |
+ RoundedRectangle(cornerRadius: 10, style: .continuous) |
|
| 2351 |
+ .fill(actionButtonBackground(for: tone)) |
|
| 2352 |
+ ) |
|
| 2353 |
+ .overlay( |
|
| 2354 |
+ RoundedRectangle(cornerRadius: 10, style: .continuous) |
|
| 2355 |
+ .stroke(actionButtonBorder(for: tone), lineWidth: 1) |
|
| 2356 |
+ ) |
|
| 2357 |
+ } |
|
| 2358 |
+ |
|
| 2359 |
+ private func toneColor(for tone: MeasurementChartSelectorActionTone) -> Color {
|
|
| 2360 |
+ switch tone {
|
|
| 2361 |
+ case .reversible: |
|
| 2362 |
+ return selectorTint |
|
| 2363 |
+ case .destructive, .destructiveProminent: |
|
| 2364 |
+ return .red |
|
| 2365 |
+ } |
|
| 2366 |
+ } |
|
| 2367 |
+ |
|
| 2368 |
+ private func actionButtonBackground(for tone: MeasurementChartSelectorActionTone) -> Color {
|
|
| 2369 |
+ switch tone {
|
|
| 2370 |
+ case .reversible: |
|
| 2371 |
+ return selectorTint.opacity(0.12) |
|
| 2372 |
+ case .destructive: |
|
| 2373 |
+ return Color.red.opacity(0.12) |
|
| 2374 |
+ case .destructiveProminent: |
|
| 2375 |
+ return Color.red.opacity(0.82) |
|
| 2376 |
+ } |
|
| 2377 |
+ } |
|
| 2378 |
+ |
|
| 2379 |
+ private func actionButtonBorder(for tone: MeasurementChartSelectorActionTone) -> Color {
|
|
| 2380 |
+ switch tone {
|
|
| 2381 |
+ case .reversible: |
|
| 2382 |
+ return selectorTint.opacity(0.22) |
|
| 2383 |
+ case .destructive: |
|
| 2384 |
+ return Color.red.opacity(0.22) |
|
| 2385 |
+ case .destructiveProminent: |
|
| 2386 |
+ return Color.red.opacity(0.72) |
|
| 2387 |
+ } |
|
| 2388 |
+ } |
|
| 2389 |
+ |
|
| 2390 |
+ private var trackingModeSymbolName: String {
|
|
| 2391 |
+ switch presentTrackingMode {
|
|
| 2392 |
+ case .keepDuration: |
|
| 2393 |
+ return "arrow.left.and.right" |
|
| 2394 |
+ case .keepStartTimestamp: |
|
| 2395 |
+ return "arrow.left.to.line.compact" |
|
| 2396 |
+ } |
|
| 2397 |
+ } |
|
| 2398 |
+ |
|
| 2399 |
+ private var trackingModeAccessibilityLabel: String {
|
|
| 2400 |
+ switch presentTrackingMode {
|
|
| 2401 |
+ case .keepDuration: |
|
| 2402 |
+ return "Keep fixed duration" |
|
| 2403 |
+ case .keepStartTimestamp: |
|
| 2404 |
+ return "Keep start fixed" |
|
| 2405 |
+ } |
|
| 2406 |
+ } |
|
| 2407 |
+ |
|
| 2408 |
+ private func alignSelectionToLeadingEdge() {
|
|
| 2409 |
+ let alignedRange = normalizedSelectionRange( |
|
| 2410 |
+ availableTimeRange.lowerBound...currentRange.upperBound |
|
| 2411 |
+ ) |
|
| 2412 |
+ applySelection(alignedRange, pinToPresent: false) |
|
| 2413 |
+ } |
|
| 2414 |
+ |
|
| 2415 |
+ private func alignSelectionToTrailingEdge() {
|
|
| 2416 |
+ let alignedRange = normalizedSelectionRange( |
|
| 2417 |
+ currentRange.lowerBound...availableTimeRange.upperBound |
|
| 2418 |
+ ) |
|
| 2419 |
+ applySelection(alignedRange, pinToPresent: true) |
|
| 2420 |
+ } |
|
| 2421 |
+ |
|
| 2422 |
+ private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
|
|
| 2423 |
+ DragGesture(minimumDistance: 0) |
|
| 2424 |
+ .onChanged { value in
|
|
| 2425 |
+ updateSelectionDrag(value: value, totalWidth: totalWidth) |
|
| 2426 |
+ } |
|
| 2427 |
+ .onEnded { _ in
|
|
| 2428 |
+ dragState = nil |
|
| 2429 |
+ } |
|
| 2430 |
+ } |
|
| 2431 |
+ |
|
| 2432 |
+ private func updateSelectionDrag( |
|
| 2433 |
+ value: DragGesture.Value, |
|
| 2434 |
+ totalWidth: CGFloat |
|
| 2435 |
+ ) {
|
|
| 2436 |
+ let startingRange = resolvedSelectionRange() |
|
| 2437 |
+ |
|
| 2438 |
+ if dragState == nil {
|
|
| 2439 |
+ dragState = DragState( |
|
| 2440 |
+ target: dragTarget( |
|
| 2441 |
+ for: value.startLocation.x, |
|
| 2442 |
+ selectionFrame: selectionFrame(for: startingRange, width: totalWidth) |
|
| 2443 |
+ ), |
|
| 2444 |
+ initialRange: startingRange |
|
| 2445 |
+ ) |
|
| 2446 |
+ } |
|
| 2447 |
+ |
|
| 2448 |
+ guard let dragState else { return }
|
|
| 2449 |
+ |
|
| 2450 |
+ let resultingRange = snappedToEdges( |
|
| 2451 |
+ adjustedRange( |
|
| 2452 |
+ from: dragState.initialRange, |
|
| 2453 |
+ target: dragState.target, |
|
| 2454 |
+ translationX: value.translation.width, |
|
| 2455 |
+ totalWidth: totalWidth |
|
| 2456 |
+ ), |
|
| 2457 |
+ target: dragState.target, |
|
| 2458 |
+ totalWidth: totalWidth |
|
| 2459 |
+ ) |
|
| 2460 |
+ |
|
| 2461 |
+ applySelection( |
|
| 2462 |
+ resultingRange, |
|
| 2463 |
+ pinToPresent: shouldKeepPresentPin( |
|
| 2464 |
+ during: dragState.target, |
|
| 2465 |
+ initialRange: dragState.initialRange, |
|
| 2466 |
+ resultingRange: resultingRange |
|
| 2467 |
+ ), |
|
| 2468 |
+ ) |
|
| 2469 |
+ } |
|
| 2470 |
+ |
|
| 2471 |
+ private func dragTarget( |
|
| 2472 |
+ for startX: CGFloat, |
|
| 2473 |
+ selectionFrame: CGRect |
|
| 2474 |
+ ) -> DragTarget {
|
|
| 2475 |
+ let handleZone: CGFloat = compactLayout ? 20 : 24 |
|
| 2476 |
+ |
|
| 2477 |
+ if abs(startX - selectionFrame.minX) <= handleZone {
|
|
| 2478 |
+ return .lowerBound |
|
| 2479 |
+ } |
|
| 2480 |
+ |
|
| 2481 |
+ if abs(startX - selectionFrame.maxX) <= handleZone {
|
|
| 2482 |
+ return .upperBound |
|
| 2483 |
+ } |
|
| 2484 |
+ |
|
| 2485 |
+ if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
|
|
| 2486 |
+ return .window |
|
| 2487 |
+ } |
|
| 2488 |
+ |
|
| 2489 |
+ return startX < selectionFrame.minX ? .lowerBound : .upperBound |
|
| 2490 |
+ } |
|
| 2491 |
+ |
|
| 2492 |
+ private func adjustedRange( |
|
| 2493 |
+ from initialRange: ClosedRange<Date>, |
|
| 2494 |
+ target: DragTarget, |
|
| 2495 |
+ translationX: CGFloat, |
|
| 2496 |
+ totalWidth: CGFloat |
|
| 2497 |
+ ) -> ClosedRange<Date> {
|
|
| 2498 |
+ guard totalSpan > 0, totalWidth > 0 else {
|
|
| 2499 |
+ return availableTimeRange |
|
| 2500 |
+ } |
|
| 2501 |
+ |
|
| 2502 |
+ let delta = TimeInterval(translationX / totalWidth) * totalSpan |
|
| 2503 |
+ let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan) |
|
| 2504 |
+ |
|
| 2505 |
+ switch target {
|
|
| 2506 |
+ case .lowerBound: |
|
| 2507 |
+ let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan) |
|
| 2508 |
+ let newLowerBound = min( |
|
| 2509 |
+ max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound), |
|
| 2510 |
+ maximumLowerBound |
|
| 2511 |
+ ) |
|
| 2512 |
+ return normalizedSelectionRange(newLowerBound...initialRange.upperBound) |
|
| 2513 |
+ |
|
| 2514 |
+ case .upperBound: |
|
| 2515 |
+ let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan) |
|
| 2516 |
+ let newUpperBound = max( |
|
| 2517 |
+ min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound), |
|
| 2518 |
+ minimumUpperBound |
|
| 2519 |
+ ) |
|
| 2520 |
+ return normalizedSelectionRange(initialRange.lowerBound...newUpperBound) |
|
| 2521 |
+ |
|
| 2522 |
+ case .window: |
|
| 2523 |
+ let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound) |
|
| 2524 |
+ guard span < totalSpan else { return availableTimeRange }
|
|
| 2525 |
+ |
|
| 2526 |
+ var lowerBound = initialRange.lowerBound.addingTimeInterval(delta) |
|
| 2527 |
+ var upperBound = initialRange.upperBound.addingTimeInterval(delta) |
|
| 2528 |
+ |
|
| 2529 |
+ if lowerBound < availableTimeRange.lowerBound {
|
|
| 2530 |
+ upperBound = upperBound.addingTimeInterval( |
|
| 2531 |
+ availableTimeRange.lowerBound.timeIntervalSince(lowerBound) |
|
| 2532 |
+ ) |
|
| 2533 |
+ lowerBound = availableTimeRange.lowerBound |
|
| 2534 |
+ } |
|
| 2535 |
+ |
|
| 2536 |
+ if upperBound > availableTimeRange.upperBound {
|
|
| 2537 |
+ lowerBound = lowerBound.addingTimeInterval( |
|
| 2538 |
+ -upperBound.timeIntervalSince(availableTimeRange.upperBound) |
|
| 2539 |
+ ) |
|
| 2540 |
+ upperBound = availableTimeRange.upperBound |
|
| 2541 |
+ } |
|
| 2542 |
+ |
|
| 2543 |
+ return normalizedSelectionRange(lowerBound...upperBound) |
|
| 2544 |
+ } |
|
| 2545 |
+ } |
|
| 2546 |
+ |
|
| 2547 |
+ private func snappedToEdges( |
|
| 2548 |
+ _ candidateRange: ClosedRange<Date>, |
|
| 2549 |
+ target: DragTarget, |
|
| 2550 |
+ totalWidth: CGFloat |
|
| 2551 |
+ ) -> ClosedRange<Date> {
|
|
| 2552 |
+ guard totalSpan > 0 else {
|
|
| 2553 |
+ return availableTimeRange |
|
| 2554 |
+ } |
|
| 2555 |
+ |
|
| 2556 |
+ let snapInterval = edgeSnapInterval(for: totalWidth) |
|
| 2557 |
+ let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound) |
|
| 2558 |
+ var lowerBound = candidateRange.lowerBound |
|
| 2559 |
+ var upperBound = candidateRange.upperBound |
|
| 2560 |
+ |
|
| 2561 |
+ if target != .upperBound, |
|
| 2562 |
+ lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
|
|
| 2563 |
+ lowerBound = availableTimeRange.lowerBound |
|
| 2564 |
+ if target == .window {
|
|
| 2565 |
+ upperBound = lowerBound.addingTimeInterval(selectionSpan) |
|
| 2566 |
+ } |
|
| 2567 |
+ } |
|
| 2568 |
+ |
|
| 2569 |
+ if target != .lowerBound, |
|
| 2570 |
+ availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
|
|
| 2571 |
+ upperBound = availableTimeRange.upperBound |
|
| 2572 |
+ if target == .window {
|
|
| 2573 |
+ lowerBound = upperBound.addingTimeInterval(-selectionSpan) |
|
| 2574 |
+ } |
|
| 2575 |
+ } |
|
| 2576 |
+ |
|
| 2577 |
+ return normalizedSelectionRange(lowerBound...upperBound) |
|
| 2578 |
+ } |
|
| 2579 |
+ |
|
| 2580 |
+ private func edgeSnapInterval( |
|
| 2581 |
+ for totalWidth: CGFloat |
|
| 2582 |
+ ) -> TimeInterval {
|
|
| 2583 |
+ guard totalWidth > 0 else { return minimumSelectionSpan }
|
|
| 2584 |
+ |
|
| 2585 |
+ let snapWidth = min( |
|
| 2586 |
+ max(compactLayout ? 18 : 22, totalWidth * 0.04), |
|
| 2587 |
+ totalWidth * 0.18 |
|
| 2588 |
+ ) |
|
| 2589 |
+ let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan |
|
| 2590 |
+ return min( |
|
| 2591 |
+ max(intervalFromWidth, minimumSelectionSpan * 0.5), |
|
| 2592 |
+ totalSpan / 4 |
|
| 2593 |
+ ) |
|
| 2594 |
+ } |
|
| 2595 |
+ |
|
| 2596 |
+ private func resolvedSelectionRange() -> ClosedRange<Date> {
|
|
| 2597 |
+ guard let selectedTimeRange else { return availableTimeRange }
|
|
| 2598 |
+ |
|
| 2599 |
+ if isPinnedToPresent {
|
|
| 2600 |
+ switch presentTrackingMode {
|
|
| 2601 |
+ case .keepDuration: |
|
| 2602 |
+ let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound) |
|
| 2603 |
+ return normalizedSelectionRange( |
|
| 2604 |
+ availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound |
|
| 2605 |
+ ) |
|
| 2606 |
+ case .keepStartTimestamp: |
|
| 2607 |
+ return normalizedSelectionRange( |
|
| 2608 |
+ selectedTimeRange.lowerBound...availableTimeRange.upperBound |
|
| 2609 |
+ ) |
|
| 2610 |
+ } |
|
| 2611 |
+ } |
|
| 2612 |
+ |
|
| 2613 |
+ return normalizedSelectionRange(selectedTimeRange) |
|
| 2614 |
+ } |
|
| 2615 |
+ |
|
| 2616 |
+ private func normalizedSelectionRange( |
|
| 2617 |
+ _ candidateRange: ClosedRange<Date> |
|
| 2618 |
+ ) -> ClosedRange<Date> {
|
|
| 2619 |
+ let availableSpan = totalSpan |
|
| 2620 |
+ guard availableSpan > 0 else { return availableTimeRange }
|
|
| 2621 |
+ |
|
| 2622 |
+ let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan) |
|
| 2623 |
+ let requestedSpan = min( |
|
| 2624 |
+ max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan), |
|
| 2625 |
+ availableSpan |
|
| 2626 |
+ ) |
|
| 2627 |
+ |
|
| 2628 |
+ if requestedSpan >= availableSpan {
|
|
| 2629 |
+ return availableTimeRange |
|
| 2630 |
+ } |
|
| 2631 |
+ |
|
| 2632 |
+ var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound) |
|
| 2633 |
+ var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound) |
|
| 2634 |
+ |
|
| 2635 |
+ if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
|
|
| 2636 |
+ if lowerBound == availableTimeRange.lowerBound {
|
|
| 2637 |
+ upperBound = lowerBound.addingTimeInterval(requestedSpan) |
|
| 2638 |
+ } else {
|
|
| 2639 |
+ lowerBound = upperBound.addingTimeInterval(-requestedSpan) |
|
| 2640 |
+ } |
|
| 2641 |
+ } |
|
| 2642 |
+ |
|
| 2643 |
+ if upperBound > availableTimeRange.upperBound {
|
|
| 2644 |
+ let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound) |
|
| 2645 |
+ upperBound = availableTimeRange.upperBound |
|
| 2646 |
+ lowerBound = lowerBound.addingTimeInterval(-delta) |
|
| 2647 |
+ } |
|
| 2648 |
+ |
|
| 2649 |
+ if lowerBound < availableTimeRange.lowerBound {
|
|
| 2650 |
+ let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound) |
|
| 2651 |
+ lowerBound = availableTimeRange.lowerBound |
|
| 2652 |
+ upperBound = upperBound.addingTimeInterval(delta) |
|
| 2653 |
+ } |
|
| 2654 |
+ |
|
| 2655 |
+ return lowerBound...upperBound |
|
| 2656 |
+ } |
|
| 2657 |
+ |
|
| 2658 |
+ private func shouldKeepPresentPin( |
|
| 2659 |
+ during target: DragTarget, |
|
| 2660 |
+ initialRange: ClosedRange<Date>, |
|
| 2661 |
+ resultingRange: ClosedRange<Date> |
|
| 2662 |
+ ) -> Bool {
|
|
| 2663 |
+ let startedPinnedToPresent = |
|
| 2664 |
+ isPinnedToPresent || |
|
| 2665 |
+ selectionCoversFullRange(initialRange) |
|
| 2666 |
+ |
|
| 2667 |
+ guard startedPinnedToPresent else {
|
|
| 2668 |
+ return selectionTouchesPresent(resultingRange) |
|
| 2669 |
+ } |
|
| 2670 |
+ |
|
| 2671 |
+ switch target {
|
|
| 2672 |
+ case .lowerBound: |
|
| 2673 |
+ return true |
|
| 2674 |
+ case .upperBound, .window: |
|
| 2675 |
+ return selectionTouchesPresent(resultingRange) |
|
| 2676 |
+ } |
|
| 2677 |
+ } |
|
| 2678 |
+ |
|
| 2679 |
+ private func applySelection( |
|
| 2680 |
+ _ candidateRange: ClosedRange<Date>, |
|
| 2681 |
+ pinToPresent: Bool |
|
| 2682 |
+ ) {
|
|
| 2683 |
+ let normalizedRange = normalizedSelectionRange(candidateRange) |
|
| 2684 |
+ |
|
| 2685 |
+ if selectionCoversFullRange(normalizedRange) && !pinToPresent {
|
|
| 2686 |
+ selectedTimeRange = nil |
|
| 2687 |
+ } else {
|
|
| 2688 |
+ selectedTimeRange = normalizedRange |
|
| 2689 |
+ } |
|
| 2690 |
+ |
|
| 2691 |
+ isPinnedToPresent = pinToPresent |
|
| 2692 |
+ } |
|
| 2693 |
+ |
|
| 2694 |
+ private func resetSelectionState() {
|
|
| 2695 |
+ selectedTimeRange = nil |
|
| 2696 |
+ isPinnedToPresent = false |
|
| 2697 |
+ } |
|
| 2698 |
+ |
|
| 2699 |
+ private func selectionTouchesPresent( |
|
| 2700 |
+ _ range: ClosedRange<Date> |
|
| 2701 |
+ ) -> Bool {
|
|
| 2702 |
+ let tolerance = max(0.5, minimumSelectionSpan * 0.25) |
|
| 2703 |
+ return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance |
|
| 2704 |
+ } |
|
| 2705 |
+ |
|
| 2706 |
+ private func selectionCoversFullRange( |
|
| 2707 |
+ _ range: ClosedRange<Date> |
|
| 2708 |
+ ) -> Bool {
|
|
| 2709 |
+ let tolerance = max(0.5, minimumSelectionSpan * 0.25) |
|
| 2710 |
+ return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance && |
|
| 2711 |
+ abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance |
|
| 2712 |
+ } |
|
| 2713 |
+ |
|
| 2714 |
+ private func selectionFrame(in size: CGSize) -> CGRect {
|
|
| 2715 |
+ selectionFrame(for: currentRange, width: size.width) |
|
| 2716 |
+ } |
|
| 2717 |
+ |
|
| 2718 |
+ private func selectionFrame( |
|
| 2719 |
+ for range: ClosedRange<Date>, |
|
| 2720 |
+ width: CGFloat |
|
| 2721 |
+ ) -> CGRect {
|
|
| 2722 |
+ guard width > 0, totalSpan > 0 else {
|
|
| 2723 |
+ return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight)) |
|
| 2724 |
+ } |
|
| 2725 |
+ |
|
| 2726 |
+ let minimumX = xPosition(for: range.lowerBound, width: width) |
|
| 2727 |
+ let maximumX = xPosition(for: range.upperBound, width: width) |
|
| 2728 |
+ return CGRect( |
|
| 2729 |
+ x: minimumX, |
|
| 2730 |
+ y: 0, |
|
| 2731 |
+ width: max(maximumX - minimumX, 2), |
|
| 2732 |
+ height: trackHeight |
|
| 2733 |
+ ) |
|
| 2734 |
+ } |
|
| 2735 |
+ |
|
| 2736 |
+ private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
|
|
| 2737 |
+ guard width > 0, totalSpan > 0 else { return 0 }
|
|
| 2738 |
+ |
|
| 2739 |
+ let offset = date.timeIntervalSince(availableTimeRange.lowerBound) |
|
| 2740 |
+ let normalizedOffset = min(max(offset / totalSpan, 0), 1) |
|
| 2741 |
+ return CGFloat(normalizedOffset) * width |
|
| 2742 |
+ } |
|
| 2743 |
+ |
|
| 2744 |
+ private var xAxisLabelsView: some View {
|
|
| 2745 |
+ let timeFormat: String = {
|
|
| 2746 |
+ switch context.size.width {
|
|
| 2747 |
+ case 0..<3600: return "HH:mm:ss" |
|
| 2748 |
+ case 3600...86400: return "HH:mm" |
|
| 2749 |
+ default: return "E HH:mm" |
|
| 2750 |
+ } |
|
| 2751 |
+ }() |
|
| 2752 |
+ |
|
| 2753 |
+ let labelCount = max(xAxisLabelCount, 2) |
|
| 2754 |
+ let labels = (1...labelCount).map {
|
|
| 2755 |
+ Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: labelCount)).format(as: timeFormat) |
|
| 2756 |
+ } |
|
| 2757 |
+ let axisLabelFont: Font = compactLayout ? .caption2.weight(.semibold) : .footnote.weight(.semibold) |
|
| 2758 |
+ |
|
| 2759 |
+ return GeometryReader { geometry in
|
|
| 2760 |
+ let labelWidth = max(geometry.size.width / CGFloat(max(labelCount - 1, 1)), 1) |
|
| 2761 |
+ |
|
| 2762 |
+ ZStack(alignment: .topLeading) {
|
|
| 2763 |
+ Path { path in
|
|
| 2764 |
+ for labelIndex in 1...labelCount {
|
|
| 2765 |
+ let x = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width) |
|
| 2766 |
+ path.move(to: CGPoint(x: x, y: 0)) |
|
| 2767 |
+ path.addLine(to: CGPoint(x: x, y: 5)) |
|
| 2768 |
+ } |
|
| 2769 |
+ } |
|
| 2770 |
+ .stroke(Color.secondary.opacity(0.22), lineWidth: 0.75) |
|
| 2771 |
+ |
|
| 2772 |
+ ForEach(Array(labels.enumerated()), id: \.offset) { item in
|
|
| 2773 |
+ let labelIndex = item.offset + 1 |
|
| 2774 |
+ let centerX = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width) |
|
| 2775 |
+ |
|
| 2776 |
+ Text(item.element) |
|
| 2777 |
+ .font(axisLabelFont) |
|
| 2778 |
+ .monospacedDigit() |
|
| 2779 |
+ .lineLimit(1) |
|
| 2780 |
+ .minimumScaleFactor(0.74) |
|
| 2781 |
+ .frame(width: labelWidth) |
|
| 2782 |
+ .position( |
|
| 2783 |
+ x: centerX, |
|
| 2784 |
+ y: geometry.size.height * 0.66 |
|
| 2785 |
+ ) |
|
| 2786 |
+ } |
|
| 2787 |
+ } |
|
| 2788 |
+ } |
|
| 2789 |
+ .frame(height: compactLayout ? 18 : 20) |
|
| 2790 |
+ } |
|
| 2791 |
+ |
|
| 2792 |
+ private func xPosition(for labelIndex: Int, totalLabels: Int, width: CGFloat) -> CGFloat {
|
|
| 2793 |
+ context.xGuidePosition(for: labelIndex, of: max(totalLabels, 2), width: width) |
|
| 2794 |
+ } |
|
| 2795 |
+} |
|
@@ -0,0 +1,46 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterInfoCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterInfoCardView<Content: View, TrailingActions: View>: View {
|
|
| 9 |
+ let title: String |
|
| 10 |
+ let infoMessage: String? |
|
| 11 |
+ let tint: Color |
|
| 12 |
+ @ViewBuilder var trailingActions: TrailingActions |
|
| 13 |
+ @ViewBuilder var content: Content |
|
| 14 |
+ |
|
| 15 |
+ init( |
|
| 16 |
+ title: String, |
|
| 17 |
+ infoMessage: String? = nil, |
|
| 18 |
+ tint: Color, |
|
| 19 |
+ @ViewBuilder trailingActions: () -> TrailingActions = { EmptyView() },
|
|
| 20 |
+ @ViewBuilder content: () -> Content |
|
| 21 |
+ ) {
|
|
| 22 |
+ self.title = title |
|
| 23 |
+ self.infoMessage = infoMessage |
|
| 24 |
+ self.tint = tint |
|
| 25 |
+ self.trailingActions = trailingActions() |
|
| 26 |
+ self.content = content() |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ var body: some View {
|
|
| 30 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 31 |
+ HStack(spacing: 8) {
|
|
| 32 |
+ Text(title) |
|
| 33 |
+ .font(.headline) |
|
| 34 |
+ if let infoMessage {
|
|
| 35 |
+ ContextInfoButton(title: title, message: infoMessage) |
|
| 36 |
+ } |
|
| 37 |
+ Spacer(minLength: 0) |
|
| 38 |
+ trailingActions |
|
| 39 |
+ } |
|
| 40 |
+ content |
|
| 41 |
+ } |
|
| 42 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 43 |
+ .padding(18) |
|
| 44 |
+ .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 45 |
+ } |
|
| 46 |
+} |
|
@@ -0,0 +1,22 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterInfoRowView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterInfoRowView: View {
|
|
| 9 |
+ let label: String |
|
| 10 |
+ let value: String |
|
| 11 |
+ |
|
| 12 |
+ var body: some View {
|
|
| 13 |
+ HStack {
|
|
| 14 |
+ Text(label) |
|
| 15 |
+ Spacer() |
|
| 16 |
+ Text(value) |
|
| 17 |
+ .foregroundColor(.secondary) |
|
| 18 |
+ .multilineTextAlignment(.trailing) |
|
| 19 |
+ } |
|
| 20 |
+ .font(.footnote) |
|
| 21 |
+ } |
|
| 22 |
+} |
|
@@ -1,298 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// LiveView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Bogdan Timofte on 09/03/2020. |
|
| 6 |
-// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
-// |
|
| 8 |
- |
|
| 9 |
-import SwiftUI |
|
| 10 |
- |
|
| 11 |
-struct LiveView: View {
|
|
| 12 |
- private struct MetricRange {
|
|
| 13 |
- let minLabel: String |
|
| 14 |
- let maxLabel: String |
|
| 15 |
- let minValue: String |
|
| 16 |
- let maxValue: String |
|
| 17 |
- } |
|
| 18 |
- |
|
| 19 |
- private struct LoadResistanceSymbol: View {
|
|
| 20 |
- let color: Color |
|
| 21 |
- |
|
| 22 |
- var body: some View {
|
|
| 23 |
- GeometryReader { proxy in
|
|
| 24 |
- let width = proxy.size.width |
|
| 25 |
- let height = proxy.size.height |
|
| 26 |
- let midY = height / 2 |
|
| 27 |
- let startX = width * 0.10 |
|
| 28 |
- let endX = width * 0.90 |
|
| 29 |
- let boxMinX = width * 0.28 |
|
| 30 |
- let boxMaxX = width * 0.72 |
|
| 31 |
- let boxHeight = height * 0.34 |
|
| 32 |
- let boxRect = CGRect( |
|
| 33 |
- x: boxMinX, |
|
| 34 |
- y: midY - (boxHeight / 2), |
|
| 35 |
- width: boxMaxX - boxMinX, |
|
| 36 |
- height: boxHeight |
|
| 37 |
- ) |
|
| 38 |
- let strokeWidth = max(1.2, height * 0.055) |
|
| 39 |
- |
|
| 40 |
- ZStack {
|
|
| 41 |
- Path { path in
|
|
| 42 |
- path.move(to: CGPoint(x: startX, y: midY)) |
|
| 43 |
- path.addLine(to: CGPoint(x: boxRect.minX, y: midY)) |
|
| 44 |
- path.move(to: CGPoint(x: boxRect.maxX, y: midY)) |
|
| 45 |
- path.addLine(to: CGPoint(x: endX, y: midY)) |
|
| 46 |
- } |
|
| 47 |
- .stroke( |
|
| 48 |
- color, |
|
| 49 |
- style: StrokeStyle( |
|
| 50 |
- lineWidth: strokeWidth, |
|
| 51 |
- lineCap: .round, |
|
| 52 |
- lineJoin: .round |
|
| 53 |
- ) |
|
| 54 |
- ) |
|
| 55 |
- |
|
| 56 |
- Path { path in
|
|
| 57 |
- path.addRect(boxRect) |
|
| 58 |
- } |
|
| 59 |
- .stroke( |
|
| 60 |
- color, |
|
| 61 |
- style: StrokeStyle( |
|
| 62 |
- lineWidth: strokeWidth, |
|
| 63 |
- lineCap: .round, |
|
| 64 |
- lineJoin: .round |
|
| 65 |
- ) |
|
| 66 |
- ) |
|
| 67 |
- } |
|
| 68 |
- } |
|
| 69 |
- .padding(4) |
|
| 70 |
- } |
|
| 71 |
- } |
|
| 72 |
- |
|
| 73 |
- @EnvironmentObject private var meter: Meter |
|
| 74 |
- var compactLayout: Bool = false |
|
| 75 |
- var availableSize: CGSize? = nil |
|
| 76 |
- |
|
| 77 |
- var body: some View {
|
|
| 78 |
- VStack(alignment: .leading, spacing: 16) {
|
|
| 79 |
- HStack {
|
|
| 80 |
- Text("Live Data")
|
|
| 81 |
- .font(.headline) |
|
| 82 |
- Spacer() |
|
| 83 |
- statusBadge |
|
| 84 |
- } |
|
| 85 |
- |
|
| 86 |
- LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
|
|
| 87 |
- liveMetricCard( |
|
| 88 |
- title: "Voltage", |
|
| 89 |
- symbol: "bolt.fill", |
|
| 90 |
- color: .green, |
|
| 91 |
- value: "\(meter.voltage.format(decimalDigits: 3)) V", |
|
| 92 |
- range: metricRange( |
|
| 93 |
- min: meter.measurements.voltage.context.minValue, |
|
| 94 |
- max: meter.measurements.voltage.context.maxValue, |
|
| 95 |
- unit: "V" |
|
| 96 |
- ) |
|
| 97 |
- ) |
|
| 98 |
- |
|
| 99 |
- liveMetricCard( |
|
| 100 |
- title: "Current", |
|
| 101 |
- symbol: "waveform.path.ecg", |
|
| 102 |
- color: .blue, |
|
| 103 |
- value: "\(meter.current.format(decimalDigits: 3)) A", |
|
| 104 |
- range: metricRange( |
|
| 105 |
- min: meter.measurements.current.context.minValue, |
|
| 106 |
- max: meter.measurements.current.context.maxValue, |
|
| 107 |
- unit: "A" |
|
| 108 |
- ) |
|
| 109 |
- ) |
|
| 110 |
- |
|
| 111 |
- liveMetricCard( |
|
| 112 |
- title: "Power", |
|
| 113 |
- symbol: "flame.fill", |
|
| 114 |
- color: .pink, |
|
| 115 |
- value: "\(meter.power.format(decimalDigits: 3)) W", |
|
| 116 |
- range: metricRange( |
|
| 117 |
- min: meter.measurements.power.context.minValue, |
|
| 118 |
- max: meter.measurements.power.context.maxValue, |
|
| 119 |
- unit: "W" |
|
| 120 |
- ) |
|
| 121 |
- ) |
|
| 122 |
- |
|
| 123 |
- liveMetricCard( |
|
| 124 |
- title: "Temperature", |
|
| 125 |
- symbol: "thermometer.medium", |
|
| 126 |
- color: .orange, |
|
| 127 |
- value: meter.primaryTemperatureDescription, |
|
| 128 |
- range: temperatureRange() |
|
| 129 |
- ) |
|
| 130 |
- |
|
| 131 |
- liveMetricCard( |
|
| 132 |
- title: "Load", |
|
| 133 |
- customSymbol: AnyView(LoadResistanceSymbol(color: .yellow)), |
|
| 134 |
- color: .yellow, |
|
| 135 |
- value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
|
|
| 136 |
- detailText: "Measured resistance" |
|
| 137 |
- ) |
|
| 138 |
- |
|
| 139 |
- liveMetricCard( |
|
| 140 |
- title: "RSSI", |
|
| 141 |
- symbol: "dot.radiowaves.left.and.right", |
|
| 142 |
- color: .mint, |
|
| 143 |
- value: "\(meter.btSerial.averageRSSI) dBm", |
|
| 144 |
- range: MetricRange( |
|
| 145 |
- minLabel: "Min", |
|
| 146 |
- maxLabel: "Max", |
|
| 147 |
- minValue: "\(meter.btSerial.minRSSI) dBm", |
|
| 148 |
- maxValue: "\(meter.btSerial.maxRSSI) dBm" |
|
| 149 |
- ), |
|
| 150 |
- valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold) |
|
| 151 |
- ) |
|
| 152 |
- } |
|
| 153 |
- } |
|
| 154 |
- .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 155 |
- } |
|
| 156 |
- |
|
| 157 |
- private var liveMetricColumns: [GridItem] {
|
|
| 158 |
- if compactLayout {
|
|
| 159 |
- return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3) |
|
| 160 |
- } |
|
| 161 |
- |
|
| 162 |
- return [GridItem(.flexible()), GridItem(.flexible())] |
|
| 163 |
- } |
|
| 164 |
- |
|
| 165 |
- private var statusBadge: some View {
|
|
| 166 |
- Text(meter.operationalState == .dataIsAvailable ? "Live" : "Waiting") |
|
| 167 |
- .font(.caption.weight(.semibold)) |
|
| 168 |
- .padding(.horizontal, 10) |
|
| 169 |
- .padding(.vertical, 6) |
|
| 170 |
- .foregroundColor(meter.operationalState == .dataIsAvailable ? .green : .secondary) |
|
| 171 |
- .meterCard( |
|
| 172 |
- tint: meter.operationalState == .dataIsAvailable ? .green : .secondary, |
|
| 173 |
- fillOpacity: 0.12, |
|
| 174 |
- strokeOpacity: 0.16, |
|
| 175 |
- cornerRadius: 999 |
|
| 176 |
- ) |
|
| 177 |
- } |
|
| 178 |
- |
|
| 179 |
- private var showsCompactMetricRange: Bool {
|
|
| 180 |
- compactLayout && (availableSize?.height ?? 0) >= 380 |
|
| 181 |
- } |
|
| 182 |
- |
|
| 183 |
- private var shouldShowMetricRange: Bool {
|
|
| 184 |
- !compactLayout || showsCompactMetricRange |
|
| 185 |
- } |
|
| 186 |
- |
|
| 187 |
- private func liveMetricCard( |
|
| 188 |
- title: String, |
|
| 189 |
- symbol: String? = nil, |
|
| 190 |
- customSymbol: AnyView? = nil, |
|
| 191 |
- color: Color, |
|
| 192 |
- value: String, |
|
| 193 |
- range: MetricRange? = nil, |
|
| 194 |
- detailText: String? = nil, |
|
| 195 |
- valueFont: Font? = nil, |
|
| 196 |
- valueLineLimit: Int = 1, |
|
| 197 |
- valueMonospacedDigits: Bool = true, |
|
| 198 |
- valueMinimumScaleFactor: CGFloat = 0.85 |
|
| 199 |
- ) -> some View {
|
|
| 200 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 201 |
- HStack(spacing: compactLayout ? 8 : 10) {
|
|
| 202 |
- Group {
|
|
| 203 |
- if let customSymbol {
|
|
| 204 |
- customSymbol |
|
| 205 |
- } else if let symbol {
|
|
| 206 |
- Image(systemName: symbol) |
|
| 207 |
- .font(.system(size: compactLayout ? 14 : 15, weight: .semibold)) |
|
| 208 |
- .foregroundColor(color) |
|
| 209 |
- } |
|
| 210 |
- } |
|
| 211 |
- .frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34) |
|
| 212 |
- .background(Circle().fill(color.opacity(0.12))) |
|
| 213 |
- |
|
| 214 |
- Text(title) |
|
| 215 |
- .font((compactLayout ? Font.caption : .subheadline).weight(.semibold)) |
|
| 216 |
- .foregroundColor(.secondary) |
|
| 217 |
- .lineLimit(1) |
|
| 218 |
- |
|
| 219 |
- Spacer(minLength: 0) |
|
| 220 |
- } |
|
| 221 |
- |
|
| 222 |
- Group {
|
|
| 223 |
- if valueMonospacedDigits {
|
|
| 224 |
- Text(value) |
|
| 225 |
- .monospacedDigit() |
|
| 226 |
- } else {
|
|
| 227 |
- Text(value) |
|
| 228 |
- } |
|
| 229 |
- } |
|
| 230 |
- .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold)) |
|
| 231 |
- .lineLimit(valueLineLimit) |
|
| 232 |
- .minimumScaleFactor(valueMinimumScaleFactor) |
|
| 233 |
- |
|
| 234 |
- if shouldShowMetricRange {
|
|
| 235 |
- if let range {
|
|
| 236 |
- metricRangeTable(range) |
|
| 237 |
- } else if let detailText, !detailText.isEmpty {
|
|
| 238 |
- Text(detailText) |
|
| 239 |
- .font(.caption) |
|
| 240 |
- .foregroundColor(.secondary) |
|
| 241 |
- .lineLimit(2) |
|
| 242 |
- } |
|
| 243 |
- } |
|
| 244 |
- } |
|
| 245 |
- .frame( |
|
| 246 |
- maxWidth: .infinity, |
|
| 247 |
- minHeight: compactLayout ? (shouldShowMetricRange ? 132 : 96) : 128, |
|
| 248 |
- alignment: .leading |
|
| 249 |
- ) |
|
| 250 |
- .padding(compactLayout ? 12 : 16) |
|
| 251 |
- .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12) |
|
| 252 |
- } |
|
| 253 |
- |
|
| 254 |
- private func metricRangeTable(_ range: MetricRange) -> some View {
|
|
| 255 |
- VStack(alignment: .leading, spacing: 4) {
|
|
| 256 |
- HStack(spacing: 12) {
|
|
| 257 |
- Text(range.minLabel) |
|
| 258 |
- Spacer(minLength: 0) |
|
| 259 |
- Text(range.maxLabel) |
|
| 260 |
- } |
|
| 261 |
- .font(.caption2.weight(.semibold)) |
|
| 262 |
- .foregroundColor(.secondary) |
|
| 263 |
- |
|
| 264 |
- HStack(spacing: 12) {
|
|
| 265 |
- Text(range.minValue) |
|
| 266 |
- .monospacedDigit() |
|
| 267 |
- Spacer(minLength: 0) |
|
| 268 |
- Text(range.maxValue) |
|
| 269 |
- .monospacedDigit() |
|
| 270 |
- } |
|
| 271 |
- .font(.caption.weight(.medium)) |
|
| 272 |
- .foregroundColor(.primary) |
|
| 273 |
- } |
|
| 274 |
- } |
|
| 275 |
- |
|
| 276 |
- private func metricRange(min: Double, max: Double, unit: String) -> MetricRange? {
|
|
| 277 |
- guard min.isFinite, max.isFinite else { return nil }
|
|
| 278 |
- |
|
| 279 |
- return MetricRange( |
|
| 280 |
- minLabel: "Min", |
|
| 281 |
- maxLabel: "Max", |
|
| 282 |
- minValue: "\(min.format(decimalDigits: 3)) \(unit)", |
|
| 283 |
- maxValue: "\(max.format(decimalDigits: 3)) \(unit)" |
|
| 284 |
- ) |
|
| 285 |
- } |
|
| 286 |
- |
|
| 287 |
- private func temperatureRange() -> MetricRange? {
|
|
| 288 |
- let value = meter.primaryTemperatureDescription |
|
| 289 |
- guard !value.isEmpty else { return nil }
|
|
| 290 |
- |
|
| 291 |
- return MetricRange( |
|
| 292 |
- minLabel: "Min", |
|
| 293 |
- maxLabel: "Max", |
|
| 294 |
- minValue: value, |
|
| 295 |
- maxValue: value |
|
| 296 |
- ) |
|
| 297 |
- } |
|
| 298 |
-} |
|
@@ -1,457 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// MeasurementChartView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Bogdan Timofte on 06/05/2020. |
|
| 6 |
-// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
-// |
|
| 8 |
- |
|
| 9 |
-import SwiftUI |
|
| 10 |
- |
|
| 11 |
-struct MeasurementChartView: View {
|
|
| 12 |
- private let minimumTimeSpan: TimeInterval = 1 |
|
| 13 |
- private let minimumVoltageSpan = 0.5 |
|
| 14 |
- private let minimumCurrentSpan = 0.5 |
|
| 15 |
- private let minimumPowerSpan = 0.5 |
|
| 16 |
- private let axisColumnWidth: CGFloat = 46 |
|
| 17 |
- private let chartSectionSpacing: CGFloat = 8 |
|
| 18 |
- private let xAxisHeight: CGFloat = 28 |
|
| 19 |
- |
|
| 20 |
- @EnvironmentObject private var measurements: Measurements |
|
| 21 |
- var timeRange: ClosedRange<Date>? = nil |
|
| 22 |
- |
|
| 23 |
- @State var displayVoltage: Bool = false |
|
| 24 |
- @State var displayCurrent: Bool = false |
|
| 25 |
- @State var displayPower: Bool = true |
|
| 26 |
- let xLabels: Int = 4 |
|
| 27 |
- let yLabels: Int = 4 |
|
| 28 |
- |
|
| 29 |
- var body: some View {
|
|
| 30 |
- let powerSeries = series(for: measurements.power, minimumYSpan: minimumPowerSpan) |
|
| 31 |
- let voltageSeries = series(for: measurements.voltage, minimumYSpan: minimumVoltageSpan) |
|
| 32 |
- let currentSeries = series(for: measurements.current, minimumYSpan: minimumCurrentSpan) |
|
| 33 |
- let primarySeries = displayedPrimarySeries( |
|
| 34 |
- powerSeries: powerSeries, |
|
| 35 |
- voltageSeries: voltageSeries, |
|
| 36 |
- currentSeries: currentSeries |
|
| 37 |
- ) |
|
| 38 |
- |
|
| 39 |
- Group {
|
|
| 40 |
- if let primarySeries {
|
|
| 41 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 42 |
- chartToggleBar |
|
| 43 |
- |
|
| 44 |
- GeometryReader { geometry in
|
|
| 45 |
- let plotHeight = max(geometry.size.height - xAxisHeight, 140) |
|
| 46 |
- |
|
| 47 |
- VStack(spacing: 6) {
|
|
| 48 |
- HStack(spacing: chartSectionSpacing) {
|
|
| 49 |
- primaryAxisView( |
|
| 50 |
- height: plotHeight, |
|
| 51 |
- powerSeries: powerSeries, |
|
| 52 |
- voltageSeries: voltageSeries, |
|
| 53 |
- currentSeries: currentSeries |
|
| 54 |
- ) |
|
| 55 |
- .frame(width: axisColumnWidth, height: plotHeight) |
|
| 56 |
- |
|
| 57 |
- ZStack {
|
|
| 58 |
- RoundedRectangle(cornerRadius: 18, style: .continuous) |
|
| 59 |
- .fill(Color.primary.opacity(0.05)) |
|
| 60 |
- |
|
| 61 |
- RoundedRectangle(cornerRadius: 18, style: .continuous) |
|
| 62 |
- .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
|
| 63 |
- |
|
| 64 |
- horizontalGuides(context: primarySeries.context) |
|
| 65 |
- verticalGuides(context: primarySeries.context) |
|
| 66 |
- renderedChart( |
|
| 67 |
- powerSeries: powerSeries, |
|
| 68 |
- voltageSeries: voltageSeries, |
|
| 69 |
- currentSeries: currentSeries |
|
| 70 |
- ) |
|
| 71 |
- } |
|
| 72 |
- .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
|
| 73 |
- .frame(maxWidth: .infinity) |
|
| 74 |
- .frame(height: plotHeight) |
|
| 75 |
- |
|
| 76 |
- secondaryAxisView( |
|
| 77 |
- height: plotHeight, |
|
| 78 |
- powerSeries: powerSeries, |
|
| 79 |
- voltageSeries: voltageSeries, |
|
| 80 |
- currentSeries: currentSeries |
|
| 81 |
- ) |
|
| 82 |
- .frame(width: axisColumnWidth, height: plotHeight) |
|
| 83 |
- } |
|
| 84 |
- |
|
| 85 |
- xAxisLabelsView(context: primarySeries.context) |
|
| 86 |
- .frame(height: xAxisHeight) |
|
| 87 |
- } |
|
| 88 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
|
| 89 |
- } |
|
| 90 |
- } |
|
| 91 |
- } else {
|
|
| 92 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 93 |
- chartToggleBar |
|
| 94 |
- Text("Nothing to show!")
|
|
| 95 |
- .foregroundColor(.secondary) |
|
| 96 |
- } |
|
| 97 |
- } |
|
| 98 |
- } |
|
| 99 |
- .font(.footnote) |
|
| 100 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 101 |
- } |
|
| 102 |
- |
|
| 103 |
- private var chartToggleBar: some View {
|
|
| 104 |
- HStack(spacing: 8) {
|
|
| 105 |
- Button(action: {
|
|
| 106 |
- self.displayVoltage.toggle() |
|
| 107 |
- if self.displayVoltage {
|
|
| 108 |
- self.displayPower = false |
|
| 109 |
- } |
|
| 110 |
- }) { Text("Voltage") }
|
|
| 111 |
- .asEnableFeatureButton(state: displayVoltage) |
|
| 112 |
- |
|
| 113 |
- Button(action: {
|
|
| 114 |
- self.displayCurrent.toggle() |
|
| 115 |
- if self.displayCurrent {
|
|
| 116 |
- self.displayPower = false |
|
| 117 |
- } |
|
| 118 |
- }) { Text("Current") }
|
|
| 119 |
- .asEnableFeatureButton(state: displayCurrent) |
|
| 120 |
- |
|
| 121 |
- Button(action: {
|
|
| 122 |
- self.displayPower.toggle() |
|
| 123 |
- if self.displayPower {
|
|
| 124 |
- self.displayCurrent = false |
|
| 125 |
- self.displayVoltage = false |
|
| 126 |
- } |
|
| 127 |
- }) { Text("Power") }
|
|
| 128 |
- .asEnableFeatureButton(state: displayPower) |
|
| 129 |
- } |
|
| 130 |
- .frame(maxWidth: .infinity, alignment: .center) |
|
| 131 |
- } |
|
| 132 |
- |
|
| 133 |
- @ViewBuilder |
|
| 134 |
- private func primaryAxisView( |
|
| 135 |
- height: CGFloat, |
|
| 136 |
- powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 137 |
- voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 138 |
- currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) |
|
| 139 |
- ) -> some View {
|
|
| 140 |
- if displayPower {
|
|
| 141 |
- yAxisLabelsView( |
|
| 142 |
- height: height, |
|
| 143 |
- context: powerSeries.context, |
|
| 144 |
- measurementUnit: "W", |
|
| 145 |
- tint: .red |
|
| 146 |
- ) |
|
| 147 |
- } else if displayVoltage {
|
|
| 148 |
- yAxisLabelsView( |
|
| 149 |
- height: height, |
|
| 150 |
- context: voltageSeries.context, |
|
| 151 |
- measurementUnit: "V", |
|
| 152 |
- tint: .green |
|
| 153 |
- ) |
|
| 154 |
- } else if displayCurrent {
|
|
| 155 |
- yAxisLabelsView( |
|
| 156 |
- height: height, |
|
| 157 |
- context: currentSeries.context, |
|
| 158 |
- measurementUnit: "A", |
|
| 159 |
- tint: .blue |
|
| 160 |
- ) |
|
| 161 |
- } |
|
| 162 |
- } |
|
| 163 |
- |
|
| 164 |
- @ViewBuilder |
|
| 165 |
- private func renderedChart( |
|
| 166 |
- powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 167 |
- voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 168 |
- currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) |
|
| 169 |
- ) -> some View {
|
|
| 170 |
- if self.displayPower {
|
|
| 171 |
- Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red) |
|
| 172 |
- .opacity(0.72) |
|
| 173 |
- } else {
|
|
| 174 |
- if self.displayVoltage {
|
|
| 175 |
- Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green) |
|
| 176 |
- .opacity(0.78) |
|
| 177 |
- } |
|
| 178 |
- if self.displayCurrent {
|
|
| 179 |
- Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue) |
|
| 180 |
- .opacity(0.78) |
|
| 181 |
- } |
|
| 182 |
- } |
|
| 183 |
- } |
|
| 184 |
- |
|
| 185 |
- @ViewBuilder |
|
| 186 |
- private func secondaryAxisView( |
|
| 187 |
- height: CGFloat, |
|
| 188 |
- powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 189 |
- voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 190 |
- currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) |
|
| 191 |
- ) -> some View {
|
|
| 192 |
- if displayVoltage && displayCurrent {
|
|
| 193 |
- yAxisLabelsView( |
|
| 194 |
- height: height, |
|
| 195 |
- context: currentSeries.context, |
|
| 196 |
- measurementUnit: "A", |
|
| 197 |
- tint: .blue |
|
| 198 |
- ) |
|
| 199 |
- } else {
|
|
| 200 |
- primaryAxisView( |
|
| 201 |
- height: height, |
|
| 202 |
- powerSeries: powerSeries, |
|
| 203 |
- voltageSeries: voltageSeries, |
|
| 204 |
- currentSeries: currentSeries |
|
| 205 |
- ) |
|
| 206 |
- } |
|
| 207 |
- } |
|
| 208 |
- |
|
| 209 |
- private func displayedPrimarySeries( |
|
| 210 |
- powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 211 |
- voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext), |
|
| 212 |
- currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext) |
|
| 213 |
- ) -> (points: [Measurements.Measurement.Point], context: ChartContext)? {
|
|
| 214 |
- if displayPower {
|
|
| 215 |
- return powerSeries.points.isEmpty ? nil : powerSeries |
|
| 216 |
- } |
|
| 217 |
- if displayVoltage {
|
|
| 218 |
- return voltageSeries.points.isEmpty ? nil : voltageSeries |
|
| 219 |
- } |
|
| 220 |
- if displayCurrent {
|
|
| 221 |
- return currentSeries.points.isEmpty ? nil : currentSeries |
|
| 222 |
- } |
|
| 223 |
- return nil |
|
| 224 |
- } |
|
| 225 |
- |
|
| 226 |
- private func series( |
|
| 227 |
- for measurement: Measurements.Measurement, |
|
| 228 |
- minimumYSpan: Double |
|
| 229 |
- ) -> (points: [Measurements.Measurement.Point], context: ChartContext) {
|
|
| 230 |
- let points = measurement.points.filter { point in
|
|
| 231 |
- guard let timeRange else { return true }
|
|
| 232 |
- return timeRange.contains(point.timestamp) |
|
| 233 |
- } |
|
| 234 |
- let context = ChartContext() |
|
| 235 |
- for point in points {
|
|
| 236 |
- context.include(point: point.point()) |
|
| 237 |
- } |
|
| 238 |
- if !points.isEmpty {
|
|
| 239 |
- context.ensureMinimumSize( |
|
| 240 |
- width: CGFloat(minimumTimeSpan), |
|
| 241 |
- height: CGFloat(minimumYSpan) |
|
| 242 |
- ) |
|
| 243 |
- } |
|
| 244 |
- return (points, context) |
|
| 245 |
- } |
|
| 246 |
- |
|
| 247 |
- private func yGuidePosition( |
|
| 248 |
- for labelIndex: Int, |
|
| 249 |
- context: ChartContext, |
|
| 250 |
- height: CGFloat |
|
| 251 |
- ) -> CGFloat {
|
|
| 252 |
- let value = context.yAxisLabel(for: labelIndex, of: yLabels) |
|
| 253 |
- let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value)) |
|
| 254 |
- return context.placeInRect(point: anchorPoint).y * height |
|
| 255 |
- } |
|
| 256 |
- |
|
| 257 |
- private func xGuidePosition( |
|
| 258 |
- for labelIndex: Int, |
|
| 259 |
- context: ChartContext, |
|
| 260 |
- width: CGFloat |
|
| 261 |
- ) -> CGFloat {
|
|
| 262 |
- let value = context.xAxisLabel(for: labelIndex, of: xLabels) |
|
| 263 |
- let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y) |
|
| 264 |
- return context.placeInRect(point: anchorPoint).x * width |
|
| 265 |
- } |
|
| 266 |
- |
|
| 267 |
- // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat |
|
| 268 |
- fileprivate func xAxisLabelsView( |
|
| 269 |
- context: ChartContext |
|
| 270 |
- ) -> some View {
|
|
| 271 |
- var timeFormat: String? |
|
| 272 |
- switch context.size.width {
|
|
| 273 |
- case 0..<3600: timeFormat = "HH:mm:ss" |
|
| 274 |
- case 3600...86400: timeFormat = "HH:mm" |
|
| 275 |
- default: timeFormat = "E HH:mm" |
|
| 276 |
- } |
|
| 277 |
- let labels = (1...xLabels).map {
|
|
| 278 |
- Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!) |
|
| 279 |
- } |
|
| 280 |
- |
|
| 281 |
- return HStack(spacing: chartSectionSpacing) {
|
|
| 282 |
- Color.clear |
|
| 283 |
- .frame(width: axisColumnWidth) |
|
| 284 |
- |
|
| 285 |
- GeometryReader { geometry in
|
|
| 286 |
- let labelWidth = max( |
|
| 287 |
- geometry.size.width / CGFloat(max(xLabels - 1, 1)), |
|
| 288 |
- 1 |
|
| 289 |
- ) |
|
| 290 |
- |
|
| 291 |
- ZStack(alignment: .topLeading) {
|
|
| 292 |
- Path { path in
|
|
| 293 |
- for labelIndex in 1...self.xLabels {
|
|
| 294 |
- let x = xGuidePosition( |
|
| 295 |
- for: labelIndex, |
|
| 296 |
- context: context, |
|
| 297 |
- width: geometry.size.width |
|
| 298 |
- ) |
|
| 299 |
- path.move(to: CGPoint(x: x, y: 0)) |
|
| 300 |
- path.addLine(to: CGPoint(x: x, y: 6)) |
|
| 301 |
- } |
|
| 302 |
- } |
|
| 303 |
- .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75) |
|
| 304 |
- |
|
| 305 |
- ForEach(Array(labels.enumerated()), id: \.offset) { item in
|
|
| 306 |
- let labelIndex = item.offset + 1 |
|
| 307 |
- let centerX = xGuidePosition( |
|
| 308 |
- for: labelIndex, |
|
| 309 |
- context: context, |
|
| 310 |
- width: geometry.size.width |
|
| 311 |
- ) |
|
| 312 |
- |
|
| 313 |
- Text(item.element) |
|
| 314 |
- .font(.caption.weight(.semibold)) |
|
| 315 |
- .monospacedDigit() |
|
| 316 |
- .lineLimit(1) |
|
| 317 |
- .minimumScaleFactor(0.68) |
|
| 318 |
- .frame(width: labelWidth) |
|
| 319 |
- .position( |
|
| 320 |
- x: centerX, |
|
| 321 |
- y: geometry.size.height * 0.7 |
|
| 322 |
- ) |
|
| 323 |
- } |
|
| 324 |
- } |
|
| 325 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 326 |
- } |
|
| 327 |
- |
|
| 328 |
- Color.clear |
|
| 329 |
- .frame(width: axisColumnWidth) |
|
| 330 |
- } |
|
| 331 |
- } |
|
| 332 |
- |
|
| 333 |
- fileprivate func yAxisLabelsView( |
|
| 334 |
- height: CGFloat, |
|
| 335 |
- context: ChartContext, |
|
| 336 |
- measurementUnit: String, |
|
| 337 |
- tint: Color |
|
| 338 |
- ) -> some View {
|
|
| 339 |
- GeometryReader { geometry in
|
|
| 340 |
- ZStack(alignment: .top) {
|
|
| 341 |
- ForEach(0..<yLabels, id: \.self) { row in
|
|
| 342 |
- let labelIndex = yLabels - row |
|
| 343 |
- |
|
| 344 |
- Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
|
|
| 345 |
- .font(.caption2.weight(.semibold)) |
|
| 346 |
- .monospacedDigit() |
|
| 347 |
- .lineLimit(1) |
|
| 348 |
- .minimumScaleFactor(0.72) |
|
| 349 |
- .frame(width: max(geometry.size.width - 6, 0)) |
|
| 350 |
- .position( |
|
| 351 |
- x: geometry.size.width / 2, |
|
| 352 |
- y: yGuidePosition( |
|
| 353 |
- for: labelIndex, |
|
| 354 |
- context: context, |
|
| 355 |
- height: geometry.size.height |
|
| 356 |
- ) |
|
| 357 |
- ) |
|
| 358 |
- } |
|
| 359 |
- |
|
| 360 |
- Text(measurementUnit) |
|
| 361 |
- .font(.caption2.weight(.bold)) |
|
| 362 |
- .foregroundColor(tint) |
|
| 363 |
- .padding(.horizontal, 6) |
|
| 364 |
- .padding(.vertical, 4) |
|
| 365 |
- .background( |
|
| 366 |
- Capsule(style: .continuous) |
|
| 367 |
- .fill(tint.opacity(0.14)) |
|
| 368 |
- ) |
|
| 369 |
- .padding(.top, 6) |
|
| 370 |
- } |
|
| 371 |
- } |
|
| 372 |
- .frame(height: height) |
|
| 373 |
- .background( |
|
| 374 |
- RoundedRectangle(cornerRadius: 16, style: .continuous) |
|
| 375 |
- .fill(tint.opacity(0.12)) |
|
| 376 |
- ) |
|
| 377 |
- .overlay( |
|
| 378 |
- RoundedRectangle(cornerRadius: 16, style: .continuous) |
|
| 379 |
- .stroke(tint.opacity(0.20), lineWidth: 1) |
|
| 380 |
- ) |
|
| 381 |
- } |
|
| 382 |
- |
|
| 383 |
- fileprivate func horizontalGuides(context: ChartContext) -> some View {
|
|
| 384 |
- GeometryReader { geometry in
|
|
| 385 |
- Path { path in
|
|
| 386 |
- for labelIndex in 1...self.yLabels {
|
|
| 387 |
- let y = yGuidePosition( |
|
| 388 |
- for: labelIndex, |
|
| 389 |
- context: context, |
|
| 390 |
- height: geometry.size.height |
|
| 391 |
- ) |
|
| 392 |
- path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y)) |
|
| 393 |
- } |
|
| 394 |
- } |
|
| 395 |
- .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85) |
|
| 396 |
- } |
|
| 397 |
- } |
|
| 398 |
- |
|
| 399 |
- fileprivate func verticalGuides(context: ChartContext) -> some View {
|
|
| 400 |
- GeometryReader { geometry in
|
|
| 401 |
- Path { path in
|
|
| 402 |
- |
|
| 403 |
- for labelIndex in 2..<self.xLabels {
|
|
| 404 |
- let x = xGuidePosition( |
|
| 405 |
- for: labelIndex, |
|
| 406 |
- context: context, |
|
| 407 |
- width: geometry.size.width |
|
| 408 |
- ) |
|
| 409 |
- path.move(to: CGPoint(x: x, y: 0) ) |
|
| 410 |
- path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) ) |
|
| 411 |
- } |
|
| 412 |
- } |
|
| 413 |
- .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4])) |
|
| 414 |
- } |
|
| 415 |
- } |
|
| 416 |
- |
|
| 417 |
-} |
|
| 418 |
- |
|
| 419 |
-struct Chart : View {
|
|
| 420 |
- |
|
| 421 |
- let points: [Measurements.Measurement.Point] |
|
| 422 |
- let context: ChartContext |
|
| 423 |
- var areaChart: Bool = false |
|
| 424 |
- var strokeColor: Color = .black |
|
| 425 |
- |
|
| 426 |
- var body : some View {
|
|
| 427 |
- GeometryReader { geometry in
|
|
| 428 |
- if self.areaChart {
|
|
| 429 |
- self.path( geometry: geometry ) |
|
| 430 |
- .fill(LinearGradient( gradient: .init(colors: [Color.red, Color.green]), startPoint: .init(x: 0.5, y: 0.1), endPoint: .init(x: 0.5, y: 0.9))) |
|
| 431 |
- } else {
|
|
| 432 |
- self.path( geometry: geometry ) |
|
| 433 |
- .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) |
|
| 434 |
- } |
|
| 435 |
- } |
|
| 436 |
- } |
|
| 437 |
- |
|
| 438 |
- fileprivate func path(geometry: GeometryProxy) -> Path {
|
|
| 439 |
- return Path { path in
|
|
| 440 |
- guard let first = points.first else { return }
|
|
| 441 |
- let firstPoint = context.placeInRect(point: first.point()) |
|
| 442 |
- path.move(to: CGPoint(x: firstPoint.x * geometry.size.width, y: firstPoint.y * geometry.size.height ) ) |
|
| 443 |
- for item in points.map({ context.placeInRect(point: $0.point()) }) {
|
|
| 444 |
- path.addLine(to: CGPoint(x: item.x * geometry.size.width, y: item.y * geometry.size.height ) ) |
|
| 445 |
- } |
|
| 446 |
- if self.areaChart {
|
|
| 447 |
- let lastPointX = context.placeInRect(point: CGPoint(x: points.last!.point().x, y: context.origin.y )) |
|
| 448 |
- let firstPointX = context.placeInRect(point: CGPoint(x: points.first!.point().x, y: context.origin.y )) |
|
| 449 |
- path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) ) |
|
| 450 |
- path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) ) |
|
| 451 |
- // MARK: Nu e nevoie. Fill inchide automat calea |
|
| 452 |
- // path.closeSubpath() |
|
| 453 |
- } |
|
| 454 |
- } |
|
| 455 |
- } |
|
| 456 |
- |
|
| 457 |
-} |
|
@@ -1,257 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// SettingsView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Bogdan Timofte on 14/03/2020. |
|
| 6 |
-// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
-// |
|
| 8 |
- |
|
| 9 |
-import SwiftUI |
|
| 10 |
- |
|
| 11 |
-struct MeterSettingsView: View {
|
|
| 12 |
- |
|
| 13 |
- @EnvironmentObject private var meter: Meter |
|
| 14 |
- @Environment(\.dismiss) private var dismiss |
|
| 15 |
- |
|
| 16 |
- private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac |
|
| 17 |
- |
|
| 18 |
- @State private var editingName = false |
|
| 19 |
- @State private var editingScreenTimeout = false |
|
| 20 |
- @State private var editingScreenBrightness = false |
|
| 21 |
- |
|
| 22 |
- var body: some View {
|
|
| 23 |
- VStack(spacing: 0) {
|
|
| 24 |
- if Self.isMacIPadApp {
|
|
| 25 |
- macSettingsHeader |
|
| 26 |
- } |
|
| 27 |
- ScrollView {
|
|
| 28 |
- VStack (spacing: 14) {
|
|
| 29 |
- settingsCard(title: "Name", tint: meter.color) {
|
|
| 30 |
- HStack {
|
|
| 31 |
- Spacer() |
|
| 32 |
- if !editingName {
|
|
| 33 |
- Text(meter.name) |
|
| 34 |
- .foregroundColor(.secondary) |
|
| 35 |
- } |
|
| 36 |
- ChevronView(rotate: $editingName) |
|
| 37 |
- } |
|
| 38 |
- if editingName {
|
|
| 39 |
- EditNameView(editingName: $editingName, newName: meter.name) |
|
| 40 |
- } |
|
| 41 |
- } |
|
| 42 |
- |
|
| 43 |
- if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
|
|
| 44 |
- settingsCard(title: "Meter Temperature Unit", tint: .orange) {
|
|
| 45 |
- Text("TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.")
|
|
| 46 |
- .font(.footnote) |
|
| 47 |
- .foregroundColor(.secondary) |
|
| 48 |
- Picker("", selection: $meter.tc66TemperatureUnitPreference) {
|
|
| 49 |
- ForEach(TemperatureUnitPreference.allCases) { unit in
|
|
| 50 |
- Text(unit.title).tag(unit) |
|
| 51 |
- } |
|
| 52 |
- } |
|
| 53 |
- .pickerStyle(SegmentedPickerStyle()) |
|
| 54 |
- } |
|
| 55 |
- } |
|
| 56 |
- |
|
| 57 |
- if meter.operationalState == .dataIsAvailable {
|
|
| 58 |
- settingsCard( |
|
| 59 |
- title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls", |
|
| 60 |
- tint: .indigo |
|
| 61 |
- ) {
|
|
| 62 |
- if meter.reportsCurrentScreenIndex {
|
|
| 63 |
- Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
|
|
| 64 |
- .font(.footnote) |
|
| 65 |
- .foregroundColor(.secondary) |
|
| 66 |
- } else {
|
|
| 67 |
- Text("Use these controls when you want to switch device pages without crowding the main meter view.")
|
|
| 68 |
- .font(.footnote) |
|
| 69 |
- .foregroundColor(.secondary) |
|
| 70 |
- } |
|
| 71 |
- |
|
| 72 |
- ControlView(showsHeader: false) |
|
| 73 |
- } |
|
| 74 |
- } |
|
| 75 |
- |
|
| 76 |
- if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
|
|
| 77 |
- settingsCard(title: "Screen Timeout", tint: .purple) {
|
|
| 78 |
- HStack {
|
|
| 79 |
- Spacer() |
|
| 80 |
- if !editingScreenTimeout {
|
|
| 81 |
- Text(meter.screenTimeout != 0 ? "\(meter.screenTimeout) Minutes" : "Off") |
|
| 82 |
- .foregroundColor(.secondary) |
|
| 83 |
- } |
|
| 84 |
- ChevronView(rotate: $editingScreenTimeout) |
|
| 85 |
- } |
|
| 86 |
- if editingScreenTimeout {
|
|
| 87 |
- EditScreenTimeoutView() |
|
| 88 |
- } |
|
| 89 |
- } |
|
| 90 |
- |
|
| 91 |
- settingsCard(title: "Screen Brightness", tint: .yellow) {
|
|
| 92 |
- HStack {
|
|
| 93 |
- Spacer() |
|
| 94 |
- if !editingScreenBrightness {
|
|
| 95 |
- Text("\(meter.screenBrightness)")
|
|
| 96 |
- .foregroundColor(.secondary) |
|
| 97 |
- } |
|
| 98 |
- ChevronView(rotate: $editingScreenBrightness) |
|
| 99 |
- } |
|
| 100 |
- if editingScreenBrightness {
|
|
| 101 |
- EditScreenBrightnessView() |
|
| 102 |
- } |
|
| 103 |
- } |
|
| 104 |
- } |
|
| 105 |
- } |
|
| 106 |
- .padding() |
|
| 107 |
- } |
|
| 108 |
- .background( |
|
| 109 |
- LinearGradient( |
|
| 110 |
- colors: [meter.color.opacity(0.14), Color.clear], |
|
| 111 |
- startPoint: .topLeading, |
|
| 112 |
- endPoint: .bottomTrailing |
|
| 113 |
- ) |
|
| 114 |
- .ignoresSafeArea() |
|
| 115 |
- ) |
|
| 116 |
- } |
|
| 117 |
- .modifier(IOSOnlySettingsNavBar( |
|
| 118 |
- apply: !Self.isMacIPadApp, |
|
| 119 |
- rssi: meter.btSerial.averageRSSI |
|
| 120 |
- )) |
|
| 121 |
- } |
|
| 122 |
- |
|
| 123 |
- // MARK: - Custom navigation header for Designed-for-iPad on Mac |
|
| 124 |
- |
|
| 125 |
- private var macSettingsHeader: some View {
|
|
| 126 |
- HStack(spacing: 12) {
|
|
| 127 |
- Button {
|
|
| 128 |
- dismiss() |
|
| 129 |
- } label: {
|
|
| 130 |
- HStack(spacing: 4) {
|
|
| 131 |
- Image(systemName: "chevron.left") |
|
| 132 |
- .font(.body.weight(.semibold)) |
|
| 133 |
- Text("Back")
|
|
| 134 |
- } |
|
| 135 |
- .foregroundColor(.accentColor) |
|
| 136 |
- } |
|
| 137 |
- .buttonStyle(.plain) |
|
| 138 |
- |
|
| 139 |
- Text("Meter Settings")
|
|
| 140 |
- .font(.headline) |
|
| 141 |
- .lineLimit(1) |
|
| 142 |
- |
|
| 143 |
- Spacer() |
|
| 144 |
- |
|
| 145 |
- if meter.operationalState > .notPresent {
|
|
| 146 |
- RSSIView(RSSI: meter.btSerial.averageRSSI) |
|
| 147 |
- .frame(width: 18, height: 18) |
|
| 148 |
- } |
|
| 149 |
- } |
|
| 150 |
- .padding(.horizontal, 16) |
|
| 151 |
- .padding(.vertical, 10) |
|
| 152 |
- .background( |
|
| 153 |
- Rectangle() |
|
| 154 |
- .fill(.ultraThinMaterial) |
|
| 155 |
- .ignoresSafeArea(edges: .top) |
|
| 156 |
- ) |
|
| 157 |
- .overlay(alignment: .bottom) {
|
|
| 158 |
- Rectangle() |
|
| 159 |
- .fill(Color.secondary.opacity(0.12)) |
|
| 160 |
- .frame(height: 1) |
|
| 161 |
- } |
|
| 162 |
- } |
|
| 163 |
- |
|
| 164 |
- private func settingsCard<Content: View>( |
|
| 165 |
- title: String, |
|
| 166 |
- tint: Color, |
|
| 167 |
- @ViewBuilder content: () -> Content |
|
| 168 |
- ) -> some View {
|
|
| 169 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 170 |
- Text(title) |
|
| 171 |
- .font(.headline) |
|
| 172 |
- content() |
|
| 173 |
- } |
|
| 174 |
- .padding(18) |
|
| 175 |
- .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 176 |
- } |
|
| 177 |
-} |
|
| 178 |
- |
|
| 179 |
-struct EditNameView: View {
|
|
| 180 |
- |
|
| 181 |
- @EnvironmentObject private var meter: Meter |
|
| 182 |
- |
|
| 183 |
- @Binding var editingName: Bool |
|
| 184 |
- @State var newName: String |
|
| 185 |
- |
|
| 186 |
- var body: some View {
|
|
| 187 |
- TextField("Name", text: self.$newName, onCommit: {
|
|
| 188 |
- self.meter.name = self.newName |
|
| 189 |
- self.editingName = false |
|
| 190 |
- }) |
|
| 191 |
- .textFieldStyle(RoundedBorderTextFieldStyle()) |
|
| 192 |
- .lineLimit(1) |
|
| 193 |
- .disableAutocorrection(true) |
|
| 194 |
- .multilineTextAlignment(.center) |
|
| 195 |
- } |
|
| 196 |
-} |
|
| 197 |
- |
|
| 198 |
-struct EditScreenTimeoutView: View {
|
|
| 199 |
- |
|
| 200 |
- @EnvironmentObject private var meter: Meter |
|
| 201 |
- |
|
| 202 |
- var body: some View {
|
|
| 203 |
- Picker("", selection: self.$meter.screenTimeout ) {
|
|
| 204 |
- Text("1").tag(1)
|
|
| 205 |
- Text("2").tag(2)
|
|
| 206 |
- Text("3").tag(3)
|
|
| 207 |
- Text("4").tag(4)
|
|
| 208 |
- Text("5").tag(5)
|
|
| 209 |
- Text("6").tag(6)
|
|
| 210 |
- Text("7").tag(7)
|
|
| 211 |
- Text("8").tag(8)
|
|
| 212 |
- Text("9").tag(9)
|
|
| 213 |
- Text("Off").tag(0)
|
|
| 214 |
- } |
|
| 215 |
- .pickerStyle( SegmentedPickerStyle() ) |
|
| 216 |
- } |
|
| 217 |
-} |
|
| 218 |
- |
|
| 219 |
-struct EditScreenBrightnessView: View {
|
|
| 220 |
- |
|
| 221 |
- @EnvironmentObject private var meter: Meter |
|
| 222 |
- |
|
| 223 |
- var body: some View {
|
|
| 224 |
- Picker("", selection: self.$meter.screenBrightness ) {
|
|
| 225 |
- Text("0").tag(0)
|
|
| 226 |
- Text("1").tag(1)
|
|
| 227 |
- Text("2").tag(2)
|
|
| 228 |
- Text("3").tag(3)
|
|
| 229 |
- Text("4").tag(4)
|
|
| 230 |
- Text("5").tag(5)
|
|
| 231 |
- } |
|
| 232 |
- .pickerStyle( SegmentedPickerStyle() ) |
|
| 233 |
- } |
|
| 234 |
-} |
|
| 235 |
- |
|
| 236 |
-// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac) |
|
| 237 |
- |
|
| 238 |
-private struct IOSOnlySettingsNavBar: ViewModifier {
|
|
| 239 |
- let apply: Bool |
|
| 240 |
- let rssi: Int |
|
| 241 |
- |
|
| 242 |
- @ViewBuilder |
|
| 243 |
- func body(content: Content) -> some View {
|
|
| 244 |
- if apply {
|
|
| 245 |
- content |
|
| 246 |
- .navigationBarTitle("Meter Settings")
|
|
| 247 |
- .toolbar {
|
|
| 248 |
- ToolbarItem(placement: .navigationBarTrailing) {
|
|
| 249 |
- RSSIView(RSSI: rssi).frame(width: 18, height: 18) |
|
| 250 |
- } |
|
| 251 |
- } |
|
| 252 |
- } else {
|
|
| 253 |
- content |
|
| 254 |
- .navigationBarHidden(true) |
|
| 255 |
- } |
|
| 256 |
- } |
|
| 257 |
-} |
|
@@ -11,24 +11,87 @@ import SwiftUI |
||
| 11 | 11 |
import CoreBluetooth |
| 12 | 12 |
|
| 13 | 13 |
struct MeterView: View {
|
| 14 |
- private enum MeterTab: Hashable {
|
|
| 15 |
- case connection |
|
| 14 |
+ private struct TabBarStyle {
|
|
| 15 |
+ let horizontalPadding: CGFloat |
|
| 16 |
+ let topPadding: CGFloat |
|
| 17 |
+ let bottomPadding: CGFloat |
|
| 18 |
+ let chipHorizontalPadding: CGFloat |
|
| 19 |
+ let chipVerticalPadding: CGFloat |
|
| 20 |
+ let outerPadding: CGFloat |
|
| 21 |
+ let barBackgroundOpacity: CGFloat |
|
| 22 |
+ let materialOpacity: CGFloat |
|
| 23 |
+ let shadowOpacity: CGFloat |
|
| 24 |
+ let floatingInset: CGFloat |
|
| 25 |
+ |
|
| 26 |
+ static let portrait = TabBarStyle( |
|
| 27 |
+ horizontalPadding: 16, |
|
| 28 |
+ topPadding: 10, |
|
| 29 |
+ bottomPadding: 8, |
|
| 30 |
+ chipHorizontalPadding: 10, |
|
| 31 |
+ chipVerticalPadding: 7, |
|
| 32 |
+ outerPadding: 6, |
|
| 33 |
+ barBackgroundOpacity: 0.10, |
|
| 34 |
+ materialOpacity: 0.78, |
|
| 35 |
+ shadowOpacity: 0, |
|
| 36 |
+ floatingInset: 0 |
|
| 37 |
+ ) |
|
| 38 |
+ |
|
| 39 |
+ static let portraitCompact = TabBarStyle( |
|
| 40 |
+ horizontalPadding: 16, |
|
| 41 |
+ topPadding: 10, |
|
| 42 |
+ bottomPadding: 8, |
|
| 43 |
+ chipHorizontalPadding: 12, |
|
| 44 |
+ chipVerticalPadding: 10, |
|
| 45 |
+ outerPadding: 6, |
|
| 46 |
+ barBackgroundOpacity: 0.14, |
|
| 47 |
+ materialOpacity: 0.90, |
|
| 48 |
+ shadowOpacity: 0, |
|
| 49 |
+ floatingInset: 0 |
|
| 50 |
+ ) |
|
| 51 |
+ |
|
| 52 |
+ static let landscapeInline = TabBarStyle( |
|
| 53 |
+ horizontalPadding: 12, |
|
| 54 |
+ topPadding: 10, |
|
| 55 |
+ bottomPadding: 8, |
|
| 56 |
+ chipHorizontalPadding: 10, |
|
| 57 |
+ chipVerticalPadding: 7, |
|
| 58 |
+ outerPadding: 6, |
|
| 59 |
+ barBackgroundOpacity: 0.10, |
|
| 60 |
+ materialOpacity: 0.78, |
|
| 61 |
+ shadowOpacity: 0, |
|
| 62 |
+ floatingInset: 0 |
|
| 63 |
+ ) |
|
| 64 |
+ |
|
| 65 |
+ static let landscapeFloating = TabBarStyle( |
|
| 66 |
+ horizontalPadding: 16, |
|
| 67 |
+ topPadding: 10, |
|
| 68 |
+ bottomPadding: 0, |
|
| 69 |
+ chipHorizontalPadding: 11, |
|
| 70 |
+ chipVerticalPadding: 11, |
|
| 71 |
+ outerPadding: 7, |
|
| 72 |
+ barBackgroundOpacity: 0.16, |
|
| 73 |
+ materialOpacity: 0.88, |
|
| 74 |
+ shadowOpacity: 0.12, |
|
| 75 |
+ floatingInset: 12 |
|
| 76 |
+ ) |
|
| 77 |
+ } |
|
| 78 |
+ |
|
| 79 |
+ private enum MeterTab: String, Hashable {
|
|
| 80 |
+ case home |
|
| 16 | 81 |
case live |
| 17 | 82 |
case chart |
| 18 |
- |
|
| 19 |
- var title: String {
|
|
| 20 |
- switch self {
|
|
| 21 |
- case .connection: return "Home" |
|
| 22 |
- case .live: return "Live" |
|
| 23 |
- case .chart: return "Chart" |
|
| 24 |
- } |
|
| 25 |
- } |
|
| 83 |
+ case chargeRecord |
|
| 84 |
+ case dataGroups |
|
| 85 |
+ case settings |
|
| 26 | 86 |
|
| 27 | 87 |
var systemImage: String {
|
| 28 | 88 |
switch self {
|
| 29 |
- case .connection: return "house.fill" |
|
| 89 |
+ case .home: return "house.fill" |
|
| 30 | 90 |
case .live: return "waveform.path.ecg" |
| 31 | 91 |
case .chart: return "chart.xyaxis.line" |
| 92 |
+ case .chargeRecord: return "gauge.with.dots.needle.50percent" |
|
| 93 |
+ case .dataGroups: return "square.grid.2x2.fill" |
|
| 94 |
+ case .settings: return "gearshape.fill" |
|
| 32 | 95 |
} |
| 33 | 96 |
} |
| 34 | 97 |
} |
@@ -37,48 +100,88 @@ struct MeterView: View {
|
||
| 37 | 100 |
@Environment(\.dismiss) private var dismiss |
| 38 | 101 |
|
| 39 | 102 |
private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac |
| 103 |
+ #if os(iOS) |
|
| 104 |
+ private static let isPhone: Bool = UIDevice.current.userInterfaceIdiom == .phone |
|
| 105 |
+ #else |
|
| 106 |
+ private static let isPhone: Bool = false |
|
| 107 |
+ #endif |
|
| 108 |
+ |
|
| 109 |
+ // True only on Mac iPad App (Designed for iPad), false on Catalyst |
|
| 110 |
+ private static let isTrueMacApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac && !ProcessInfo.processInfo.isMacCatalystApp |
|
| 40 | 111 |
|
| 41 |
- @State var dataGroupsViewVisibility: Bool = false |
|
| 42 |
- @State var recordingViewVisibility: Bool = false |
|
| 43 |
- @State var measurementsViewVisibility: Bool = false |
|
| 44 |
- @State private var selectedMeterTab: MeterTab = .connection |
|
| 112 |
+ @State private var selectedMeterTab: MeterTab = .home |
|
| 45 | 113 |
@State private var navBarTitle: String = "Meter" |
| 46 | 114 |
@State private var navBarShowRSSI: Bool = false |
| 47 | 115 |
@State private var navBarRSSI: Int = 0 |
| 48 |
- private var myBounds: CGRect { UIScreen.main.bounds }
|
|
| 49 |
- private let actionStripPadding: CGFloat = 10 |
|
| 50 |
- private let actionDividerWidth: CGFloat = 1 |
|
| 51 |
- private let actionButtonMaxWidth: CGFloat = 156 |
|
| 52 |
- private let actionButtonMinWidth: CGFloat = 88 |
|
| 53 |
- private let actionButtonHeight: CGFloat = 108 |
|
| 54 |
- private let pageHorizontalPadding: CGFloat = 12 |
|
| 55 |
- private let pageVerticalPadding: CGFloat = 12 |
|
| 56 |
- private let contentCardPadding: CGFloat = 16 |
|
| 116 |
+ @State private var landscapeTabBarHeight: CGFloat = 0 |
|
| 117 |
+ |
|
| 118 |
+ // Offline mode state |
|
| 119 |
+ private enum OfflineTab: String { case info, settings }
|
|
| 120 |
+ @State private var selectedOfflineTab: OfflineTab = .info |
|
| 121 |
+ @State private var offlineEditingName: Bool = false |
|
| 122 |
+ @State private var offlineName: String = "" |
|
| 123 |
+ @State private var offlineDeleteConfirmation: Bool = false |
|
| 124 |
+ @State private var offlineTemperatureUnit: TemperatureUnitPreference = .celsius |
|
| 125 |
+ |
|
| 126 |
+ private let offlineSummary: AppData.MeterSummary? |
|
| 127 |
+ |
|
| 128 |
+ init() { offlineSummary = nil }
|
|
| 129 |
+ init(offlineSummary: AppData.MeterSummary) { self.offlineSummary = offlineSummary }
|
|
| 57 | 130 |
|
| 58 | 131 |
var body: some View {
|
| 132 |
+ if let summary = offlineSummary {
|
|
| 133 |
+ offlineBody(summary: summary) |
|
| 134 |
+ } else {
|
|
| 135 |
+ liveBody |
|
| 136 |
+ } |
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 139 |
+ private var liveBody: some View {
|
|
| 59 | 140 |
GeometryReader { proxy in
|
| 60 | 141 |
let landscape = isLandscape(size: proxy.size) |
| 142 |
+ let usesOverlayTabBar = landscape && Self.isPhone |
|
| 143 |
+ let tabBarStyle = tabBarStyle( |
|
| 144 |
+ for: landscape, |
|
| 145 |
+ usesOverlayTabBar: usesOverlayTabBar, |
|
| 146 |
+ size: proxy.size |
|
| 147 |
+ ) |
|
| 148 |
+ let tabBarPresentation = tabBarPresentation( |
|
| 149 |
+ for: proxy.size, |
|
| 150 |
+ usesOverlayTabBar: usesOverlayTabBar |
|
| 151 |
+ ) |
|
| 61 | 152 |
|
| 62 | 153 |
VStack(spacing: 0) {
|
| 63 |
- if Self.isMacIPadApp {
|
|
| 154 |
+ // Use custom header only on true Mac iPad App (Designed for iPad on Mac) |
|
| 155 |
+ if Self.isTrueMacApp {
|
|
| 64 | 156 |
macNavigationHeader |
| 65 | 157 |
} |
| 66 | 158 |
Group {
|
| 67 | 159 |
if landscape {
|
| 68 |
- landscapeDeck(size: proxy.size) |
|
| 160 |
+ landscapeDeck( |
|
| 161 |
+ size: proxy.size, |
|
| 162 |
+ usesOverlayTabBar: usesOverlayTabBar, |
|
| 163 |
+ tabBarStyle: tabBarStyle, |
|
| 164 |
+ tabBarPresentation: tabBarPresentation |
|
| 165 |
+ ) |
|
| 69 | 166 |
} else {
|
| 70 |
- portraitContent(size: proxy.size) |
|
| 167 |
+ portraitContent( |
|
| 168 |
+ size: proxy.size, |
|
| 169 |
+ tabBarStyle: tabBarStyle, |
|
| 170 |
+ tabBarPresentation: tabBarPresentation |
|
| 171 |
+ ) |
|
| 71 | 172 |
} |
| 72 | 173 |
} |
| 73 | 174 |
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
| 74 | 175 |
} |
| 75 | 176 |
#if !targetEnvironment(macCatalyst) |
| 76 |
- .navigationBarHidden(Self.isMacIPadApp || landscape) |
|
| 177 |
+ .navigationBarHidden(Self.isTrueMacApp && landscape) |
|
| 178 |
+ #else |
|
| 179 |
+ .navigationBarHidden(landscape) |
|
| 77 | 180 |
#endif |
| 78 | 181 |
} |
| 79 | 182 |
.background(meterBackground) |
| 80 | 183 |
.modifier(IOSOnlyNavBar( |
| 81 |
- apply: !Self.isMacIPadApp, |
|
| 184 |
+ apply: !Self.isTrueMacApp, |
|
| 82 | 185 |
title: navBarTitle, |
| 83 | 186 |
showRSSI: navBarShowRSSI, |
| 84 | 187 |
rssi: navBarRSSI, |
@@ -100,6 +203,9 @@ struct MeterView: View {
|
||
| 100 | 203 |
navBarRSSI = newRSSI |
| 101 | 204 |
} |
| 102 | 205 |
} |
| 206 |
+ .onChange(of: selectedMeterTab) { newTab in
|
|
| 207 |
+ meter.preferredTabIdentifier = newTab.rawValue |
|
| 208 |
+ } |
|
| 103 | 209 |
} |
| 104 | 210 |
|
| 105 | 211 |
// MARK: - Custom navigation header for Designed-for-iPad on Mac |
@@ -124,16 +230,18 @@ struct MeterView: View {
|
||
| 124 | 230 |
|
| 125 | 231 |
Spacer() |
| 126 | 232 |
|
| 233 |
+ MeterConnectionToolbarButton( |
|
| 234 |
+ operationalState: meter.operationalState, |
|
| 235 |
+ showsTitle: true, |
|
| 236 |
+ connectAction: { meter.connect() },
|
|
| 237 |
+ disconnectAction: { meter.disconnect() }
|
|
| 238 |
+ ) |
|
| 239 |
+ |
|
| 127 | 240 |
if meter.operationalState > .notPresent {
|
| 128 | 241 |
RSSIView(RSSI: meter.btSerial.averageRSSI) |
| 129 | 242 |
.frame(width: 18, height: 18) |
| 130 | 243 |
} |
| 131 | 244 |
|
| 132 |
- NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
|
|
| 133 |
- Image(systemName: "gearshape.fill") |
|
| 134 |
- .foregroundColor(.accentColor) |
|
| 135 |
- } |
|
| 136 |
- .buttonStyle(.plain) |
|
| 137 | 245 |
} |
| 138 | 246 |
.padding(.horizontal, 16) |
| 139 | 247 |
.padding(.vertical, 10) |
@@ -149,59 +257,134 @@ struct MeterView: View {
|
||
| 149 | 257 |
} |
| 150 | 258 |
} |
| 151 | 259 |
|
| 152 |
- private func portraitContent(size: CGSize) -> some View {
|
|
| 153 |
- portraitSegmentedDeck(size: size) |
|
| 260 |
+ private func portraitContent( |
|
| 261 |
+ size: CGSize, |
|
| 262 |
+ tabBarStyle: TabBarStyle, |
|
| 263 |
+ tabBarPresentation: AdaptiveTabBarPresentation |
|
| 264 |
+ ) -> some View {
|
|
| 265 |
+ portraitSegmentedDeck( |
|
| 266 |
+ size: size, |
|
| 267 |
+ tabBarStyle: tabBarStyle, |
|
| 268 |
+ tabBarPresentation: tabBarPresentation |
|
| 269 |
+ ) |
|
| 270 |
+ } |
|
| 271 |
+ |
|
| 272 |
+ @ViewBuilder |
|
| 273 |
+ private func landscapeDeck( |
|
| 274 |
+ size: CGSize, |
|
| 275 |
+ usesOverlayTabBar: Bool, |
|
| 276 |
+ tabBarStyle: TabBarStyle, |
|
| 277 |
+ tabBarPresentation: AdaptiveTabBarPresentation |
|
| 278 |
+ ) -> some View {
|
|
| 279 |
+ if usesOverlayTabBar {
|
|
| 280 |
+ landscapeOverlaySegmentedDeck( |
|
| 281 |
+ size: size, |
|
| 282 |
+ tabBarStyle: tabBarStyle, |
|
| 283 |
+ tabBarPresentation: tabBarPresentation |
|
| 284 |
+ ) |
|
| 285 |
+ } else {
|
|
| 286 |
+ landscapeSegmentedDeck( |
|
| 287 |
+ size: size, |
|
| 288 |
+ tabBarStyle: tabBarStyle, |
|
| 289 |
+ tabBarPresentation: tabBarPresentation |
|
| 290 |
+ ) |
|
| 291 |
+ } |
|
| 154 | 292 |
} |
| 155 | 293 |
|
| 156 |
- private func landscapeDeck(size: CGSize) -> some View {
|
|
| 157 |
- landscapeSegmentedDeck(size: size) |
|
| 294 |
+ private func landscapeOverlaySegmentedDeck( |
|
| 295 |
+ size: CGSize, |
|
| 296 |
+ tabBarStyle: TabBarStyle, |
|
| 297 |
+ tabBarPresentation: AdaptiveTabBarPresentation |
|
| 298 |
+ ) -> some View {
|
|
| 299 |
+ ZStack(alignment: .top) {
|
|
| 300 |
+ landscapeSegmentedContent(size: size) |
|
| 301 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 302 |
+ .padding(.top, landscapeContentTopPadding(for: tabBarStyle)) |
|
| 303 |
+ .id(displayedMeterTab) |
|
| 304 |
+ .transition(.opacity.combined(with: .move(edge: .trailing))) |
|
| 305 |
+ |
|
| 306 |
+ segmentedTabBar( |
|
| 307 |
+ style: tabBarStyle, |
|
| 308 |
+ presentation: tabBarPresentation, |
|
| 309 |
+ showsConnectionAction: !Self.isMacIPadApp |
|
| 310 |
+ ) |
|
| 311 |
+ } |
|
| 312 |
+ .animation(.easeInOut(duration: 0.22), value: displayedMeterTab) |
|
| 313 |
+ .animation(.easeInOut(duration: 0.22), value: availableMeterTabs) |
|
| 314 |
+ .onAppear {
|
|
| 315 |
+ restoreSelectedTab() |
|
| 316 |
+ } |
|
| 317 |
+ .onPreferenceChange(MeterTabBarHeightPreferenceKey.self) { height in
|
|
| 318 |
+ if height > 0 {
|
|
| 319 |
+ landscapeTabBarHeight = height |
|
| 320 |
+ } |
|
| 321 |
+ } |
|
| 158 | 322 |
} |
| 159 | 323 |
|
| 160 |
- private func landscapeSegmentedDeck(size: CGSize) -> some View {
|
|
| 324 |
+ private func landscapeSegmentedDeck( |
|
| 325 |
+ size: CGSize, |
|
| 326 |
+ tabBarStyle: TabBarStyle, |
|
| 327 |
+ tabBarPresentation: AdaptiveTabBarPresentation |
|
| 328 |
+ ) -> some View {
|
|
| 161 | 329 |
VStack(spacing: 0) {
|
| 162 |
- segmentedTabBar(horizontalPadding: 12) |
|
| 330 |
+ segmentedTabBar( |
|
| 331 |
+ style: tabBarStyle, |
|
| 332 |
+ presentation: tabBarPresentation, |
|
| 333 |
+ showsConnectionAction: !Self.isMacIPadApp |
|
| 334 |
+ ) |
|
| 163 | 335 |
|
| 164 | 336 |
landscapeSegmentedContent(size: size) |
| 165 | 337 |
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
| 166 |
- .id(selectedMeterTab) |
|
| 338 |
+ .id(displayedMeterTab) |
|
| 167 | 339 |
.transition(.opacity.combined(with: .move(edge: .trailing))) |
| 168 | 340 |
} |
| 169 |
- .animation(.easeInOut(duration: 0.22), value: selectedMeterTab) |
|
| 341 |
+ .animation(.easeInOut(duration: 0.22), value: displayedMeterTab) |
|
| 170 | 342 |
.animation(.easeInOut(duration: 0.22), value: availableMeterTabs) |
| 171 | 343 |
.onAppear {
|
| 172 |
- normalizeSelectedTab() |
|
| 173 |
- } |
|
| 174 |
- .onChange(of: availableMeterTabs) { _ in
|
|
| 175 |
- normalizeSelectedTab() |
|
| 344 |
+ restoreSelectedTab() |
|
| 176 | 345 |
} |
| 177 | 346 |
} |
| 178 | 347 |
|
| 179 |
- private func portraitSegmentedDeck(size: CGSize) -> some View {
|
|
| 348 |
+ private func portraitSegmentedDeck( |
|
| 349 |
+ size: CGSize, |
|
| 350 |
+ tabBarStyle: TabBarStyle, |
|
| 351 |
+ tabBarPresentation: AdaptiveTabBarPresentation |
|
| 352 |
+ ) -> some View {
|
|
| 180 | 353 |
VStack(spacing: 0) {
|
| 181 |
- segmentedTabBar(horizontalPadding: 16) |
|
| 354 |
+ segmentedTabBar( |
|
| 355 |
+ style: tabBarStyle, |
|
| 356 |
+ presentation: tabBarPresentation |
|
| 357 |
+ ) |
|
| 182 | 358 |
|
| 183 | 359 |
portraitSegmentedContent(size: size) |
| 184 | 360 |
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
| 185 |
- .id(selectedMeterTab) |
|
| 361 |
+ .id(displayedMeterTab) |
|
| 186 | 362 |
.transition(.opacity.combined(with: .move(edge: .trailing))) |
| 187 | 363 |
} |
| 188 |
- .animation(.easeInOut(duration: 0.22), value: selectedMeterTab) |
|
| 364 |
+ .animation(.easeInOut(duration: 0.22), value: displayedMeterTab) |
|
| 189 | 365 |
.animation(.easeInOut(duration: 0.22), value: availableMeterTabs) |
| 190 | 366 |
.onAppear {
|
| 191 |
- normalizeSelectedTab() |
|
| 192 |
- } |
|
| 193 |
- .onChange(of: availableMeterTabs) { _ in
|
|
| 194 |
- normalizeSelectedTab() |
|
| 367 |
+ restoreSelectedTab() |
|
| 195 | 368 |
} |
| 196 | 369 |
} |
| 197 | 370 |
|
| 198 |
- private func segmentedTabBar(horizontalPadding: CGFloat) -> some View {
|
|
| 199 |
- HStack {
|
|
| 371 |
+ private func segmentedTabBar( |
|
| 372 |
+ style: TabBarStyle, |
|
| 373 |
+ presentation: AdaptiveTabBarPresentation, |
|
| 374 |
+ showsConnectionAction: Bool = false |
|
| 375 |
+ ) -> some View {
|
|
| 376 |
+ let isFloating = style.floatingInset > 0 |
|
| 377 |
+ let cornerRadius = presentation.showsTitles ? 14.0 : 22.0 |
|
| 378 |
+ let unselectedForegroundColor = isFloating ? Color.primary.opacity(0.86) : Color.primary |
|
| 379 |
+ let unselectedChipFill = isFloating ? Color.black.opacity(0.08) : Color.secondary.opacity(0.12) |
|
| 380 |
+ |
|
| 381 |
+ return HStack {
|
|
| 200 | 382 |
Spacer(minLength: 0) |
| 201 | 383 |
|
| 202 | 384 |
HStack(spacing: 8) {
|
| 203 | 385 |
ForEach(availableMeterTabs, id: \.self) { tab in
|
| 204 |
- let isSelected = selectedMeterTab == tab |
|
| 386 |
+ let isSelected = displayedMeterTab == tab |
|
| 387 |
+ let isUnavailable = requiresLiveData(tab) && !isLiveDataAvailable |
|
| 205 | 388 |
|
| 206 | 389 |
Button {
|
| 207 | 390 |
withAnimation(.easeInOut(duration: 0.2)) {
|
@@ -211,247 +394,209 @@ struct MeterView: View {
|
||
| 211 | 394 |
HStack(spacing: 6) {
|
| 212 | 395 |
Image(systemName: tab.systemImage) |
| 213 | 396 |
.font(.subheadline.weight(.semibold)) |
| 214 |
- Text(tab.title) |
|
| 215 |
- .font(.subheadline.weight(.semibold)) |
|
| 216 |
- .lineLimit(1) |
|
| 397 |
+ if presentation.showsTitles {
|
|
| 398 |
+ Text(title(for: tab)) |
|
| 399 |
+ .font(.subheadline.weight(.semibold)) |
|
| 400 |
+ .lineLimit(1) |
|
| 401 |
+ } |
|
| 217 | 402 |
} |
| 218 |
- .foregroundColor(isSelected ? .white : .primary) |
|
| 219 |
- .padding(.horizontal, 10) |
|
| 220 |
- .padding(.vertical, 7) |
|
| 403 |
+ .foregroundColor( |
|
| 404 |
+ isSelected |
|
| 405 |
+ ? .white |
|
| 406 |
+ : (isUnavailable ? Color.secondary.opacity(0.5) : unselectedForegroundColor) |
|
| 407 |
+ ) |
|
| 408 |
+ .padding(.horizontal, style.chipHorizontalPadding) |
|
| 409 |
+ .padding(.vertical, style.chipVerticalPadding) |
|
| 221 | 410 |
.frame(maxWidth: .infinity) |
| 222 | 411 |
.background( |
| 223 | 412 |
Capsule() |
| 224 |
- .fill(isSelected ? meter.color : Color.secondary.opacity(0.12)) |
|
| 413 |
+ .fill( |
|
| 414 |
+ isSelected |
|
| 415 |
+ ? meter.color.opacity(isFloating ? 0.94 : 1) |
|
| 416 |
+ : (isUnavailable ? Color.secondary.opacity(0.06) : unselectedChipFill) |
|
| 417 |
+ ) |
|
| 225 | 418 |
) |
| 226 | 419 |
} |
| 227 | 420 |
.buttonStyle(.plain) |
| 228 |
- .accessibilityLabel(tab.title) |
|
| 421 |
+ .accessibilityLabel(title(for: tab)) |
|
| 229 | 422 |
} |
| 230 | 423 |
} |
| 231 |
- .frame(maxWidth: 420) |
|
| 232 |
- .padding(6) |
|
| 424 |
+ .frame(maxWidth: presentation.maxWidth) |
|
| 425 |
+ .padding(style.outerPadding) |
|
| 233 | 426 |
.background( |
| 234 |
- RoundedRectangle(cornerRadius: 14, style: .continuous) |
|
| 235 |
- .fill(Color.secondary.opacity(0.10)) |
|
| 427 |
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) |
|
| 428 |
+ .fill( |
|
| 429 |
+ isFloating |
|
| 430 |
+ ? LinearGradient( |
|
| 431 |
+ colors: [ |
|
| 432 |
+ Color.white.opacity(0.76), |
|
| 433 |
+ Color.white.opacity(0.52) |
|
| 434 |
+ ], |
|
| 435 |
+ startPoint: .topLeading, |
|
| 436 |
+ endPoint: .bottomTrailing |
|
| 437 |
+ ) |
|
| 438 |
+ : LinearGradient( |
|
| 439 |
+ colors: [ |
|
| 440 |
+ Color.secondary.opacity(style.barBackgroundOpacity), |
|
| 441 |
+ Color.secondary.opacity(style.barBackgroundOpacity) |
|
| 442 |
+ ], |
|
| 443 |
+ startPoint: .topLeading, |
|
| 444 |
+ endPoint: .bottomTrailing |
|
| 445 |
+ ) |
|
| 446 |
+ ) |
|
| 236 | 447 |
) |
| 448 |
+ .overlay {
|
|
| 449 |
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) |
|
| 450 |
+ .stroke( |
|
| 451 |
+ isFloating ? Color.black.opacity(0.08) : Color.clear, |
|
| 452 |
+ lineWidth: 1 |
|
| 453 |
+ ) |
|
| 454 |
+ } |
|
| 455 |
+ .background {
|
|
| 456 |
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) |
|
| 457 |
+ .fill(.ultraThinMaterial) |
|
| 458 |
+ .opacity(style.materialOpacity) |
|
| 459 |
+ } |
|
| 460 |
+ .shadow(color: Color.black.opacity(style.shadowOpacity), radius: isFloating ? 28 : 24, x: 0, y: isFloating ? 16 : 12) |
|
| 237 | 461 |
|
| 238 | 462 |
Spacer(minLength: 0) |
| 239 | 463 |
} |
| 240 |
- .padding(.horizontal, horizontalPadding) |
|
| 241 |
- .padding(.top, 10) |
|
| 242 |
- .padding(.bottom, 8) |
|
| 464 |
+ .padding(.horizontal, style.horizontalPadding) |
|
| 465 |
+ .padding(.top, style.topPadding) |
|
| 466 |
+ .padding(.bottom, style.bottomPadding) |
|
| 243 | 467 |
.background( |
| 244 |
- Rectangle() |
|
| 245 |
- .fill(.ultraThinMaterial) |
|
| 246 |
- .opacity(0.78) |
|
| 247 |
- .ignoresSafeArea(edges: .top) |
|
| 468 |
+ GeometryReader { geometry in
|
|
| 469 |
+ Color.clear |
|
| 470 |
+ .preference(key: MeterTabBarHeightPreferenceKey.self, value: geometry.size.height) |
|
| 471 |
+ } |
|
| 248 | 472 |
) |
| 473 |
+ .padding(.horizontal, style.floatingInset) |
|
| 474 |
+ .background {
|
|
| 475 |
+ if style.floatingInset == 0 {
|
|
| 476 |
+ Rectangle() |
|
| 477 |
+ .fill(.ultraThinMaterial) |
|
| 478 |
+ .opacity(style.materialOpacity) |
|
| 479 |
+ .ignoresSafeArea(edges: .top) |
|
| 480 |
+ } |
|
| 481 |
+ } |
|
| 249 | 482 |
.overlay(alignment: .bottom) {
|
| 250 |
- Rectangle() |
|
| 251 |
- .fill(Color.secondary.opacity(0.12)) |
|
| 252 |
- .frame(height: 1) |
|
| 483 |
+ if style.floatingInset == 0 {
|
|
| 484 |
+ Rectangle() |
|
| 485 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 486 |
+ .frame(height: 1) |
|
| 487 |
+ } |
|
| 488 |
+ } |
|
| 489 |
+ .overlay(alignment: .trailing) {
|
|
| 490 |
+ if showsConnectionAction {
|
|
| 491 |
+ MeterConnectionToolbarButton( |
|
| 492 |
+ operationalState: meter.operationalState, |
|
| 493 |
+ showsTitle: false, |
|
| 494 |
+ connectAction: { meter.connect() },
|
|
| 495 |
+ disconnectAction: { meter.disconnect() }
|
|
| 496 |
+ ) |
|
| 497 |
+ .font(.title3.weight(.semibold)) |
|
| 498 |
+ .padding(.trailing, style.horizontalPadding + style.floatingInset + 4) |
|
| 499 |
+ .padding(.top, style.topPadding) |
|
| 500 |
+ .padding(.bottom, style.bottomPadding) |
|
| 501 |
+ } |
|
| 253 | 502 |
} |
| 254 | 503 |
} |
| 255 | 504 |
|
| 256 | 505 |
@ViewBuilder |
| 257 | 506 |
private func landscapeSegmentedContent(size: CGSize) -> some View {
|
| 258 |
- switch selectedMeterTab {
|
|
| 259 |
- case .connection: |
|
| 260 |
- landscapeConnectionPage |
|
| 507 |
+ switch displayedMeterTab {
|
|
| 508 |
+ case .home: |
|
| 509 |
+ MeterHomeTabView( |
|
| 510 |
+ size: size, |
|
| 511 |
+ isLandscape: true, |
|
| 512 |
+ showChargeRecordTab: {
|
|
| 513 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 514 |
+ selectedMeterTab = .chargeRecord |
|
| 515 |
+ } |
|
| 516 |
+ }, |
|
| 517 |
+ showDataGroupsTab: {
|
|
| 518 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 519 |
+ selectedMeterTab = .dataGroups |
|
| 520 |
+ } |
|
| 521 |
+ } |
|
| 522 |
+ ) |
|
| 261 | 523 |
case .live: |
| 262 |
- if meter.operationalState == .dataIsAvailable {
|
|
| 263 |
- landscapeLivePage(size: size) |
|
| 264 |
- } else {
|
|
| 265 |
- landscapeConnectionPage |
|
| 266 |
- } |
|
| 524 |
+ MeterLiveTabView(size: size, isLandscape: true) |
|
| 267 | 525 |
case .chart: |
| 268 |
- if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
|
|
| 269 |
- landscapeChartPage(size: size) |
|
| 270 |
- } else {
|
|
| 271 |
- landscapeConnectionPage |
|
| 526 |
+ MeterChartTabView(size: size, isLandscape: true) |
|
| 527 |
+ case .chargeRecord: |
|
| 528 |
+ MeterChargeRecordTabView().equatable() |
|
| 529 |
+ case .dataGroups: |
|
| 530 |
+ MeterDataGroupsTabView() |
|
| 531 |
+ case .settings: |
|
| 532 |
+ MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
|
|
| 533 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 534 |
+ selectedMeterTab = .home |
|
| 535 |
+ } |
|
| 272 | 536 |
} |
| 273 | 537 |
} |
| 274 | 538 |
} |
| 275 | 539 |
|
| 276 | 540 |
@ViewBuilder |
| 277 | 541 |
private func portraitSegmentedContent(size: CGSize) -> some View {
|
| 278 |
- switch selectedMeterTab {
|
|
| 279 |
- case .connection: |
|
| 280 |
- portraitConnectionPage(size: size) |
|
| 281 |
- case .live: |
|
| 282 |
- if meter.operationalState == .dataIsAvailable {
|
|
| 283 |
- portraitLivePage(size: size) |
|
| 284 |
- } else {
|
|
| 285 |
- portraitConnectionPage(size: size) |
|
| 286 |
- } |
|
| 287 |
- case .chart: |
|
| 288 |
- if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
|
|
| 289 |
- portraitChartPage |
|
| 290 |
- } else {
|
|
| 291 |
- portraitConnectionPage(size: size) |
|
| 292 |
- } |
|
| 293 |
- } |
|
| 294 |
- } |
|
| 295 |
- |
|
| 296 |
- private func portraitConnectionPage(size: CGSize) -> some View {
|
|
| 297 |
- portraitFace {
|
|
| 298 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 299 |
- connectionCard( |
|
| 300 |
- compact: prefersCompactPortraitConnection(for: size), |
|
| 301 |
- showsActions: meter.operationalState == .dataIsAvailable |
|
| 302 |
- ) |
|
| 303 |
- |
|
| 304 |
- homeInfoPreview |
|
| 305 |
- } |
|
| 306 |
- } |
|
| 307 |
- } |
|
| 308 |
- |
|
| 309 |
- private func portraitLivePage(size: CGSize) -> some View {
|
|
| 310 |
- portraitFace {
|
|
| 311 |
- LiveView(compactLayout: prefersCompactPortraitConnection(for: size), availableSize: size) |
|
| 312 |
- .padding(contentCardPadding) |
|
| 313 |
- .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 314 |
- } |
|
| 315 |
- } |
|
| 316 |
- |
|
| 317 |
- private var portraitChartPage: some View {
|
|
| 318 |
- portraitFace {
|
|
| 319 |
- MeasurementChartView() |
|
| 320 |
- .environmentObject(meter.measurements) |
|
| 321 |
- .frame(minHeight: myBounds.height / 3.4) |
|
| 322 |
- .padding(contentCardPadding) |
|
| 323 |
- .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 324 |
- } |
|
| 325 |
- } |
|
| 326 |
- |
|
| 327 |
- private var landscapeConnectionPage: some View {
|
|
| 328 |
- landscapeFace {
|
|
| 329 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 330 |
- connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable) |
|
| 331 |
- |
|
| 332 |
- homeInfoPreview |
|
| 333 |
- } |
|
| 334 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 335 |
- } |
|
| 336 |
- } |
|
| 337 |
- |
|
| 338 |
- private var homeInfoPreview: some View {
|
|
| 339 |
- VStack(spacing: 14) {
|
|
| 340 |
- MeterInfoCard(title: "Overview", tint: meter.color) {
|
|
| 341 |
- MeterInfoRow(label: "Name", value: meter.name) |
|
| 342 |
- MeterInfoRow(label: "Device Model", value: meter.deviceModelName) |
|
| 343 |
- MeterInfoRow(label: "Advertised Model", value: meter.modelString) |
|
| 344 |
- MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage) |
|
| 345 |
- MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription) |
|
| 346 |
- } |
|
| 347 |
- |
|
| 348 |
- MeterInfoCard(title: "Identifiers", tint: .blue) {
|
|
| 349 |
- MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description) |
|
| 350 |
- if meter.modelNumber != 0 {
|
|
| 351 |
- MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)") |
|
| 352 |
- } |
|
| 353 |
- } |
|
| 354 |
- |
|
| 355 |
- MeterInfoCard(title: "Screen Reporting", tint: .orange) {
|
|
| 356 |
- if meter.reportsCurrentScreenIndex {
|
|
| 357 |
- MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription) |
|
| 358 |
- Text("The active screen index is reported by the meter and mapped by the app to a known label.")
|
|
| 359 |
- .font(.footnote) |
|
| 360 |
- .foregroundColor(.secondary) |
|
| 361 |
- } else {
|
|
| 362 |
- MeterInfoRow(label: "Current Screen", value: "Not Reported") |
|
| 363 |
- Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
|
|
| 364 |
- .font(.footnote) |
|
| 365 |
- .foregroundColor(.secondary) |
|
| 366 |
- } |
|
| 367 |
- } |
|
| 368 |
- |
|
| 369 |
- MeterInfoCard(title: "Live Device Details", tint: .indigo) {
|
|
| 370 |
- if meter.operationalState == .dataIsAvailable {
|
|
| 371 |
- if !meter.firmwareVersion.isEmpty {
|
|
| 372 |
- MeterInfoRow(label: "Firmware", value: meter.firmwareVersion) |
|
| 373 |
- } |
|
| 374 |
- if meter.supportsChargerDetection {
|
|
| 375 |
- MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription) |
|
| 542 |
+ switch displayedMeterTab {
|
|
| 543 |
+ case .home: |
|
| 544 |
+ MeterHomeTabView( |
|
| 545 |
+ size: size, |
|
| 546 |
+ isLandscape: false, |
|
| 547 |
+ showChargeRecordTab: {
|
|
| 548 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 549 |
+ selectedMeterTab = .chargeRecord |
|
| 376 | 550 |
} |
| 377 |
- if meter.serialNumber != 0 {
|
|
| 378 |
- MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)") |
|
| 551 |
+ }, |
|
| 552 |
+ showDataGroupsTab: {
|
|
| 553 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 554 |
+ selectedMeterTab = .dataGroups |
|
| 379 | 555 |
} |
| 380 |
- if meter.bootCount != 0 {
|
|
| 381 |
- MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)") |
|
| 382 |
- } |
|
| 383 |
- } else {
|
|
| 384 |
- Text("Connect to the meter to load firmware, serial, and boot details.")
|
|
| 385 |
- .font(.footnote) |
|
| 386 |
- .foregroundColor(.secondary) |
|
| 556 |
+ } |
|
| 557 |
+ ) |
|
| 558 |
+ case .live: |
|
| 559 |
+ MeterLiveTabView(size: size, isLandscape: false) |
|
| 560 |
+ case .chart: |
|
| 561 |
+ MeterChartTabView(size: size, isLandscape: false) |
|
| 562 |
+ case .chargeRecord: |
|
| 563 |
+ MeterChargeRecordTabView().equatable() |
|
| 564 |
+ case .dataGroups: |
|
| 565 |
+ MeterDataGroupsTabView() |
|
| 566 |
+ case .settings: |
|
| 567 |
+ MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
|
|
| 568 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 569 |
+ selectedMeterTab = .home |
|
| 387 | 570 |
} |
| 388 | 571 |
} |
| 389 | 572 |
} |
| 390 |
- .padding(.horizontal, pageHorizontalPadding) |
|
| 391 |
- } |
|
| 392 |
- |
|
| 393 |
- private func landscapeLivePage(size: CGSize) -> some View {
|
|
| 394 |
- landscapeFace {
|
|
| 395 |
- LiveView(compactLayout: true, availableSize: size) |
|
| 396 |
- .padding(contentCardPadding) |
|
| 397 |
- .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 398 |
- .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 399 |
- } |
|
| 400 |
- } |
|
| 401 |
- |
|
| 402 |
- private func landscapeChartPage(size: CGSize) -> some View {
|
|
| 403 |
- landscapeFace {
|
|
| 404 |
- MeasurementChartView() |
|
| 405 |
- .environmentObject(meter.measurements) |
|
| 406 |
- .frame(height: max(250, size.height - 44)) |
|
| 407 |
- .padding(contentCardPadding) |
|
| 408 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) |
|
| 409 |
- .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 410 |
- } |
|
| 411 | 573 |
} |
| 412 | 574 |
|
| 413 | 575 |
private var availableMeterTabs: [MeterTab] {
|
| 414 |
- var tabs: [MeterTab] = [.connection] |
|
| 415 |
- |
|
| 416 |
- if meter.operationalState == .dataIsAvailable {
|
|
| 417 |
- tabs.append(.live) |
|
| 418 |
- |
|
| 419 |
- if meter.measurements.power.context.isValid {
|
|
| 420 |
- tabs.append(.chart) |
|
| 421 |
- } |
|
| 576 |
+ var tabs: [MeterTab] = [.home, .live, .chart] |
|
| 577 |
+ if meter.supportsRecordingView {
|
|
| 578 |
+ tabs.append(.chargeRecord) |
|
| 422 | 579 |
} |
| 423 |
- |
|
| 580 |
+ tabs.append(.dataGroups) |
|
| 581 |
+ tabs.append(.settings) |
|
| 424 | 582 |
return tabs |
| 425 | 583 |
} |
| 426 | 584 |
|
| 427 |
- private func normalizeSelectedTab() {
|
|
| 428 |
- guard availableMeterTabs.contains(selectedMeterTab) else {
|
|
| 429 |
- withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 430 |
- selectedMeterTab = .connection |
|
| 431 |
- } |
|
| 432 |
- return |
|
| 585 |
+ private var displayedMeterTab: MeterTab {
|
|
| 586 |
+ if availableMeterTabs.contains(selectedMeterTab) {
|
|
| 587 |
+ return selectedMeterTab |
|
| 433 | 588 |
} |
| 589 |
+ return .home |
|
| 434 | 590 |
} |
| 435 | 591 |
|
| 436 |
- private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
|
|
| 437 |
- size.height < 760 || size.width < 380 |
|
| 438 |
- } |
|
| 439 |
- |
|
| 440 |
- private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 441 |
- ScrollView {
|
|
| 442 |
- content() |
|
| 443 |
- .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 444 |
- .padding(.horizontal, pageHorizontalPadding) |
|
| 445 |
- .padding(.vertical, pageVerticalPadding) |
|
| 592 |
+ private func restoreSelectedTab() {
|
|
| 593 |
+ guard let restoredTab = MeterTab(rawValue: meter.preferredTabIdentifier) else {
|
|
| 594 |
+ meter.preferredTabIdentifier = MeterTab.home.rawValue |
|
| 595 |
+ selectedMeterTab = .home |
|
| 596 |
+ return |
|
| 446 | 597 |
} |
| 447 |
- } |
|
| 448 | 598 |
|
| 449 |
- private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 450 |
- content() |
|
| 451 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 452 |
- .padding(.horizontal, pageHorizontalPadding) |
|
| 453 |
- .padding(.vertical, pageVerticalPadding) |
|
| 454 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 599 |
+ selectedMeterTab = availableMeterTabs.contains(restoredTab) ? restoredTab : .home |
|
| 455 | 600 |
} |
| 456 | 601 |
|
| 457 | 602 |
private var meterBackground: some View {
|
@@ -471,259 +616,341 @@ struct MeterView: View {
|
||
| 471 | 616 |
size.width > size.height |
| 472 | 617 |
} |
| 473 | 618 |
|
| 474 |
- private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
|
|
| 475 |
- VStack(alignment: .leading, spacing: compact ? 12 : 18) {
|
|
| 476 |
- HStack(alignment: .top) {
|
|
| 477 |
- meterIdentity(compact: compact) |
|
| 478 |
- Spacer() |
|
| 479 |
- statusBadge |
|
| 480 |
- } |
|
| 619 |
+ private func tabBarStyle(for landscape: Bool, usesOverlayTabBar: Bool, size: CGSize) -> TabBarStyle {
|
|
| 620 |
+ if usesOverlayTabBar {
|
|
| 621 |
+ return .landscapeFloating |
|
| 622 |
+ } |
|
| 481 | 623 |
|
| 482 |
- if compact {
|
|
| 483 |
- Spacer(minLength: 0) |
|
| 484 |
- } |
|
| 624 |
+ if landscape {
|
|
| 625 |
+ return .landscapeInline |
|
| 626 |
+ } |
|
| 485 | 627 |
|
| 486 |
- connectionActionArea(compact: compact) |
|
| 628 |
+ if Self.isPhone && size.width < 390 {
|
|
| 629 |
+ return .portraitCompact |
|
| 630 |
+ } |
|
| 487 | 631 |
|
| 488 |
- if showsActions {
|
|
| 489 |
- VStack(spacing: compact ? 10 : 12) {
|
|
| 490 |
- Rectangle() |
|
| 491 |
- .fill(Color.secondary.opacity(0.12)) |
|
| 492 |
- .frame(height: 1) |
|
| 632 |
+ return .portrait |
|
| 633 |
+ } |
|
| 493 | 634 |
|
| 494 |
- actionGrid(compact: compact, embedded: true) |
|
| 495 |
- } |
|
| 496 |
- } |
|
| 635 |
+ private func tabBarPresentation(for size: CGSize, usesOverlayTabBar: Bool) -> AdaptiveTabBarPresentation {
|
|
| 636 |
+ if usesOverlayTabBar {
|
|
| 637 |
+ return AdaptiveTabBarPresentation( |
|
| 638 |
+ showsTitles: false, |
|
| 639 |
+ maxWidth: 260 |
|
| 640 |
+ ) |
|
| 497 | 641 |
} |
| 498 |
- .padding(compact ? 16 : 20) |
|
| 499 |
- .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading) |
|
| 500 |
- .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24) |
|
| 642 |
+ |
|
| 643 |
+ return AdaptiveTabBarPresentation.standard(for: size) |
|
| 501 | 644 |
} |
| 502 | 645 |
|
| 503 |
- private func meterIdentity(compact: Bool) -> some View {
|
|
| 504 |
- HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
| 505 |
- Text(meter.name) |
|
| 506 |
- .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold)) |
|
| 507 |
- .lineLimit(1) |
|
| 508 |
- .minimumScaleFactor(0.8) |
|
| 646 |
+ private func landscapeContentTopPadding(for style: TabBarStyle) -> CGFloat {
|
|
| 647 |
+ if style.floatingInset > 0 {
|
|
| 648 |
+ return max(landscapeTabBarHeight * 0.44, 26) |
|
| 649 |
+ } |
|
| 509 | 650 |
|
| 510 |
- Text(meter.deviceModelName) |
|
| 511 |
- .font((compact ? Font.caption : .subheadline).weight(.semibold)) |
|
| 512 |
- .foregroundColor(.secondary) |
|
| 513 |
- .lineLimit(1) |
|
| 514 |
- .minimumScaleFactor(0.8) |
|
| 651 |
+ return max(landscapeTabBarHeight - 6, 0) |
|
| 652 |
+ } |
|
| 653 |
+ |
|
| 654 |
+ private func title(for tab: MeterTab) -> String {
|
|
| 655 |
+ switch tab {
|
|
| 656 |
+ case .home: |
|
| 657 |
+ return "Home" |
|
| 658 |
+ case .live: |
|
| 659 |
+ return "Live" |
|
| 660 |
+ case .chart: |
|
| 661 |
+ return "Chart" |
|
| 662 |
+ case .chargeRecord: |
|
| 663 |
+ return "Charge Record" |
|
| 664 |
+ case .dataGroups: |
|
| 665 |
+ return meter.dataGroupsTitle |
|
| 666 |
+ case .settings: |
|
| 667 |
+ return "Settings" |
|
| 515 | 668 |
} |
| 516 | 669 |
} |
| 517 | 670 |
|
| 518 |
- private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
|
|
| 519 |
- let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight |
|
| 671 |
+ private func requiresLiveData(_ tab: MeterTab) -> Bool {
|
|
| 672 |
+ switch tab {
|
|
| 673 |
+ case .live, .chart: return true |
|
| 674 |
+ case .home, .chargeRecord, .dataGroups, .settings: return false |
|
| 675 |
+ } |
|
| 676 |
+ } |
|
| 520 | 677 |
|
| 521 |
- return GeometryReader { proxy in
|
|
| 522 |
- let buttonWidth = actionButtonWidth(for: proxy.size.width) |
|
| 523 |
- let stripWidth = actionStripWidth(for: buttonWidth) |
|
| 524 |
- let stripContent = HStack(spacing: 0) {
|
|
| 525 |
- meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 526 |
- dataGroupsViewVisibility.toggle() |
|
| 527 |
- } |
|
| 528 |
- .sheet(isPresented: $dataGroupsViewVisibility) {
|
|
| 529 |
- DataGroupsView(visibility: $dataGroupsViewVisibility) |
|
| 530 |
- .environmentObject(meter) |
|
| 531 |
- } |
|
| 678 |
+ private var isLiveDataAvailable: Bool {
|
|
| 679 |
+ meter.operationalState >= .dataIsAvailable |
|
| 680 |
+ } |
|
| 532 | 681 |
|
| 533 |
- if meter.supportsRecordingView {
|
|
| 534 |
- actionStripDivider(height: currentActionHeight) |
|
| 535 |
- meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 536 |
- recordingViewVisibility.toggle() |
|
| 537 |
- } |
|
| 538 |
- .sheet(isPresented: $recordingViewVisibility) {
|
|
| 539 |
- RecordingView(visibility: $recordingViewVisibility) |
|
| 540 |
- .environmentObject(meter) |
|
| 682 |
+ // MARK: - Offline mode |
|
| 683 |
+ |
|
| 684 |
+ @ViewBuilder |
|
| 685 |
+ private func offlineBody(summary: AppData.MeterSummary) -> some View {
|
|
| 686 |
+ VStack(spacing: 0) {
|
|
| 687 |
+ if Self.isTrueMacApp {
|
|
| 688 |
+ offlineMacHeader(name: summary.displayName) |
|
| 689 |
+ } |
|
| 690 |
+ offlineTabBar(tint: summary.tint) |
|
| 691 |
+ offlineTabContent(summary: summary) |
|
| 692 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 693 |
+ .id(selectedOfflineTab) |
|
| 694 |
+ .transition(.opacity.combined(with: .move(edge: .trailing))) |
|
| 695 |
+ .animation(.easeInOut(duration: 0.22), value: selectedOfflineTab) |
|
| 696 |
+ } |
|
| 697 |
+ .background(offlineBackground(tint: summary.tint)) |
|
| 698 |
+ #if !targetEnvironment(macCatalyst) |
|
| 699 |
+ .navigationBarHidden(Self.isTrueMacApp) |
|
| 700 |
+ #else |
|
| 701 |
+ .navigationBarHidden(false) |
|
| 702 |
+ #endif |
|
| 703 |
+ .navigationBarTitle(summary.displayName, displayMode: .inline) |
|
| 704 |
+ .onAppear {
|
|
| 705 |
+ offlineName = summary.displayName |
|
| 706 |
+ offlineTemperatureUnit = appData.temperatureUnitPreference(for: summary.macAddress) |
|
| 707 |
+ } |
|
| 708 |
+ } |
|
| 709 |
+ |
|
| 710 |
+ private func offlineTabBar(tint: Color) -> some View {
|
|
| 711 |
+ HStack {
|
|
| 712 |
+ Spacer(minLength: 0) |
|
| 713 |
+ HStack(spacing: 8) {
|
|
| 714 |
+ ForEach([OfflineTab.info, OfflineTab.settings], id: \.rawValue) { tab in
|
|
| 715 |
+ let isSelected = selectedOfflineTab == tab |
|
| 716 |
+ Button {
|
|
| 717 |
+ withAnimation(.easeInOut(duration: 0.2)) { selectedOfflineTab = tab }
|
|
| 718 |
+ } label: {
|
|
| 719 |
+ HStack(spacing: 6) {
|
|
| 720 |
+ Image(systemName: tab == .info ? "house.fill" : "gearshape.fill") |
|
| 721 |
+ .font(.subheadline.weight(.semibold)) |
|
| 722 |
+ Text(tab == .info ? "Info" : "Settings") |
|
| 723 |
+ .font(.subheadline.weight(.semibold)) |
|
| 724 |
+ .lineLimit(1) |
|
| 725 |
+ } |
|
| 726 |
+ .foregroundColor(isSelected ? .white : .primary) |
|
| 727 |
+ .padding(.horizontal, 10) |
|
| 728 |
+ .padding(.vertical, 7) |
|
| 729 |
+ .frame(maxWidth: .infinity) |
|
| 730 |
+ .background(Capsule().fill(isSelected ? tint : Color.secondary.opacity(0.12))) |
|
| 541 | 731 |
} |
| 732 |
+ .buttonStyle(.plain) |
|
| 542 | 733 |
} |
| 734 |
+ } |
|
| 735 |
+ .padding(6) |
|
| 736 |
+ .background( |
|
| 737 |
+ RoundedRectangle(cornerRadius: 14, style: .continuous) |
|
| 738 |
+ .fill(Color.secondary.opacity(0.10)) |
|
| 739 |
+ ) |
|
| 740 |
+ .background( |
|
| 741 |
+ RoundedRectangle(cornerRadius: 14, style: .continuous) |
|
| 742 |
+ .fill(.ultraThinMaterial) |
|
| 743 |
+ .opacity(0.78) |
|
| 744 |
+ ) |
|
| 745 |
+ Spacer(minLength: 0) |
|
| 746 |
+ } |
|
| 747 |
+ .padding(.horizontal, 16) |
|
| 748 |
+ .padding(.top, 10) |
|
| 749 |
+ .padding(.bottom, 8) |
|
| 750 |
+ .background( |
|
| 751 |
+ Rectangle() |
|
| 752 |
+ .fill(.ultraThinMaterial) |
|
| 753 |
+ .opacity(0.78) |
|
| 754 |
+ .ignoresSafeArea(edges: .top) |
|
| 755 |
+ ) |
|
| 756 |
+ .overlay(alignment: .bottom) {
|
|
| 757 |
+ Rectangle() |
|
| 758 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 759 |
+ .frame(height: 1) |
|
| 760 |
+ } |
|
| 761 |
+ } |
|
| 543 | 762 |
|
| 544 |
- actionStripDivider(height: currentActionHeight) |
|
| 545 |
- meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 546 |
- measurementsViewVisibility.toggle() |
|
| 547 |
- } |
|
| 548 |
- .sheet(isPresented: $measurementsViewVisibility) {
|
|
| 549 |
- MeasurementsView(visibility: $measurementsViewVisibility) |
|
| 550 |
- .environmentObject(meter.measurements) |
|
| 763 |
+ @ViewBuilder |
|
| 764 |
+ private func offlineTabContent(summary: AppData.MeterSummary) -> some View {
|
|
| 765 |
+ switch selectedOfflineTab {
|
|
| 766 |
+ case .info: |
|
| 767 |
+ ScrollView {
|
|
| 768 |
+ VStack(alignment: .leading, spacing: 20) {
|
|
| 769 |
+ offlineStatusHeader(summary: summary) |
|
| 770 |
+ MeterInfoCardView(title: "Meter", tint: summary.tint) {
|
|
| 771 |
+ MeterInfoRowView(label: "Name", value: summary.displayName) |
|
| 772 |
+ MeterInfoRowView(label: "Model", value: summary.modelSummary.isEmpty ? "Unknown" : summary.modelSummary) |
|
| 773 |
+ if let advertisedName = summary.advertisedName {
|
|
| 774 |
+ MeterInfoRowView(label: "Advertised Name", value: advertisedName) |
|
| 775 |
+ } |
|
| 776 |
+ MeterInfoRowView(label: "MAC", value: summary.macAddress) |
|
| 777 |
+ MeterInfoRowView(label: "Last Seen", value: historyText(for: summary.lastSeen)) |
|
| 778 |
+ MeterInfoRowView(label: "Last Connected", value: historyText(for: summary.lastConnected)) |
|
| 779 |
+ } |
|
| 551 | 780 |
} |
| 781 |
+ .padding(16) |
|
| 552 | 782 |
} |
| 553 |
- .padding(actionStripPadding) |
|
| 554 |
- .frame(width: stripWidth) |
|
| 555 |
- |
|
| 556 |
- HStack {
|
|
| 557 |
- Spacer(minLength: 0) |
|
| 558 |
- stripContent |
|
| 559 |
- .meterCard( |
|
| 560 |
- tint: embedded ? meter.color : Color.secondary, |
|
| 561 |
- fillOpacity: embedded ? 0.08 : 0.10, |
|
| 562 |
- strokeOpacity: embedded ? 0.14 : 0.16, |
|
| 563 |
- cornerRadius: embedded ? 24 : 22 |
|
| 564 |
- ) |
|
| 565 |
- Spacer(minLength: 0) |
|
| 566 |
- } |
|
| 783 |
+ case .settings: |
|
| 784 |
+ offlineSettingsContent(summary: summary) |
|
| 567 | 785 |
} |
| 568 |
- .frame(height: currentActionHeight + (actionStripPadding * 2)) |
|
| 569 | 786 |
} |
| 570 | 787 |
|
| 571 |
- private func connectionActionArea(compact: Bool = false) -> some View {
|
|
| 572 |
- let connected = meter.operationalState >= .peripheralConnectionPending |
|
| 573 |
- let tint = connected ? disconnectActionTint : connectActionTint |
|
| 574 |
- |
|
| 575 |
- return Group {
|
|
| 576 |
- if meter.operationalState == .notPresent {
|
|
| 577 |
- HStack(spacing: 10) {
|
|
| 578 |
- Image(systemName: "exclamationmark.triangle.fill") |
|
| 579 |
- .foregroundColor(.orange) |
|
| 580 |
- Text("Not found at this time.")
|
|
| 581 |
- .fontWeight(.semibold) |
|
| 582 |
- Spacer() |
|
| 788 |
+ private func offlineSettingsContent(summary: AppData.MeterSummary) -> some View {
|
|
| 789 |
+ let isTC66 = summary.modelSummary == "TC66C" |
|
| 790 |
+ return ScrollView {
|
|
| 791 |
+ VStack(spacing: 14) {
|
|
| 792 |
+ offlineSettingsCard(title: "Name", tint: summary.tint) {
|
|
| 793 |
+ HStack {
|
|
| 794 |
+ Spacer() |
|
| 795 |
+ if !offlineEditingName {
|
|
| 796 |
+ Text(offlineName).foregroundColor(.secondary) |
|
| 797 |
+ } |
|
| 798 |
+ ChevronView(rotate: $offlineEditingName) |
|
| 799 |
+ } |
|
| 800 |
+ if offlineEditingName {
|
|
| 801 |
+ TextField("Name", text: $offlineName, onCommit: {
|
|
| 802 |
+ appData.setMeterName(offlineName, for: summary.macAddress) |
|
| 803 |
+ offlineEditingName = false |
|
| 804 |
+ }) |
|
| 805 |
+ .textFieldStyle(RoundedBorderTextFieldStyle()) |
|
| 806 |
+ .lineLimit(1) |
|
| 807 |
+ .disableAutocorrection(true) |
|
| 808 |
+ .multilineTextAlignment(.center) |
|
| 809 |
+ } |
|
| 583 | 810 |
} |
| 584 |
- .padding(compact ? 12 : 16) |
|
| 585 |
- .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18) |
|
| 586 |
- } else {
|
|
| 587 |
- Button(action: {
|
|
| 588 |
- if meter.operationalState < .peripheralConnectionPending {
|
|
| 589 |
- meter.connect() |
|
| 590 |
- } else {
|
|
| 591 |
- meter.disconnect() |
|
| 811 |
+ |
|
| 812 |
+ if isTC66 {
|
|
| 813 |
+ offlineSettingsCard( |
|
| 814 |
+ title: "Meter Temperature Unit", |
|
| 815 |
+ infoMessage: "TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.", |
|
| 816 |
+ tint: .orange |
|
| 817 |
+ ) {
|
|
| 818 |
+ Picker("", selection: $offlineTemperatureUnit) {
|
|
| 819 |
+ ForEach(TemperatureUnitPreference.allCases) { unit in
|
|
| 820 |
+ Text(unit.title).tag(unit) |
|
| 821 |
+ } |
|
| 822 |
+ } |
|
| 823 |
+ .pickerStyle(SegmentedPickerStyle()) |
|
| 824 |
+ .onChange(of: offlineTemperatureUnit) { newValue in
|
|
| 825 |
+ appData.setTemperatureUnitPreference(newValue, for: summary.macAddress) |
|
| 826 |
+ } |
|
| 592 | 827 |
} |
| 593 |
- }) {
|
|
| 594 |
- HStack(spacing: 12) {
|
|
| 595 |
- Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill") |
|
| 596 |
- .foregroundColor(tint) |
|
| 597 |
- .frame(width: 30, height: 30) |
|
| 598 |
- .background(Circle().fill(tint.opacity(0.12))) |
|
| 599 |
- Text(connected ? "Disconnect" : "Connect") |
|
| 600 |
- .fontWeight(.semibold) |
|
| 601 |
- .foregroundColor(.primary) |
|
| 602 |
- Spacer() |
|
| 828 |
+ } |
|
| 829 |
+ |
|
| 830 |
+ offlineSettingsCard( |
|
| 831 |
+ title: "Danger Zone", |
|
| 832 |
+ infoMessage: "Delete this meter from the sidebar and clear its saved metadata. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.", |
|
| 833 |
+ tint: .red |
|
| 834 |
+ ) {
|
|
| 835 |
+ Button("Delete Meter") {
|
|
| 836 |
+ offlineDeleteConfirmation = true |
|
| 603 | 837 |
} |
| 604 |
- .padding(.horizontal, 18) |
|
| 605 |
- .padding(.vertical, compact ? 10 : 14) |
|
| 606 | 838 |
.frame(maxWidth: .infinity) |
| 607 |
- .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 839 |
+ .padding(.vertical, 10) |
|
| 840 |
+ .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 841 |
+ .buttonStyle(.plain) |
|
| 608 | 842 |
} |
| 609 |
- .buttonStyle(.plain) |
|
| 610 | 843 |
} |
| 844 |
+ .padding() |
|
| 611 | 845 |
} |
| 612 |
- } |
|
| 613 |
- |
|
| 614 |
- fileprivate func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
|
|
| 615 |
- Button(action: action) {
|
|
| 616 |
- VStack(spacing: compact ? 8 : 10) {
|
|
| 617 |
- Image(systemName: icon) |
|
| 618 |
- .font(.system(size: compact ? 18 : 20, weight: .semibold)) |
|
| 619 |
- .frame(width: compact ? 34 : 40, height: compact ? 34 : 40) |
|
| 620 |
- .background(Circle().fill(tint.opacity(0.14))) |
|
| 621 |
- Text(title) |
|
| 622 |
- .font((compact ? Font.caption : .footnote).weight(.semibold)) |
|
| 623 |
- .multilineTextAlignment(.center) |
|
| 624 |
- .lineLimit(2) |
|
| 625 |
- .minimumScaleFactor(0.9) |
|
| 846 |
+ .alert("Delete Meter?", isPresented: $offlineDeleteConfirmation) {
|
|
| 847 |
+ Button("Delete", role: .destructive) {
|
|
| 848 |
+ appData.deleteMeter(macAddress: summary.macAddress) |
|
| 849 |
+ dismiss() |
|
| 626 | 850 |
} |
| 627 |
- .foregroundColor(tint) |
|
| 628 |
- .frame(width: width, height: height) |
|
| 629 |
- .contentShape(Rectangle()) |
|
| 851 |
+ Button("Cancel", role: .cancel) {}
|
|
| 852 |
+ } message: {
|
|
| 853 |
+ Text("This removes the saved meter entry. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.")
|
|
| 630 | 854 |
} |
| 631 |
- .buttonStyle(.plain) |
|
| 632 | 855 |
} |
| 633 | 856 |
|
| 634 |
- private var visibleActionButtonCount: CGFloat {
|
|
| 635 |
- meter.supportsRecordingView ? 3 : 2 |
|
| 636 |
- } |
|
| 637 |
- |
|
| 638 |
- private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
|
|
| 639 |
- let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0) |
|
| 640 |
- let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth |
|
| 641 |
- let fittedWidth = floor(contentWidth / visibleActionButtonCount) |
|
| 642 |
- return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth)) |
|
| 643 |
- } |
|
| 644 |
- |
|
| 645 |
- private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
|
|
| 646 |
- let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0) |
|
| 647 |
- return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2) |
|
| 857 |
+ private func offlineSettingsCard<Content: View>( |
|
| 858 |
+ title: String, |
|
| 859 |
+ infoMessage: String? = nil, |
|
| 860 |
+ tint: Color, |
|
| 861 |
+ @ViewBuilder content: () -> Content |
|
| 862 |
+ ) -> some View {
|
|
| 863 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 864 |
+ HStack(spacing: 8) {
|
|
| 865 |
+ Text(title).font(.headline) |
|
| 866 |
+ if let infoMessage {
|
|
| 867 |
+ ContextInfoButton(title: title, message: infoMessage) |
|
| 868 |
+ } |
|
| 869 |
+ } |
|
| 870 |
+ content() |
|
| 871 |
+ } |
|
| 872 |
+ .padding(18) |
|
| 873 |
+ .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 648 | 874 |
} |
| 649 | 875 |
|
| 650 |
- private func actionStripDivider(height: CGFloat) -> some View {
|
|
| 651 |
- Rectangle() |
|
| 652 |
- .fill(Color.secondary.opacity(0.16)) |
|
| 653 |
- .frame(width: actionDividerWidth, height: max(44, height - 22)) |
|
| 876 |
+ private func offlineMacHeader(name: String) -> some View {
|
|
| 877 |
+ HStack(spacing: 12) {
|
|
| 878 |
+ Button { dismiss() } label: {
|
|
| 879 |
+ HStack(spacing: 4) {
|
|
| 880 |
+ Image(systemName: "chevron.left") |
|
| 881 |
+ .font(.body.weight(.semibold)) |
|
| 882 |
+ Text("USB Meters")
|
|
| 883 |
+ } |
|
| 884 |
+ .foregroundColor(.accentColor) |
|
| 885 |
+ } |
|
| 886 |
+ .buttonStyle(.plain) |
|
| 887 |
+ Text(name).font(.headline).lineLimit(1) |
|
| 888 |
+ Spacer() |
|
| 889 |
+ } |
|
| 890 |
+ .padding(.horizontal, 16) |
|
| 891 |
+ .padding(.vertical, 10) |
|
| 892 |
+ .background( |
|
| 893 |
+ Rectangle() |
|
| 894 |
+ .fill(.ultraThinMaterial) |
|
| 895 |
+ .ignoresSafeArea(edges: .top) |
|
| 896 |
+ ) |
|
| 897 |
+ .overlay(alignment: .bottom) {
|
|
| 898 |
+ Rectangle() |
|
| 899 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 900 |
+ .frame(height: 1) |
|
| 901 |
+ } |
|
| 654 | 902 |
} |
| 655 | 903 |
|
| 656 |
- private var statusBadge: some View {
|
|
| 657 |
- Text(statusText) |
|
| 658 |
- .font(.caption.weight(.bold)) |
|
| 659 |
- .padding(.horizontal, 12) |
|
| 904 |
+ private func offlineStatusHeader(summary: AppData.MeterSummary) -> some View {
|
|
| 905 |
+ HStack(spacing: 12) {
|
|
| 906 |
+ Image(systemName: "sensor.tag.radiowaves.forward.fill") |
|
| 907 |
+ .font(.system(size: 22, weight: .semibold)) |
|
| 908 |
+ .foregroundColor(.secondary) |
|
| 909 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 910 |
+ Text(summary.displayName) |
|
| 911 |
+ .font(.title3.weight(.semibold)) |
|
| 912 |
+ .lineLimit(1) |
|
| 913 |
+ Text(summary.modelSummary.isEmpty ? "Known Meter" : summary.modelSummary) |
|
| 914 |
+ .font(.caption) |
|
| 915 |
+ .foregroundColor(.secondary) |
|
| 916 |
+ } |
|
| 917 |
+ Spacer() |
|
| 918 |
+ HStack(spacing: 6) {
|
|
| 919 |
+ Circle().fill(Color.secondary).frame(width: 8, height: 8) |
|
| 920 |
+ Text("Offline")
|
|
| 921 |
+ .font(.caption.weight(.semibold)) |
|
| 922 |
+ .foregroundColor(.secondary) |
|
| 923 |
+ } |
|
| 924 |
+ .padding(.horizontal, 10) |
|
| 660 | 925 |
.padding(.vertical, 6) |
| 661 |
- .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999) |
|
| 662 |
- } |
|
| 663 |
- |
|
| 664 |
- private var connectActionTint: Color {
|
|
| 665 |
- Color(red: 0.20, green: 0.46, blue: 0.43) |
|
| 666 |
- } |
|
| 667 |
- |
|
| 668 |
- private var disconnectActionTint: Color {
|
|
| 669 |
- Color(red: 0.66, green: 0.39, blue: 0.35) |
|
| 670 |
- } |
|
| 671 |
- |
|
| 672 |
- private var statusText: String {
|
|
| 673 |
- switch meter.operationalState {
|
|
| 674 |
- case .notPresent: |
|
| 675 |
- return "Missing" |
|
| 676 |
- case .peripheralNotConnected: |
|
| 677 |
- return "Ready" |
|
| 678 |
- case .peripheralConnectionPending: |
|
| 679 |
- return "Connecting" |
|
| 680 |
- case .peripheralConnected: |
|
| 681 |
- return "Linked" |
|
| 682 |
- case .peripheralReady: |
|
| 683 |
- return "Preparing" |
|
| 684 |
- case .comunicating: |
|
| 685 |
- return "Syncing" |
|
| 686 |
- case .dataIsAvailable: |
|
| 687 |
- return "Live" |
|
| 926 |
+ .background(Capsule(style: .continuous).fill(Color.secondary.opacity(0.12))) |
|
| 927 |
+ .overlay(Capsule(style: .continuous).stroke(Color.secondary.opacity(0.22), lineWidth: 1)) |
|
| 688 | 928 |
} |
| 929 |
+ .padding(14) |
|
| 930 |
+ .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 18) |
|
| 689 | 931 |
} |
| 690 | 932 |
|
| 691 |
- private var statusColor: Color {
|
|
| 692 |
- Meter.operationalColor(for: meter.operationalState) |
|
| 933 |
+ private func offlineBackground(tint: Color) -> some View {
|
|
| 934 |
+ LinearGradient( |
|
| 935 |
+ colors: [tint.opacity(0.14), Color.secondary.opacity(0.06), Color.clear], |
|
| 936 |
+ startPoint: .topLeading, |
|
| 937 |
+ endPoint: .bottomTrailing |
|
| 938 |
+ ) |
|
| 939 |
+ .ignoresSafeArea() |
|
| 693 | 940 |
} |
| 694 |
-} |
|
| 695 | 941 |
|
| 696 |
- |
|
| 697 |
-private struct MeterInfoCard<Content: View>: View {
|
|
| 698 |
- let title: String |
|
| 699 |
- let tint: Color |
|
| 700 |
- @ViewBuilder var content: Content |
|
| 701 |
- |
|
| 702 |
- var body: some View {
|
|
| 703 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 704 |
- Text(title) |
|
| 705 |
- .font(.headline) |
|
| 706 |
- content |
|
| 707 |
- } |
|
| 708 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 709 |
- .padding(18) |
|
| 710 |
- .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 942 |
+ private func historyText(for date: Date?) -> String {
|
|
| 943 |
+ guard let date else { return "Never" }
|
|
| 944 |
+ return date.format(as: "yyyy-MM-dd HH:mm") |
|
| 711 | 945 |
} |
| 946 |
+ |
|
| 712 | 947 |
} |
| 713 | 948 |
|
| 714 |
-private struct MeterInfoRow: View {
|
|
| 715 |
- let label: String |
|
| 716 |
- let value: String |
|
| 949 |
+private struct MeterTabBarHeightPreferenceKey: PreferenceKey {
|
|
| 950 |
+ static var defaultValue: CGFloat = 0 |
|
| 717 | 951 |
|
| 718 |
- var body: some View {
|
|
| 719 |
- HStack {
|
|
| 720 |
- Text(label) |
|
| 721 |
- Spacer() |
|
| 722 |
- Text(value) |
|
| 723 |
- .foregroundColor(.secondary) |
|
| 724 |
- .multilineTextAlignment(.trailing) |
|
| 725 |
- } |
|
| 726 |
- .font(.footnote) |
|
| 952 |
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
| 953 |
+ value = max(value, nextValue()) |
|
| 727 | 954 |
} |
| 728 | 955 |
} |
| 729 | 956 |
|
@@ -740,18 +967,27 @@ private struct IOSOnlyNavBar: ViewModifier {
|
||
| 740 | 967 |
func body(content: Content) -> some View {
|
| 741 | 968 |
if apply {
|
| 742 | 969 |
content |
| 743 |
- .navigationBarTitle(title) |
|
| 970 |
+ .navigationBarTitle(title, displayMode: .inline) |
|
| 744 | 971 |
.toolbar {
|
| 745 | 972 |
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
| 973 |
+ MeterConnectionToolbarButton( |
|
| 974 |
+ operationalState: meter.operationalState, |
|
| 975 |
+ showsTitle: false, |
|
| 976 |
+ connectAction: { meter.connect() },
|
|
| 977 |
+ disconnectAction: { meter.disconnect() }
|
|
| 978 |
+ ) |
|
| 979 |
+ .font(.body.weight(.semibold)) |
|
| 746 | 980 |
if showRSSI {
|
| 747 | 981 |
RSSIView(RSSI: rssi) |
| 748 | 982 |
.frame(width: 18, height: 18) |
| 749 | 983 |
} |
| 750 |
- NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
|
|
| 751 |
- Image(systemName: "gearshape.fill") |
|
| 752 |
- } |
|
| 753 | 984 |
} |
| 754 | 985 |
} |
| 986 |
+ #if targetEnvironment(macCatalyst) |
|
| 987 |
+ .toolbar {
|
|
| 988 |
+ ToolbarItemGroup(placement: .primaryAction) {}
|
|
| 989 |
+ } |
|
| 990 |
+ #endif |
|
| 755 | 991 |
} else {
|
| 756 | 992 |
content |
| 757 | 993 |
} |
@@ -1,168 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// RecordingView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Bogdan Timofte on 09/03/2020. |
|
| 6 |
-// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
-// |
|
| 8 |
- |
|
| 9 |
-import SwiftUI |
|
| 10 |
- |
|
| 11 |
-struct RecordingView: View {
|
|
| 12 |
- |
|
| 13 |
- @Binding var visibility: Bool |
|
| 14 |
- @EnvironmentObject private var usbMeter: Meter |
|
| 15 |
- |
|
| 16 |
- var body: some View {
|
|
| 17 |
- NavigationView {
|
|
| 18 |
- ScrollView {
|
|
| 19 |
- VStack(spacing: 16) {
|
|
| 20 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 21 |
- HStack {
|
|
| 22 |
- Text("Charge Record")
|
|
| 23 |
- .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 24 |
- Spacer() |
|
| 25 |
- Text(usbMeter.chargeRecordStatusText) |
|
| 26 |
- .font(.caption.weight(.bold)) |
|
| 27 |
- .foregroundColor(usbMeter.chargeRecordStatusColor) |
|
| 28 |
- .padding(.horizontal, 10) |
|
| 29 |
- .padding(.vertical, 6) |
|
| 30 |
- .meterCard( |
|
| 31 |
- tint: usbMeter.chargeRecordStatusColor, |
|
| 32 |
- fillOpacity: 0.18, |
|
| 33 |
- strokeOpacity: 0.24, |
|
| 34 |
- cornerRadius: 999 |
|
| 35 |
- ) |
|
| 36 |
- } |
|
| 37 |
- Text("App-side charge accumulation based on the stop-threshold workflow.")
|
|
| 38 |
- .font(.footnote) |
|
| 39 |
- .foregroundColor(.secondary) |
|
| 40 |
- } |
|
| 41 |
- .frame(maxWidth: .infinity) |
|
| 42 |
- .padding(18) |
|
| 43 |
- .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 44 |
- |
|
| 45 |
- HStack(alignment: .top) {
|
|
| 46 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 47 |
- Text("Capacity")
|
|
| 48 |
- Text("Energy")
|
|
| 49 |
- Text("Duration")
|
|
| 50 |
- Text("Stop Threshold")
|
|
| 51 |
- } |
|
| 52 |
- Spacer() |
|
| 53 |
- VStack(alignment: .trailing, spacing: 10) {
|
|
| 54 |
- Text("\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah")
|
|
| 55 |
- Text("\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh")
|
|
| 56 |
- Text(usbMeter.chargeRecordDurationDescription) |
|
| 57 |
- Text("\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A")
|
|
| 58 |
- } |
|
| 59 |
- .monospacedDigit() |
|
| 60 |
- } |
|
| 61 |
- .font(.footnote.weight(.semibold)) |
|
| 62 |
- .padding(18) |
|
| 63 |
- .meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 64 |
- |
|
| 65 |
- if usbMeter.chargeRecordTimeRange != nil {
|
|
| 66 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 67 |
- HStack {
|
|
| 68 |
- Text("Charge Curve")
|
|
| 69 |
- .font(.headline) |
|
| 70 |
- Spacer() |
|
| 71 |
- Button("Reset Graph") {
|
|
| 72 |
- usbMeter.resetChargeRecordGraph() |
|
| 73 |
- } |
|
| 74 |
- .foregroundColor(.red) |
|
| 75 |
- } |
|
| 76 |
- MeasurementChartView(timeRange: usbMeter.chargeRecordTimeRange) |
|
| 77 |
- .environmentObject(usbMeter.measurements) |
|
| 78 |
- .frame(minHeight: 220) |
|
| 79 |
- Text("Reset Graph clears the current charge-record session and removes older shared samples that are no longer needed for this curve.")
|
|
| 80 |
- .font(.footnote) |
|
| 81 |
- .foregroundColor(.secondary) |
|
| 82 |
- } |
|
| 83 |
- .padding(18) |
|
| 84 |
- .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 85 |
- } |
|
| 86 |
- |
|
| 87 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 88 |
- Text("Stop Threshold")
|
|
| 89 |
- .font(.headline) |
|
| 90 |
- Slider(value: $usbMeter.chargeRecordStopThreshold, in: 0...0.30, step: 0.01) |
|
| 91 |
- Text("The app starts accumulating when current rises above this threshold and stops when it falls back to or below it.")
|
|
| 92 |
- .font(.footnote) |
|
| 93 |
- .foregroundColor(.secondary) |
|
| 94 |
- Button("Reset") {
|
|
| 95 |
- usbMeter.resetChargeRecord() |
|
| 96 |
- } |
|
| 97 |
- .frame(maxWidth: .infinity) |
|
| 98 |
- .padding(.vertical, 10) |
|
| 99 |
- .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 100 |
- .buttonStyle(.plain) |
|
| 101 |
- } |
|
| 102 |
- .padding(18) |
|
| 103 |
- .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 104 |
- |
|
| 105 |
- if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
|
|
| 106 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 107 |
- Text("Meter Totals")
|
|
| 108 |
- .font(.headline) |
|
| 109 |
- HStack(alignment: .top) {
|
|
| 110 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 111 |
- Text("Capacity")
|
|
| 112 |
- Text("Energy")
|
|
| 113 |
- Text("Duration")
|
|
| 114 |
- Text("Meter Threshold")
|
|
| 115 |
- } |
|
| 116 |
- Spacer() |
|
| 117 |
- VStack(alignment: .trailing, spacing: 10) {
|
|
| 118 |
- Text("\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah")
|
|
| 119 |
- Text("\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh")
|
|
| 120 |
- Text(usbMeter.recordingDurationDescription) |
|
| 121 |
- if usbMeter.supportsRecordingThreshold {
|
|
| 122 |
- Text("\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A")
|
|
| 123 |
- } else {
|
|
| 124 |
- Text("Read-only")
|
|
| 125 |
- } |
|
| 126 |
- } |
|
| 127 |
- .monospacedDigit() |
|
| 128 |
- } |
|
| 129 |
- .font(.footnote.weight(.semibold)) |
|
| 130 |
- Text("These values are reported by the meter for the active data group.")
|
|
| 131 |
- .font(.footnote) |
|
| 132 |
- .foregroundColor(.secondary) |
|
| 133 |
- if usbMeter.supportsDataGroupCommands {
|
|
| 134 |
- Button("Reset Active Group") {
|
|
| 135 |
- usbMeter.clear() |
|
| 136 |
- } |
|
| 137 |
- .frame(maxWidth: .infinity) |
|
| 138 |
- .padding(.vertical, 10) |
|
| 139 |
- .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 140 |
- .buttonStyle(.plain) |
|
| 141 |
- } |
|
| 142 |
- } |
|
| 143 |
- .padding(18) |
|
| 144 |
- .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 145 |
- } |
|
| 146 |
- } |
|
| 147 |
- .padding() |
|
| 148 |
- } |
|
| 149 |
- .background( |
|
| 150 |
- LinearGradient( |
|
| 151 |
- colors: [.pink.opacity(0.14), Color.clear], |
|
| 152 |
- startPoint: .topLeading, |
|
| 153 |
- endPoint: .bottomTrailing |
|
| 154 |
- ) |
|
| 155 |
- .ignoresSafeArea() |
|
| 156 |
- ) |
|
| 157 |
- .navigationBarTitle("Charge Record", displayMode: .inline)
|
|
| 158 |
- .navigationBarItems(trailing: Button("Done") { visibility.toggle() })
|
|
| 159 |
- } |
|
| 160 |
- .navigationViewStyle(StackNavigationViewStyle()) |
|
| 161 |
- } |
|
| 162 |
-} |
|
| 163 |
- |
|
| 164 |
-struct RecordingView_Previews: PreviewProvider {
|
|
| 165 |
- static var previews: some View {
|
|
| 166 |
- RecordingView(visibility: .constant(true)) |
|
| 167 |
- } |
|
| 168 |
-} |
|
@@ -0,0 +1,85 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargeRecordSheetView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 09/03/2020. |
|
| 6 |
+// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
+// |
|
| 8 |
+ |
|
| 9 |
+import SwiftUI |
|
| 10 |
+ |
|
| 11 |
+struct ChargeRecordSheetView: View {
|
|
| 12 |
+ @Binding var visibility: Bool |
|
| 13 |
+ |
|
| 14 |
+ var body: some View {
|
|
| 15 |
+ NavigationView {
|
|
| 16 |
+ MeterChargeRecordContentView() |
|
| 17 |
+ .navigationTitle("Charge Record")
|
|
| 18 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 19 |
+ .toolbar {
|
|
| 20 |
+ ToolbarItem(placement: .cancellationAction) {
|
|
| 21 |
+ Button("Done") {
|
|
| 22 |
+ visibility = false |
|
| 23 |
+ } |
|
| 24 |
+ } |
|
| 25 |
+ } |
|
| 26 |
+ } |
|
| 27 |
+ .navigationViewStyle(StackNavigationViewStyle()) |
|
| 28 |
+ } |
|
| 29 |
+} |
|
| 30 |
+ |
|
| 31 |
+struct BatteryTargetNotificationEditorSheetView: View {
|
|
| 32 |
+ @Environment(\.dismiss) private var dismiss |
|
| 33 |
+ @EnvironmentObject private var appData: AppData |
|
| 34 |
+ |
|
| 35 |
+ let sessionID: UUID |
|
| 36 |
+ let initialTargetPercent: Double? |
|
| 37 |
+ |
|
| 38 |
+ @State private var targetPercent: Double |
|
| 39 |
+ |
|
| 40 |
+ init(sessionID: UUID, initialTargetPercent: Double?) {
|
|
| 41 |
+ self.sessionID = sessionID |
|
| 42 |
+ self.initialTargetPercent = initialTargetPercent |
|
| 43 |
+ _targetPercent = State(initialValue: initialTargetPercent ?? 80) |
|
| 44 |
+ } |
|
| 45 |
+ |
|
| 46 |
+ var body: some View {
|
|
| 47 |
+ NavigationView {
|
|
| 48 |
+ Form {
|
|
| 49 |
+ Section( |
|
| 50 |
+ header: ContextInfoHeader( |
|
| 51 |
+ title: "Target Level", |
|
| 52 |
+ message: "A local notification will be generated on synced devices when the estimated battery level reaches this target." |
|
| 53 |
+ ) |
|
| 54 |
+ ) {
|
|
| 55 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 56 |
+ Text("\(targetPercent.format(decimalDigits: 0))%")
|
|
| 57 |
+ .font(.title3.weight(.bold)) |
|
| 58 |
+ Slider(value: $targetPercent, in: 20...100, step: 1) |
|
| 59 |
+ } |
|
| 60 |
+ } |
|
| 61 |
+ } |
|
| 62 |
+ .navigationTitle("Battery Target")
|
|
| 63 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 64 |
+ .toolbar {
|
|
| 65 |
+ ToolbarItem(placement: .cancellationAction) {
|
|
| 66 |
+ Button("Cancel") {
|
|
| 67 |
+ dismiss() |
|
| 68 |
+ } |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ ToolbarItem(placement: .confirmationAction) {
|
|
| 72 |
+ Button("Save") {
|
|
| 73 |
+ if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
|
|
| 74 |
+ dismiss() |
|
| 75 |
+ } |
|
| 76 |
+ } |
|
| 77 |
+ } |
|
| 78 |
+ } |
|
| 79 |
+ } |
|
| 80 |
+ } |
|
| 81 |
+} |
|
| 82 |
+ |
|
| 83 |
+#Preview {
|
|
| 84 |
+ ChargeRecordSheetView(visibility: .constant(true)) |
|
| 85 |
+} |
|
@@ -0,0 +1,33 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargeRecordMetricsTableView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct ChargeRecordMetricsTableView: View {
|
|
| 13 |
+ let labels: [String] |
|
| 14 |
+ let values: [String] |
|
| 15 |
+ |
|
| 16 |
+ var body: some View {
|
|
| 17 |
+ HStack(alignment: .top) {
|
|
| 18 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 19 |
+ ForEach(labels, id: \.self) { label in
|
|
| 20 |
+ Text(label) |
|
| 21 |
+ } |
|
| 22 |
+ } |
|
| 23 |
+ Spacer() |
|
| 24 |
+ VStack(alignment: .trailing, spacing: 10) {
|
|
| 25 |
+ ForEach(Array(values.enumerated()), id: \.offset) { _, value in
|
|
| 26 |
+ Text(value) |
|
| 27 |
+ } |
|
| 28 |
+ } |
|
| 29 |
+ .monospacedDigit() |
|
| 30 |
+ } |
|
| 31 |
+ .font(.footnote.weight(.semibold)) |
|
| 32 |
+ } |
|
| 33 |
+} |
|
@@ -1,5 +1,5 @@ |
||
| 1 | 1 |
// |
| 2 |
-// DataGroupsView.swift |
|
| 2 |
+// DataGroupsSheetView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 | 5 |
// Created by Bogdan Timofte on 10/03/2020. |
@@ -8,7 +8,7 @@ |
||
| 8 | 8 |
|
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
|
| 11 |
-struct DataGroupsView: View {
|
|
| 11 |
+struct DataGroupsSheetView: View {
|
|
| 12 | 12 |
|
| 13 | 13 |
@Binding var visibility: Bool |
| 14 | 14 |
@EnvironmentObject private var usbMeter: Meter |
@@ -16,7 +16,7 @@ struct DataGroupsView: View {
|
||
| 16 | 16 |
var body: some View {
|
| 17 | 17 |
NavigationView {
|
| 18 | 18 |
GeometryReader { box in
|
| 19 |
- let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory", "Ah"] |
|
| 19 |
+ let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory"] |
|
| 20 | 20 |
+ (usbMeter.showsDataGroupEnergy ? ["Wh"] : []) |
| 21 | 21 |
+ (usbMeter.supportsDataGroupCommands ? ["Clear"] : []) |
| 22 | 22 |
let columnWidth = (box.size.width - 60) / CGFloat(columnTitles.count) |
@@ -24,12 +24,16 @@ struct DataGroupsView: View {
|
||
| 24 | 24 |
ScrollView {
|
| 25 | 25 |
VStack(alignment: .leading, spacing: 14) {
|
| 26 | 26 |
VStack(alignment: .leading, spacing: 8) {
|
| 27 |
- Text(usbMeter.dataGroupsTitle) |
|
| 28 |
- .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 29 |
- if let hint = usbMeter.dataGroupsHint {
|
|
| 30 |
- Text(hint) |
|
| 31 |
- .font(.footnote) |
|
| 32 |
- .foregroundColor(.secondary) |
|
| 27 |
+ HStack {
|
|
| 28 |
+ Text(usbMeter.dataGroupsTitle) |
|
| 29 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 30 |
+ if let hint = usbMeter.dataGroupsHint {
|
|
| 31 |
+ ContextInfoButton( |
|
| 32 |
+ title: usbMeter.dataGroupsTitle, |
|
| 33 |
+ message: hint |
|
| 34 |
+ ) |
|
| 35 |
+ } |
|
| 36 |
+ Spacer(minLength: 0) |
|
| 33 | 37 |
} |
| 34 | 38 |
} |
| 35 | 39 |
.padding(18) |
@@ -1,5 +1,5 @@ |
||
| 1 | 1 |
// |
| 2 |
-// DataGroupView.swift |
|
| 2 |
+// DataGroupRowView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 | 5 |
// Created by Bogdan Timofte on 10/03/2020. |
@@ -34,12 +34,7 @@ struct DataGroupRowView: View {
|
||
| 34 | 34 |
.fontWeight(.semibold) |
| 35 | 35 |
} |
| 36 | 36 |
} |
| 37 |
- |
|
| 38 |
- cell(width: width) {
|
|
| 39 |
- Text("\(usbMeter.dataGroupRecords[Int(id)]!.ah.format(decimalDigits: 3))")
|
|
| 40 |
- .monospacedDigit() |
|
| 41 |
- } |
|
| 42 |
- |
|
| 37 |
+ |
|
| 43 | 38 |
if showsEnergy {
|
| 44 | 39 |
cell(width: width) {
|
| 45 | 40 |
Text("\(usbMeter.dataGroupRecords[Int(id)]!.wh.format(decimalDigits: 3))")
|
@@ -1,5 +1,5 @@ |
||
| 1 | 1 |
// |
| 2 |
-// MeasurementView.swift |
|
| 2 |
+// MeasurementSeriesSheetView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 | 5 |
// Created by Bogdan Timofte on 13/04/2020. |
@@ -8,39 +8,46 @@ |
||
| 8 | 8 |
|
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
|
| 11 |
-struct MeasurementsView: View {
|
|
| 11 |
+struct MeasurementSeriesSheetView: View {
|
|
| 12 | 12 |
|
| 13 | 13 |
@EnvironmentObject private var measurements: Measurements |
| 14 | 14 |
|
| 15 | 15 |
@Binding var visibility: Bool |
| 16 | 16 |
|
| 17 | 17 |
var body: some View {
|
| 18 |
+ let seriesPoints = measurements.power.samplePoints |
|
| 19 |
+ |
|
| 18 | 20 |
NavigationView {
|
| 19 | 21 |
ScrollView {
|
| 20 | 22 |
VStack(alignment: .leading, spacing: 14) {
|
| 21 | 23 |
VStack(alignment: .leading, spacing: 8) {
|
| 22 |
- Text("App History")
|
|
| 23 |
- .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 24 |
- Text("Local timeline captured by the app while connected to the meter.")
|
|
| 25 |
- .font(.footnote) |
|
| 26 |
- .foregroundColor(.secondary) |
|
| 24 |
+ HStack {
|
|
| 25 |
+ Text("Measurement Series")
|
|
| 26 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 27 |
+ ContextInfoButton( |
|
| 28 |
+ title: "Measurement Series", |
|
| 29 |
+ message: "Buffered measurement series captured from the meter for analysis, charts, and correlations." |
|
| 30 |
+ ) |
|
| 31 |
+ Spacer(minLength: 0) |
|
| 32 |
+ } |
|
| 27 | 33 |
} |
| 28 | 34 |
.padding(18) |
| 29 | 35 |
.meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24) |
| 30 | 36 |
|
| 31 |
- if measurements.power.points.isEmpty {
|
|
| 32 |
- Text("No history samples have been captured yet.")
|
|
| 37 |
+ if seriesPoints.isEmpty {
|
|
| 38 |
+ Text("No measurement samples have been captured yet.")
|
|
| 33 | 39 |
.font(.footnote) |
| 34 | 40 |
.foregroundColor(.secondary) |
| 35 | 41 |
.padding(18) |
| 36 | 42 |
.meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20) |
| 37 | 43 |
} else {
|
| 38 | 44 |
LazyVStack(spacing: 12) {
|
| 39 |
- ForEach(measurements.power.points) { point in
|
|
| 40 |
- MeasurementPointView( |
|
| 45 |
+ ForEach(seriesPoints) { point in
|
|
| 46 |
+ MeasurementSeriesSampleView( |
|
| 41 | 47 |
power: point, |
| 42 | 48 |
voltage: measurements.voltage.points[point.id], |
| 43 |
- current: measurements.current.points[point.id] |
|
| 49 |
+ current: measurements.current.points[point.id], |
|
| 50 |
+ energy: energyPoint(for: point.timestamp) |
|
| 44 | 51 |
) |
| 45 | 52 |
} |
| 46 | 53 |
} |
@@ -58,13 +65,17 @@ struct MeasurementsView: View {
|
||
| 58 | 65 |
) |
| 59 | 66 |
.navigationBarItems( |
| 60 | 67 |
leading: Button("Done") { visibility.toggle() },
|
| 61 |
- trailing: Button("Clear") {
|
|
| 62 |
- measurements.reset() |
|
| 68 |
+ trailing: Button("Reset Series") {
|
|
| 69 |
+ measurements.resetSeries() |
|
| 63 | 70 |
} |
| 64 | 71 |
.foregroundColor(.red) |
| 65 | 72 |
) |
| 66 |
- .navigationBarTitle("App History", displayMode: .inline)
|
|
| 73 |
+ .navigationBarTitle("Measurement Series", displayMode: .inline)
|
|
| 67 | 74 |
} |
| 68 | 75 |
.navigationViewStyle(StackNavigationViewStyle()) |
| 69 | 76 |
} |
| 77 |
+ |
|
| 78 |
+ private func energyPoint(for timestamp: Date) -> Measurements.Measurement.Point? {
|
|
| 79 |
+ measurements.energy.samplePoints.last { $0.timestamp == timestamp }
|
|
| 80 |
+ } |
|
| 70 | 81 |
} |
@@ -1,5 +1,5 @@ |
||
| 1 | 1 |
// |
| 2 |
-// MeasurementView.swift |
|
| 2 |
+// MeasurementSeriesSampleView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 | 5 |
// Created by Bogdan Timofte on 13/04/2020. |
@@ -8,11 +8,12 @@ |
||
| 8 | 8 |
|
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
|
| 11 |
-struct MeasurementPointView: View {
|
|
| 11 |
+struct MeasurementSeriesSampleView: View {
|
|
| 12 | 12 |
|
| 13 | 13 |
var power: Measurements.Measurement.Point |
| 14 | 14 |
var voltage: Measurements.Measurement.Point |
| 15 | 15 |
var current: Measurements.Measurement.Point |
| 16 |
+ var energy: Measurements.Measurement.Point? |
|
| 16 | 17 |
|
| 17 | 18 |
@State var showDetail: Bool = false |
| 18 | 19 |
|
@@ -48,6 +49,9 @@ struct MeasurementPointView: View {
|
||
| 48 | 49 |
detailRow(title: "Power", value: "\(power.value.format(fractionDigits: 4)) W") |
| 49 | 50 |
detailRow(title: "Voltage", value: "\(voltage.value.format(fractionDigits: 4)) V") |
| 50 | 51 |
detailRow(title: "Current", value: "\(current.value.format(fractionDigits: 4)) A") |
| 52 |
+ if let energy {
|
|
| 53 |
+ detailRow(title: "Energy", value: "\(energy.value.format(fractionDigits: 4)) Wh") |
|
| 54 |
+ } |
|
| 51 | 55 |
} |
| 52 | 56 |
} |
| 53 | 57 |
} |
@@ -0,0 +1,736 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterChargeRecordTabView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterChargeRecordTabView: View, Equatable {
|
|
| 9 |
+ static func == (lhs: MeterChargeRecordTabView, rhs: MeterChargeRecordTabView) -> Bool {
|
|
| 10 |
+ true |
|
| 11 |
+ } |
|
| 12 |
+ |
|
| 13 |
+ var body: some View {
|
|
| 14 |
+ MeterChargeRecordContentView() |
|
| 15 |
+ } |
|
| 16 |
+} |
|
| 17 |
+ |
|
| 18 |
+struct MeterChargeRecordContentView: View {
|
|
| 19 |
+ private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
|
|
| 20 |
+ case known |
|
| 21 |
+ case unknown |
|
| 22 |
+ case flat |
|
| 23 |
+ |
|
| 24 |
+ var id: String { rawValue }
|
|
| 25 |
+ |
|
| 26 |
+ var title: String {
|
|
| 27 |
+ switch self {
|
|
| 28 |
+ case .known: return "Known" |
|
| 29 |
+ case .unknown: return "Unknown" |
|
| 30 |
+ case .flat: return "Flat" |
|
| 31 |
+ } |
|
| 32 |
+ } |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ private enum ActiveMode: Hashable {
|
|
| 36 |
+ case chargeSession |
|
| 37 |
+ case standbyPower |
|
| 38 |
+ } |
|
| 39 |
+ |
|
| 40 |
+ private enum SessionStartRequirement: Identifiable {
|
|
| 41 |
+ case existingSession |
|
| 42 |
+ case device |
|
| 43 |
+ case chargingType |
|
| 44 |
+ case chargingMode |
|
| 45 |
+ case charger |
|
| 46 |
+ case initialCheckpointEmpty |
|
| 47 |
+ case initialCheckpointInvalid |
|
| 48 |
+ |
|
| 49 |
+ var id: String {
|
|
| 50 |
+ switch self {
|
|
| 51 |
+ case .existingSession: return "existing-session" |
|
| 52 |
+ case .device: return "device" |
|
| 53 |
+ case .chargingType: return "charging-type" |
|
| 54 |
+ case .chargingMode: return "charging-mode" |
|
| 55 |
+ case .charger: return "charger" |
|
| 56 |
+ case .initialCheckpointEmpty: return "initial-checkpoint-empty" |
|
| 57 |
+ case .initialCheckpointInvalid:return "initial-checkpoint-invalid" |
|
| 58 |
+ } |
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 61 |
+ var message: String {
|
|
| 62 |
+ switch self {
|
|
| 63 |
+ case .existingSession: return "Stop or pause the current session before starting another one." |
|
| 64 |
+ case .device: return "Select the device that is charging." |
|
| 65 |
+ case .chargingType: return "Choose the charging type for this session." |
|
| 66 |
+ case .chargingMode: return "Choose whether the device is on or off for this session." |
|
| 67 |
+ case .charger: return "Select the wireless charger used in this session." |
|
| 68 |
+ case .initialCheckpointEmpty: return "Enter the initial battery percentage." |
|
| 69 |
+ case .initialCheckpointInvalid: return "Initial battery percentage must be between 0 and 100." |
|
| 70 |
+ } |
|
| 71 |
+ } |
|
| 72 |
+ } |
|
| 73 |
+ |
|
| 74 |
+ @EnvironmentObject private var appData: AppData |
|
| 75 |
+ @EnvironmentObject private var usbMeter: Meter |
|
| 76 |
+ |
|
| 77 |
+ @State private var draftChargingTransportMode: ChargingTransportMode? |
|
| 78 |
+ @State private var draftChargingStateMode: ChargingStateMode? |
|
| 79 |
+ @State private var initialCheckpointMode: InitialCheckpointMode = .known |
|
| 80 |
+ @State private var initialCheckpoint = "" |
|
| 81 |
+ @State private var showsMeterTotalsInfo = false |
|
| 82 |
+ @State private var activeMode: ActiveMode = .chargeSession |
|
| 83 |
+ |
|
| 84 |
+ var body: some View {
|
|
| 85 |
+ Group {
|
|
| 86 |
+ if let openChargeSession {
|
|
| 87 |
+ ChargeSessionDetailView( |
|
| 88 |
+ chargedDeviceID: openChargeSession.chargedDeviceID, |
|
| 89 |
+ sessionID: openChargeSession.id, |
|
| 90 |
+ monitoringMeter: usbMeter, |
|
| 91 |
+ presentation: .embedded |
|
| 92 |
+ ) |
|
| 93 |
+ } else {
|
|
| 94 |
+ ScrollView {
|
|
| 95 |
+ VStack(spacing: 14) {
|
|
| 96 |
+ statusHeader |
|
| 97 |
+ liveMeterStripView |
|
| 98 |
+ modePicker |
|
| 99 |
+ |
|
| 100 |
+ switch activeMode {
|
|
| 101 |
+ case .chargeSession: |
|
| 102 |
+ chargeSessionSetupCard |
|
| 103 |
+ case .standbyPower: |
|
| 104 |
+ standbyPowerCard |
|
| 105 |
+ } |
|
| 106 |
+ } |
|
| 107 |
+ .padding() |
|
| 108 |
+ } |
|
| 109 |
+ } |
|
| 110 |
+ } |
|
| 111 |
+ .background( |
|
| 112 |
+ LinearGradient( |
|
| 113 |
+ colors: [.pink.opacity(0.14), Color.clear], |
|
| 114 |
+ startPoint: .topLeading, |
|
| 115 |
+ endPoint: .bottomTrailing |
|
| 116 |
+ ) |
|
| 117 |
+ .ignoresSafeArea() |
|
| 118 |
+ ) |
|
| 119 |
+ .onAppear {
|
|
| 120 |
+ syncDraftSelections() |
|
| 121 |
+ } |
|
| 122 |
+ .onChange(of: selectedChargedDevice?.id) { _ in
|
|
| 123 |
+ syncDraftSelections() |
|
| 124 |
+ } |
|
| 125 |
+ .onChange(of: openChargeSession?.id) { _ in
|
|
| 126 |
+ syncDraftSelections() |
|
| 127 |
+ } |
|
| 128 |
+ } |
|
| 129 |
+ |
|
| 130 |
+ // MARK: - Computed Properties |
|
| 131 |
+ |
|
| 132 |
+ private var meterMACAddress: String {
|
|
| 133 |
+ usbMeter.btSerial.macAddress.description |
|
| 134 |
+ } |
|
| 135 |
+ |
|
| 136 |
+ private var selectedChargedDevice: ChargedDeviceSummary? {
|
|
| 137 |
+ appData.currentChargedDeviceSummary(for: meterMACAddress) |
|
| 138 |
+ } |
|
| 139 |
+ |
|
| 140 |
+ private var availableChargedDevices: [ChargedDeviceSummary] {
|
|
| 141 |
+ appData.deviceSummaries |
|
| 142 |
+ } |
|
| 143 |
+ |
|
| 144 |
+ private var selectedChargedDeviceID: Binding<UUID?> {
|
|
| 145 |
+ Binding( |
|
| 146 |
+ get: { selectedChargedDevice?.id },
|
|
| 147 |
+ set: { newValue in
|
|
| 148 |
+ guard let newValue else { return }
|
|
| 149 |
+ _ = appData.assignChargedDevice(newValue, to: meterMACAddress) |
|
| 150 |
+ } |
|
| 151 |
+ ) |
|
| 152 |
+ } |
|
| 153 |
+ |
|
| 154 |
+ private var selectedCharger: ChargedDeviceSummary? {
|
|
| 155 |
+ appData.currentChargerSummary(for: meterMACAddress) |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ private var availableChargers: [ChargedDeviceSummary] {
|
|
| 159 |
+ appData.chargerSummaries |
|
| 160 |
+ } |
|
| 161 |
+ |
|
| 162 |
+ private var selectedChargerID: Binding<UUID?> {
|
|
| 163 |
+ Binding( |
|
| 164 |
+ get: { selectedCharger?.id },
|
|
| 165 |
+ set: { newValue in
|
|
| 166 |
+ guard let newValue else { return }
|
|
| 167 |
+ _ = appData.assignCharger(newValue, to: meterMACAddress) |
|
| 168 |
+ } |
|
| 169 |
+ ) |
|
| 170 |
+ } |
|
| 171 |
+ |
|
| 172 |
+ private var openChargeSession: ChargeSessionSummary? {
|
|
| 173 |
+ appData.activeChargeSessionSummary(for: meterMACAddress) |
|
| 174 |
+ } |
|
| 175 |
+ |
|
| 176 |
+ private var showsMeterTotalsCard: Bool {
|
|
| 177 |
+ usbMeter.supportsRecordingView |
|
| 178 |
+ || usbMeter.supportsDataGroupCommands |
|
| 179 |
+ || usbMeter.recordedAH > 0 |
|
| 180 |
+ || usbMeter.recordedWH > 0 |
|
| 181 |
+ || usbMeter.recordingDuration > 0 |
|
| 182 |
+ } |
|
| 183 |
+ |
|
| 184 |
+ private var selectedDraftTransportMode: ChargingTransportMode? {
|
|
| 185 |
+ openChargeSession?.chargingTransportMode ?? draftChargingTransportMode |
|
| 186 |
+ } |
|
| 187 |
+ |
|
| 188 |
+ private var selectedDraftChargingStateMode: ChargingStateMode? {
|
|
| 189 |
+ openChargeSession?.chargingStateMode ?? draftChargingStateMode |
|
| 190 |
+ } |
|
| 191 |
+ |
|
| 192 |
+ private var initialCheckpointValue: Double? {
|
|
| 193 |
+ guard initialCheckpointMode == .known else { return nil }
|
|
| 194 |
+ let normalized = initialCheckpoint |
|
| 195 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 196 |
+ .replacingOccurrences(of: ",", with: ".") |
|
| 197 |
+ guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
|
|
| 198 |
+ return value |
|
| 199 |
+ } |
|
| 200 |
+ |
|
| 201 |
+ private var hasInitialCheckpointInput: Bool {
|
|
| 202 |
+ initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false |
|
| 203 |
+ } |
|
| 204 |
+ |
|
| 205 |
+ private var shouldRequireInitialCheckpoint: Bool {
|
|
| 206 |
+ initialCheckpointMode == .known |
|
| 207 |
+ } |
|
| 208 |
+ |
|
| 209 |
+ private var requiresExplicitTransportSelection: Bool {
|
|
| 210 |
+ (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1 |
|
| 211 |
+ } |
|
| 212 |
+ |
|
| 213 |
+ private var requiresExplicitChargingStateSelection: Bool {
|
|
| 214 |
+ (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1 |
|
| 215 |
+ } |
|
| 216 |
+ |
|
| 217 |
+ private var startRequirements: [SessionStartRequirement] {
|
|
| 218 |
+ var requirements: [SessionStartRequirement] = [] |
|
| 219 |
+ |
|
| 220 |
+ if openChargeSession != nil {
|
|
| 221 |
+ requirements.append(.existingSession) |
|
| 222 |
+ } |
|
| 223 |
+ |
|
| 224 |
+ guard let selectedChargedDevice else {
|
|
| 225 |
+ requirements.append(.device) |
|
| 226 |
+ return requirements |
|
| 227 |
+ } |
|
| 228 |
+ |
|
| 229 |
+ guard let chargingTransportMode = selectedDraftTransportMode else {
|
|
| 230 |
+ requirements.append(.chargingType) |
|
| 231 |
+ return requirements |
|
| 232 |
+ } |
|
| 233 |
+ |
|
| 234 |
+ if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
|
|
| 235 |
+ requirements.append(.chargingType) |
|
| 236 |
+ } |
|
| 237 |
+ |
|
| 238 |
+ guard let chargingStateMode = selectedDraftChargingStateMode else {
|
|
| 239 |
+ requirements.append(.chargingMode) |
|
| 240 |
+ return requirements |
|
| 241 |
+ } |
|
| 242 |
+ |
|
| 243 |
+ if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
|
|
| 244 |
+ requirements.append(.chargingMode) |
|
| 245 |
+ } |
|
| 246 |
+ |
|
| 247 |
+ if chargingTransportMode == .wireless, selectedCharger == nil {
|
|
| 248 |
+ requirements.append(.charger) |
|
| 249 |
+ } |
|
| 250 |
+ |
|
| 251 |
+ if shouldRequireInitialCheckpoint {
|
|
| 252 |
+ if hasInitialCheckpointInput == false {
|
|
| 253 |
+ requirements.append(.initialCheckpointEmpty) |
|
| 254 |
+ } else if initialCheckpointValue == nil {
|
|
| 255 |
+ requirements.append(.initialCheckpointInvalid) |
|
| 256 |
+ } |
|
| 257 |
+ } |
|
| 258 |
+ |
|
| 259 |
+ return requirements |
|
| 260 |
+ } |
|
| 261 |
+ |
|
| 262 |
+ private var canStartSession: Bool {
|
|
| 263 |
+ startRequirements.isEmpty |
|
| 264 |
+ } |
|
| 265 |
+ |
|
| 266 |
+ private var headerStatusTitle: String {
|
|
| 267 |
+ guard let openChargeSession else { return "Idle" }
|
|
| 268 |
+ return openChargeSession.status.title |
|
| 269 |
+ } |
|
| 270 |
+ |
|
| 271 |
+ private var headerStatusColor: Color {
|
|
| 272 |
+ guard let openChargeSession else { return .secondary }
|
|
| 273 |
+ switch openChargeSession.status {
|
|
| 274 |
+ case .active: return .red |
|
| 275 |
+ case .paused: return .orange |
|
| 276 |
+ case .completed: return .green |
|
| 277 |
+ case .abandoned: return .secondary |
|
| 278 |
+ } |
|
| 279 |
+ } |
|
| 280 |
+ |
|
| 281 |
+ private var showsWirelessChargerSection: Bool {
|
|
| 282 |
+ let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first |
|
| 283 |
+ return transportMode == .wireless |
|
| 284 |
+ } |
|
| 285 |
+ |
|
| 286 |
+ // MARK: - Status Header |
|
| 287 |
+ |
|
| 288 |
+ private var statusHeader: some View {
|
|
| 289 |
+ HStack {
|
|
| 290 |
+ Image(systemName: "bolt.fill") |
|
| 291 |
+ .foregroundColor(.pink) |
|
| 292 |
+ Text("Charging Session")
|
|
| 293 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 294 |
+ Spacer() |
|
| 295 |
+ Text(headerStatusTitle) |
|
| 296 |
+ .font(.caption.weight(.bold)) |
|
| 297 |
+ .foregroundColor(headerStatusColor) |
|
| 298 |
+ .padding(.horizontal, 10) |
|
| 299 |
+ .padding(.vertical, 6) |
|
| 300 |
+ .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
|
| 301 |
+ } |
|
| 302 |
+ .padding(.horizontal, 18) |
|
| 303 |
+ .padding(.vertical, 12) |
|
| 304 |
+ .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 305 |
+ } |
|
| 306 |
+ |
|
| 307 |
+ // MARK: - Mode Picker |
|
| 308 |
+ |
|
| 309 |
+ private var modePicker: some View {
|
|
| 310 |
+ Picker("", selection: $activeMode) {
|
|
| 311 |
+ Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
|
|
| 312 |
+ Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
|
|
| 313 |
+ } |
|
| 314 |
+ .pickerStyle(.segmented) |
|
| 315 |
+ .labelsHidden() |
|
| 316 |
+ } |
|
| 317 |
+ |
|
| 318 |
+ // MARK: - Charge Session Setup |
|
| 319 |
+ |
|
| 320 |
+ private var chargeSessionSetupCard: some View {
|
|
| 321 |
+ VStack(alignment: .leading, spacing: 0) {
|
|
| 322 |
+ // Device |
|
| 323 |
+ setupRow(icon: "iphone", iconColor: .blue) {
|
|
| 324 |
+ Picker(selection: selectedChargedDeviceID) {
|
|
| 325 |
+ Text("Choose device").tag(UUID?.none)
|
|
| 326 |
+ ForEach(availableChargedDevices) { device in
|
|
| 327 |
+ Text(device.name).tag(Optional(device.id)) |
|
| 328 |
+ } |
|
| 329 |
+ } label: {
|
|
| 330 |
+ HStack(spacing: 8) {
|
|
| 331 |
+ if let device = selectedChargedDevice {
|
|
| 332 |
+ ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15) |
|
| 333 |
+ .font(.subheadline.weight(.semibold)) |
|
| 334 |
+ } else {
|
|
| 335 |
+ Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device") |
|
| 336 |
+ .foregroundColor(.secondary) |
|
| 337 |
+ .font(.subheadline) |
|
| 338 |
+ } |
|
| 339 |
+ Spacer(minLength: 8) |
|
| 340 |
+ Image(systemName: "chevron.up.chevron.down") |
|
| 341 |
+ .font(.caption.weight(.semibold)) |
|
| 342 |
+ .foregroundColor(.secondary) |
|
| 343 |
+ } |
|
| 344 |
+ } |
|
| 345 |
+ .pickerStyle(.menu) |
|
| 346 |
+ .disabled(availableChargedDevices.isEmpty) |
|
| 347 |
+ } |
|
| 348 |
+ |
|
| 349 |
+ // Charging type — only when device supports multiple |
|
| 350 |
+ if requiresExplicitTransportSelection, let device = selectedChargedDevice {
|
|
| 351 |
+ Divider().padding(.leading, 46) |
|
| 352 |
+ setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
|
|
| 353 |
+ Text("Type")
|
|
| 354 |
+ .foregroundColor(.secondary) |
|
| 355 |
+ .font(.subheadline) |
|
| 356 |
+ Spacer() |
|
| 357 |
+ compactSelectionMenu( |
|
| 358 |
+ title: draftChargingTransportMode?.title ?? "Choose", |
|
| 359 |
+ options: device.supportedChargingModes.map { mode in
|
|
| 360 |
+ CompactSelectionOption( |
|
| 361 |
+ id: mode.id, title: mode.title, |
|
| 362 |
+ isSelected: draftChargingTransportMode == mode, |
|
| 363 |
+ action: { draftChargingTransportMode = mode }
|
|
| 364 |
+ ) |
|
| 365 |
+ } |
|
| 366 |
+ ) |
|
| 367 |
+ } |
|
| 368 |
+ } |
|
| 369 |
+ |
|
| 370 |
+ // Charging state — only when device supports multiple |
|
| 371 |
+ if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
|
|
| 372 |
+ Divider().padding(.leading, 46) |
|
| 373 |
+ setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
|
|
| 374 |
+ Text("Mode")
|
|
| 375 |
+ .foregroundColor(.secondary) |
|
| 376 |
+ .font(.subheadline) |
|
| 377 |
+ Spacer() |
|
| 378 |
+ compactSelectionMenu( |
|
| 379 |
+ title: draftChargingStateMode?.title ?? "Choose", |
|
| 380 |
+ options: device.supportedChargingStateModes.map { mode in
|
|
| 381 |
+ CompactSelectionOption( |
|
| 382 |
+ id: mode.id, title: mode.title, |
|
| 383 |
+ isSelected: draftChargingStateMode == mode, |
|
| 384 |
+ action: { draftChargingStateMode = mode }
|
|
| 385 |
+ ) |
|
| 386 |
+ } |
|
| 387 |
+ ) |
|
| 388 |
+ } |
|
| 389 |
+ } |
|
| 390 |
+ |
|
| 391 |
+ // Wireless charger — only when wireless transport |
|
| 392 |
+ if showsWirelessChargerSection {
|
|
| 393 |
+ Divider().padding(.leading, 46) |
|
| 394 |
+ setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
|
|
| 395 |
+ Picker(selection: selectedChargerID) {
|
|
| 396 |
+ Text("Choose charger").tag(UUID?.none)
|
|
| 397 |
+ ForEach(availableChargers) { charger in
|
|
| 398 |
+ Text(charger.name).tag(Optional(charger.id)) |
|
| 399 |
+ } |
|
| 400 |
+ } label: {
|
|
| 401 |
+ HStack(spacing: 8) {
|
|
| 402 |
+ if let charger = selectedCharger {
|
|
| 403 |
+ ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15) |
|
| 404 |
+ .font(.subheadline.weight(.semibold)) |
|
| 405 |
+ } else {
|
|
| 406 |
+ Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger") |
|
| 407 |
+ .foregroundColor(.secondary) |
|
| 408 |
+ .font(.subheadline) |
|
| 409 |
+ } |
|
| 410 |
+ Spacer(minLength: 8) |
|
| 411 |
+ Image(systemName: "chevron.up.chevron.down") |
|
| 412 |
+ .font(.caption.weight(.semibold)) |
|
| 413 |
+ .foregroundColor(.secondary) |
|
| 414 |
+ } |
|
| 415 |
+ } |
|
| 416 |
+ .pickerStyle(.menu) |
|
| 417 |
+ .disabled(availableChargers.isEmpty) |
|
| 418 |
+ } |
|
| 419 |
+ } |
|
| 420 |
+ |
|
| 421 |
+ // Battery checkpoint |
|
| 422 |
+ Divider().padding(.leading, 46) |
|
| 423 |
+ setupRow(icon: "battery.75percent", iconColor: .green) {
|
|
| 424 |
+ if initialCheckpointMode == .known {
|
|
| 425 |
+ Button { adjustInitialCheckpoint(by: -1) } label: {
|
|
| 426 |
+ Image(systemName: "minus.circle").font(.title3) |
|
| 427 |
+ } |
|
| 428 |
+ .buttonStyle(.plain) |
|
| 429 |
+ |
|
| 430 |
+ TextField("—", text: $initialCheckpoint)
|
|
| 431 |
+ .keyboardType(.decimalPad) |
|
| 432 |
+ .textFieldStyle(.roundedBorder) |
|
| 433 |
+ .frame(width: 52) |
|
| 434 |
+ .multilineTextAlignment(.center) |
|
| 435 |
+ |
|
| 436 |
+ Text("%")
|
|
| 437 |
+ .font(.subheadline) |
|
| 438 |
+ .foregroundColor(.secondary) |
|
| 439 |
+ |
|
| 440 |
+ Button { adjustInitialCheckpoint(by: 1) } label: {
|
|
| 441 |
+ Image(systemName: "plus.circle").font(.title3) |
|
| 442 |
+ } |
|
| 443 |
+ .buttonStyle(.plain) |
|
| 444 |
+ } else {
|
|
| 445 |
+ Text(initialCheckpointMode == .flat |
|
| 446 |
+ ? "Flat (device off / discharged)" |
|
| 447 |
+ : "Unknown") |
|
| 448 |
+ .font(.subheadline) |
|
| 449 |
+ .foregroundColor(.secondary) |
|
| 450 |
+ } |
|
| 451 |
+ Spacer() |
|
| 452 |
+ compactSelectionMenu( |
|
| 453 |
+ title: initialCheckpointMode.title, |
|
| 454 |
+ options: InitialCheckpointMode.allCases.map { mode in
|
|
| 455 |
+ CompactSelectionOption( |
|
| 456 |
+ id: mode.id, title: mode.title, |
|
| 457 |
+ isSelected: initialCheckpointMode == mode, |
|
| 458 |
+ action: { initialCheckpointMode = mode }
|
|
| 459 |
+ ) |
|
| 460 |
+ } |
|
| 461 |
+ ) |
|
| 462 |
+ } |
|
| 463 |
+ |
|
| 464 |
+ // Requirement errors |
|
| 465 |
+ if startRequirements.isEmpty == false {
|
|
| 466 |
+ Divider() |
|
| 467 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 468 |
+ ForEach(startRequirements) { requirement in
|
|
| 469 |
+ Label(requirement.message, systemImage: "exclamationmark.circle") |
|
| 470 |
+ .font(.caption) |
|
| 471 |
+ .foregroundColor(.orange) |
|
| 472 |
+ } |
|
| 473 |
+ } |
|
| 474 |
+ .padding(.horizontal, 14) |
|
| 475 |
+ .padding(.vertical, 10) |
|
| 476 |
+ } |
|
| 477 |
+ |
|
| 478 |
+ // Start button |
|
| 479 |
+ Divider() |
|
| 480 |
+ Button("Start Session") {
|
|
| 481 |
+ startSession() |
|
| 482 |
+ } |
|
| 483 |
+ .frame(maxWidth: .infinity) |
|
| 484 |
+ .padding(.vertical, 11) |
|
| 485 |
+ .font(.subheadline.weight(.semibold)) |
|
| 486 |
+ .foregroundColor(canStartSession ? .green : .secondary) |
|
| 487 |
+ .buttonStyle(.plain) |
|
| 488 |
+ .disabled(!canStartSession) |
|
| 489 |
+ } |
|
| 490 |
+ .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 491 |
+ } |
|
| 492 |
+ |
|
| 493 |
+ // MARK: - Standby Power Card |
|
| 494 |
+ |
|
| 495 |
+ private var standbyPowerCard: some View {
|
|
| 496 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 497 |
+ HStack(spacing: 10) {
|
|
| 498 |
+ Image(systemName: "powersleep") |
|
| 499 |
+ .foregroundColor(.orange) |
|
| 500 |
+ .font(.title3) |
|
| 501 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 502 |
+ Text("Charger Standby Power")
|
|
| 503 |
+ .font(.subheadline.weight(.semibold)) |
|
| 504 |
+ Text("Measure idle draw with no device connected.")
|
|
| 505 |
+ .font(.caption) |
|
| 506 |
+ .foregroundColor(.secondary) |
|
| 507 |
+ } |
|
| 508 |
+ } |
|
| 509 |
+ |
|
| 510 |
+ NavigationLink( |
|
| 511 |
+ destination: ChargerStandbyPowerWizardView( |
|
| 512 |
+ preferredMeterMACAddress: meterMACAddress |
|
| 513 |
+ ) |
|
| 514 |
+ ) {
|
|
| 515 |
+ HStack {
|
|
| 516 |
+ Image(systemName: "plus.circle.fill") |
|
| 517 |
+ .foregroundColor(.orange) |
|
| 518 |
+ Text("New Measurement")
|
|
| 519 |
+ .font(.subheadline.weight(.semibold)) |
|
| 520 |
+ Spacer() |
|
| 521 |
+ Image(systemName: "chevron.right") |
|
| 522 |
+ .font(.caption.weight(.semibold)) |
|
| 523 |
+ .foregroundColor(.secondary) |
|
| 524 |
+ } |
|
| 525 |
+ .padding(.vertical, 10) |
|
| 526 |
+ .padding(.horizontal, 14) |
|
| 527 |
+ .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14) |
|
| 528 |
+ } |
|
| 529 |
+ .buttonStyle(.plain) |
|
| 530 |
+ } |
|
| 531 |
+ .padding(18) |
|
| 532 |
+ .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16) |
|
| 533 |
+ } |
|
| 534 |
+ |
|
| 535 |
+ // MARK: - Live Meter Strip (idle state) |
|
| 536 |
+ |
|
| 537 |
+ private var liveMeterStripView: some View {
|
|
| 538 |
+ let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] |
|
| 539 |
+ return LazyVGrid(columns: columns, spacing: 8) {
|
|
| 540 |
+ metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow) |
|
| 541 |
+ metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue) |
|
| 542 |
+ metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal) |
|
| 543 |
+ } |
|
| 544 |
+ } |
|
| 545 |
+ |
|
| 546 |
+ private func metricCell(label: String, value: String, tint: Color) -> some View {
|
|
| 547 |
+ VStack(alignment: .leading, spacing: 3) {
|
|
| 548 |
+ Text(label) |
|
| 549 |
+ .font(.caption2) |
|
| 550 |
+ .foregroundColor(.secondary) |
|
| 551 |
+ Text(value) |
|
| 552 |
+ .font(.subheadline.weight(.semibold)) |
|
| 553 |
+ .lineLimit(1) |
|
| 554 |
+ .minimumScaleFactor(0.7) |
|
| 555 |
+ .monospacedDigit() |
|
| 556 |
+ } |
|
| 557 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 558 |
+ .padding(.horizontal, 12) |
|
| 559 |
+ .padding(.vertical, 10) |
|
| 560 |
+ .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) |
|
| 561 |
+ } |
|
| 562 |
+ |
|
| 563 |
+ private var meterTotalsCard: some View {
|
|
| 564 |
+ return VStack(alignment: .leading, spacing: 12) {
|
|
| 565 |
+ HStack(spacing: 8) {
|
|
| 566 |
+ Text("Meter Recorder")
|
|
| 567 |
+ .font(.headline) |
|
| 568 |
+ |
|
| 569 |
+ Spacer(minLength: 0) |
|
| 570 |
+ |
|
| 571 |
+ Button {
|
|
| 572 |
+ showsMeterTotalsInfo.toggle() |
|
| 573 |
+ } label: {
|
|
| 574 |
+ Image(systemName: "info.circle") |
|
| 575 |
+ .font(.body.weight(.semibold)) |
|
| 576 |
+ .foregroundColor(.secondary) |
|
| 577 |
+ } |
|
| 578 |
+ .buttonStyle(.plain) |
|
| 579 |
+ .accessibilityLabel("Meter recorder info")
|
|
| 580 |
+ .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
|
|
| 581 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 582 |
+ Text("Meter Recorder")
|
|
| 583 |
+ .font(.headline) |
|
| 584 |
+ Text("These values come directly from the meter's built-in recorder. Keep them visible while comparing the app session against what the meter captured on its own.")
|
|
| 585 |
+ .font(.body) |
|
| 586 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 587 |
+ } |
|
| 588 |
+ .padding(16) |
|
| 589 |
+ .frame(width: 280, alignment: .leading) |
|
| 590 |
+ } |
|
| 591 |
+ } |
|
| 592 |
+ |
|
| 593 |
+ ChargeRecordMetricsTableView( |
|
| 594 |
+ labels: ["Energy", "Duration", "Meter Threshold"], |
|
| 595 |
+ values: [ |
|
| 596 |
+ "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh", |
|
| 597 |
+ usbMeter.recordingDurationDescription, |
|
| 598 |
+ usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only" |
|
| 599 |
+ ] |
|
| 600 |
+ ) |
|
| 601 |
+ |
|
| 602 |
+ if let recordingBootedAt = usbMeter.recordingBootedAt {
|
|
| 603 |
+ Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
|
|
| 604 |
+ .font(.caption) |
|
| 605 |
+ .foregroundColor(.secondary) |
|
| 606 |
+ } |
|
| 607 |
+ } |
|
| 608 |
+ .padding(18) |
|
| 609 |
+ .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 610 |
+ } |
|
| 611 |
+ |
|
| 612 |
+ // MARK: - Helpers |
|
| 613 |
+ |
|
| 614 |
+ private func setupRow<Content: View>( |
|
| 615 |
+ icon: String, |
|
| 616 |
+ iconColor: Color = .secondary, |
|
| 617 |
+ @ViewBuilder content: () -> Content |
|
| 618 |
+ ) -> some View {
|
|
| 619 |
+ HStack(spacing: 10) {
|
|
| 620 |
+ Image(systemName: icon) |
|
| 621 |
+ .foregroundColor(iconColor) |
|
| 622 |
+ .font(.body.weight(.medium)) |
|
| 623 |
+ .frame(width: 22, alignment: .center) |
|
| 624 |
+ content() |
|
| 625 |
+ } |
|
| 626 |
+ .padding(.horizontal, 14) |
|
| 627 |
+ .padding(.vertical, 11) |
|
| 628 |
+ } |
|
| 629 |
+ |
|
| 630 |
+ private func startSession() {
|
|
| 631 |
+ guard let selectedChargedDevice, |
|
| 632 |
+ let chargingTransportMode = selectedDraftTransportMode, |
|
| 633 |
+ let chargingStateMode = selectedDraftChargingStateMode else {
|
|
| 634 |
+ return |
|
| 635 |
+ } |
|
| 636 |
+ |
|
| 637 |
+ let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil |
|
| 638 |
+ let didStart = appData.startChargeSession( |
|
| 639 |
+ for: usbMeter, |
|
| 640 |
+ chargedDeviceID: selectedChargedDevice.id, |
|
| 641 |
+ chargerID: chargerID, |
|
| 642 |
+ chargingTransportMode: chargingTransportMode, |
|
| 643 |
+ chargingStateMode: chargingStateMode, |
|
| 644 |
+ autoStopEnabled: false, |
|
| 645 |
+ initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil, |
|
| 646 |
+ startsFromFlatBattery: initialCheckpointMode == .flat |
|
| 647 |
+ ) |
|
| 648 |
+ |
|
| 649 |
+ if didStart {
|
|
| 650 |
+ initialCheckpoint = "" |
|
| 651 |
+ initialCheckpointMode = .known |
|
| 652 |
+ } |
|
| 653 |
+ } |
|
| 654 |
+ |
|
| 655 |
+ private func adjustInitialCheckpoint(by delta: Double) {
|
|
| 656 |
+ guard initialCheckpointMode == .known else { return }
|
|
| 657 |
+ let currentValue = initialCheckpointValue ?? 0 |
|
| 658 |
+ let nextValue = min(max(currentValue + delta, 0), 100) |
|
| 659 |
+ initialCheckpoint = nextValue.format(decimalDigits: 0) |
|
| 660 |
+ } |
|
| 661 |
+ |
|
| 662 |
+ private func syncDraftSelections() {
|
|
| 663 |
+ guard let selectedChargedDevice else {
|
|
| 664 |
+ draftChargingTransportMode = nil |
|
| 665 |
+ draftChargingStateMode = nil |
|
| 666 |
+ return |
|
| 667 |
+ } |
|
| 668 |
+ |
|
| 669 |
+ if let openChargeSession {
|
|
| 670 |
+ draftChargingTransportMode = openChargeSession.chargingTransportMode |
|
| 671 |
+ draftChargingStateMode = openChargeSession.chargingStateMode |
|
| 672 |
+ return |
|
| 673 |
+ } |
|
| 674 |
+ |
|
| 675 |
+ if let draftChargingTransportMode, |
|
| 676 |
+ selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
|
|
| 677 |
+ self.draftChargingTransportMode = nil |
|
| 678 |
+ } |
|
| 679 |
+ |
|
| 680 |
+ if let draftChargingStateMode, |
|
| 681 |
+ selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
|
|
| 682 |
+ self.draftChargingStateMode = nil |
|
| 683 |
+ } |
|
| 684 |
+ |
|
| 685 |
+ if selectedChargedDevice.supportedChargingModes.count == 1 {
|
|
| 686 |
+ draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first |
|
| 687 |
+ } |
|
| 688 |
+ |
|
| 689 |
+ if let draftChargingTransportMode {
|
|
| 690 |
+ draftChargingStateMode = draftChargingStateMode |
|
| 691 |
+ ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode) |
|
| 692 |
+ } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
|
|
| 693 |
+ draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first |
|
| 694 |
+ } |
|
| 695 |
+ } |
|
| 696 |
+ |
|
| 697 |
+ private struct CompactSelectionOption: Identifiable {
|
|
| 698 |
+ let id: String |
|
| 699 |
+ let title: String |
|
| 700 |
+ let isSelected: Bool |
|
| 701 |
+ let action: () -> Void |
|
| 702 |
+ } |
|
| 703 |
+ |
|
| 704 |
+ private func compactSelectionMenu( |
|
| 705 |
+ title: String, |
|
| 706 |
+ options: [CompactSelectionOption] |
|
| 707 |
+ ) -> some View {
|
|
| 708 |
+ Menu {
|
|
| 709 |
+ ForEach(options) { option in
|
|
| 710 |
+ Button {
|
|
| 711 |
+ option.action() |
|
| 712 |
+ } label: {
|
|
| 713 |
+ if option.isSelected {
|
|
| 714 |
+ Label(option.title, systemImage: "checkmark") |
|
| 715 |
+ } else {
|
|
| 716 |
+ Text(option.title) |
|
| 717 |
+ } |
|
| 718 |
+ } |
|
| 719 |
+ } |
|
| 720 |
+ } label: {
|
|
| 721 |
+ HStack(spacing: 8) {
|
|
| 722 |
+ Text(title) |
|
| 723 |
+ .foregroundColor(.primary) |
|
| 724 |
+ Spacer() |
|
| 725 |
+ Image(systemName: "chevron.up.chevron.down") |
|
| 726 |
+ .font(.caption.weight(.semibold)) |
|
| 727 |
+ .foregroundColor(.secondary) |
|
| 728 |
+ } |
|
| 729 |
+ .padding(.horizontal, 12) |
|
| 730 |
+ .padding(.vertical, 9) |
|
| 731 |
+ .frame(width: 160, alignment: .leading) |
|
| 732 |
+ .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12) |
|
| 733 |
+ } |
|
| 734 |
+ .buttonStyle(.plain) |
|
| 735 |
+ } |
|
| 736 |
+} |
|
@@ -0,0 +1,66 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterChartTabView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterChartTabView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ let size: CGSize |
|
| 12 |
+ let isLandscape: Bool |
|
| 13 |
+ |
|
| 14 |
+ private let pageHorizontalPadding: CGFloat = 12 |
|
| 15 |
+ private let pageVerticalPadding: CGFloat = 12 |
|
| 16 |
+ private let portraitContentCardHorizontalPadding: CGFloat = 8 |
|
| 17 |
+ private let portraitContentCardVerticalPadding: CGFloat = 12 |
|
| 18 |
+ |
|
| 19 |
+ private var prefersCompactPortraitLayout: Bool {
|
|
| 20 |
+ size.height < 760 || size.width < 380 |
|
| 21 |
+ } |
|
| 22 |
+ |
|
| 23 |
+ private var prefersCompactLandscapeLayout: Bool {
|
|
| 24 |
+ size.height < 430 |
|
| 25 |
+ } |
|
| 26 |
+ |
|
| 27 |
+ var body: some View {
|
|
| 28 |
+ Group {
|
|
| 29 |
+ if isLandscape {
|
|
| 30 |
+ landscapeFace {
|
|
| 31 |
+ MeasurementChartView(sizing: .provided(size: size, compact: prefersCompactLandscapeLayout)) |
|
| 32 |
+ .environmentObject(meter.measurements) |
|
| 33 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 34 |
+ .padding(10) |
|
| 35 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 36 |
+ .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 37 |
+ } |
|
| 38 |
+ } else {
|
|
| 39 |
+ portraitFace {
|
|
| 40 |
+ MeasurementChartView(sizing: .provided(size: size, compact: prefersCompactPortraitLayout)) |
|
| 41 |
+ .environmentObject(meter.measurements) |
|
| 42 |
+ .padding(.horizontal, portraitContentCardHorizontalPadding) |
|
| 43 |
+ .padding(.vertical, portraitContentCardVerticalPadding) |
|
| 44 |
+ .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 45 |
+ } |
|
| 46 |
+ } |
|
| 47 |
+ } |
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 51 |
+ ScrollView {
|
|
| 52 |
+ content() |
|
| 53 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 54 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 55 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 56 |
+ } |
|
| 57 |
+ } |
|
| 58 |
+ |
|
| 59 |
+ private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 60 |
+ content() |
|
| 61 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 62 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 63 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 64 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 65 |
+ } |
|
| 66 |
+} |
|
@@ -0,0 +1,75 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterDataGroupsTabView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterDataGroupsTabView: View {
|
|
| 9 |
+ @EnvironmentObject private var usbMeter: Meter |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ GeometryReader { box in
|
|
| 13 |
+ let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory"] |
|
| 14 |
+ + (usbMeter.showsDataGroupEnergy ? ["Wh"] : []) |
|
| 15 |
+ + (usbMeter.supportsDataGroupCommands ? ["Clear"] : []) |
|
| 16 |
+ let columnWidth = (box.size.width - 60) / CGFloat(columnTitles.count) |
|
| 17 |
+ |
|
| 18 |
+ ScrollView {
|
|
| 19 |
+ VStack(alignment: .leading, spacing: 14) {
|
|
| 20 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 21 |
+ HStack(spacing: 8) {
|
|
| 22 |
+ Text(usbMeter.dataGroupsTitle) |
|
| 23 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 24 |
+ if let hint = usbMeter.dataGroupsHint {
|
|
| 25 |
+ ContextInfoButton( |
|
| 26 |
+ title: usbMeter.dataGroupsTitle, |
|
| 27 |
+ message: hint |
|
| 28 |
+ ) |
|
| 29 |
+ } |
|
| 30 |
+ } |
|
| 31 |
+ } |
|
| 32 |
+ .padding(18) |
|
| 33 |
+ .meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 34 |
+ |
|
| 35 |
+ HStack(spacing: 8) {
|
|
| 36 |
+ ForEach(columnTitles, id: \.self) { text in
|
|
| 37 |
+ headerCell(text: text, width: columnWidth) |
|
| 38 |
+ } |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ VStack(spacing: 10) {
|
|
| 42 |
+ ForEach(usbMeter.availableDataGroupIDs, id: \.self) { groupId in
|
|
| 43 |
+ DataGroupRowView( |
|
| 44 |
+ id: groupId, |
|
| 45 |
+ width: columnWidth, |
|
| 46 |
+ opacity: groupId.isMultiple(of: 2) ? 0.1 : 0.2, |
|
| 47 |
+ showsCommands: usbMeter.supportsDataGroupCommands, |
|
| 48 |
+ showsEnergy: usbMeter.showsDataGroupEnergy, |
|
| 49 |
+ highlightsSelection: usbMeter.highlightsActiveDataGroup |
|
| 50 |
+ ) |
|
| 51 |
+ } |
|
| 52 |
+ } |
|
| 53 |
+ } |
|
| 54 |
+ .padding() |
|
| 55 |
+ } |
|
| 56 |
+ .background( |
|
| 57 |
+ LinearGradient( |
|
| 58 |
+ colors: [.teal.opacity(0.14), Color.clear], |
|
| 59 |
+ startPoint: .topLeading, |
|
| 60 |
+ endPoint: .bottomTrailing |
|
| 61 |
+ ) |
|
| 62 |
+ .ignoresSafeArea() |
|
| 63 |
+ ) |
|
| 64 |
+ } |
|
| 65 |
+ } |
|
| 66 |
+ |
|
| 67 |
+ private func headerCell(text: String, width: CGFloat) -> some View {
|
|
| 68 |
+ Text(text) |
|
| 69 |
+ .font(.footnote.weight(.bold)) |
|
| 70 |
+ .frame(width: width) |
|
| 71 |
+ .frame(minHeight: 38) |
|
| 72 |
+ .foregroundColor(.secondary) |
|
| 73 |
+ .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 14) |
|
| 74 |
+ } |
|
| 75 |
+} |
|
@@ -0,0 +1,235 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterHomeTabView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterHomeTabView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ let size: CGSize |
|
| 12 |
+ let isLandscape: Bool |
|
| 13 |
+ let showChargeRecordTab: () -> Void |
|
| 14 |
+ let showDataGroupsTab: () -> Void |
|
| 15 |
+ |
|
| 16 |
+ @State private var measurementsViewVisibility = false |
|
| 17 |
+ |
|
| 18 |
+ private let actionStripPadding: CGFloat = 10 |
|
| 19 |
+ private let actionDividerWidth: CGFloat = 1 |
|
| 20 |
+ private let actionButtonMaxWidth: CGFloat = 156 |
|
| 21 |
+ private let actionButtonMinWidth: CGFloat = 88 |
|
| 22 |
+ private let actionButtonHeight: CGFloat = 108 |
|
| 23 |
+ private let pageHorizontalPadding: CGFloat = 12 |
|
| 24 |
+ private let pageVerticalPadding: CGFloat = 12 |
|
| 25 |
+ |
|
| 26 |
+ var body: some View {
|
|
| 27 |
+ Group {
|
|
| 28 |
+ if isLandscape {
|
|
| 29 |
+ landscapeFace {
|
|
| 30 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 31 |
+ connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable) |
|
| 32 |
+ MeterOverviewSectionView(meter: meter) |
|
| 33 |
+ } |
|
| 34 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 35 |
+ } |
|
| 36 |
+ } else {
|
|
| 37 |
+ portraitFace {
|
|
| 38 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 39 |
+ connectionCard( |
|
| 40 |
+ compact: prefersCompactPortraitLayout, |
|
| 41 |
+ showsActions: meter.operationalState == .dataIsAvailable |
|
| 42 |
+ ) |
|
| 43 |
+ MeterOverviewSectionView(meter: meter) |
|
| 44 |
+ } |
|
| 45 |
+ } |
|
| 46 |
+ } |
|
| 47 |
+ } |
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ private var prefersCompactPortraitLayout: Bool {
|
|
| 51 |
+ size.height < 760 || size.width < 380 |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 55 |
+ ScrollView {
|
|
| 56 |
+ content() |
|
| 57 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 58 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 59 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 60 |
+ } |
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 63 |
+ private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 64 |
+ ScrollView {
|
|
| 65 |
+ content() |
|
| 66 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 67 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 68 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 69 |
+ } |
|
| 70 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 71 |
+ } |
|
| 72 |
+ |
|
| 73 |
+ private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
|
|
| 74 |
+ let cardContent = VStack(alignment: .leading, spacing: compact ? 12 : 18) {
|
|
| 75 |
+ HStack(alignment: .top) {
|
|
| 76 |
+ meterIdentity(compact: compact) |
|
| 77 |
+ Spacer() |
|
| 78 |
+ statusBadge |
|
| 79 |
+ } |
|
| 80 |
+ |
|
| 81 |
+ connectionActionArea(compact: compact) |
|
| 82 |
+ |
|
| 83 |
+ if showsActions {
|
|
| 84 |
+ VStack(spacing: compact ? 10 : 12) {
|
|
| 85 |
+ Rectangle() |
|
| 86 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 87 |
+ .frame(height: 1) |
|
| 88 |
+ |
|
| 89 |
+ actionGrid(compact: compact, embedded: true) |
|
| 90 |
+ } |
|
| 91 |
+ } |
|
| 92 |
+ } |
|
| 93 |
+ .padding(compact ? 16 : 20) |
|
| 94 |
+ .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24) |
|
| 95 |
+ |
|
| 96 |
+ return cardContent |
|
| 97 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ private func meterIdentity(compact: Bool) -> some View {
|
|
| 101 |
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
| 102 |
+ Text(meter.name) |
|
| 103 |
+ .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold)) |
|
| 104 |
+ .lineLimit(1) |
|
| 105 |
+ .minimumScaleFactor(0.8) |
|
| 106 |
+ |
|
| 107 |
+ Text(meter.deviceModelName) |
|
| 108 |
+ .font((compact ? Font.caption : .subheadline).weight(.semibold)) |
|
| 109 |
+ .foregroundColor(.secondary) |
|
| 110 |
+ .lineLimit(1) |
|
| 111 |
+ .minimumScaleFactor(0.8) |
|
| 112 |
+ } |
|
| 113 |
+ } |
|
| 114 |
+ |
|
| 115 |
+ private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
|
|
| 116 |
+ let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight |
|
| 117 |
+ |
|
| 118 |
+ return GeometryReader { proxy in
|
|
| 119 |
+ let buttonWidth = actionButtonWidth(for: proxy.size.width) |
|
| 120 |
+ let stripWidth = actionStripWidth(for: buttonWidth) |
|
| 121 |
+ let stripContent = HStack(spacing: 0) {
|
|
| 122 |
+ meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 123 |
+ showDataGroupsTab() |
|
| 124 |
+ } |
|
| 125 |
+ |
|
| 126 |
+ if meter.supportsRecordingView {
|
|
| 127 |
+ actionStripDivider(height: currentActionHeight) |
|
| 128 |
+ meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 129 |
+ showChargeRecordTab() |
|
| 130 |
+ } |
|
| 131 |
+ } |
|
| 132 |
+ |
|
| 133 |
+ actionStripDivider(height: currentActionHeight) |
|
| 134 |
+ meterSheetButton(icon: "waveform.path.ecg", title: "Measurement Series", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
|
|
| 135 |
+ measurementsViewVisibility.toggle() |
|
| 136 |
+ } |
|
| 137 |
+ .sheet(isPresented: $measurementsViewVisibility) {
|
|
| 138 |
+ MeasurementSeriesSheetView(visibility: $measurementsViewVisibility) |
|
| 139 |
+ .environmentObject(meter.measurements) |
|
| 140 |
+ } |
|
| 141 |
+ } |
|
| 142 |
+ .padding(actionStripPadding) |
|
| 143 |
+ .frame(width: stripWidth) |
|
| 144 |
+ |
|
| 145 |
+ HStack {
|
|
| 146 |
+ Spacer(minLength: 0) |
|
| 147 |
+ stripContent |
|
| 148 |
+ .meterCard( |
|
| 149 |
+ tint: embedded ? meter.color : Color.secondary, |
|
| 150 |
+ fillOpacity: embedded ? 0.08 : 0.10, |
|
| 151 |
+ strokeOpacity: embedded ? 0.14 : 0.16, |
|
| 152 |
+ cornerRadius: embedded ? 24 : 22 |
|
| 153 |
+ ) |
|
| 154 |
+ Spacer(minLength: 0) |
|
| 155 |
+ } |
|
| 156 |
+ } |
|
| 157 |
+ .frame(height: currentActionHeight + (actionStripPadding * 2)) |
|
| 158 |
+ } |
|
| 159 |
+ |
|
| 160 |
+ private func connectionActionArea(compact: Bool = false) -> some View {
|
|
| 161 |
+ MeterConnectionActionView( |
|
| 162 |
+ operationalState: meter.operationalState, |
|
| 163 |
+ compact: compact |
|
| 164 |
+ ) |
|
| 165 |
+ } |
|
| 166 |
+ |
|
| 167 |
+ private func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
|
|
| 168 |
+ Button(action: action) {
|
|
| 169 |
+ VStack(spacing: compact ? 8 : 10) {
|
|
| 170 |
+ Image(systemName: icon) |
|
| 171 |
+ .font(.system(size: compact ? 18 : 20, weight: .semibold)) |
|
| 172 |
+ .frame(width: compact ? 34 : 40, height: compact ? 34 : 40) |
|
| 173 |
+ .background(Circle().fill(tint.opacity(0.14))) |
|
| 174 |
+ Text(title) |
|
| 175 |
+ .font((compact ? Font.caption : .footnote).weight(.semibold)) |
|
| 176 |
+ .multilineTextAlignment(.center) |
|
| 177 |
+ .lineLimit(2) |
|
| 178 |
+ .minimumScaleFactor(0.9) |
|
| 179 |
+ } |
|
| 180 |
+ .foregroundColor(tint) |
|
| 181 |
+ .frame(width: width, height: height) |
|
| 182 |
+ .contentShape(Rectangle()) |
|
| 183 |
+ } |
|
| 184 |
+ .buttonStyle(.plain) |
|
| 185 |
+ } |
|
| 186 |
+ |
|
| 187 |
+ private var visibleActionButtonCount: CGFloat {
|
|
| 188 |
+ meter.supportsRecordingView ? 3 : 2 |
|
| 189 |
+ } |
|
| 190 |
+ |
|
| 191 |
+ private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
|
|
| 192 |
+ let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0) |
|
| 193 |
+ let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth |
|
| 194 |
+ let fittedWidth = floor(contentWidth / visibleActionButtonCount) |
|
| 195 |
+ return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth)) |
|
| 196 |
+ } |
|
| 197 |
+ |
|
| 198 |
+ private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
|
|
| 199 |
+ let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0) |
|
| 200 |
+ return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2) |
|
| 201 |
+ } |
|
| 202 |
+ |
|
| 203 |
+ private func actionStripDivider(height: CGFloat) -> some View {
|
|
| 204 |
+ Rectangle() |
|
| 205 |
+ .fill(Color.secondary.opacity(0.16)) |
|
| 206 |
+ .frame(width: actionDividerWidth, height: max(44, height - 22)) |
|
| 207 |
+ } |
|
| 208 |
+ |
|
| 209 |
+ private var statusBadge: some View {
|
|
| 210 |
+ MeterConnectionStatusBadgeView(text: statusText, color: statusColor) |
|
| 211 |
+ } |
|
| 212 |
+ |
|
| 213 |
+ private var statusText: String {
|
|
| 214 |
+ switch meter.operationalState {
|
|
| 215 |
+ case .notPresent: |
|
| 216 |
+ return "Missing" |
|
| 217 |
+ case .peripheralNotConnected: |
|
| 218 |
+ return "Ready" |
|
| 219 |
+ case .peripheralConnectionPending: |
|
| 220 |
+ return "Connecting" |
|
| 221 |
+ case .peripheralConnected: |
|
| 222 |
+ return "Linked" |
|
| 223 |
+ case .peripheralReady: |
|
| 224 |
+ return "Preparing" |
|
| 225 |
+ case .comunicating: |
|
| 226 |
+ return "Syncing" |
|
| 227 |
+ case .dataIsAvailable: |
|
| 228 |
+ return "Live" |
|
| 229 |
+ } |
|
| 230 |
+ } |
|
| 231 |
+ |
|
| 232 |
+ private var statusColor: Color {
|
|
| 233 |
+ Meter.operationalColor(for: meter.operationalState) |
|
| 234 |
+ } |
|
| 235 |
+} |
|
@@ -0,0 +1,80 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterConnectionActionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct MeterConnectionActionView: View {
|
|
| 13 |
+ let operationalState: Meter.OperationalState |
|
| 14 |
+ let compact: Bool |
|
| 15 |
+ |
|
| 16 |
+ var body: some View {
|
|
| 17 |
+ if operationalState == .notPresent {
|
|
| 18 |
+ HStack(spacing: 10) {
|
|
| 19 |
+ Image(systemName: "exclamationmark.triangle.fill") |
|
| 20 |
+ .foregroundColor(.orange) |
|
| 21 |
+ Text("Not found at this time.")
|
|
| 22 |
+ .fontWeight(.semibold) |
|
| 23 |
+ Spacer() |
|
| 24 |
+ } |
|
| 25 |
+ .padding(compact ? 12 : 16) |
|
| 26 |
+ .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18) |
|
| 27 |
+ } |
|
| 28 |
+ } |
|
| 29 |
+} |
|
| 30 |
+ |
|
| 31 |
+struct MeterConnectionToolbarButton: View {
|
|
| 32 |
+ let operationalState: Meter.OperationalState |
|
| 33 |
+ let showsTitle: Bool |
|
| 34 |
+ let connectAction: () -> Void |
|
| 35 |
+ let disconnectAction: () -> Void |
|
| 36 |
+ |
|
| 37 |
+ private var connected: Bool {
|
|
| 38 |
+ operationalState >= .peripheralConnectionPending |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ private var actionTint: Color {
|
|
| 42 |
+ connected ? Color(red: 0.66, green: 0.39, blue: 0.35) : Color(red: 0.20, green: 0.46, blue: 0.43) |
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ private var title: String {
|
|
| 46 |
+ connected ? "Disconnect" : "Connect" |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ private var systemImage: String {
|
|
| 50 |
+ if connected {
|
|
| 51 |
+ #if targetEnvironment(macCatalyst) |
|
| 52 |
+ return "bolt.slash.circle.fill" |
|
| 53 |
+ #else |
|
| 54 |
+ return "link.badge.minus" |
|
| 55 |
+ #endif |
|
| 56 |
+ } |
|
| 57 |
+ return "bolt.horizontal.circle.fill" |
|
| 58 |
+ } |
|
| 59 |
+ |
|
| 60 |
+ var body: some View {
|
|
| 61 |
+ if operationalState != .notPresent {
|
|
| 62 |
+ Button(action: {
|
|
| 63 |
+ if connected {
|
|
| 64 |
+ disconnectAction() |
|
| 65 |
+ } else {
|
|
| 66 |
+ connectAction() |
|
| 67 |
+ } |
|
| 68 |
+ }) {
|
|
| 69 |
+ if showsTitle {
|
|
| 70 |
+ Label(title, systemImage: systemImage) |
|
| 71 |
+ } else {
|
|
| 72 |
+ Image(systemName: systemImage) |
|
| 73 |
+ } |
|
| 74 |
+ } |
|
| 75 |
+ .foregroundStyle(actionTint) |
|
| 76 |
+ .accessibilityLabel(title) |
|
| 77 |
+ .help(title) |
|
| 78 |
+ } |
|
| 79 |
+ } |
|
| 80 |
+} |
|
@@ -0,0 +1,23 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterConnectionStatusBadgeView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct MeterConnectionStatusBadgeView: View {
|
|
| 13 |
+ let text: String |
|
| 14 |
+ let color: Color |
|
| 15 |
+ |
|
| 16 |
+ var body: some View {
|
|
| 17 |
+ Text(text) |
|
| 18 |
+ .font(.caption.weight(.bold)) |
|
| 19 |
+ .padding(.horizontal, 12) |
|
| 20 |
+ .padding(.vertical, 6) |
|
| 21 |
+ .meterCard(tint: color, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999) |
|
| 22 |
+ } |
|
| 23 |
+} |
|
@@ -0,0 +1,63 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterOverviewSectionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct MeterOverviewSectionView: View {
|
|
| 13 |
+ let meter: Meter |
|
| 14 |
+ |
|
| 15 |
+ private var overviewInfoMessage: String? {
|
|
| 16 |
+ guard meter.operationalState != .dataIsAvailable else {
|
|
| 17 |
+ return nil |
|
| 18 |
+ } |
|
| 19 |
+ return "Connect to the meter to load firmware, serial, and boot details." |
|
| 20 |
+ } |
|
| 21 |
+ |
|
| 22 |
+ var body: some View {
|
|
| 23 |
+ VStack(spacing: 14) {
|
|
| 24 |
+ MeterInfoCardView( |
|
| 25 |
+ title: "Overview", |
|
| 26 |
+ infoMessage: overviewInfoMessage, |
|
| 27 |
+ tint: meter.color |
|
| 28 |
+ ) {
|
|
| 29 |
+ MeterInfoRowView(label: "Name", value: meter.name) |
|
| 30 |
+ MeterInfoRowView(label: "Device Model", value: meter.deviceModelName) |
|
| 31 |
+ MeterInfoRowView(label: "Advertised Model", value: meter.modelString) |
|
| 32 |
+ MeterInfoRowView(label: "MAC", value: meter.btSerial.macAddress.description) |
|
| 33 |
+ if meter.modelNumber != 0 {
|
|
| 34 |
+ MeterInfoRowView(label: "Model Identifier", value: "\(meter.modelNumber)") |
|
| 35 |
+ } |
|
| 36 |
+ MeterInfoRowView(label: "Working Voltage", value: meter.documentedWorkingVoltage) |
|
| 37 |
+ MeterInfoRowView(label: "Temperature Unit", value: meter.temperatureUnitDescription) |
|
| 38 |
+ MeterInfoRowView(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen)) |
|
| 39 |
+ MeterInfoRowView(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt)) |
|
| 40 |
+ if meter.operationalState == .dataIsAvailable {
|
|
| 41 |
+ if !meter.firmwareVersion.isEmpty {
|
|
| 42 |
+ MeterInfoRowView(label: "Firmware", value: meter.firmwareVersion) |
|
| 43 |
+ } |
|
| 44 |
+ if meter.serialNumber != 0 {
|
|
| 45 |
+ MeterInfoRowView(label: "Serial", value: "\(meter.serialNumber)") |
|
| 46 |
+ } |
|
| 47 |
+ if meter.bootCount != 0 {
|
|
| 48 |
+ MeterInfoRowView(label: "Boot Count", value: "\(meter.bootCount)") |
|
| 49 |
+ } |
|
| 50 |
+ } |
|
| 51 |
+ } |
|
| 52 |
+ |
|
| 53 |
+ } |
|
| 54 |
+ .padding(.horizontal, 12) |
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 57 |
+ private func meterHistoryText(for date: Date?) -> String {
|
|
| 58 |
+ guard let date else {
|
|
| 59 |
+ return "Never" |
|
| 60 |
+ } |
|
| 61 |
+ return date.format(as: "yyyy-MM-dd HH:mm") |
|
| 62 |
+ } |
|
| 63 |
+} |
|
@@ -0,0 +1,991 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargerStandbyPowerWizardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 13/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+import UniformTypeIdentifiers |
|
| 10 |
+ |
|
| 11 |
+struct ChargerStandbyPowerWizardView: View {
|
|
| 12 |
+ @EnvironmentObject private var appData: AppData |
|
| 13 |
+ |
|
| 14 |
+ @State private var chargerLibraryVisibility = false |
|
| 15 |
+ @State private var discardConfirmationVisibility = false |
|
| 16 |
+ @State private var selectedMeterMACAddress: String? |
|
| 17 |
+ @State private var selectedChargerID: UUID? |
|
| 18 |
+ |
|
| 19 |
+ let preferredMeterMACAddress: String? |
|
| 20 |
+ let preferredChargerID: UUID? |
|
| 21 |
+ let locksChargerSelection: Bool |
|
| 22 |
+ |
|
| 23 |
+ init( |
|
| 24 |
+ preferredMeterMACAddress: String? = nil, |
|
| 25 |
+ preferredChargerID: UUID? = nil, |
|
| 26 |
+ locksChargerSelection: Bool = false |
|
| 27 |
+ ) {
|
|
| 28 |
+ self.preferredMeterMACAddress = preferredMeterMACAddress |
|
| 29 |
+ self.preferredChargerID = preferredChargerID |
|
| 30 |
+ self.locksChargerSelection = locksChargerSelection |
|
| 31 |
+ _selectedMeterMACAddress = State(initialValue: nil) |
|
| 32 |
+ _selectedChargerID = State(initialValue: preferredChargerID) |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ var body: some View {
|
|
| 36 |
+ ScrollView {
|
|
| 37 |
+ VStack(spacing: 18) {
|
|
| 38 |
+ if let session = activeSession {
|
|
| 39 |
+ activeMeasurementCard(session) |
|
| 40 |
+ liveSessionCard(session) |
|
| 41 |
+ } else {
|
|
| 42 |
+ newMeasurementWizardCard |
|
| 43 |
+ } |
|
| 44 |
+ } |
|
| 45 |
+ .padding() |
|
| 46 |
+ } |
|
| 47 |
+ .background( |
|
| 48 |
+ LinearGradient( |
|
| 49 |
+ colors: [.orange.opacity(0.16), Color.clear], |
|
| 50 |
+ startPoint: .topLeading, |
|
| 51 |
+ endPoint: .bottomTrailing |
|
| 52 |
+ ) |
|
| 53 |
+ .ignoresSafeArea() |
|
| 54 |
+ ) |
|
| 55 |
+ .navigationTitle(navigationTitleText) |
|
| 56 |
+ .sheet(isPresented: $chargerLibraryVisibility) {
|
|
| 57 |
+ ChargedDeviceLibrarySheetView( |
|
| 58 |
+ meterMACAddress: selectedMeterSummary?.macAddress ?? "", |
|
| 59 |
+ meterTint: selectedMeter?.color ?? .orange, |
|
| 60 |
+ mode: .charger |
|
| 61 |
+ ) |
|
| 62 |
+ .environmentObject(appData) |
|
| 63 |
+ } |
|
| 64 |
+ .confirmationDialog( |
|
| 65 |
+ "Discard the current standby measurement?", |
|
| 66 |
+ isPresented: $discardConfirmationVisibility, |
|
| 67 |
+ titleVisibility: .visible |
|
| 68 |
+ ) {
|
|
| 69 |
+ Button("Discard", role: .destructive) {
|
|
| 70 |
+ if let activeSession {
|
|
| 71 |
+ _ = appData.finishChargerStandbyMeasurement(for: activeSession.meterMACAddress, save: false) |
|
| 72 |
+ } |
|
| 73 |
+ } |
|
| 74 |
+ Button("Cancel", role: .cancel) {}
|
|
| 75 |
+ } message: {
|
|
| 76 |
+ Text("The current sample set will be removed and nothing will be saved for this charger.")
|
|
| 77 |
+ } |
|
| 78 |
+ } |
|
| 79 |
+ |
|
| 80 |
+ private var liveMeterSummaries: [AppData.MeterSummary] {
|
|
| 81 |
+ appData.meterSummaries.filter { $0.meter != nil }
|
|
| 82 |
+ } |
|
| 83 |
+ |
|
| 84 |
+ private var availableChargers: [ChargedDeviceSummary] {
|
|
| 85 |
+ appData.chargerSummaries |
|
| 86 |
+ } |
|
| 87 |
+ |
|
| 88 |
+ private var preferredChargerMeterMACAddress: String? {
|
|
| 89 |
+ preferredChargerID.flatMap { appData.chargedDeviceSummary(id: $0)?.lastAssociatedMeterMAC }
|
|
| 90 |
+ } |
|
| 91 |
+ |
|
| 92 |
+ private var activeSession: ChargerStandbyPowerMonitorSession? {
|
|
| 93 |
+ let candidateMACAddresses = [ |
|
| 94 |
+ selectedMeterMACAddress ?? "", |
|
| 95 |
+ preferredMeterMACAddress ?? "", |
|
| 96 |
+ preferredChargerMeterMACAddress ?? "" |
|
| 97 |
+ ] |
|
| 98 |
+ .filter { $0.isEmpty == false }
|
|
| 99 |
+ |
|
| 100 |
+ for macAddress in candidateMACAddresses {
|
|
| 101 |
+ if let session = appData.chargerStandbyMeasurementSession(for: macAddress) {
|
|
| 102 |
+ return session |
|
| 103 |
+ } |
|
| 104 |
+ } |
|
| 105 |
+ |
|
| 106 |
+ for meterSummary in liveMeterSummaries {
|
|
| 107 |
+ if let session = appData.chargerStandbyMeasurementSession(for: meterSummary.macAddress) {
|
|
| 108 |
+ return session |
|
| 109 |
+ } |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ return nil |
|
| 113 |
+ } |
|
| 114 |
+ |
|
| 115 |
+ private var suggestedMeterSummary: AppData.MeterSummary? {
|
|
| 116 |
+ if let preferredMeterMACAddress {
|
|
| 117 |
+ return liveMeterSummaries.first(where: { $0.macAddress == preferredMeterMACAddress })
|
|
| 118 |
+ } |
|
| 119 |
+ |
|
| 120 |
+ if let preferredChargerMeterMACAddress {
|
|
| 121 |
+ return liveMeterSummaries.first(where: { $0.macAddress == preferredChargerMeterMACAddress })
|
|
| 122 |
+ } |
|
| 123 |
+ |
|
| 124 |
+ return liveMeterSummaries.count == 1 ? liveMeterSummaries.first : nil |
|
| 125 |
+ } |
|
| 126 |
+ |
|
| 127 |
+ private var selectedMeterSummary: AppData.MeterSummary? {
|
|
| 128 |
+ if let activeSession {
|
|
| 129 |
+ return liveMeterSummaries.first(where: { $0.macAddress == activeSession.meterMACAddress })
|
|
| 130 |
+ } |
|
| 131 |
+ |
|
| 132 |
+ guard let selectedMeterMACAddress else {
|
|
| 133 |
+ return nil |
|
| 134 |
+ } |
|
| 135 |
+ |
|
| 136 |
+ return liveMeterSummaries.first(where: { $0.macAddress == selectedMeterMACAddress })
|
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 139 |
+ private var selectedMeter: Meter? {
|
|
| 140 |
+ selectedMeterSummary?.meter |
|
| 141 |
+ } |
|
| 142 |
+ |
|
| 143 |
+ private var isChargerSelectionLocked: Bool {
|
|
| 144 |
+ locksChargerSelection || preferredChargerID != nil |
|
| 145 |
+ } |
|
| 146 |
+ |
|
| 147 |
+ private var meterSelectionBinding: Binding<String?> {
|
|
| 148 |
+ Binding( |
|
| 149 |
+ get: { selectedMeterMACAddress },
|
|
| 150 |
+ set: { selectedMeterMACAddress = $0 }
|
|
| 151 |
+ ) |
|
| 152 |
+ } |
|
| 153 |
+ |
|
| 154 |
+ private var selectedCharger: ChargedDeviceSummary? {
|
|
| 155 |
+ if let activeSession {
|
|
| 156 |
+ return appData.chargedDeviceSummary(id: activeSession.chargerID) |
|
| 157 |
+ } |
|
| 158 |
+ |
|
| 159 |
+ guard let selectedChargerID else {
|
|
| 160 |
+ return nil |
|
| 161 |
+ } |
|
| 162 |
+ |
|
| 163 |
+ return appData.chargedDeviceSummary(id: selectedChargerID) |
|
| 164 |
+ } |
|
| 165 |
+ |
|
| 166 |
+ private var chargerSelectionBinding: Binding<UUID?> {
|
|
| 167 |
+ Binding( |
|
| 168 |
+ get: { selectedChargerID },
|
|
| 169 |
+ set: { selectedChargerID = $0 }
|
|
| 170 |
+ ) |
|
| 171 |
+ } |
|
| 172 |
+ |
|
| 173 |
+ private var preferredChargerSummary: ChargedDeviceSummary? {
|
|
| 174 |
+ guard let preferredChargerID else {
|
|
| 175 |
+ return nil |
|
| 176 |
+ } |
|
| 177 |
+ return appData.chargedDeviceSummary(id: preferredChargerID) |
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ private var navigationTitleText: String {
|
|
| 181 |
+ "New Standby Consumption Measurement" |
|
| 182 |
+ } |
|
| 183 |
+ |
|
| 184 |
+ private var wizardCardTitle: String {
|
|
| 185 |
+ if let selectedMeterSummary {
|
|
| 186 |
+ return selectedMeterSummary.displayName |
|
| 187 |
+ } |
|
| 188 |
+ |
|
| 189 |
+ if let suggestedMeterSummary {
|
|
| 190 |
+ return suggestedMeterSummary.displayName |
|
| 191 |
+ } |
|
| 192 |
+ |
|
| 193 |
+ return "Use Meter" |
|
| 194 |
+ } |
|
| 195 |
+ |
|
| 196 |
+ private var newMeasurementWizardCard: some View {
|
|
| 197 |
+ MeterInfoCardView( |
|
| 198 |
+ title: wizardCardTitle, |
|
| 199 |
+ tint: .orange |
|
| 200 |
+ ) {
|
|
| 201 |
+ if liveMeterSummaries.isEmpty {
|
|
| 202 |
+ Text("Connect a live meter first. Standby measurement uses a live feed, so meter selection happens here in the wizard.")
|
|
| 203 |
+ .font(.footnote) |
|
| 204 |
+ .foregroundColor(.secondary) |
|
| 205 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 206 |
+ } else {
|
|
| 207 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 208 |
+ if isChargerSelectionLocked == false {
|
|
| 209 |
+ HStack(spacing: 8) {
|
|
| 210 |
+ Text("Charger")
|
|
| 211 |
+ .font(.subheadline.weight(.semibold)) |
|
| 212 |
+ ContextInfoButton( |
|
| 213 |
+ title: "Charger", |
|
| 214 |
+ message: "Choose the charger whose standby consumption you want to measure in this run." |
|
| 215 |
+ ) |
|
| 216 |
+ } |
|
| 217 |
+ |
|
| 218 |
+ if availableChargers.isEmpty {
|
|
| 219 |
+ Text("No charger available yet. Open the charger library to create one first.")
|
|
| 220 |
+ .font(.caption) |
|
| 221 |
+ .foregroundColor(.secondary) |
|
| 222 |
+ } else {
|
|
| 223 |
+ Picker("Charger", selection: chargerSelectionBinding) {
|
|
| 224 |
+ Text("Select Charger").tag(Optional<UUID>.none)
|
|
| 225 |
+ ForEach(availableChargers) { charger in
|
|
| 226 |
+ Text(charger.name).tag(Optional(charger.id)) |
|
| 227 |
+ } |
|
| 228 |
+ } |
|
| 229 |
+ .pickerStyle(.menu) |
|
| 230 |
+ } |
|
| 231 |
+ } |
|
| 232 |
+ |
|
| 233 |
+ HStack(spacing: 8) {
|
|
| 234 |
+ Text("Use Meter")
|
|
| 235 |
+ .font(.subheadline.weight(.semibold)) |
|
| 236 |
+ ContextInfoButton( |
|
| 237 |
+ title: "Use Meter", |
|
| 238 |
+ message: "Choose the live meter explicitly. Standby consumption can vary with the upstream source or when the charger is connected to a computer, so re-run after setup changes." |
|
| 239 |
+ ) |
|
| 240 |
+ } |
|
| 241 |
+ |
|
| 242 |
+ Picker("Use Meter", selection: meterSelectionBinding) {
|
|
| 243 |
+ Text("Select Meter").tag(Optional<String>.none)
|
|
| 244 |
+ ForEach(liveMeterSummaries) { meterSummary in
|
|
| 245 |
+ Text(meterSummary.displayName).tag(Optional(meterSummary.macAddress)) |
|
| 246 |
+ } |
|
| 247 |
+ } |
|
| 248 |
+ .pickerStyle(.menu) |
|
| 249 |
+ .disabled(activeSession != nil) |
|
| 250 |
+ |
|
| 251 |
+ if activeSession == nil, let suggestedMeterSummary, selectedMeterSummary == nil {
|
|
| 252 |
+ Text("Suggested from the current context: \(suggestedMeterSummary.displayName). Select it explicitly if this is the meter you want to use.")
|
|
| 253 |
+ .font(.caption) |
|
| 254 |
+ .foregroundColor(.secondary) |
|
| 255 |
+ } |
|
| 256 |
+ |
|
| 257 |
+ HStack(spacing: 12) {
|
|
| 258 |
+ if isChargerSelectionLocked == false {
|
|
| 259 |
+ Button("Manage Charger Library") {
|
|
| 260 |
+ chargerLibraryVisibility = true |
|
| 261 |
+ } |
|
| 262 |
+ .disabled(selectedMeter == nil) |
|
| 263 |
+ } |
|
| 264 |
+ |
|
| 265 |
+ Button("Start Measurement") {
|
|
| 266 |
+ startMeasurement() |
|
| 267 |
+ } |
|
| 268 |
+ .disabled(selectedCharger == nil || selectedMeter == nil) |
|
| 269 |
+ } |
|
| 270 |
+ .buttonStyle(.borderedProminent) |
|
| 271 |
+ } |
|
| 272 |
+ } |
|
| 273 |
+ |
|
| 274 |
+ if selectedMeter == nil {
|
|
| 275 |
+ Text("Choose the live meter explicitly before starting. The wizard no longer auto-confirms a suggested meter.")
|
|
| 276 |
+ .font(.caption) |
|
| 277 |
+ .foregroundColor(.secondary) |
|
| 278 |
+ } else if activeSession == nil, selectedCharger == nil {
|
|
| 279 |
+ Text("Select the charger you want to measure, then start the run.")
|
|
| 280 |
+ .font(.caption) |
|
| 281 |
+ .foregroundColor(.secondary) |
|
| 282 |
+ } else if activeSession == nil, selectedMeter?.operationalState != .dataIsAvailable {
|
|
| 283 |
+ Text("The wizard can start now, but samples will only be captured while live meter data is available.")
|
|
| 284 |
+ .font(.caption) |
|
| 285 |
+ .foregroundColor(.secondary) |
|
| 286 |
+ } else if let activeSession {
|
|
| 287 |
+ Text( |
|
| 288 |
+ "\(activeSession.readinessDescription) • \(formattedDuration(Date().timeIntervalSince(activeSession.startedAt))) • \(activeSession.sampleCount) samples" |
|
| 289 |
+ ) |
|
| 290 |
+ .font(.caption) |
|
| 291 |
+ .foregroundColor(.secondary) |
|
| 292 |
+ } |
|
| 293 |
+ } |
|
| 294 |
+ } |
|
| 295 |
+ |
|
| 296 |
+ private func activeMeasurementCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
|
|
| 297 |
+ MeterInfoCardView( |
|
| 298 |
+ title: "Measurement Running", |
|
| 299 |
+ infoMessage: "The run keeps collecting samples while this meter stays live. Save when you are happy with the sample set, or discard to cancel it.", |
|
| 300 |
+ tint: .orange |
|
| 301 |
+ ) {
|
|
| 302 |
+ MeterInfoRowView(label: "Meter", value: selectedMeterSummary?.displayName ?? session.meterMACAddress) |
|
| 303 |
+ MeterInfoRowView(label: "Charger", value: selectedCharger?.name ?? "Selected charger") |
|
| 304 |
+ MeterInfoRowView(label: "Status", value: session.readinessDescription) |
|
| 305 |
+ MeterInfoRowView(label: "Samples", value: "\(session.sampleCount)") |
|
| 306 |
+ |
|
| 307 |
+ HStack(spacing: 12) {
|
|
| 308 |
+ Button("Save Result") {
|
|
| 309 |
+ _ = appData.finishChargerStandbyMeasurement(for: session.meterMACAddress, save: true) |
|
| 310 |
+ } |
|
| 311 |
+ .disabled(session.hasSamples == false) |
|
| 312 |
+ |
|
| 313 |
+ Button("Discard") {
|
|
| 314 |
+ discardConfirmationVisibility = true |
|
| 315 |
+ } |
|
| 316 |
+ .foregroundColor(.red) |
|
| 317 |
+ } |
|
| 318 |
+ .buttonStyle(.borderedProminent) |
|
| 319 |
+ } |
|
| 320 |
+ } |
|
| 321 |
+ |
|
| 322 |
+ private func liveSessionCard(_ session: ChargerStandbyPowerMonitorSession) -> some View {
|
|
| 323 |
+ VStack(spacing: 18) {
|
|
| 324 |
+ if let statistics = session.statistics {
|
|
| 325 |
+ stabilityCard( |
|
| 326 |
+ isStable: statistics.isStable, |
|
| 327 |
+ averagePowerWatts: statistics.averagePowerWatts, |
|
| 328 |
+ stabilityDeltaWatts: statistics.stabilityDeltaWatts, |
|
| 329 |
+ stabilityToleranceWatts: statistics.stabilityToleranceWatts, |
|
| 330 |
+ sampleCount: statistics.sampleCount |
|
| 331 |
+ ) |
|
| 332 |
+ |
|
| 333 |
+ projectionCard( |
|
| 334 |
+ averagePowerWatts: statistics.averagePowerWatts, |
|
| 335 |
+ projectedDailyEnergyWh: statistics.projectedDailyEnergyWh, |
|
| 336 |
+ projectedWeeklyEnergyWh: statistics.projectedWeeklyEnergyWh, |
|
| 337 |
+ projectedMonthlyEnergyWh: statistics.projectedMonthlyEnergyWh, |
|
| 338 |
+ projectedYearlyEnergyWh: statistics.projectedYearlyEnergyWh |
|
| 339 |
+ ) |
|
| 340 |
+ |
|
| 341 |
+ StandbyPowerDistributionCard( |
|
| 342 |
+ histogram: statistics.histogram, |
|
| 343 |
+ averagePowerWatts: statistics.averagePowerWatts, |
|
| 344 |
+ standardDeviationPowerWatts: statistics.standardDeviationPowerWatts, |
|
| 345 |
+ tint: .orange |
|
| 346 |
+ ) |
|
| 347 |
+ |
|
| 348 |
+ statisticsCard( |
|
| 349 |
+ averagePowerWatts: statistics.averagePowerWatts, |
|
| 350 |
+ medianPowerWatts: statistics.medianPowerWatts, |
|
| 351 |
+ minimumPowerWatts: statistics.minimumPowerWatts, |
|
| 352 |
+ maximumPowerWatts: statistics.maximumPowerWatts, |
|
| 353 |
+ standardDeviationPowerWatts: statistics.standardDeviationPowerWatts, |
|
| 354 |
+ coefficientOfVariation: statistics.coefficientOfVariation, |
|
| 355 |
+ averageCurrentAmps: statistics.averageCurrentAmps, |
|
| 356 |
+ averageVoltageVolts: statistics.averageVoltageVolts |
|
| 357 |
+ ) |
|
| 358 |
+ } else {
|
|
| 359 |
+ MeterInfoCardView(title: "Live Stats", tint: .orange) {
|
|
| 360 |
+ Text("Waiting for the first valid power samples from the meter.")
|
|
| 361 |
+ .font(.footnote) |
|
| 362 |
+ .foregroundColor(.secondary) |
|
| 363 |
+ } |
|
| 364 |
+ } |
|
| 365 |
+ } |
|
| 366 |
+ } |
|
| 367 |
+ |
|
| 368 |
+ private func stabilityCard( |
|
| 369 |
+ isStable: Bool, |
|
| 370 |
+ averagePowerWatts: Double, |
|
| 371 |
+ stabilityDeltaWatts: Double, |
|
| 372 |
+ stabilityToleranceWatts: Double, |
|
| 373 |
+ sampleCount: Int, |
|
| 374 |
+ subtitle: String? = nil |
|
| 375 |
+ ) -> some View {
|
|
| 376 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 377 |
+ HStack {
|
|
| 378 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 379 |
+ Text(isStable ? "Enough Samples" : "Still Settling") |
|
| 380 |
+ .font(.headline) |
|
| 381 |
+ Text(subtitle ?? (isStable ? "The running average has stabilised." : "The wizard is still watching the average drift.")) |
|
| 382 |
+ .font(.caption) |
|
| 383 |
+ .foregroundColor(.secondary) |
|
| 384 |
+ } |
|
| 385 |
+ |
|
| 386 |
+ Spacer() |
|
| 387 |
+ |
|
| 388 |
+ Text(isStable ? "Ready" : "Live") |
|
| 389 |
+ .font(.caption.weight(.semibold)) |
|
| 390 |
+ .padding(.horizontal, 10) |
|
| 391 |
+ .padding(.vertical, 6) |
|
| 392 |
+ .foregroundColor(isStable ? .green : .orange) |
|
| 393 |
+ .meterCard( |
|
| 394 |
+ tint: isStable ? .green : .orange, |
|
| 395 |
+ fillOpacity: 0.10, |
|
| 396 |
+ strokeOpacity: 0.16, |
|
| 397 |
+ cornerRadius: 999 |
|
| 398 |
+ ) |
|
| 399 |
+ } |
|
| 400 |
+ |
|
| 401 |
+ Text("\(averagePowerWatts.format(decimalDigits: 3)) W")
|
|
| 402 |
+ .font(.system(.largeTitle, design: .rounded).weight(.bold)) |
|
| 403 |
+ .monospacedDigit() |
|
| 404 |
+ |
|
| 405 |
+ Text( |
|
| 406 |
+ "Recent drift: \((stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(sampleCount) samples." |
|
| 407 |
+ ) |
|
| 408 |
+ .font(.footnote) |
|
| 409 |
+ .foregroundColor(.secondary) |
|
| 410 |
+ } |
|
| 411 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 412 |
+ .padding(18) |
|
| 413 |
+ .meterCard(tint: isStable ? .green : .orange, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 414 |
+ } |
|
| 415 |
+ |
|
| 416 |
+ private func projectionCard( |
|
| 417 |
+ averagePowerWatts: Double, |
|
| 418 |
+ projectedDailyEnergyWh: Double, |
|
| 419 |
+ projectedWeeklyEnergyWh: Double, |
|
| 420 |
+ projectedMonthlyEnergyWh: Double, |
|
| 421 |
+ projectedYearlyEnergyWh: Double |
|
| 422 |
+ ) -> some View {
|
|
| 423 |
+ MeterInfoCardView( |
|
| 424 |
+ title: "Consumption Projection", |
|
| 425 |
+ infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.", |
|
| 426 |
+ tint: .teal |
|
| 427 |
+ ) {
|
|
| 428 |
+ MeterInfoRowView(label: "Average Power", value: "\(averagePowerWatts.format(decimalDigits: 3)) W") |
|
| 429 |
+ MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(projectedDailyEnergyWh)) |
|
| 430 |
+ MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(projectedWeeklyEnergyWh)) |
|
| 431 |
+ MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(projectedMonthlyEnergyWh)) |
|
| 432 |
+ MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(projectedYearlyEnergyWh)) |
|
| 433 |
+ } |
|
| 434 |
+ } |
|
| 435 |
+ |
|
| 436 |
+ private func statisticsCard( |
|
| 437 |
+ averagePowerWatts: Double, |
|
| 438 |
+ medianPowerWatts: Double, |
|
| 439 |
+ minimumPowerWatts: Double, |
|
| 440 |
+ maximumPowerWatts: Double, |
|
| 441 |
+ standardDeviationPowerWatts: Double, |
|
| 442 |
+ coefficientOfVariation: Double, |
|
| 443 |
+ averageCurrentAmps: Double, |
|
| 444 |
+ averageVoltageVolts: Double |
|
| 445 |
+ ) -> some View {
|
|
| 446 |
+ MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
|
|
| 447 |
+ MeterInfoRowView(label: "Median", value: "\(medianPowerWatts.format(decimalDigits: 3)) W") |
|
| 448 |
+ MeterInfoRowView(label: "Minimum", value: "\(minimumPowerWatts.format(decimalDigits: 3)) W") |
|
| 449 |
+ MeterInfoRowView(label: "Maximum", value: "\(maximumPowerWatts.format(decimalDigits: 3)) W") |
|
| 450 |
+ MeterInfoRowView(label: "Spread σ", value: "\(standardDeviationPowerWatts.format(decimalDigits: 4)) W") |
|
| 451 |
+ MeterInfoRowView(label: "Variation", value: "\(Int((coefficientOfVariation * 100).rounded()))%") |
|
| 452 |
+ MeterInfoRowView(label: "Mean Current", value: "\(averageCurrentAmps.format(decimalDigits: 3)) A") |
|
| 453 |
+ MeterInfoRowView(label: "Mean Voltage", value: "\(averageVoltageVolts.format(decimalDigits: 3)) V") |
|
| 454 |
+ MeterInfoRowView(label: "Power Density", value: "\(averagePowerWatts.format(decimalDigits: 3)) W steady") |
|
| 455 |
+ } |
|
| 456 |
+ } |
|
| 457 |
+ |
|
| 458 |
+ private func startMeasurement() {
|
|
| 459 |
+ guard let selectedCharger, let selectedMeter else {
|
|
| 460 |
+ return |
|
| 461 |
+ } |
|
| 462 |
+ |
|
| 463 |
+ _ = appData.startChargerStandbyMeasurement(for: selectedCharger.id, on: selectedMeter) |
|
| 464 |
+ } |
|
| 465 |
+ |
|
| 466 |
+ private func standbyEnergyLabel(_ wattHours: Double) -> String {
|
|
| 467 |
+ if wattHours >= 1000 {
|
|
| 468 |
+ return "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 469 |
+ } |
|
| 470 |
+ return "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 471 |
+ } |
|
| 472 |
+ |
|
| 473 |
+ private func formattedDuration(_ duration: TimeInterval) -> String {
|
|
| 474 |
+ let formatter = DateComponentsFormatter() |
|
| 475 |
+ formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second] |
|
| 476 |
+ formatter.unitsStyle = .abbreviated |
|
| 477 |
+ formatter.zeroFormattingBehavior = .pad |
|
| 478 |
+ return formatter.string(from: max(duration, 0)) ?? "0s" |
|
| 479 |
+ } |
|
| 480 |
+ |
|
| 481 |
+} |
|
| 482 |
+ |
|
| 483 |
+// MARK: - Distribution card with resolution picker and CSV export |
|
| 484 |
+ |
|
| 485 |
+private struct StandbyPowerDistributionCard: View {
|
|
| 486 |
+ let histogram: [ChargerStandbyPowerDistributionBin] |
|
| 487 |
+ let averagePowerWatts: Double |
|
| 488 |
+ let standardDeviationPowerWatts: Double |
|
| 489 |
+ let tint: Color |
|
| 490 |
+ var showExport: Bool = false |
|
| 491 |
+ |
|
| 492 |
+ private func resolution(for width: CGFloat) -> HistogramResolution {
|
|
| 493 |
+ if width >= 600 { return .x4 }
|
|
| 494 |
+ if width >= 360 { return .x2 }
|
|
| 495 |
+ return .x1 |
|
| 496 |
+ } |
|
| 497 |
+ |
|
| 498 |
+ private func displayedHistogram(width: CGFloat) -> [ChargerStandbyPowerDistributionBin] {
|
|
| 499 |
+ let factor = HistogramResolution.x4.rawValue / resolution(for: width).rawValue |
|
| 500 |
+ return ChargerStandbyPowerMeasurementAnalyzer.downsample(histogram, factor: factor) |
|
| 501 |
+ } |
|
| 502 |
+ |
|
| 503 |
+ private var csvString: String {
|
|
| 504 |
+ var lines = ["Bin,Lower Bound (W),Upper Bound (W),Count,Relative Frequency (%)"] |
|
| 505 |
+ for bin in histogram {
|
|
| 506 |
+ lines.append( |
|
| 507 |
+ "\(bin.index + 1)," |
|
| 508 |
+ + String(format: "%.6f", bin.lowerBoundWatts) + "," |
|
| 509 |
+ + String(format: "%.6f", bin.upperBoundWatts) + "," |
|
| 510 |
+ + "\(bin.count)," |
|
| 511 |
+ + String(format: "%.4f", bin.relativeFrequency * 100) |
|
| 512 |
+ ) |
|
| 513 |
+ } |
|
| 514 |
+ return lines.joined(separator: "\n") |
|
| 515 |
+ } |
|
| 516 |
+ |
|
| 517 |
+ var body: some View {
|
|
| 518 |
+ MeterInfoCardView( |
|
| 519 |
+ title: "Value Spread", |
|
| 520 |
+ infoMessage: "Bars show how often each power interval appeared. The curve overlays a normal approximation around the observed mean and standard deviation.", |
|
| 521 |
+ tint: tint, |
|
| 522 |
+ trailingActions: {
|
|
| 523 |
+ if showExport {
|
|
| 524 |
+ if #available(iOS 16, *) {
|
|
| 525 |
+ ShareLink( |
|
| 526 |
+ item: DistributionCSVExport(content: csvString), |
|
| 527 |
+ preview: SharePreview("distribution.csv")
|
|
| 528 |
+ ) {
|
|
| 529 |
+ Image(systemName: "square.and.arrow.up") |
|
| 530 |
+ .font(.subheadline.weight(.medium)) |
|
| 531 |
+ .foregroundStyle(.secondary) |
|
| 532 |
+ } |
|
| 533 |
+ } else {
|
|
| 534 |
+ Button {
|
|
| 535 |
+ exportCSVLegacy(csvString) |
|
| 536 |
+ } label: {
|
|
| 537 |
+ Image(systemName: "square.and.arrow.up") |
|
| 538 |
+ .font(.subheadline.weight(.medium)) |
|
| 539 |
+ .foregroundStyle(.secondary) |
|
| 540 |
+ } |
|
| 541 |
+ } |
|
| 542 |
+ } |
|
| 543 |
+ } |
|
| 544 |
+ ) {
|
|
| 545 |
+ GeometryReader { proxy in
|
|
| 546 |
+ let bins = displayedHistogram(width: proxy.size.width) |
|
| 547 |
+ StandbyPowerHistogramView( |
|
| 548 |
+ histogram: bins, |
|
| 549 |
+ averagePowerWatts: averagePowerWatts, |
|
| 550 |
+ standardDeviationPowerWatts: standardDeviationPowerWatts, |
|
| 551 |
+ tint: tint |
|
| 552 |
+ ) |
|
| 553 |
+ |
|
| 554 |
+ if let firstBin = bins.first, let lastBin = bins.last {
|
|
| 555 |
+ let midpointWatts = (firstBin.lowerBoundWatts + lastBin.upperBoundWatts) / 2 |
|
| 556 |
+ VStack {
|
|
| 557 |
+ Spacer() |
|
| 558 |
+ HStack {
|
|
| 559 |
+ Text("\(firstBin.lowerBoundWatts.format(decimalDigits: 3)) W")
|
|
| 560 |
+ Spacer() |
|
| 561 |
+ Text("\(midpointWatts.format(decimalDigits: 3)) W")
|
|
| 562 |
+ Spacer() |
|
| 563 |
+ Text("\(lastBin.upperBoundWatts.format(decimalDigits: 3)) W")
|
|
| 564 |
+ } |
|
| 565 |
+ .font(.caption) |
|
| 566 |
+ .foregroundColor(.secondary) |
|
| 567 |
+ .monospacedDigit() |
|
| 568 |
+ } |
|
| 569 |
+ } |
|
| 570 |
+ } |
|
| 571 |
+ .frame(height: 240) |
|
| 572 |
+ } |
|
| 573 |
+ } |
|
| 574 |
+ |
|
| 575 |
+ private func exportCSVLegacy(_ csv: String) {
|
|
| 576 |
+ guard let windowScene = UIApplication.shared.connectedScenes |
|
| 577 |
+ .compactMap({ $0 as? UIWindowScene }).first,
|
|
| 578 |
+ let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
| 579 |
+ let activityVC = UIActivityViewController( |
|
| 580 |
+ activityItems: [csv], |
|
| 581 |
+ applicationActivities: nil |
|
| 582 |
+ ) |
|
| 583 |
+ rootVC.present(activityVC, animated: true) |
|
| 584 |
+ } |
|
| 585 |
+} |
|
| 586 |
+ |
|
| 587 |
+@available(iOS 16, *) |
|
| 588 |
+struct DistributionCSVExport: Transferable {
|
|
| 589 |
+ let content: String |
|
| 590 |
+ |
|
| 591 |
+ static var transferRepresentation: some TransferRepresentation {
|
|
| 592 |
+ DataRepresentation(exportedContentType: .commaSeparatedText) { export in
|
|
| 593 |
+ Data(export.content.utf8) |
|
| 594 |
+ } |
|
| 595 |
+ .suggestedFileName("distribution")
|
|
| 596 |
+ } |
|
| 597 |
+} |
|
| 598 |
+ |
|
| 599 |
+// MARK: - Histogram bars + Gaussian curve |
|
| 600 |
+ |
|
| 601 |
+private struct StandbyPowerHistogramView: View {
|
|
| 602 |
+ let histogram: [ChargerStandbyPowerDistributionBin] |
|
| 603 |
+ let averagePowerWatts: Double |
|
| 604 |
+ let standardDeviationPowerWatts: Double |
|
| 605 |
+ let tint: Color |
|
| 606 |
+ |
|
| 607 |
+ var body: some View {
|
|
| 608 |
+ GeometryReader { proxy in
|
|
| 609 |
+ let maxCount = max(Double(histogram.map(\.count).max() ?? 1), 1) |
|
| 610 |
+ |
|
| 611 |
+ ZStack {
|
|
| 612 |
+ HStack(alignment: .bottom, spacing: 6) {
|
|
| 613 |
+ ForEach(histogram) { bin in
|
|
| 614 |
+ RoundedRectangle(cornerRadius: 8, style: .continuous) |
|
| 615 |
+ .fill(tint.opacity(0.24)) |
|
| 616 |
+ .overlay( |
|
| 617 |
+ RoundedRectangle(cornerRadius: 8, style: .continuous) |
|
| 618 |
+ .stroke(tint.opacity(0.22), lineWidth: 1) |
|
| 619 |
+ ) |
|
| 620 |
+ .frame(height: max(10, (Double(bin.count) / maxCount) * proxy.size.height)) |
|
| 621 |
+ } |
|
| 622 |
+ } |
|
| 623 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) |
|
| 624 |
+ |
|
| 625 |
+ gaussianCurve(in: proxy.size) |
|
| 626 |
+ .stroke(tint, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) |
|
| 627 |
+ |
|
| 628 |
+ meanMarker(in: proxy.size) |
|
| 629 |
+ .stroke(tint.opacity(0.9), style: StrokeStyle(lineWidth: 1.5, dash: [6, 4])) |
|
| 630 |
+ } |
|
| 631 |
+ } |
|
| 632 |
+ } |
|
| 633 |
+ |
|
| 634 |
+ private func gaussianCurve(in size: CGSize) -> Path {
|
|
| 635 |
+ guard histogram.count > 1, |
|
| 636 |
+ standardDeviationPowerWatts > 0, |
|
| 637 |
+ let firstBin = histogram.first, |
|
| 638 |
+ let lastBin = histogram.last else {
|
|
| 639 |
+ return Path() |
|
| 640 |
+ } |
|
| 641 |
+ |
|
| 642 |
+ let minimum = firstBin.lowerBoundWatts |
|
| 643 |
+ let maximum = lastBin.upperBoundWatts |
|
| 644 |
+ let span = max(maximum - minimum, 0.000_001) |
|
| 645 |
+ let sampleCount = 48 |
|
| 646 |
+ let peakDensity = 1 / (standardDeviationPowerWatts * sqrt(2 * .pi)) |
|
| 647 |
+ |
|
| 648 |
+ return Path { path in
|
|
| 649 |
+ for index in 0...sampleCount {
|
|
| 650 |
+ let progress = Double(index) / Double(sampleCount) |
|
| 651 |
+ let value = minimum + (span * progress) |
|
| 652 |
+ let zScore = (value - averagePowerWatts) / standardDeviationPowerWatts |
|
| 653 |
+ let density = exp(-0.5 * zScore * zScore) / (standardDeviationPowerWatts * sqrt(2 * .pi)) |
|
| 654 |
+ let normalizedHeight = density / peakDensity |
|
| 655 |
+ |
|
| 656 |
+ let x = progress * size.width |
|
| 657 |
+ let y = size.height - (normalizedHeight * (Double(size.height) * 0.92)) |
|
| 658 |
+ let point = CGPoint(x: x, y: y) |
|
| 659 |
+ |
|
| 660 |
+ if index == 0 {
|
|
| 661 |
+ path.move(to: point) |
|
| 662 |
+ } else {
|
|
| 663 |
+ path.addLine(to: point) |
|
| 664 |
+ } |
|
| 665 |
+ } |
|
| 666 |
+ } |
|
| 667 |
+ } |
|
| 668 |
+ |
|
| 669 |
+ private func meanMarker(in size: CGSize) -> Path {
|
|
| 670 |
+ guard let firstBin = histogram.first, let lastBin = histogram.last else {
|
|
| 671 |
+ return Path() |
|
| 672 |
+ } |
|
| 673 |
+ |
|
| 674 |
+ let minimum = firstBin.lowerBoundWatts |
|
| 675 |
+ let maximum = lastBin.upperBoundWatts |
|
| 676 |
+ let span = max(maximum - minimum, 0.000_001) |
|
| 677 |
+ let normalizedX = min(max((averagePowerWatts - minimum) / span, 0), 1) |
|
| 678 |
+ let x = normalizedX * size.width |
|
| 679 |
+ |
|
| 680 |
+ return Path { path in
|
|
| 681 |
+ path.move(to: CGPoint(x: x, y: 0)) |
|
| 682 |
+ path.addLine(to: CGPoint(x: x, y: size.height)) |
|
| 683 |
+ } |
|
| 684 |
+ } |
|
| 685 |
+} |
|
| 686 |
+ |
|
| 687 |
+struct ChargerStandbyPowerMeasurementsView: View {
|
|
| 688 |
+ @EnvironmentObject private var appData: AppData |
|
| 689 |
+ @State private var selectedMeasurementIDs = Set<UUID>() |
|
| 690 |
+ @State private var editMode: EditMode = .inactive |
|
| 691 |
+ |
|
| 692 |
+ let chargerID: UUID |
|
| 693 |
+ |
|
| 694 |
+ var body: some View {
|
|
| 695 |
+ Group {
|
|
| 696 |
+ if let charger = appData.chargedDeviceSummary(id: chargerID) {
|
|
| 697 |
+ measurementsList(for: charger) |
|
| 698 |
+ } else {
|
|
| 699 |
+ Text("This charger is no longer available.")
|
|
| 700 |
+ .foregroundColor(.secondary) |
|
| 701 |
+ .navigationTitle("Saved Measurements")
|
|
| 702 |
+ } |
|
| 703 |
+ } |
|
| 704 |
+ } |
|
| 705 |
+ |
|
| 706 |
+ @ViewBuilder |
|
| 707 |
+ private func measurementsList(for charger: ChargedDeviceSummary) -> some View {
|
|
| 708 |
+ let content = List(selection: $selectedMeasurementIDs) {
|
|
| 709 |
+ if charger.standbyPowerMeasurements.isEmpty {
|
|
| 710 |
+ Text("No standby measurements saved yet.")
|
|
| 711 |
+ .foregroundColor(.secondary) |
|
| 712 |
+ } else {
|
|
| 713 |
+ ForEach(charger.standbyPowerMeasurements) { measurement in
|
|
| 714 |
+ NavigationLink( |
|
| 715 |
+ destination: ChargerStandbyPowerMeasurementDetailView( |
|
| 716 |
+ chargerID: charger.id, |
|
| 717 |
+ measurementID: measurement.id |
|
| 718 |
+ ) |
|
| 719 |
+ ) {
|
|
| 720 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 721 |
+ HStack {
|
|
| 722 |
+ Text(measurement.endedAt.format()) |
|
| 723 |
+ .font(.subheadline.weight(.semibold)) |
|
| 724 |
+ Spacer() |
|
| 725 |
+ Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
|
|
| 726 |
+ .font(.subheadline.weight(.bold)) |
|
| 727 |
+ .monospacedDigit() |
|
| 728 |
+ } |
|
| 729 |
+ |
|
| 730 |
+ Text( |
|
| 731 |
+ "\(formattedDuration(measurement.duration)) • \(measurement.sampleCount) samples • \(standbyEnergyLabel(measurement.projectedYearlyEnergyWh)) / year" |
|
| 732 |
+ ) |
|
| 733 |
+ .font(.caption) |
|
| 734 |
+ .foregroundColor(.secondary) |
|
| 735 |
+ } |
|
| 736 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 737 |
+ } |
|
| 738 |
+ .tag(measurement.id) |
|
| 739 |
+ } |
|
| 740 |
+ .onDelete { offsets in
|
|
| 741 |
+ let measurements = charger.standbyPowerMeasurements |
|
| 742 |
+ for index in offsets {
|
|
| 743 |
+ guard measurements.indices.contains(index) else { continue }
|
|
| 744 |
+ let measurement = measurements[index] |
|
| 745 |
+ _ = appData.deleteChargerStandbyMeasurement( |
|
| 746 |
+ id: measurement.id, |
|
| 747 |
+ chargerID: charger.id |
|
| 748 |
+ ) |
|
| 749 |
+ } |
|
| 750 |
+ } |
|
| 751 |
+ } |
|
| 752 |
+ } |
|
| 753 |
+ .environment(\.editMode, $editMode) |
|
| 754 |
+ .navigationTitle("Saved Measurements")
|
|
| 755 |
+ .toolbar {
|
|
| 756 |
+ ToolbarItem(placement: .primaryAction) {
|
|
| 757 |
+ Button(editMode.isEditing ? "Done" : "Select") {
|
|
| 758 |
+ if editMode.isEditing {
|
|
| 759 |
+ editMode = .inactive |
|
| 760 |
+ selectedMeasurementIDs.removeAll() |
|
| 761 |
+ } else {
|
|
| 762 |
+ editMode = .active |
|
| 763 |
+ } |
|
| 764 |
+ } |
|
| 765 |
+ } |
|
| 766 |
+ } |
|
| 767 |
+ |
|
| 768 |
+ if selectedMeasurementIDs.isEmpty {
|
|
| 769 |
+ content |
|
| 770 |
+ } else {
|
|
| 771 |
+ content.toolbar {
|
|
| 772 |
+ ToolbarItem(placement: .destructiveAction) {
|
|
| 773 |
+ Button(role: .destructive) {
|
|
| 774 |
+ deleteMeasurements( |
|
| 775 |
+ ids: selectedMeasurementIDs, |
|
| 776 |
+ for: charger.id |
|
| 777 |
+ ) |
|
| 778 |
+ } label: {
|
|
| 779 |
+ Image(systemName: "trash") |
|
| 780 |
+ } |
|
| 781 |
+ } |
|
| 782 |
+ } |
|
| 783 |
+ } |
|
| 784 |
+ } |
|
| 785 |
+ |
|
| 786 |
+ private func standbyEnergyLabel(_ wattHours: Double) -> String {
|
|
| 787 |
+ if wattHours >= 1000 {
|
|
| 788 |
+ return "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 789 |
+ } |
|
| 790 |
+ return "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 791 |
+ } |
|
| 792 |
+ |
|
| 793 |
+ private func formattedDuration(_ duration: TimeInterval) -> String {
|
|
| 794 |
+ let formatter = DateComponentsFormatter() |
|
| 795 |
+ formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second] |
|
| 796 |
+ formatter.unitsStyle = .abbreviated |
|
| 797 |
+ formatter.zeroFormattingBehavior = .pad |
|
| 798 |
+ return formatter.string(from: max(duration, 0)) ?? "0s" |
|
| 799 |
+ } |
|
| 800 |
+ |
|
| 801 |
+ private func deleteMeasurements(ids: Set<UUID>, for chargerID: UUID) {
|
|
| 802 |
+ for id in ids {
|
|
| 803 |
+ _ = appData.deleteChargerStandbyMeasurement(id: id, chargerID: chargerID) |
|
| 804 |
+ } |
|
| 805 |
+ selectedMeasurementIDs.removeAll() |
|
| 806 |
+ editMode = .inactive |
|
| 807 |
+ } |
|
| 808 |
+} |
|
| 809 |
+ |
|
| 810 |
+struct ChargerStandbyPowerMeasurementDetailView: View {
|
|
| 811 |
+ @EnvironmentObject private var appData: AppData |
|
| 812 |
+ @Environment(\.dismiss) private var dismiss |
|
| 813 |
+ |
|
| 814 |
+ @State private var deleteConfirmationVisibility = false |
|
| 815 |
+ |
|
| 816 |
+ let chargerID: UUID |
|
| 817 |
+ let measurementID: UUID |
|
| 818 |
+ |
|
| 819 |
+ var body: some View {
|
|
| 820 |
+ Group {
|
|
| 821 |
+ if let charger = appData.chargedDeviceSummary(id: chargerID), |
|
| 822 |
+ let measurement = charger.standbyPowerMeasurements.first(where: { $0.id == measurementID }) {
|
|
| 823 |
+ ScrollView {
|
|
| 824 |
+ VStack(spacing: 18) {
|
|
| 825 |
+ MeterInfoCardView(title: charger.name, tint: .orange) {
|
|
| 826 |
+ MeterInfoRowView(label: "Saved", value: measurement.endedAt.format()) |
|
| 827 |
+ MeterInfoRowView(label: "Average", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W") |
|
| 828 |
+ MeterInfoRowView(label: "Samples", value: "\(measurement.sampleCount)") |
|
| 829 |
+ MeterInfoRowView(label: "Duration", value: formattedDuration(measurement.duration)) |
|
| 830 |
+ } |
|
| 831 |
+ |
|
| 832 |
+ ChargerStandbyPowerMeasurementSnapshotView(measurement: measurement) |
|
| 833 |
+ } |
|
| 834 |
+ .padding() |
|
| 835 |
+ } |
|
| 836 |
+ .background( |
|
| 837 |
+ LinearGradient( |
|
| 838 |
+ colors: [.orange.opacity(0.16), Color.clear], |
|
| 839 |
+ startPoint: .topLeading, |
|
| 840 |
+ endPoint: .bottomTrailing |
|
| 841 |
+ ) |
|
| 842 |
+ .ignoresSafeArea() |
|
| 843 |
+ ) |
|
| 844 |
+ .navigationTitle("Measurement")
|
|
| 845 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 846 |
+ .toolbar {
|
|
| 847 |
+ ToolbarItem(placement: .primaryAction) {
|
|
| 848 |
+ Button(role: .destructive) {
|
|
| 849 |
+ deleteConfirmationVisibility = true |
|
| 850 |
+ } label: {
|
|
| 851 |
+ Label("Delete Measurement", systemImage: "trash")
|
|
| 852 |
+ } |
|
| 853 |
+ } |
|
| 854 |
+ } |
|
| 855 |
+ .confirmationDialog( |
|
| 856 |
+ "Delete this measurement?", |
|
| 857 |
+ isPresented: $deleteConfirmationVisibility, |
|
| 858 |
+ titleVisibility: .visible |
|
| 859 |
+ ) {
|
|
| 860 |
+ Button("Delete", role: .destructive) {
|
|
| 861 |
+ let didDelete = appData.deleteChargerStandbyMeasurement( |
|
| 862 |
+ id: measurement.id, |
|
| 863 |
+ chargerID: charger.id |
|
| 864 |
+ ) |
|
| 865 |
+ if didDelete {
|
|
| 866 |
+ dismiss() |
|
| 867 |
+ } |
|
| 868 |
+ } |
|
| 869 |
+ Button("Cancel", role: .cancel) {}
|
|
| 870 |
+ } message: {
|
|
| 871 |
+ Text("This removes the saved standby measurement from the charger history and iCloud sync.")
|
|
| 872 |
+ } |
|
| 873 |
+ } else {
|
|
| 874 |
+ Text("This measurement is no longer available.")
|
|
| 875 |
+ .foregroundColor(.secondary) |
|
| 876 |
+ .navigationTitle("Measurement")
|
|
| 877 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 878 |
+ } |
|
| 879 |
+ } |
|
| 880 |
+ } |
|
| 881 |
+ |
|
| 882 |
+ private func formattedDuration(_ duration: TimeInterval) -> String {
|
|
| 883 |
+ let formatter = DateComponentsFormatter() |
|
| 884 |
+ formatter.allowedUnits = duration >= 3600 ? [.hour, .minute, .second] : [.minute, .second] |
|
| 885 |
+ formatter.unitsStyle = .abbreviated |
|
| 886 |
+ formatter.zeroFormattingBehavior = .pad |
|
| 887 |
+ return formatter.string(from: max(duration, 0)) ?? "0s" |
|
| 888 |
+ } |
|
| 889 |
+} |
|
| 890 |
+ |
|
| 891 |
+private struct ChargerStandbyPowerMeasurementSnapshotView: View {
|
|
| 892 |
+ let measurement: ChargerStandbyPowerMeasurementSummary |
|
| 893 |
+ |
|
| 894 |
+ var body: some View {
|
|
| 895 |
+ VStack(spacing: 18) {
|
|
| 896 |
+ stabilityCard |
|
| 897 |
+ projectionCard |
|
| 898 |
+ distributionCard |
|
| 899 |
+ statisticsCard |
|
| 900 |
+ } |
|
| 901 |
+ } |
|
| 902 |
+ |
|
| 903 |
+ private var stabilityCard: some View {
|
|
| 904 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 905 |
+ HStack {
|
|
| 906 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 907 |
+ Text(measurement.isStable ? "Enough Samples" : "Still Settling") |
|
| 908 |
+ .font(.headline) |
|
| 909 |
+ Text("Saved \(measurement.endedAt.format())")
|
|
| 910 |
+ .font(.caption) |
|
| 911 |
+ .foregroundColor(.secondary) |
|
| 912 |
+ } |
|
| 913 |
+ |
|
| 914 |
+ Spacer() |
|
| 915 |
+ |
|
| 916 |
+ Text(measurement.isStable ? "Ready" : "Live") |
|
| 917 |
+ .font(.caption.weight(.semibold)) |
|
| 918 |
+ .padding(.horizontal, 10) |
|
| 919 |
+ .padding(.vertical, 6) |
|
| 920 |
+ .foregroundColor(measurement.isStable ? .green : .orange) |
|
| 921 |
+ .meterCard( |
|
| 922 |
+ tint: measurement.isStable ? .green : .orange, |
|
| 923 |
+ fillOpacity: 0.10, |
|
| 924 |
+ strokeOpacity: 0.16, |
|
| 925 |
+ cornerRadius: 999 |
|
| 926 |
+ ) |
|
| 927 |
+ } |
|
| 928 |
+ |
|
| 929 |
+ Text("\(measurement.averagePowerWatts.format(decimalDigits: 3)) W")
|
|
| 930 |
+ .font(.system(.largeTitle, design: .rounded).weight(.bold)) |
|
| 931 |
+ .monospacedDigit() |
|
| 932 |
+ |
|
| 933 |
+ Text( |
|
| 934 |
+ "Recent drift: \((measurement.stabilityDeltaWatts * 1000).format(decimalDigits: 2)) mW, tolerance \((measurement.stabilityToleranceWatts * 1000).format(decimalDigits: 2)) mW over \(measurement.sampleCount) samples." |
|
| 935 |
+ ) |
|
| 936 |
+ .font(.footnote) |
|
| 937 |
+ .foregroundColor(.secondary) |
|
| 938 |
+ } |
|
| 939 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 940 |
+ .padding(18) |
|
| 941 |
+ .meterCard( |
|
| 942 |
+ tint: measurement.isStable ? .green : .orange, |
|
| 943 |
+ fillOpacity: 0.18, |
|
| 944 |
+ strokeOpacity: 0.24 |
|
| 945 |
+ ) |
|
| 946 |
+ } |
|
| 947 |
+ |
|
| 948 |
+ private var projectionCard: some View {
|
|
| 949 |
+ MeterInfoCardView( |
|
| 950 |
+ title: "Consumption Projection", |
|
| 951 |
+ infoMessage: "These projections extrapolate only from the measured standby average. They do not say anything about charger behaviour under load.", |
|
| 952 |
+ tint: .teal |
|
| 953 |
+ ) {
|
|
| 954 |
+ MeterInfoRowView(label: "Average Power", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W") |
|
| 955 |
+ MeterInfoRowView(label: "24 Hours", value: standbyEnergyLabel(measurement.projectedDailyEnergyWh)) |
|
| 956 |
+ MeterInfoRowView(label: "7 Days", value: standbyEnergyLabel(measurement.projectedWeeklyEnergyWh)) |
|
| 957 |
+ MeterInfoRowView(label: "30 Days", value: standbyEnergyLabel(measurement.projectedMonthlyEnergyWh)) |
|
| 958 |
+ MeterInfoRowView(label: "1 Year", value: standbyEnergyLabel(measurement.projectedYearlyEnergyWh)) |
|
| 959 |
+ } |
|
| 960 |
+ } |
|
| 961 |
+ |
|
| 962 |
+ private var distributionCard: some View {
|
|
| 963 |
+ StandbyPowerDistributionCard( |
|
| 964 |
+ histogram: measurement.storedHistogram, |
|
| 965 |
+ averagePowerWatts: measurement.averagePowerWatts, |
|
| 966 |
+ standardDeviationPowerWatts: measurement.standardDeviationPowerWatts, |
|
| 967 |
+ tint: .orange, |
|
| 968 |
+ showExport: true |
|
| 969 |
+ ) |
|
| 970 |
+ } |
|
| 971 |
+ |
|
| 972 |
+ private var statisticsCard: some View {
|
|
| 973 |
+ MeterInfoCardView(title: "Interesting Stats", tint: .indigo) {
|
|
| 974 |
+ MeterInfoRowView(label: "Median", value: "\(measurement.medianPowerWatts.format(decimalDigits: 3)) W") |
|
| 975 |
+ MeterInfoRowView(label: "Minimum", value: "\(measurement.minimumPowerWatts.format(decimalDigits: 3)) W") |
|
| 976 |
+ MeterInfoRowView(label: "Maximum", value: "\(measurement.maximumPowerWatts.format(decimalDigits: 3)) W") |
|
| 977 |
+ MeterInfoRowView(label: "Spread σ", value: "\(measurement.standardDeviationPowerWatts.format(decimalDigits: 4)) W") |
|
| 978 |
+ MeterInfoRowView(label: "Variation", value: "\(Int((measurement.coefficientOfVariation * 100).rounded()))%") |
|
| 979 |
+ MeterInfoRowView(label: "Mean Current", value: "\(measurement.averageCurrentAmps.format(decimalDigits: 3)) A") |
|
| 980 |
+ MeterInfoRowView(label: "Mean Voltage", value: "\(measurement.averageVoltageVolts.format(decimalDigits: 3)) V") |
|
| 981 |
+ MeterInfoRowView(label: "Power Density", value: "\(measurement.averagePowerWatts.format(decimalDigits: 3)) W steady") |
|
| 982 |
+ } |
|
| 983 |
+ } |
|
| 984 |
+ |
|
| 985 |
+ private func standbyEnergyLabel(_ wattHours: Double) -> String {
|
|
| 986 |
+ if wattHours >= 1000 {
|
|
| 987 |
+ return "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 988 |
+ } |
|
| 989 |
+ return "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 990 |
+ } |
|
| 991 |
+} |
|
@@ -0,0 +1,59 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterLiveTabView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterLiveTabView: View {
|
|
| 9 |
+ let size: CGSize |
|
| 10 |
+ let isLandscape: Bool |
|
| 11 |
+ |
|
| 12 |
+ private let pageHorizontalPadding: CGFloat = 12 |
|
| 13 |
+ private let pageVerticalPadding: CGFloat = 12 |
|
| 14 |
+ private let contentCardPadding: CGFloat = 16 |
|
| 15 |
+ |
|
| 16 |
+ var body: some View {
|
|
| 17 |
+ Group {
|
|
| 18 |
+ if isLandscape {
|
|
| 19 |
+ landscapeFace {
|
|
| 20 |
+ MeterLiveContentView(compactLayout: true, availableSize: size) |
|
| 21 |
+ .padding(contentCardPadding) |
|
| 22 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 23 |
+ .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 24 |
+ } |
|
| 25 |
+ } else {
|
|
| 26 |
+ portraitFace {
|
|
| 27 |
+ MeterLiveContentView(compactLayout: prefersCompactPortraitLayout, availableSize: size) |
|
| 28 |
+ .padding(contentCardPadding) |
|
| 29 |
+ .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) |
|
| 30 |
+ } |
|
| 31 |
+ } |
|
| 32 |
+ } |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ @EnvironmentObject private var meter: Meter |
|
| 36 |
+ |
|
| 37 |
+ private var prefersCompactPortraitLayout: Bool {
|
|
| 38 |
+ size.height < 760 || size.width < 380 |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 42 |
+ ScrollView {
|
|
| 43 |
+ content() |
|
| 44 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 45 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 46 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 47 |
+ } |
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
| 51 |
+ ScrollView {
|
|
| 52 |
+ content() |
|
| 53 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 54 |
+ .padding(.horizontal, pageHorizontalPadding) |
|
| 55 |
+ .padding(.vertical, pageVerticalPadding) |
|
| 56 |
+ } |
|
| 57 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 58 |
+ } |
|
| 59 |
+} |
|
@@ -0,0 +1,60 @@ |
||
| 1 |
+// |
|
| 2 |
+// LoadResistanceIconView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct LoadResistanceIconView: View {
|
|
| 9 |
+ let color: Color |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ GeometryReader { proxy in
|
|
| 13 |
+ let width = proxy.size.width |
|
| 14 |
+ let height = proxy.size.height |
|
| 15 |
+ let midY = height / 2 |
|
| 16 |
+ let startX = width * 0.10 |
|
| 17 |
+ let endX = width * 0.90 |
|
| 18 |
+ let boxMinX = width * 0.28 |
|
| 19 |
+ let boxMaxX = width * 0.72 |
|
| 20 |
+ let boxHeight = height * 0.34 |
|
| 21 |
+ let boxRect = CGRect( |
|
| 22 |
+ x: boxMinX, |
|
| 23 |
+ y: midY - (boxHeight / 2), |
|
| 24 |
+ width: boxMaxX - boxMinX, |
|
| 25 |
+ height: boxHeight |
|
| 26 |
+ ) |
|
| 27 |
+ let strokeWidth = max(1.2, height * 0.055) |
|
| 28 |
+ |
|
| 29 |
+ ZStack {
|
|
| 30 |
+ Path { path in
|
|
| 31 |
+ path.move(to: CGPoint(x: startX, y: midY)) |
|
| 32 |
+ path.addLine(to: CGPoint(x: boxRect.minX, y: midY)) |
|
| 33 |
+ path.move(to: CGPoint(x: boxRect.maxX, y: midY)) |
|
| 34 |
+ path.addLine(to: CGPoint(x: endX, y: midY)) |
|
| 35 |
+ } |
|
| 36 |
+ .stroke( |
|
| 37 |
+ color, |
|
| 38 |
+ style: StrokeStyle( |
|
| 39 |
+ lineWidth: strokeWidth, |
|
| 40 |
+ lineCap: .round, |
|
| 41 |
+ lineJoin: .round |
|
| 42 |
+ ) |
|
| 43 |
+ ) |
|
| 44 |
+ |
|
| 45 |
+ Path { path in
|
|
| 46 |
+ path.addRect(boxRect) |
|
| 47 |
+ } |
|
| 48 |
+ .stroke( |
|
| 49 |
+ color, |
|
| 50 |
+ style: StrokeStyle( |
|
| 51 |
+ lineWidth: strokeWidth, |
|
| 52 |
+ lineCap: .round, |
|
| 53 |
+ lineJoin: .round |
|
| 54 |
+ ) |
|
| 55 |
+ ) |
|
| 56 |
+ } |
|
| 57 |
+ } |
|
| 58 |
+ .padding(4) |
|
| 59 |
+ } |
|
| 60 |
+} |
|
@@ -0,0 +1,909 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterLiveContentView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 09/03/2020. |
|
| 6 |
+// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
+// |
|
| 8 |
+ |
|
| 9 |
+import SwiftUI |
|
| 10 |
+ |
|
| 11 |
+struct MeterLiveContentView: View {
|
|
| 12 |
+ @EnvironmentObject private var appData: AppData |
|
| 13 |
+ @EnvironmentObject private var meter: Meter |
|
| 14 |
+ @State private var powerAverageSheetVisibility = false |
|
| 15 |
+ @State private var energyProjectionSheetVisibility = false |
|
| 16 |
+ @State private var rssiHistorySheetVisibility = false |
|
| 17 |
+ var compactLayout: Bool = false |
|
| 18 |
+ var availableSize: CGSize? = nil |
|
| 19 |
+ |
|
| 20 |
+ var body: some View {
|
|
| 21 |
+ VStack(alignment: .leading, spacing: 16) {
|
|
| 22 |
+ HStack {
|
|
| 23 |
+ Text("Live Data")
|
|
| 24 |
+ .font(.headline) |
|
| 25 |
+ Spacer() |
|
| 26 |
+ statusBadge |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ MeterInfoCardView(title: "Detected Meter", tint: .indigo) {
|
|
| 30 |
+ MeterInfoRowView(label: "Name", value: meter.name.isEmpty ? "Meter" : meter.name) |
|
| 31 |
+ MeterInfoRowView(label: "Model", value: meter.deviceModelSummary) |
|
| 32 |
+ MeterInfoRowView(label: "Advertised Model", value: meter.modelString) |
|
| 33 |
+ MeterInfoRowView(label: "MAC", value: meter.btSerial.macAddress.description) |
|
| 34 |
+ MeterInfoRowView(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen)) |
|
| 35 |
+ MeterInfoRowView(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt)) |
|
| 36 |
+ } |
|
| 37 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 38 |
+ |
|
| 39 |
+ LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
|
|
| 40 |
+ if shouldShowVoltageCard {
|
|
| 41 |
+ liveMetricCard( |
|
| 42 |
+ title: "Voltage", |
|
| 43 |
+ symbol: "bolt.fill", |
|
| 44 |
+ color: .green, |
|
| 45 |
+ value: "\(meter.voltage.format(decimalDigits: 3)) V", |
|
| 46 |
+ range: metricRange( |
|
| 47 |
+ min: meter.measurements.voltage.context.minValue, |
|
| 48 |
+ max: meter.measurements.voltage.context.maxValue, |
|
| 49 |
+ unit: "V" |
|
| 50 |
+ ) |
|
| 51 |
+ ) |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ if shouldShowCurrentCard {
|
|
| 55 |
+ liveMetricCard( |
|
| 56 |
+ title: "Current", |
|
| 57 |
+ symbol: "waveform.path.ecg", |
|
| 58 |
+ color: .blue, |
|
| 59 |
+ value: "\(meter.current.format(decimalDigits: 3)) A", |
|
| 60 |
+ range: metricRange( |
|
| 61 |
+ min: meter.measurements.current.context.minValue, |
|
| 62 |
+ max: meter.measurements.current.context.maxValue, |
|
| 63 |
+ unit: "A" |
|
| 64 |
+ ) |
|
| 65 |
+ ) |
|
| 66 |
+ } |
|
| 67 |
+ |
|
| 68 |
+ if shouldShowPowerCard {
|
|
| 69 |
+ liveMetricCard( |
|
| 70 |
+ title: "Power", |
|
| 71 |
+ symbol: "flame.fill", |
|
| 72 |
+ color: .pink, |
|
| 73 |
+ value: "\(meter.power.format(decimalDigits: 3)) W", |
|
| 74 |
+ range: metricRange( |
|
| 75 |
+ min: meter.measurements.power.context.minValue, |
|
| 76 |
+ max: meter.measurements.power.context.maxValue, |
|
| 77 |
+ unit: "W" |
|
| 78 |
+ ), |
|
| 79 |
+ action: {
|
|
| 80 |
+ powerAverageSheetVisibility = true |
|
| 81 |
+ } |
|
| 82 |
+ ) |
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 85 |
+ if shouldShowEnergyCard {
|
|
| 86 |
+ liveMetricCard( |
|
| 87 |
+ title: "Accumulated Energy", |
|
| 88 |
+ symbol: "battery.100.bolt", |
|
| 89 |
+ color: .teal, |
|
| 90 |
+ value: "\(liveBufferedEnergyValue.format(decimalDigits: 3)) Wh", |
|
| 91 |
+ detailText: "Tap for monthly and yearly projections", |
|
| 92 |
+ action: {
|
|
| 93 |
+ energyProjectionSheetVisibility = true |
|
| 94 |
+ } |
|
| 95 |
+ ) |
|
| 96 |
+ } |
|
| 97 |
+ |
|
| 98 |
+ if shouldShowTemperatureCard {
|
|
| 99 |
+ liveMetricCard( |
|
| 100 |
+ title: "Temperature", |
|
| 101 |
+ symbol: "thermometer.medium", |
|
| 102 |
+ color: .orange, |
|
| 103 |
+ value: meter.primaryTemperatureDescription, |
|
| 104 |
+ range: temperatureRange( |
|
| 105 |
+ min: meter.measurements.temperature.context.minValue, |
|
| 106 |
+ max: meter.measurements.temperature.context.maxValue |
|
| 107 |
+ ) |
|
| 108 |
+ ) |
|
| 109 |
+ } |
|
| 110 |
+ |
|
| 111 |
+ if shouldShowLoadCard {
|
|
| 112 |
+ liveMetricCard( |
|
| 113 |
+ title: "Load", |
|
| 114 |
+ customSymbol: AnyView(LoadResistanceIconView(color: .yellow)), |
|
| 115 |
+ color: .yellow, |
|
| 116 |
+ value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
|
|
| 117 |
+ detailText: "Measured resistance" |
|
| 118 |
+ ) |
|
| 119 |
+ } |
|
| 120 |
+ |
|
| 121 |
+ liveMetricCard( |
|
| 122 |
+ title: "RSSI", |
|
| 123 |
+ symbol: "dot.radiowaves.left.and.right", |
|
| 124 |
+ color: .mint, |
|
| 125 |
+ value: "\(meter.btSerial.averageRSSI) dBm", |
|
| 126 |
+ range: metricRange( |
|
| 127 |
+ min: meter.measurements.rssi.context.minValue, |
|
| 128 |
+ max: meter.measurements.rssi.context.maxValue, |
|
| 129 |
+ unit: "dBm", |
|
| 130 |
+ decimalDigits: 0 |
|
| 131 |
+ ), |
|
| 132 |
+ valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold), |
|
| 133 |
+ action: {
|
|
| 134 |
+ rssiHistorySheetVisibility = true |
|
| 135 |
+ } |
|
| 136 |
+ ) |
|
| 137 |
+ |
|
| 138 |
+ if meter.supportsChargerDetection && hasLiveMetrics {
|
|
| 139 |
+ liveMetricCard( |
|
| 140 |
+ title: "Detected Charger", |
|
| 141 |
+ symbol: "powerplug.fill", |
|
| 142 |
+ color: .indigo, |
|
| 143 |
+ value: meter.chargerTypeDescription, |
|
| 144 |
+ detailText: "Source handshake", |
|
| 145 |
+ valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold), |
|
| 146 |
+ valueLineLimit: 2, |
|
| 147 |
+ valueMonospacedDigits: false, |
|
| 148 |
+ valueMinimumScaleFactor: 0.72 |
|
| 149 |
+ ) |
|
| 150 |
+ } |
|
| 151 |
+ } |
|
| 152 |
+ } |
|
| 153 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 154 |
+ .sheet(isPresented: $powerAverageSheetVisibility) {
|
|
| 155 |
+ PowerAverageSheetView(visibility: $powerAverageSheetVisibility) |
|
| 156 |
+ .environmentObject(meter.measurements) |
|
| 157 |
+ } |
|
| 158 |
+ .sheet(isPresented: $energyProjectionSheetVisibility) {
|
|
| 159 |
+ EnergyProjectionSheetView(visibility: $energyProjectionSheetVisibility) |
|
| 160 |
+ .environmentObject(meter.measurements) |
|
| 161 |
+ .environmentObject(meter) |
|
| 162 |
+ } |
|
| 163 |
+ .sheet(isPresented: $rssiHistorySheetVisibility) {
|
|
| 164 |
+ RSSIHistorySheetView(visibility: $rssiHistorySheetVisibility) |
|
| 165 |
+ .environmentObject(meter.measurements) |
|
| 166 |
+ } |
|
| 167 |
+ } |
|
| 168 |
+ |
|
| 169 |
+ private var hasLiveMetrics: Bool {
|
|
| 170 |
+ meter.operationalState == .dataIsAvailable |
|
| 171 |
+ } |
|
| 172 |
+ |
|
| 173 |
+ private var shouldShowVoltageCard: Bool {
|
|
| 174 |
+ hasLiveMetrics && meter.measurements.voltage.context.isValid && meter.voltage.isFinite |
|
| 175 |
+ } |
|
| 176 |
+ |
|
| 177 |
+ private var shouldShowCurrentCard: Bool {
|
|
| 178 |
+ hasLiveMetrics && meter.measurements.current.context.isValid && meter.current.isFinite |
|
| 179 |
+ } |
|
| 180 |
+ |
|
| 181 |
+ private var shouldShowPowerCard: Bool {
|
|
| 182 |
+ hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite |
|
| 183 |
+ } |
|
| 184 |
+ |
|
| 185 |
+ private var shouldShowEnergyCard: Bool {
|
|
| 186 |
+ hasLiveMetrics && meter.measurements.energy.context.isValid && liveBufferedEnergyValue.isFinite |
|
| 187 |
+ } |
|
| 188 |
+ |
|
| 189 |
+ private var shouldShowTemperatureCard: Bool {
|
|
| 190 |
+ hasLiveMetrics && meter.displayedTemperatureValue.isFinite |
|
| 191 |
+ } |
|
| 192 |
+ |
|
| 193 |
+ private var liveBufferedEnergyValue: Double {
|
|
| 194 |
+ meter.measurements.energy.samplePoints.last?.value ?? 0 |
|
| 195 |
+ } |
|
| 196 |
+ |
|
| 197 |
+ private var shouldShowLoadCard: Bool {
|
|
| 198 |
+ hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0 |
|
| 199 |
+ } |
|
| 200 |
+ |
|
| 201 |
+ private var liveMetricColumns: [GridItem] {
|
|
| 202 |
+ if compactLayout {
|
|
| 203 |
+ return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3) |
|
| 204 |
+ } |
|
| 205 |
+ |
|
| 206 |
+ return [GridItem(.flexible()), GridItem(.flexible())] |
|
| 207 |
+ } |
|
| 208 |
+ |
|
| 209 |
+ private var statusBadge: some View {
|
|
| 210 |
+ Text(meter.operationalState == .dataIsAvailable ? "Live" : "Waiting") |
|
| 211 |
+ .font(.caption.weight(.semibold)) |
|
| 212 |
+ .padding(.horizontal, 10) |
|
| 213 |
+ .padding(.vertical, 6) |
|
| 214 |
+ .foregroundColor(meter.operationalState == .dataIsAvailable ? .green : .secondary) |
|
| 215 |
+ .meterCard( |
|
| 216 |
+ tint: meter.operationalState == .dataIsAvailable ? .green : .secondary, |
|
| 217 |
+ fillOpacity: 0.12, |
|
| 218 |
+ strokeOpacity: 0.16, |
|
| 219 |
+ cornerRadius: 999 |
|
| 220 |
+ ) |
|
| 221 |
+ } |
|
| 222 |
+ |
|
| 223 |
+ |
|
| 224 |
+ private var showsCompactMetricRange: Bool {
|
|
| 225 |
+ compactLayout && (availableSize?.height ?? 0) >= 380 |
|
| 226 |
+ } |
|
| 227 |
+ |
|
| 228 |
+ private var shouldShowMetricRange: Bool {
|
|
| 229 |
+ !compactLayout || showsCompactMetricRange |
|
| 230 |
+ } |
|
| 231 |
+ |
|
| 232 |
+ private func liveMetricCard( |
|
| 233 |
+ title: String, |
|
| 234 |
+ symbol: String? = nil, |
|
| 235 |
+ customSymbol: AnyView? = nil, |
|
| 236 |
+ color: Color, |
|
| 237 |
+ value: String, |
|
| 238 |
+ range: MeterLiveMetricRange? = nil, |
|
| 239 |
+ detailText: String? = nil, |
|
| 240 |
+ valueFont: Font? = nil, |
|
| 241 |
+ valueLineLimit: Int = 1, |
|
| 242 |
+ valueMonospacedDigits: Bool = true, |
|
| 243 |
+ valueMinimumScaleFactor: CGFloat = 0.85, |
|
| 244 |
+ action: (() -> Void)? = nil |
|
| 245 |
+ ) -> some View {
|
|
| 246 |
+ let cardContent = VStack(alignment: .leading, spacing: 10) {
|
|
| 247 |
+ HStack(spacing: compactLayout ? 8 : 10) {
|
|
| 248 |
+ Group {
|
|
| 249 |
+ if let customSymbol {
|
|
| 250 |
+ customSymbol |
|
| 251 |
+ } else if let symbol {
|
|
| 252 |
+ Image(systemName: symbol) |
|
| 253 |
+ .font(.system(size: compactLayout ? 14 : 15, weight: .semibold)) |
|
| 254 |
+ .foregroundColor(color) |
|
| 255 |
+ } |
|
| 256 |
+ } |
|
| 257 |
+ .frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34) |
|
| 258 |
+ .background(Circle().fill(color.opacity(0.12))) |
|
| 259 |
+ |
|
| 260 |
+ Text(title) |
|
| 261 |
+ .font((compactLayout ? Font.caption : .subheadline).weight(.semibold)) |
|
| 262 |
+ .foregroundColor(.secondary) |
|
| 263 |
+ .lineLimit(1) |
|
| 264 |
+ |
|
| 265 |
+ Spacer(minLength: 0) |
|
| 266 |
+ } |
|
| 267 |
+ |
|
| 268 |
+ Group {
|
|
| 269 |
+ if valueMonospacedDigits {
|
|
| 270 |
+ Text(value) |
|
| 271 |
+ .monospacedDigit() |
|
| 272 |
+ } else {
|
|
| 273 |
+ Text(value) |
|
| 274 |
+ } |
|
| 275 |
+ } |
|
| 276 |
+ .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold)) |
|
| 277 |
+ .lineLimit(valueLineLimit) |
|
| 278 |
+ .minimumScaleFactor(valueMinimumScaleFactor) |
|
| 279 |
+ |
|
| 280 |
+ if shouldShowMetricRange {
|
|
| 281 |
+ if let range {
|
|
| 282 |
+ metricRangeTable(range) |
|
| 283 |
+ } else if let detailText, !detailText.isEmpty {
|
|
| 284 |
+ Text(detailText) |
|
| 285 |
+ .font(.caption) |
|
| 286 |
+ .foregroundColor(.secondary) |
|
| 287 |
+ .lineLimit(2) |
|
| 288 |
+ } |
|
| 289 |
+ } |
|
| 290 |
+ } |
|
| 291 |
+ .frame( |
|
| 292 |
+ maxWidth: .infinity, |
|
| 293 |
+ minHeight: compactLayout ? (shouldShowMetricRange ? 132 : 96) : 128, |
|
| 294 |
+ alignment: .leading |
|
| 295 |
+ ) |
|
| 296 |
+ .padding(compactLayout ? 12 : 16) |
|
| 297 |
+ .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12) |
|
| 298 |
+ |
|
| 299 |
+ if let action {
|
|
| 300 |
+ return AnyView( |
|
| 301 |
+ Button(action: action) {
|
|
| 302 |
+ cardContent |
|
| 303 |
+ } |
|
| 304 |
+ .buttonStyle(.plain) |
|
| 305 |
+ ) |
|
| 306 |
+ } |
|
| 307 |
+ |
|
| 308 |
+ return AnyView(cardContent) |
|
| 309 |
+ } |
|
| 310 |
+ |
|
| 311 |
+ private func metricRangeTable(_ range: MeterLiveMetricRange) -> some View {
|
|
| 312 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 313 |
+ HStack(spacing: 12) {
|
|
| 314 |
+ Text(range.minLabel) |
|
| 315 |
+ Spacer(minLength: 0) |
|
| 316 |
+ Text(range.maxLabel) |
|
| 317 |
+ } |
|
| 318 |
+ .font(.caption2.weight(.semibold)) |
|
| 319 |
+ .foregroundColor(.secondary) |
|
| 320 |
+ |
|
| 321 |
+ HStack(spacing: 12) {
|
|
| 322 |
+ Text(range.minValue) |
|
| 323 |
+ .monospacedDigit() |
|
| 324 |
+ Spacer(minLength: 0) |
|
| 325 |
+ Text(range.maxValue) |
|
| 326 |
+ .monospacedDigit() |
|
| 327 |
+ } |
|
| 328 |
+ .font(.caption.weight(.medium)) |
|
| 329 |
+ .foregroundColor(.primary) |
|
| 330 |
+ } |
|
| 331 |
+ } |
|
| 332 |
+ |
|
| 333 |
+ private func metricRange(min: Double, max: Double, unit: String, decimalDigits: Int = 3) -> MeterLiveMetricRange? {
|
|
| 334 |
+ guard min.isFinite, max.isFinite else { return nil }
|
|
| 335 |
+ |
|
| 336 |
+ return MeterLiveMetricRange( |
|
| 337 |
+ minLabel: "Min", |
|
| 338 |
+ maxLabel: "Max", |
|
| 339 |
+ minValue: "\(min.format(decimalDigits: decimalDigits)) \(unit)", |
|
| 340 |
+ maxValue: "\(max.format(decimalDigits: decimalDigits)) \(unit)" |
|
| 341 |
+ ) |
|
| 342 |
+ } |
|
| 343 |
+ |
|
| 344 |
+ private func temperatureRange(min: Double, max: Double) -> MeterLiveMetricRange? {
|
|
| 345 |
+ guard min.isFinite, max.isFinite else { return nil }
|
|
| 346 |
+ |
|
| 347 |
+ let unitSuffix = temperatureUnitSuffix() |
|
| 348 |
+ |
|
| 349 |
+ return MeterLiveMetricRange( |
|
| 350 |
+ minLabel: "Min", |
|
| 351 |
+ maxLabel: "Max", |
|
| 352 |
+ minValue: "\(min.format(decimalDigits: 0))\(unitSuffix)", |
|
| 353 |
+ maxValue: "\(max.format(decimalDigits: 0))\(unitSuffix)" |
|
| 354 |
+ ) |
|
| 355 |
+ } |
|
| 356 |
+ |
|
| 357 |
+ private func meterHistoryText(for date: Date?) -> String {
|
|
| 358 |
+ guard let date else {
|
|
| 359 |
+ return "Never" |
|
| 360 |
+ } |
|
| 361 |
+ return date.format(as: "yyyy-MM-dd HH:mm") |
|
| 362 |
+ } |
|
| 363 |
+ |
|
| 364 |
+ private func temperatureUnitSuffix() -> String {
|
|
| 365 |
+ if meter.supportsManualTemperatureUnitSelection {
|
|
| 366 |
+ return "°" |
|
| 367 |
+ } |
|
| 368 |
+ |
|
| 369 |
+ let locale = Locale.autoupdatingCurrent |
|
| 370 |
+ if #available(iOS 16.0, *) {
|
|
| 371 |
+ switch locale.measurementSystem {
|
|
| 372 |
+ case .us: |
|
| 373 |
+ return "°F" |
|
| 374 |
+ default: |
|
| 375 |
+ return "°C" |
|
| 376 |
+ } |
|
| 377 |
+ } |
|
| 378 |
+ |
|
| 379 |
+ let regionCode = locale.regionCode ?? "" |
|
| 380 |
+ let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"] |
|
| 381 |
+ return fahrenheitRegions.contains(regionCode) ? "°F" : "°C" |
|
| 382 |
+ } |
|
| 383 |
+} |
|
| 384 |
+ |
|
| 385 |
+private struct PowerAverageSheetView: View {
|
|
| 386 |
+ @EnvironmentObject private var measurements: Measurements |
|
| 387 |
+ |
|
| 388 |
+ @Binding var visibility: Bool |
|
| 389 |
+ |
|
| 390 |
+ @State private var selectedSampleCount: Int = 20 |
|
| 391 |
+ |
|
| 392 |
+ var body: some View {
|
|
| 393 |
+ let bufferedSamples = measurements.powerSampleCount() |
|
| 394 |
+ |
|
| 395 |
+ NavigationView {
|
|
| 396 |
+ ScrollView {
|
|
| 397 |
+ VStack(alignment: .leading, spacing: 14) {
|
|
| 398 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 399 |
+ HStack(spacing: 8) {
|
|
| 400 |
+ Text("Power Average")
|
|
| 401 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 402 |
+ ContextInfoButton( |
|
| 403 |
+ title: "Power Average", |
|
| 404 |
+ message: "Inspect the recent power buffer, choose how many values to include, and compute the average power over that window." |
|
| 405 |
+ ) |
|
| 406 |
+ } |
|
| 407 |
+ } |
|
| 408 |
+ .padding(18) |
|
| 409 |
+ .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 410 |
+ |
|
| 411 |
+ MeterInfoCardView(title: "Average Calculator", tint: .pink) {
|
|
| 412 |
+ if bufferedSamples == 0 {
|
|
| 413 |
+ Text("No power samples are available yet.")
|
|
| 414 |
+ .font(.footnote) |
|
| 415 |
+ .foregroundColor(.secondary) |
|
| 416 |
+ } else {
|
|
| 417 |
+ VStack(alignment: .leading, spacing: 14) {
|
|
| 418 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 419 |
+ Text("Values used")
|
|
| 420 |
+ .font(.subheadline.weight(.semibold)) |
|
| 421 |
+ |
|
| 422 |
+ Picker("Values used", selection: selectedSampleCountBinding(bufferedSamples: bufferedSamples)) {
|
|
| 423 |
+ ForEach(availableSampleOptions(bufferedSamples: bufferedSamples), id: \.self) { option in
|
|
| 424 |
+ Text(sampleOptionTitle(option, bufferedSamples: bufferedSamples)).tag(option) |
|
| 425 |
+ } |
|
| 426 |
+ } |
|
| 427 |
+ .pickerStyle(.menu) |
|
| 428 |
+ } |
|
| 429 |
+ |
|
| 430 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 431 |
+ Text(averagePowerLabel(bufferedSamples: bufferedSamples)) |
|
| 432 |
+ .font(.system(.title2, design: .rounded).weight(.bold)) |
|
| 433 |
+ .monospacedDigit() |
|
| 434 |
+ |
|
| 435 |
+ Text("Buffered samples: \(bufferedSamples)")
|
|
| 436 |
+ .font(.caption) |
|
| 437 |
+ .foregroundColor(.secondary) |
|
| 438 |
+ } |
|
| 439 |
+ } |
|
| 440 |
+ } |
|
| 441 |
+ } |
|
| 442 |
+ |
|
| 443 |
+ MeterInfoCardView( |
|
| 444 |
+ title: "Buffer Actions", |
|
| 445 |
+ infoMessage: "Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.", |
|
| 446 |
+ tint: .secondary |
|
| 447 |
+ ) {
|
|
| 448 |
+ Button("Reset Buffer") {
|
|
| 449 |
+ measurements.resetSeries() |
|
| 450 |
+ selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false)) |
|
| 451 |
+ } |
|
| 452 |
+ .foregroundColor(.red) |
|
| 453 |
+ } |
|
| 454 |
+ } |
|
| 455 |
+ .padding() |
|
| 456 |
+ } |
|
| 457 |
+ .background( |
|
| 458 |
+ LinearGradient( |
|
| 459 |
+ colors: [.pink.opacity(0.14), Color.clear], |
|
| 460 |
+ startPoint: .topLeading, |
|
| 461 |
+ endPoint: .bottomTrailing |
|
| 462 |
+ ) |
|
| 463 |
+ .ignoresSafeArea() |
|
| 464 |
+ ) |
|
| 465 |
+ .navigationBarItems( |
|
| 466 |
+ leading: Button("Done") { visibility.toggle() }
|
|
| 467 |
+ ) |
|
| 468 |
+ .navigationBarTitle("Power", displayMode: .inline)
|
|
| 469 |
+ } |
|
| 470 |
+ .navigationViewStyle(StackNavigationViewStyle()) |
|
| 471 |
+ .onAppear {
|
|
| 472 |
+ selectedSampleCount = defaultSampleCount(bufferedSamples: bufferedSamples) |
|
| 473 |
+ } |
|
| 474 |
+ .onChange(of: bufferedSamples) { newValue in
|
|
| 475 |
+ selectedSampleCount = min(max(1, selectedSampleCount), max(1, newValue)) |
|
| 476 |
+ } |
|
| 477 |
+ } |
|
| 478 |
+ |
|
| 479 |
+ private func availableSampleOptions(bufferedSamples: Int) -> [Int] {
|
|
| 480 |
+ guard bufferedSamples > 0 else { return [] }
|
|
| 481 |
+ |
|
| 482 |
+ let filtered = measurements.averagePowerSampleOptions.filter { $0 < bufferedSamples }
|
|
| 483 |
+ return (filtered + [bufferedSamples]).sorted() |
|
| 484 |
+ } |
|
| 485 |
+ |
|
| 486 |
+ private func defaultSampleCount(bufferedSamples: Int) -> Int {
|
|
| 487 |
+ guard bufferedSamples > 0 else { return 20 }
|
|
| 488 |
+ return min(20, bufferedSamples) |
|
| 489 |
+ } |
|
| 490 |
+ |
|
| 491 |
+ private func selectedSampleCountBinding(bufferedSamples: Int) -> Binding<Int> {
|
|
| 492 |
+ Binding( |
|
| 493 |
+ get: {
|
|
| 494 |
+ let availableOptions = availableSampleOptions(bufferedSamples: bufferedSamples) |
|
| 495 |
+ guard !availableOptions.isEmpty else { return defaultSampleCount(bufferedSamples: bufferedSamples) }
|
|
| 496 |
+ if availableOptions.contains(selectedSampleCount) {
|
|
| 497 |
+ return selectedSampleCount |
|
| 498 |
+ } |
|
| 499 |
+ return min(availableOptions.last ?? bufferedSamples, defaultSampleCount(bufferedSamples: bufferedSamples)) |
|
| 500 |
+ }, |
|
| 501 |
+ set: { newValue in
|
|
| 502 |
+ selectedSampleCount = newValue |
|
| 503 |
+ } |
|
| 504 |
+ ) |
|
| 505 |
+ } |
|
| 506 |
+ |
|
| 507 |
+ private func sampleOptionTitle(_ option: Int, bufferedSamples: Int) -> String {
|
|
| 508 |
+ if option == bufferedSamples {
|
|
| 509 |
+ return "All (\(option))" |
|
| 510 |
+ } |
|
| 511 |
+ return "\(option) values" |
|
| 512 |
+ } |
|
| 513 |
+ |
|
| 514 |
+ private func averagePowerLabel(bufferedSamples: Int) -> String {
|
|
| 515 |
+ guard let average = measurements.averagePower(forRecentSampleCount: selectedSampleCount, flushPendingValues: false) else {
|
|
| 516 |
+ return "No data" |
|
| 517 |
+ } |
|
| 518 |
+ |
|
| 519 |
+ let effectiveSampleCount = min(selectedSampleCount, bufferedSamples) |
|
| 520 |
+ return "\(average.format(decimalDigits: 3)) W avg (\(effectiveSampleCount))" |
|
| 521 |
+ } |
|
| 522 |
+} |
|
| 523 |
+ |
|
| 524 |
+private struct RSSIHistorySheetView: View {
|
|
| 525 |
+ @EnvironmentObject private var measurements: Measurements |
|
| 526 |
+ |
|
| 527 |
+ @Binding var visibility: Bool |
|
| 528 |
+ |
|
| 529 |
+ private let xLabels: Int = 4 |
|
| 530 |
+ private let yLabels: Int = 4 |
|
| 531 |
+ |
|
| 532 |
+ var body: some View {
|
|
| 533 |
+ let points = measurements.rssi.points |
|
| 534 |
+ let samplePoints = measurements.rssi.samplePoints |
|
| 535 |
+ let chartContext = buildChartContext(for: samplePoints) |
|
| 536 |
+ |
|
| 537 |
+ NavigationView {
|
|
| 538 |
+ ScrollView {
|
|
| 539 |
+ VStack(alignment: .leading, spacing: 14) {
|
|
| 540 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 541 |
+ HStack(spacing: 8) {
|
|
| 542 |
+ Text("RSSI History")
|
|
| 543 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 544 |
+ ContextInfoButton( |
|
| 545 |
+ title: "RSSI History", |
|
| 546 |
+ message: "Signal strength captured over time while the meter stays connected." |
|
| 547 |
+ ) |
|
| 548 |
+ } |
|
| 549 |
+ } |
|
| 550 |
+ .padding(18) |
|
| 551 |
+ .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 552 |
+ |
|
| 553 |
+ if samplePoints.isEmpty {
|
|
| 554 |
+ Text("No RSSI samples have been captured yet.")
|
|
| 555 |
+ .font(.footnote) |
|
| 556 |
+ .foregroundColor(.secondary) |
|
| 557 |
+ .padding(18) |
|
| 558 |
+ .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 559 |
+ } else {
|
|
| 560 |
+ MeterInfoCardView(title: "Signal Chart", tint: .mint) {
|
|
| 561 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 562 |
+ HStack(spacing: 12) {
|
|
| 563 |
+ signalSummaryChip(title: "Current", value: "\(Int(samplePoints.last?.value ?? 0)) dBm") |
|
| 564 |
+ signalSummaryChip(title: "Min", value: "\(Int(samplePoints.map(\.value).min() ?? 0)) dBm") |
|
| 565 |
+ signalSummaryChip(title: "Max", value: "\(Int(samplePoints.map(\.value).max() ?? 0)) dBm") |
|
| 566 |
+ } |
|
| 567 |
+ |
|
| 568 |
+ HStack(spacing: 8) {
|
|
| 569 |
+ rssiYAxisView(context: chartContext) |
|
| 570 |
+ .frame(width: 52, height: 220) |
|
| 571 |
+ |
|
| 572 |
+ ZStack {
|
|
| 573 |
+ RoundedRectangle(cornerRadius: 18, style: .continuous) |
|
| 574 |
+ .fill(Color.primary.opacity(0.05)) |
|
| 575 |
+ |
|
| 576 |
+ RoundedRectangle(cornerRadius: 18, style: .continuous) |
|
| 577 |
+ .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
|
| 578 |
+ |
|
| 579 |
+ rssiHorizontalGuides(context: chartContext) |
|
| 580 |
+ rssiVerticalGuides(context: chartContext) |
|
| 581 |
+ TimeSeriesChart(points: points, context: chartContext, strokeColor: .mint) |
|
| 582 |
+ .opacity(0.82) |
|
| 583 |
+ } |
|
| 584 |
+ .frame(maxWidth: .infinity) |
|
| 585 |
+ .frame(height: 220) |
|
| 586 |
+ } |
|
| 587 |
+ |
|
| 588 |
+ rssiXAxisLabelsView(context: chartContext) |
|
| 589 |
+ .frame(height: 28) |
|
| 590 |
+ } |
|
| 591 |
+ } |
|
| 592 |
+ } |
|
| 593 |
+ } |
|
| 594 |
+ .padding() |
|
| 595 |
+ } |
|
| 596 |
+ .background( |
|
| 597 |
+ LinearGradient( |
|
| 598 |
+ colors: [.mint.opacity(0.14), Color.clear], |
|
| 599 |
+ startPoint: .topLeading, |
|
| 600 |
+ endPoint: .bottomTrailing |
|
| 601 |
+ ) |
|
| 602 |
+ .ignoresSafeArea() |
|
| 603 |
+ ) |
|
| 604 |
+ .navigationBarItems( |
|
| 605 |
+ leading: Button("Done") { visibility.toggle() }
|
|
| 606 |
+ ) |
|
| 607 |
+ .navigationBarTitle("RSSI", displayMode: .inline)
|
|
| 608 |
+ } |
|
| 609 |
+ .navigationViewStyle(StackNavigationViewStyle()) |
|
| 610 |
+ } |
|
| 611 |
+ |
|
| 612 |
+ private func buildChartContext(for samplePoints: [Measurements.Measurement.Point]) -> ChartContext {
|
|
| 613 |
+ let context = ChartContext() |
|
| 614 |
+ let upperBound = max(samplePoints.last?.timestamp ?? Date(), Date()) |
|
| 615 |
+ let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-60) |
|
| 616 |
+ let minimumValue = samplePoints.map(\.value).min() ?? -100 |
|
| 617 |
+ let maximumValue = samplePoints.map(\.value).max() ?? -40 |
|
| 618 |
+ let padding = max((maximumValue - minimumValue) * 0.12, 4) |
|
| 619 |
+ |
|
| 620 |
+ context.setBounds( |
|
| 621 |
+ xMin: CGFloat(lowerBound.timeIntervalSince1970), |
|
| 622 |
+ xMax: CGFloat(max(upperBound.timeIntervalSince1970, lowerBound.timeIntervalSince1970 + 1)), |
|
| 623 |
+ yMin: CGFloat(minimumValue - padding), |
|
| 624 |
+ yMax: CGFloat(maximumValue + padding) |
|
| 625 |
+ ) |
|
| 626 |
+ return context |
|
| 627 |
+ } |
|
| 628 |
+ |
|
| 629 |
+ private func signalSummaryChip(title: String, value: String) -> some View {
|
|
| 630 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 631 |
+ Text(title) |
|
| 632 |
+ .font(.caption.weight(.semibold)) |
|
| 633 |
+ .foregroundColor(.secondary) |
|
| 634 |
+ Text(value) |
|
| 635 |
+ .font(.subheadline.weight(.bold)) |
|
| 636 |
+ .monospacedDigit() |
|
| 637 |
+ } |
|
| 638 |
+ .padding(.horizontal, 12) |
|
| 639 |
+ .padding(.vertical, 10) |
|
| 640 |
+ .meterCard(tint: .mint, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14) |
|
| 641 |
+ } |
|
| 642 |
+ |
|
| 643 |
+ private func rssiXAxisLabelsView(context: ChartContext) -> some View {
|
|
| 644 |
+ let labels = (1...xLabels).map {
|
|
| 645 |
+ Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: xLabels)).format(as: "HH:mm:ss") |
|
| 646 |
+ } |
|
| 647 |
+ |
|
| 648 |
+ return HStack {
|
|
| 649 |
+ ForEach(Array(labels.enumerated()), id: \.offset) { item in
|
|
| 650 |
+ Text(item.element) |
|
| 651 |
+ .font(.caption2.weight(.semibold)) |
|
| 652 |
+ .monospacedDigit() |
|
| 653 |
+ .frame(maxWidth: .infinity) |
|
| 654 |
+ } |
|
| 655 |
+ } |
|
| 656 |
+ .foregroundColor(.secondary) |
|
| 657 |
+ } |
|
| 658 |
+ |
|
| 659 |
+ private func rssiYAxisView(context: ChartContext) -> some View {
|
|
| 660 |
+ VStack(spacing: 0) {
|
|
| 661 |
+ ForEach((1...yLabels).reversed(), id: \.self) { labelIndex in
|
|
| 662 |
+ Spacer(minLength: 0) |
|
| 663 |
+ Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(decimalDigits: 0))")
|
|
| 664 |
+ .font(.caption2.weight(.semibold)) |
|
| 665 |
+ .monospacedDigit() |
|
| 666 |
+ .foregroundColor(.primary) |
|
| 667 |
+ Spacer(minLength: 0) |
|
| 668 |
+ } |
|
| 669 |
+ } |
|
| 670 |
+ .padding(.vertical, 12) |
|
| 671 |
+ .background( |
|
| 672 |
+ RoundedRectangle(cornerRadius: 16, style: .continuous) |
|
| 673 |
+ .fill(Color.mint.opacity(0.12)) |
|
| 674 |
+ ) |
|
| 675 |
+ .overlay( |
|
| 676 |
+ RoundedRectangle(cornerRadius: 16, style: .continuous) |
|
| 677 |
+ .stroke(Color.mint.opacity(0.20), lineWidth: 1) |
|
| 678 |
+ ) |
|
| 679 |
+ } |
|
| 680 |
+ |
|
| 681 |
+ private func rssiHorizontalGuides(context: ChartContext) -> some View {
|
|
| 682 |
+ TimeSeriesChartHorizontalGuides( |
|
| 683 |
+ context: context, |
|
| 684 |
+ labelCount: yLabels, |
|
| 685 |
+ strokeColor: Color.secondary.opacity(0.30), |
|
| 686 |
+ lineWidth: 0.8 |
|
| 687 |
+ ) |
|
| 688 |
+ } |
|
| 689 |
+ |
|
| 690 |
+ private func rssiVerticalGuides(context: ChartContext) -> some View {
|
|
| 691 |
+ TimeSeriesChartVerticalGuides( |
|
| 692 |
+ context: context, |
|
| 693 |
+ labelCount: xLabels, |
|
| 694 |
+ strokeColor: Color.secondary.opacity(0.26), |
|
| 695 |
+ strokeStyle: StrokeStyle(lineWidth: 0.8, dash: [4, 4]) |
|
| 696 |
+ ) |
|
| 697 |
+ } |
|
| 698 |
+} |
|
| 699 |
+ |
|
| 700 |
+private struct EnergyProjectionSheetView: View {
|
|
| 701 |
+ @EnvironmentObject private var measurements: Measurements |
|
| 702 |
+ @EnvironmentObject private var meter: Meter |
|
| 703 |
+ |
|
| 704 |
+ @Binding var visibility: Bool |
|
| 705 |
+ @State private var selectedProjectionMethodID: String = "" |
|
| 706 |
+ |
|
| 707 |
+ var body: some View {
|
|
| 708 |
+ let snapshot = measurements.energyProjectionSnapshot() |
|
| 709 |
+ let projectionVariants = measurements.energyProjectionVariants() |
|
| 710 |
+ let projectionVariantIDs = projectionVariants.map(\.id) |
|
| 711 |
+ let selectedProjectionVariant = resolvedProjectionVariant(from: projectionVariants) |
|
| 712 |
+ |
|
| 713 |
+ NavigationView {
|
|
| 714 |
+ ScrollView {
|
|
| 715 |
+ VStack(alignment: .leading, spacing: 14) {
|
|
| 716 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 717 |
+ HStack(spacing: 8) {
|
|
| 718 |
+ Text("Energy Projections")
|
|
| 719 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 720 |
+ ContextInfoButton( |
|
| 721 |
+ title: "Energy Projections", |
|
| 722 |
+ message: "Projected consumption is estimated from multiple real windows in the live buffer. A method is shown only when that full interval exists in the recent continuous data." |
|
| 723 |
+ ) |
|
| 724 |
+ } |
|
| 725 |
+ } |
|
| 726 |
+ .padding(18) |
|
| 727 |
+ .meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 728 |
+ |
|
| 729 |
+ MeterInfoCardView(title: "Current Session", tint: meter.color) {
|
|
| 730 |
+ if let snapshot {
|
|
| 731 |
+ MeterInfoRowView( |
|
| 732 |
+ label: "Accumulated Energy", |
|
| 733 |
+ value: "\(snapshot.accumulatedEnergy.format(decimalDigits: 3)) Wh" |
|
| 734 |
+ ) |
|
| 735 |
+ MeterInfoRowView( |
|
| 736 |
+ label: "Observed Interval", |
|
| 737 |
+ value: observedIntervalText(snapshot.observedDuration) |
|
| 738 |
+ ) |
|
| 739 |
+ MeterInfoRowView( |
|
| 740 |
+ label: "Buffered Samples", |
|
| 741 |
+ value: "\(snapshot.sampleCount)" |
|
| 742 |
+ ) |
|
| 743 |
+ MeterInfoRowView( |
|
| 744 |
+ label: "Average Power", |
|
| 745 |
+ value: averagePowerText(snapshot.averagePower) |
|
| 746 |
+ ) |
|
| 747 |
+ } else {
|
|
| 748 |
+ Text("Not enough live energy data yet. Keep the meter connected for a little longer, then reopen this view.")
|
|
| 749 |
+ .font(.footnote) |
|
| 750 |
+ .foregroundColor(.secondary) |
|
| 751 |
+ } |
|
| 752 |
+ } |
|
| 753 |
+ |
|
| 754 |
+ MeterInfoCardView( |
|
| 755 |
+ title: "Projection Method", |
|
| 756 |
+ infoMessage: "Projection methods appear after the live buffer contains at least one continuous interval with enough data to estimate a rate.", |
|
| 757 |
+ tint: .teal |
|
| 758 |
+ ) {
|
|
| 759 |
+ if projectionVariants.isEmpty {
|
|
| 760 |
+ Text("No projection methods available yet.")
|
|
| 761 |
+ .font(.footnote) |
|
| 762 |
+ .foregroundColor(.secondary) |
|
| 763 |
+ } else {
|
|
| 764 |
+ VStack(alignment: .leading, spacing: 14) {
|
|
| 765 |
+ Picker("Projection Method", selection: selectedProjectionMethodBinding(for: projectionVariants)) {
|
|
| 766 |
+ ForEach(projectionVariants) { variant in
|
|
| 767 |
+ Text(variant.title).tag(variant.id) |
|
| 768 |
+ } |
|
| 769 |
+ } |
|
| 770 |
+ .pickerStyle(.menu) |
|
| 771 |
+ |
|
| 772 |
+ if let selectedProjectionVariant {
|
|
| 773 |
+ projectionVariantView(selectedProjectionVariant) |
|
| 774 |
+ } |
|
| 775 |
+ } |
|
| 776 |
+ } |
|
| 777 |
+ } |
|
| 778 |
+ } |
|
| 779 |
+ .padding() |
|
| 780 |
+ .padding(.top, 8) |
|
| 781 |
+ } |
|
| 782 |
+ .background( |
|
| 783 |
+ LinearGradient( |
|
| 784 |
+ colors: [.teal.opacity(0.14), Color.clear], |
|
| 785 |
+ startPoint: .topLeading, |
|
| 786 |
+ endPoint: .bottomTrailing |
|
| 787 |
+ ) |
|
| 788 |
+ .ignoresSafeArea() |
|
| 789 |
+ ) |
|
| 790 |
+ .navigationTitle("Energy")
|
|
| 791 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 792 |
+ .toolbar {
|
|
| 793 |
+ ToolbarItem(placement: .cancellationAction) {
|
|
| 794 |
+ Button("Done") { visibility.toggle() }
|
|
| 795 |
+ } |
|
| 796 |
+ } |
|
| 797 |
+ } |
|
| 798 |
+ .navigationViewStyle(StackNavigationViewStyle()) |
|
| 799 |
+ .onAppear {
|
|
| 800 |
+ updateSelectedProjectionMethod(with: projectionVariants) |
|
| 801 |
+ } |
|
| 802 |
+ .onChange(of: projectionVariantIDs) { _ in
|
|
| 803 |
+ updateSelectedProjectionMethod(with: projectionVariants) |
|
| 804 |
+ } |
|
| 805 |
+ } |
|
| 806 |
+ |
|
| 807 |
+ private func projectionRow(title: String, value: String) -> some View {
|
|
| 808 |
+ MeterInfoRowView(label: title, value: value) |
|
| 809 |
+ } |
|
| 810 |
+ |
|
| 811 |
+ private func projectionVariantView(_ variant: Measurements.EnergyProjectionVariant) -> some View {
|
|
| 812 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 813 |
+ Text(variant.title) |
|
| 814 |
+ .font(.subheadline.weight(.semibold)) |
|
| 815 |
+ |
|
| 816 |
+ projectionRow(title: "Observed Interval", value: observedIntervalText(variant.observedDuration)) |
|
| 817 |
+ projectionRow(title: "Window Energy", value: energyText(variant.accumulatedEnergy)) |
|
| 818 |
+ projectionRow(title: "Average Power", value: averagePowerText(variant.averagePower)) |
|
| 819 |
+ projectionRow(title: "Monthly", value: projectedEnergyText(variant.projectedMonthlyEnergy)) |
|
| 820 |
+ projectionRow(title: "Yearly", value: projectedEnergyText(variant.projectedYearlyEnergy)) |
|
| 821 |
+ } |
|
| 822 |
+ .padding(.bottom, 2) |
|
| 823 |
+ } |
|
| 824 |
+ |
|
| 825 |
+ private func resolvedProjectionVariant(from variants: [Measurements.EnergyProjectionVariant]) -> Measurements.EnergyProjectionVariant? {
|
|
| 826 |
+ if let selectedVariant = variants.first(where: { $0.id == selectedProjectionMethodID }) {
|
|
| 827 |
+ return selectedVariant |
|
| 828 |
+ } |
|
| 829 |
+ |
|
| 830 |
+ return variants.last |
|
| 831 |
+ } |
|
| 832 |
+ |
|
| 833 |
+ private func selectedProjectionMethodBinding( |
|
| 834 |
+ for variants: [Measurements.EnergyProjectionVariant] |
|
| 835 |
+ ) -> Binding<String> {
|
|
| 836 |
+ Binding( |
|
| 837 |
+ get: {
|
|
| 838 |
+ resolvedProjectionVariant(from: variants)?.id ?? "" |
|
| 839 |
+ }, |
|
| 840 |
+ set: { newValue in
|
|
| 841 |
+ selectedProjectionMethodID = newValue |
|
| 842 |
+ } |
|
| 843 |
+ ) |
|
| 844 |
+ } |
|
| 845 |
+ |
|
| 846 |
+ private func updateSelectedProjectionMethod(with variants: [Measurements.EnergyProjectionVariant]) {
|
|
| 847 |
+ guard !variants.isEmpty else {
|
|
| 848 |
+ selectedProjectionMethodID = "" |
|
| 849 |
+ return |
|
| 850 |
+ } |
|
| 851 |
+ |
|
| 852 |
+ if variants.contains(where: { $0.id == selectedProjectionMethodID }) {
|
|
| 853 |
+ return |
|
| 854 |
+ } |
|
| 855 |
+ |
|
| 856 |
+ selectedProjectionMethodID = variants.last?.id ?? "" |
|
| 857 |
+ } |
|
| 858 |
+ |
|
| 859 |
+ private func observedIntervalText(_ duration: TimeInterval) -> String {
|
|
| 860 |
+ guard duration > 0 else { return "Insufficient data" }
|
|
| 861 |
+ |
|
| 862 |
+ let totalSeconds = Int(duration.rounded()) |
|
| 863 |
+ let hours = totalSeconds / 3600 |
|
| 864 |
+ let minutes = (totalSeconds % 3600) / 60 |
|
| 865 |
+ let seconds = totalSeconds % 60 |
|
| 866 |
+ |
|
| 867 |
+ if hours > 0 {
|
|
| 868 |
+ return "\(hours)h \(minutes)m" |
|
| 869 |
+ } |
|
| 870 |
+ |
|
| 871 |
+ if minutes > 0 {
|
|
| 872 |
+ return "\(minutes)m \(seconds)s" |
|
| 873 |
+ } |
|
| 874 |
+ |
|
| 875 |
+ return "\(seconds)s" |
|
| 876 |
+ } |
|
| 877 |
+ |
|
| 878 |
+ private func averagePowerText(_ averagePower: Double?) -> String {
|
|
| 879 |
+ guard let averagePower, averagePower.isFinite else {
|
|
| 880 |
+ return "Insufficient data" |
|
| 881 |
+ } |
|
| 882 |
+ |
|
| 883 |
+ return "\(averagePower.format(decimalDigits: 3)) W" |
|
| 884 |
+ } |
|
| 885 |
+ |
|
| 886 |
+ private func averagePowerText(_ averagePower: Double) -> String {
|
|
| 887 |
+ averagePowerText(Optional(averagePower)) |
|
| 888 |
+ } |
|
| 889 |
+ |
|
| 890 |
+ private func energyText(_ energy: Double) -> String {
|
|
| 891 |
+ if energy >= 1000 {
|
|
| 892 |
+ return "\((energy / 1000).format(decimalDigits: 3)) kWh" |
|
| 893 |
+ } |
|
| 894 |
+ |
|
| 895 |
+ return "\(energy.format(decimalDigits: 3)) Wh" |
|
| 896 |
+ } |
|
| 897 |
+ |
|
| 898 |
+ private func projectedEnergyText(_ energy: Double?) -> String {
|
|
| 899 |
+ guard let energy, energy.isFinite else {
|
|
| 900 |
+ return "Insufficient data" |
|
| 901 |
+ } |
|
| 902 |
+ |
|
| 903 |
+ if energy >= 1000 {
|
|
| 904 |
+ return "\((energy / 1000).format(decimalDigits: 3)) kWh" |
|
| 905 |
+ } |
|
| 906 |
+ |
|
| 907 |
+ return "\(energy.format(decimalDigits: 1)) Wh" |
|
| 908 |
+ } |
|
| 909 |
+} |
|
@@ -0,0 +1,13 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterLiveMetricRange.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import Foundation |
|
| 7 |
+ |
|
| 8 |
+struct MeterLiveMetricRange {
|
|
| 9 |
+ let minLabel: String |
|
| 10 |
+ let maxLabel: String |
|
| 11 |
+ let minValue: String |
|
| 12 |
+ let maxValue: String |
|
| 13 |
+} |
|
@@ -0,0 +1,201 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterSettingsTabView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterSettingsTabView: View {
|
|
| 9 |
+ @EnvironmentObject private var appData: AppData |
|
| 10 |
+ @EnvironmentObject private var meter: Meter |
|
| 11 |
+ |
|
| 12 |
+ let isMacIPadApp: Bool |
|
| 13 |
+ let onBackToHome: () -> Void |
|
| 14 |
+ |
|
| 15 |
+ @State private var editingName = false |
|
| 16 |
+ @State private var editingScreenTimeout = false |
|
| 17 |
+ @State private var editingScreenBrightness = false |
|
| 18 |
+ @State private var deleteConfirmationVisibility = false |
|
| 19 |
+ |
|
| 20 |
+ var body: some View {
|
|
| 21 |
+ VStack(spacing: 0) {
|
|
| 22 |
+ if isMacIPadApp {
|
|
| 23 |
+ settingsMacHeader |
|
| 24 |
+ } |
|
| 25 |
+ ScrollView {
|
|
| 26 |
+ VStack(spacing: 14) {
|
|
| 27 |
+ settingsCard(title: "Name", tint: meter.color) {
|
|
| 28 |
+ HStack {
|
|
| 29 |
+ Spacer() |
|
| 30 |
+ if !editingName {
|
|
| 31 |
+ Text(meter.name) |
|
| 32 |
+ .foregroundColor(.secondary) |
|
| 33 |
+ } |
|
| 34 |
+ ChevronView(rotate: $editingName) |
|
| 35 |
+ } |
|
| 36 |
+ if editingName {
|
|
| 37 |
+ MeterNameEditorView(editingName: $editingName, newName: meter.name) |
|
| 38 |
+ } |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
|
|
| 42 |
+ settingsCard( |
|
| 43 |
+ title: "Meter Temperature Unit", |
|
| 44 |
+ infoMessage: "TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.", |
|
| 45 |
+ tint: .orange |
|
| 46 |
+ ) {
|
|
| 47 |
+ Picker("", selection: $meter.tc66TemperatureUnitPreference) {
|
|
| 48 |
+ ForEach(TemperatureUnitPreference.allCases) { unit in
|
|
| 49 |
+ Text(unit.title).tag(unit) |
|
| 50 |
+ } |
|
| 51 |
+ } |
|
| 52 |
+ .pickerStyle(SegmentedPickerStyle()) |
|
| 53 |
+ } |
|
| 54 |
+ } |
|
| 55 |
+ |
|
| 56 |
+ if meter.operationalState == .dataIsAvailable && meter.model == .TC66C {
|
|
| 57 |
+ settingsCard( |
|
| 58 |
+ title: "Screen Reporting", |
|
| 59 |
+ infoMessage: "TC66 is the exception: it does not report the current screen in the payload, so the app keeps this note here instead of showing it on the home screen.", |
|
| 60 |
+ tint: .orange |
|
| 61 |
+ ) {
|
|
| 62 |
+ MeterInfoRowView(label: "Current Screen", value: "Not Reported") |
|
| 63 |
+ } |
|
| 64 |
+ } |
|
| 65 |
+ |
|
| 66 |
+ if meter.operationalState == .dataIsAvailable {
|
|
| 67 |
+ settingsCard( |
|
| 68 |
+ title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls", |
|
| 69 |
+ infoMessage: meter.reportsCurrentScreenIndex |
|
| 70 |
+ ? "Use these controls when you want to change the screen shown on the device without crowding the main meter view." |
|
| 71 |
+ : "Use these controls when you want to switch device pages without crowding the main meter view.", |
|
| 72 |
+ tint: .indigo |
|
| 73 |
+ ) {
|
|
| 74 |
+ MeterScreenControlsView(showsHeader: false) |
|
| 75 |
+ } |
|
| 76 |
+ } |
|
| 77 |
+ |
|
| 78 |
+ if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
|
|
| 79 |
+ settingsCard(title: "Screen Timeout", tint: .purple) {
|
|
| 80 |
+ HStack {
|
|
| 81 |
+ Spacer() |
|
| 82 |
+ if !editingScreenTimeout {
|
|
| 83 |
+ Text(meter.screenTimeout > 0 ? "\(meter.screenTimeout) Minutes" : "Off") |
|
| 84 |
+ .foregroundColor(.secondary) |
|
| 85 |
+ } |
|
| 86 |
+ ChevronView(rotate: $editingScreenTimeout) |
|
| 87 |
+ } |
|
| 88 |
+ if editingScreenTimeout {
|
|
| 89 |
+ ScreenTimeoutEditorView() |
|
| 90 |
+ } |
|
| 91 |
+ } |
|
| 92 |
+ |
|
| 93 |
+ settingsCard(title: "Screen Brightness", tint: .yellow) {
|
|
| 94 |
+ HStack {
|
|
| 95 |
+ Spacer() |
|
| 96 |
+ if !editingScreenBrightness {
|
|
| 97 |
+ Text("\(meter.screenBrightness)")
|
|
| 98 |
+ .foregroundColor(.secondary) |
|
| 99 |
+ } |
|
| 100 |
+ ChevronView(rotate: $editingScreenBrightness) |
|
| 101 |
+ } |
|
| 102 |
+ if editingScreenBrightness {
|
|
| 103 |
+ ScreenBrightnessEditorView() |
|
| 104 |
+ } |
|
| 105 |
+ } |
|
| 106 |
+ } |
|
| 107 |
+ |
|
| 108 |
+ settingsCard( |
|
| 109 |
+ title: "Danger Zone", |
|
| 110 |
+ infoMessage: "Delete this meter from the sidebar and clear its saved metadata. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.", |
|
| 111 |
+ tint: .red |
|
| 112 |
+ ) {
|
|
| 113 |
+ Button("Delete Meter") {
|
|
| 114 |
+ deleteConfirmationVisibility = true |
|
| 115 |
+ } |
|
| 116 |
+ .frame(maxWidth: .infinity) |
|
| 117 |
+ .padding(.vertical, 10) |
|
| 118 |
+ .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 119 |
+ .buttonStyle(.plain) |
|
| 120 |
+ } |
|
| 121 |
+ } |
|
| 122 |
+ .padding() |
|
| 123 |
+ } |
|
| 124 |
+ .background( |
|
| 125 |
+ LinearGradient( |
|
| 126 |
+ colors: [meter.color.opacity(0.14), Color.clear], |
|
| 127 |
+ startPoint: .topLeading, |
|
| 128 |
+ endPoint: .bottomTrailing |
|
| 129 |
+ ) |
|
| 130 |
+ .ignoresSafeArea() |
|
| 131 |
+ ) |
|
| 132 |
+ .alert("Delete Meter?", isPresented: $deleteConfirmationVisibility) {
|
|
| 133 |
+ Button("Delete", role: .destructive) {
|
|
| 134 |
+ if appData.deleteMeter(macAddress: meter.btSerial.macAddress.description) {
|
|
| 135 |
+ onBackToHome() |
|
| 136 |
+ } |
|
| 137 |
+ } |
|
| 138 |
+ Button("Cancel", role: .cancel) {}
|
|
| 139 |
+ } message: {
|
|
| 140 |
+ Text("This removes the saved meter entry and disconnects the live meter view.")
|
|
| 141 |
+ } |
|
| 142 |
+ } |
|
| 143 |
+ } |
|
| 144 |
+ |
|
| 145 |
+ private var settingsMacHeader: some View {
|
|
| 146 |
+ HStack(spacing: 12) {
|
|
| 147 |
+ Button(action: onBackToHome) {
|
|
| 148 |
+ HStack(spacing: 4) {
|
|
| 149 |
+ Image(systemName: "chevron.left") |
|
| 150 |
+ .font(.body.weight(.semibold)) |
|
| 151 |
+ Text("Back")
|
|
| 152 |
+ } |
|
| 153 |
+ .foregroundColor(.accentColor) |
|
| 154 |
+ } |
|
| 155 |
+ .buttonStyle(.plain) |
|
| 156 |
+ |
|
| 157 |
+ Text("Meter Settings")
|
|
| 158 |
+ .font(.headline) |
|
| 159 |
+ .lineLimit(1) |
|
| 160 |
+ |
|
| 161 |
+ Spacer() |
|
| 162 |
+ |
|
| 163 |
+ if meter.operationalState > .notPresent {
|
|
| 164 |
+ RSSIView(RSSI: meter.btSerial.averageRSSI) |
|
| 165 |
+ .frame(width: 18, height: 18) |
|
| 166 |
+ } |
|
| 167 |
+ } |
|
| 168 |
+ .padding(.horizontal, 16) |
|
| 169 |
+ .padding(.vertical, 10) |
|
| 170 |
+ .background( |
|
| 171 |
+ Rectangle() |
|
| 172 |
+ .fill(.ultraThinMaterial) |
|
| 173 |
+ .ignoresSafeArea(edges: .top) |
|
| 174 |
+ ) |
|
| 175 |
+ .overlay(alignment: .bottom) {
|
|
| 176 |
+ Rectangle() |
|
| 177 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 178 |
+ .frame(height: 1) |
|
| 179 |
+ } |
|
| 180 |
+ } |
|
| 181 |
+ |
|
| 182 |
+ private func settingsCard<Content: View>( |
|
| 183 |
+ title: String, |
|
| 184 |
+ infoMessage: String? = nil, |
|
| 185 |
+ tint: Color, |
|
| 186 |
+ @ViewBuilder content: () -> Content |
|
| 187 |
+ ) -> some View {
|
|
| 188 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 189 |
+ HStack(spacing: 8) {
|
|
| 190 |
+ Text(title) |
|
| 191 |
+ .font(.headline) |
|
| 192 |
+ if let infoMessage {
|
|
| 193 |
+ ContextInfoButton(title: title, message: infoMessage) |
|
| 194 |
+ } |
|
| 195 |
+ } |
|
| 196 |
+ content() |
|
| 197 |
+ } |
|
| 198 |
+ .padding(18) |
|
| 199 |
+ .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 200 |
+ } |
|
| 201 |
+} |
|
@@ -0,0 +1,30 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterCurrentScreenSummaryView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct MeterCurrentScreenSummaryView: View {
|
|
| 13 |
+ let reportsCurrentScreenIndex: Bool |
|
| 14 |
+ let currentScreenDescription: String |
|
| 15 |
+ let isExpandedCompactLayout: Bool |
|
| 16 |
+ |
|
| 17 |
+ var body: some View {
|
|
| 18 |
+ if reportsCurrentScreenIndex {
|
|
| 19 |
+ Text(currentScreenDescription) |
|
| 20 |
+ .font((isExpandedCompactLayout ? Font.title3 : .subheadline).weight(.semibold)) |
|
| 21 |
+ .multilineTextAlignment(.center) |
|
| 22 |
+ } else {
|
|
| 23 |
+ VStack {
|
|
| 24 |
+ Image(systemName: "questionmark.square.dashed") |
|
| 25 |
+ .font(.system(size: isExpandedCompactLayout ? 30 : 24, weight: .semibold)) |
|
| 26 |
+ .foregroundColor(.secondary) |
|
| 27 |
+ } |
|
| 28 |
+ } |
|
| 29 |
+ } |
|
| 30 |
+} |
|
@@ -0,0 +1,24 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterNameEditorView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterNameEditorView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ @Binding var editingName: Bool |
|
| 12 |
+ @State var newName: String |
|
| 13 |
+ |
|
| 14 |
+ var body: some View {
|
|
| 15 |
+ TextField("Name", text: self.$newName, onCommit: {
|
|
| 16 |
+ self.meter.name = self.newName |
|
| 17 |
+ self.editingName = false |
|
| 18 |
+ }) |
|
| 19 |
+ .textFieldStyle(RoundedBorderTextFieldStyle()) |
|
| 20 |
+ .lineLimit(1) |
|
| 21 |
+ .disableAutocorrection(true) |
|
| 22 |
+ .multilineTextAlignment(.center) |
|
| 23 |
+ } |
|
| 24 |
+} |
|
@@ -0,0 +1,36 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterScreenControlButtonView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Bogdan Timofte on 29/03/2026. |
|
| 6 |
+// Co-authored-by: GPT-5.3-Codex. |
|
| 7 |
+// Copyright © 2026 Bogdan Timofte. All rights reserved. |
|
| 8 |
+// |
|
| 9 |
+ |
|
| 10 |
+import SwiftUI |
|
| 11 |
+ |
|
| 12 |
+struct MeterScreenControlButtonView: View {
|
|
| 13 |
+ let title: String |
|
| 14 |
+ let symbol: String |
|
| 15 |
+ let tint: Color |
|
| 16 |
+ let compact: Bool |
|
| 17 |
+ let isExpandedCompactLayout: Bool |
|
| 18 |
+ let action: () -> Void |
|
| 19 |
+ |
|
| 20 |
+ var body: some View {
|
|
| 21 |
+ Button(action: action) {
|
|
| 22 |
+ VStack(spacing: 10) {
|
|
| 23 |
+ Image(systemName: symbol) |
|
| 24 |
+ .font(.system(size: compact ? 18 : 20, weight: .semibold)) |
|
| 25 |
+ Text(title) |
|
| 26 |
+ .font(.footnote.weight(.semibold)) |
|
| 27 |
+ .multilineTextAlignment(.center) |
|
| 28 |
+ } |
|
| 29 |
+ .foregroundColor(tint) |
|
| 30 |
+ .frame(maxWidth: .infinity, minHeight: compact ? (isExpandedCompactLayout ? 112 : 92) : 68) |
|
| 31 |
+ .padding(.horizontal, 8) |
|
| 32 |
+ .meterCard(tint: tint, fillOpacity: 0.10, strokeOpacity: 0.14) |
|
| 33 |
+ } |
|
| 34 |
+ .buttonStyle(.plain) |
|
| 35 |
+ } |
|
| 36 |
+} |
|
@@ -1,5 +1,5 @@ |
||
| 1 | 1 |
// |
| 2 |
-// ControlView.swift |
|
| 2 |
+// MeterScreenControlsView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 | 5 |
// Created by Bogdan Timofte on 09/03/2020. |
@@ -8,7 +8,7 @@ |
||
| 8 | 8 |
|
| 9 | 9 |
import SwiftUI |
| 10 | 10 |
|
| 11 |
-struct ControlView: View {
|
|
| 11 |
+struct MeterScreenControlsView: View {
|
|
| 12 | 12 |
|
| 13 | 13 |
@EnvironmentObject private var meter: Meter |
| 14 | 14 |
var compactLayout: Bool = false |
@@ -34,31 +34,41 @@ struct ControlView: View {
|
||
| 34 | 34 |
|
| 35 | 35 |
VStack(spacing: 12) {
|
| 36 | 36 |
HStack(spacing: 12) {
|
| 37 |
- controlButton( |
|
| 37 |
+ MeterScreenControlButtonView( |
|
| 38 | 38 |
title: "Prev", |
| 39 | 39 |
symbol: "chevron.left", |
| 40 | 40 |
tint: .indigo, |
| 41 |
+ compact: true, |
|
| 42 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 41 | 43 |
action: { meter.previousScreen() }
|
| 42 | 44 |
) |
| 43 | 45 |
|
| 44 |
- currentScreenCard |
|
| 46 |
+ MeterCurrentScreenSummaryView( |
|
| 47 |
+ reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex, |
|
| 48 |
+ currentScreenDescription: meter.currentScreenDescription, |
|
| 49 |
+ isExpandedCompactLayout: usesExpandedCompactLayout |
|
| 50 |
+ ) |
|
| 45 | 51 |
.frame(maxWidth: .infinity, minHeight: 112) |
| 46 | 52 |
.padding(.horizontal, 14) |
| 47 | 53 |
.meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10) |
| 48 | 54 |
} |
| 49 | 55 |
|
| 50 | 56 |
HStack(spacing: 12) {
|
| 51 |
- controlButton( |
|
| 57 |
+ MeterScreenControlButtonView( |
|
| 52 | 58 |
title: "Rotate", |
| 53 | 59 |
symbol: "rotate.right.fill", |
| 54 | 60 |
tint: .orange, |
| 61 |
+ compact: true, |
|
| 62 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 55 | 63 |
action: { meter.rotateScreen() }
|
| 56 | 64 |
) |
| 57 | 65 |
|
| 58 |
- controlButton( |
|
| 66 |
+ MeterScreenControlButtonView( |
|
| 59 | 67 |
title: "Next", |
| 60 | 68 |
symbol: "chevron.right", |
| 61 | 69 |
tint: .indigo, |
| 70 |
+ compact: true, |
|
| 71 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 62 | 72 |
action: { meter.nextScreen() }
|
| 63 | 73 |
) |
| 64 | 74 |
} |
@@ -67,60 +77,79 @@ struct ControlView: View {
|
||
| 67 | 77 |
Spacer(minLength: 0) |
| 68 | 78 |
} else {
|
| 69 | 79 |
HStack(spacing: 10) {
|
| 70 |
- controlButton( |
|
| 80 |
+ MeterScreenControlButtonView( |
|
| 71 | 81 |
title: "Prev", |
| 72 | 82 |
symbol: "chevron.left", |
| 73 | 83 |
tint: .indigo, |
| 84 |
+ compact: true, |
|
| 85 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 74 | 86 |
action: { meter.previousScreen() }
|
| 75 | 87 |
) |
| 76 | 88 |
|
| 77 |
- currentScreenCard |
|
| 89 |
+ MeterCurrentScreenSummaryView( |
|
| 90 |
+ reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex, |
|
| 91 |
+ currentScreenDescription: meter.currentScreenDescription, |
|
| 92 |
+ isExpandedCompactLayout: usesExpandedCompactLayout |
|
| 93 |
+ ) |
|
| 78 | 94 |
.frame(maxWidth: .infinity, minHeight: 82) |
| 79 | 95 |
.padding(.horizontal, 10) |
| 80 | 96 |
.meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10) |
| 81 | 97 |
|
| 82 |
- controlButton( |
|
| 98 |
+ MeterScreenControlButtonView( |
|
| 83 | 99 |
title: "Rotate", |
| 84 | 100 |
symbol: "rotate.right.fill", |
| 85 | 101 |
tint: .orange, |
| 102 |
+ compact: true, |
|
| 103 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 86 | 104 |
action: { meter.rotateScreen() }
|
| 87 | 105 |
) |
| 88 | 106 |
|
| 89 |
- controlButton( |
|
| 107 |
+ MeterScreenControlButtonView( |
|
| 90 | 108 |
title: "Next", |
| 91 | 109 |
symbol: "chevron.right", |
| 92 | 110 |
tint: .indigo, |
| 111 |
+ compact: true, |
|
| 112 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 93 | 113 |
action: { meter.nextScreen() }
|
| 94 | 114 |
) |
| 95 | 115 |
} |
| 96 | 116 |
} |
| 97 | 117 |
} else {
|
| 98 | 118 |
HStack(spacing: 12) {
|
| 99 |
- controlButton( |
|
| 119 |
+ MeterScreenControlButtonView( |
|
| 100 | 120 |
title: "Prev", |
| 101 | 121 |
symbol: "chevron.left", |
| 102 | 122 |
tint: .indigo, |
| 123 |
+ compact: true, |
|
| 124 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 103 | 125 |
action: { meter.previousScreen() }
|
| 104 | 126 |
) |
| 105 | 127 |
|
| 106 |
- currentScreenCard |
|
| 128 |
+ MeterCurrentScreenSummaryView( |
|
| 129 |
+ reportsCurrentScreenIndex: meter.reportsCurrentScreenIndex, |
|
| 130 |
+ currentScreenDescription: meter.currentScreenDescription, |
|
| 131 |
+ isExpandedCompactLayout: usesExpandedCompactLayout |
|
| 132 |
+ ) |
|
| 107 | 133 |
.frame(maxWidth: .infinity, minHeight: 92) |
| 108 | 134 |
.padding(.horizontal, 12) |
| 109 | 135 |
.meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10) |
| 110 | 136 |
|
| 111 |
- controlButton( |
|
| 137 |
+ MeterScreenControlButtonView( |
|
| 112 | 138 |
title: "Next", |
| 113 | 139 |
symbol: "chevron.right", |
| 114 | 140 |
tint: .indigo, |
| 141 |
+ compact: true, |
|
| 142 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 115 | 143 |
action: { meter.nextScreen() }
|
| 116 | 144 |
) |
| 117 | 145 |
} |
| 118 | 146 |
|
| 119 |
- controlButton( |
|
| 147 |
+ MeterScreenControlButtonView( |
|
| 120 | 148 |
title: "Rotate Screen", |
| 121 | 149 |
symbol: "rotate.right.fill", |
| 122 | 150 |
tint: .orange, |
| 123 | 151 |
compact: false, |
| 152 |
+ isExpandedCompactLayout: usesExpandedCompactLayout, |
|
| 124 | 153 |
action: { meter.rotateScreen() }
|
| 125 | 154 |
) |
| 126 | 155 |
} |
@@ -128,51 +157,14 @@ struct ControlView: View {
|
||
| 128 | 157 |
.frame(maxWidth: .infinity, maxHeight: compactLayout ? .infinity : nil, alignment: .topLeading) |
| 129 | 158 |
} |
| 130 | 159 |
|
| 131 |
- @ViewBuilder |
|
| 132 |
- private var currentScreenCard: some View {
|
|
| 133 |
- if meter.reportsCurrentScreenIndex {
|
|
| 134 |
- Text(meter.currentScreenDescription) |
|
| 135 |
- .font((usesExpandedCompactLayout ? Font.title3 : .subheadline).weight(.semibold)) |
|
| 136 |
- .multilineTextAlignment(.center) |
|
| 137 |
- } else {
|
|
| 138 |
- VStack {
|
|
| 139 |
- Image(systemName: "questionmark.square.dashed") |
|
| 140 |
- .font(.system(size: usesExpandedCompactLayout ? 30 : 24, weight: .semibold)) |
|
| 141 |
- .foregroundColor(.secondary) |
|
| 142 |
- } |
|
| 143 |
- } |
|
| 144 |
- } |
|
| 145 |
- |
|
| 146 | 160 |
private var usesExpandedCompactLayout: Bool {
|
| 147 | 161 |
compactLayout && (availableSize?.height ?? 0) >= 520 |
| 148 | 162 |
} |
| 149 | 163 |
|
| 150 |
- private func controlButton( |
|
| 151 |
- title: String, |
|
| 152 |
- symbol: String, |
|
| 153 |
- tint: Color, |
|
| 154 |
- compact: Bool = true, |
|
| 155 |
- action: @escaping () -> Void |
|
| 156 |
- ) -> some View {
|
|
| 157 |
- Button(action: action) {
|
|
| 158 |
- VStack(spacing: 10) {
|
|
| 159 |
- Image(systemName: symbol) |
|
| 160 |
- .font(.system(size: compact ? 18 : 20, weight: .semibold)) |
|
| 161 |
- Text(title) |
|
| 162 |
- .font(.footnote.weight(.semibold)) |
|
| 163 |
- .multilineTextAlignment(.center) |
|
| 164 |
- } |
|
| 165 |
- .foregroundColor(tint) |
|
| 166 |
- .frame(maxWidth: .infinity, minHeight: compact ? (usesExpandedCompactLayout ? 112 : 92) : 68) |
|
| 167 |
- .padding(.horizontal, 8) |
|
| 168 |
- .meterCard(tint: tint, fillOpacity: 0.10, strokeOpacity: 0.14) |
|
| 169 |
- } |
|
| 170 |
- .buttonStyle(.plain) |
|
| 171 |
- } |
|
| 172 | 164 |
} |
| 173 | 165 |
|
| 174 |
-struct ControlView_Previews: PreviewProvider {
|
|
| 166 |
+struct MeterScreenControlsView_Previews: PreviewProvider {
|
|
| 175 | 167 |
static var previews: some View {
|
| 176 |
- ControlView() |
|
| 168 |
+ MeterScreenControlsView() |
|
| 177 | 169 |
} |
| 178 | 170 |
} |
@@ -0,0 +1,19 @@ |
||
| 1 |
+// |
|
| 2 |
+// ScreenBrightnessEditorView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct ScreenBrightnessEditorView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ Picker("", selection: self.$meter.screenBrightness) {
|
|
| 13 |
+ ForEach(0...5, id: \.self) { value in
|
|
| 14 |
+ Text("\(value)").tag(value)
|
|
| 15 |
+ } |
|
| 16 |
+ } |
|
| 17 |
+ .pickerStyle(SegmentedPickerStyle()) |
|
| 18 |
+ } |
|
| 19 |
+} |
|
@@ -0,0 +1,20 @@ |
||
| 1 |
+// |
|
| 2 |
+// ScreenTimeoutEditorView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct ScreenTimeoutEditorView: View {
|
|
| 9 |
+ @EnvironmentObject private var meter: Meter |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ Picker("", selection: self.$meter.screenTimeout) {
|
|
| 13 |
+ ForEach(1...9, id: \.self) { value in
|
|
| 14 |
+ Text("\(value)").tag(value)
|
|
| 15 |
+ } |
|
| 16 |
+ Text("Off").tag(0)
|
|
| 17 |
+ } |
|
| 18 |
+ .pickerStyle(SegmentedPickerStyle()) |
|
| 19 |
+ } |
|
| 20 |
+} |
|
@@ -0,0 +1,82 @@ |
||
| 1 |
+// MeterMappingDebugView.swift |
|
| 2 |
+// USB Meter |
|
| 3 |
+// |
|
| 4 |
+// Created by Codex on 2026. |
|
| 5 |
+// |
|
| 6 |
+ |
|
| 7 |
+import SwiftUI |
|
| 8 |
+ |
|
| 9 |
+struct MeterMappingDebugView: View {
|
|
| 10 |
+ @State private var records: [MeterNameRecord] = [] |
|
| 11 |
+ private let store = MeterNameStore.shared |
|
| 12 |
+ private let changePublisher = NotificationCenter.default.publisher(for: .meterNameStoreDidChange) |
|
| 13 |
+ |
|
| 14 |
+ var body: some View {
|
|
| 15 |
+ List {
|
|
| 16 |
+ Section {
|
|
| 17 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 18 |
+ Text(store.currentCloudAvailability.helpTitle) |
|
| 19 |
+ .font(.headline) |
|
| 20 |
+ Text(store.currentCloudAvailability.helpMessage) |
|
| 21 |
+ .font(.caption) |
|
| 22 |
+ .foregroundColor(.secondary) |
|
| 23 |
+ } |
|
| 24 |
+ .padding(.vertical, 6) |
|
| 25 |
+ } header: {
|
|
| 26 |
+ ContextInfoHeader( |
|
| 27 |
+ title: "Sync Status", |
|
| 28 |
+ message: "This screen is limited to meter sync metadata visible on this device through the local store and iCloud KVS." |
|
| 29 |
+ ) |
|
| 30 |
+ } |
|
| 31 |
+ |
|
| 32 |
+ Section {
|
|
| 33 |
+ ForEach(records) { record in
|
|
| 34 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 35 |
+ Text(record.customName) |
|
| 36 |
+ .font(.headline) |
|
| 37 |
+ Text(record.macAddress) |
|
| 38 |
+ .font(.caption.monospaced()) |
|
| 39 |
+ .foregroundColor(.secondary) |
|
| 40 |
+ HStack {
|
|
| 41 |
+ Text("TC66 unit:")
|
|
| 42 |
+ .font(.caption.weight(.semibold)) |
|
| 43 |
+ Text(record.temperatureUnit) |
|
| 44 |
+ .font(.caption.monospaced()) |
|
| 45 |
+ .foregroundColor(.blue) |
|
| 46 |
+ } |
|
| 47 |
+ } |
|
| 48 |
+ .padding(.vertical, 8) |
|
| 49 |
+ } |
|
| 50 |
+ } header: {
|
|
| 51 |
+ Text("KVS Meter Mapping")
|
|
| 52 |
+ } |
|
| 53 |
+ } |
|
| 54 |
+ .listStyle(.insetGrouped) |
|
| 55 |
+ .navigationTitle("Meter Sync Debug")
|
|
| 56 |
+ .onAppear(perform: reload) |
|
| 57 |
+ .onReceive(changePublisher) { _ in reload() }
|
|
| 58 |
+ .toolbar {
|
|
| 59 |
+ Button("Refresh") {
|
|
| 60 |
+ reload() |
|
| 61 |
+ } |
|
| 62 |
+ } |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ private func reload() {
|
|
| 66 |
+ records = store.allRecords().map { record in
|
|
| 67 |
+ MeterNameRecord( |
|
| 68 |
+ id: record.id, |
|
| 69 |
+ macAddress: record.macAddress, |
|
| 70 |
+ customName: record.customName ?? "<unnamed>", |
|
| 71 |
+ temperatureUnit: record.temperatureUnit ?? "n/a" |
|
| 72 |
+ ) |
|
| 73 |
+ } |
|
| 74 |
+ } |
|
| 75 |
+} |
|
| 76 |
+ |
|
| 77 |
+private struct MeterNameRecord: Identifiable {
|
|
| 78 |
+ let id: String |
|
| 79 |
+ let macAddress: String |
|
| 80 |
+ let customName: String |
|
| 81 |
+ let temperatureUnit: String |
|
| 82 |
+} |
|
@@ -0,0 +1,75 @@ |
||
| 1 |
+// |
|
| 2 |
+// MeterCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct MeterCardView: View {
|
|
| 9 |
+ let meterSummary: AppData.MeterSummary |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ HStack(spacing: 14) {
|
|
| 13 |
+ Image(systemName: "sensor.tag.radiowaves.forward.fill") |
|
| 14 |
+ .font(.system(size: 18, weight: .semibold)) |
|
| 15 |
+ .foregroundColor(meterSummary.tint) |
|
| 16 |
+ .frame(width: 42, height: 42) |
|
| 17 |
+ .background( |
|
| 18 |
+ Circle() |
|
| 19 |
+ .fill(meterSummary.tint.opacity(0.18)) |
|
| 20 |
+ ) |
|
| 21 |
+ .overlay(alignment: .bottomTrailing) {
|
|
| 22 |
+ Circle() |
|
| 23 |
+ .fill(Color.red) |
|
| 24 |
+ .frame(width: 12, height: 12) |
|
| 25 |
+ .overlay( |
|
| 26 |
+ Circle() |
|
| 27 |
+ .stroke(Color(uiColor: .systemBackground), lineWidth: 2) |
|
| 28 |
+ ) |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 32 |
+ Text(meterSummary.displayName) |
|
| 33 |
+ .font(.headline) |
|
| 34 |
+ Text(meterSummary.modelSummary) |
|
| 35 |
+ .font(.caption) |
|
| 36 |
+ .foregroundColor(.secondary) |
|
| 37 |
+ if let advertisedName = meterSummary.advertisedName, advertisedName != meterSummary.modelSummary {
|
|
| 38 |
+ Text("Advertised as \(advertisedName)")
|
|
| 39 |
+ .font(.caption2) |
|
| 40 |
+ .foregroundColor(.secondary) |
|
| 41 |
+ } |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ Spacer() |
|
| 45 |
+ |
|
| 46 |
+ VStack(alignment: .trailing, spacing: 4) {
|
|
| 47 |
+ HStack(spacing: 6) {
|
|
| 48 |
+ Circle() |
|
| 49 |
+ .fill(Color.red) |
|
| 50 |
+ .frame(width: 8, height: 8) |
|
| 51 |
+ Text("Missing")
|
|
| 52 |
+ .font(.caption.weight(.semibold)) |
|
| 53 |
+ .foregroundColor(.secondary) |
|
| 54 |
+ } |
|
| 55 |
+ .padding(.horizontal, 10) |
|
| 56 |
+ .padding(.vertical, 6) |
|
| 57 |
+ .background( |
|
| 58 |
+ Capsule(style: .continuous) |
|
| 59 |
+ .fill(Color.red.opacity(0.12)) |
|
| 60 |
+ ) |
|
| 61 |
+ .overlay( |
|
| 62 |
+ Capsule(style: .continuous) |
|
| 63 |
+ .stroke(Color.red.opacity(0.22), lineWidth: 1) |
|
| 64 |
+ ) |
|
| 65 |
+ } |
|
| 66 |
+ } |
|
| 67 |
+ .padding(14) |
|
| 68 |
+ .meterCard( |
|
| 69 |
+ tint: meterSummary.tint, |
|
| 70 |
+ fillOpacity: 0.16, |
|
| 71 |
+ strokeOpacity: 0.22, |
|
| 72 |
+ cornerRadius: 18 |
|
| 73 |
+ ) |
|
| 74 |
+ } |
|
| 75 |
+} |
|
@@ -0,0 +1,41 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarLinkCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarLinkCardView: View {
|
|
| 9 |
+ let title: String |
|
| 10 |
+ let subtitle: String? |
|
| 11 |
+ let symbol: String |
|
| 12 |
+ let tint: Color |
|
| 13 |
+ |
|
| 14 |
+ var body: some View {
|
|
| 15 |
+ HStack(spacing: 14) {
|
|
| 16 |
+ Image(systemName: symbol) |
|
| 17 |
+ .font(.system(size: 18, weight: .semibold)) |
|
| 18 |
+ .foregroundColor(tint) |
|
| 19 |
+ .frame(width: 42, height: 42) |
|
| 20 |
+ .background(Circle().fill(tint.opacity(0.18))) |
|
| 21 |
+ |
|
| 22 |
+ VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 4) {
|
|
| 23 |
+ Text(title) |
|
| 24 |
+ .font(.headline) |
|
| 25 |
+ if let subtitle {
|
|
| 26 |
+ Text(subtitle) |
|
| 27 |
+ .font(.caption) |
|
| 28 |
+ .foregroundColor(.secondary) |
|
| 29 |
+ } |
|
| 30 |
+ } |
|
| 31 |
+ |
|
| 32 |
+ Spacer() |
|
| 33 |
+ |
|
| 34 |
+ Image(systemName: "chevron.right") |
|
| 35 |
+ .font(.footnote.weight(.bold)) |
|
| 36 |
+ .foregroundColor(.secondary) |
|
| 37 |
+ } |
|
| 38 |
+ .padding(14) |
|
| 39 |
+ .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) |
|
| 40 |
+ } |
|
| 41 |
+} |
|
@@ -1,17 +1,13 @@ |
||
| 1 | 1 |
// |
| 2 |
-// MeterComunicationView.swift |
|
| 2 |
+// SidebarMeterCardView.swift |
|
| 3 | 3 |
// USB Meter |
| 4 | 4 |
// |
| 5 |
-// Created by Bogdan Timofte on 05/05/2020. |
|
| 6 |
-// Copyright © 2020 Bogdan Timofte. All rights reserved. |
|
| 7 |
-// |
|
| 8 | 5 |
|
| 9 | 6 |
import SwiftUI |
| 10 | 7 |
|
| 11 |
-struct MeterRowView: View {
|
|
| 12 |
- |
|
| 8 |
+struct SidebarMeterCardView: View {
|
|
| 13 | 9 |
@EnvironmentObject private var meter: Meter |
| 14 |
- |
|
| 10 |
+ |
|
| 15 | 11 |
var body: some View {
|
| 16 | 12 |
HStack(spacing: 14) {
|
| 17 | 13 |
Image(systemName: "sensor.tag.radiowaves.forward.fill") |
@@ -61,9 +57,6 @@ struct MeterRowView: View {
|
||
| 61 | 57 |
Capsule(style: .continuous) |
| 62 | 58 |
.stroke(connectivityTint.opacity(0.22), lineWidth: 1) |
| 63 | 59 |
) |
| 64 |
- Text(meter.btSerial.macAddress.description) |
|
| 65 |
- .font(.caption2) |
|
| 66 |
- .foregroundColor(.secondary) |
|
| 67 | 60 |
} |
| 68 | 61 |
} |
| 69 | 62 |
.padding(14) |
@@ -0,0 +1,70 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarOfflineMeterCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarOfflineMeterCardView: View {
|
|
| 9 |
+ let summary: AppData.MeterSummary |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ HStack(spacing: 14) {
|
|
| 13 |
+ Image(systemName: "sensor.tag.radiowaves.forward.fill") |
|
| 14 |
+ .font(.system(size: 18, weight: .semibold)) |
|
| 15 |
+ .foregroundColor(.secondary) |
|
| 16 |
+ .frame(width: 42, height: 42) |
|
| 17 |
+ .background( |
|
| 18 |
+ Circle() |
|
| 19 |
+ .fill(Color.secondary.opacity(0.14)) |
|
| 20 |
+ ) |
|
| 21 |
+ .overlay(alignment: .bottomTrailing) {
|
|
| 22 |
+ Circle() |
|
| 23 |
+ .fill(Color.secondary) |
|
| 24 |
+ .frame(width: 12, height: 12) |
|
| 25 |
+ .overlay( |
|
| 26 |
+ Circle() |
|
| 27 |
+ .stroke(Color(uiColor: .systemBackground), lineWidth: 2) |
|
| 28 |
+ ) |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 32 |
+ Text(summary.displayName) |
|
| 33 |
+ .font(.headline) |
|
| 34 |
+ Text(summary.modelSummary.isEmpty ? "Unknown Model" : summary.modelSummary) |
|
| 35 |
+ .font(.caption) |
|
| 36 |
+ .foregroundColor(.secondary) |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 39 |
+ Spacer() |
|
| 40 |
+ |
|
| 41 |
+ VStack(alignment: .trailing, spacing: 4) {
|
|
| 42 |
+ HStack(spacing: 6) {
|
|
| 43 |
+ Circle() |
|
| 44 |
+ .fill(Color.secondary) |
|
| 45 |
+ .frame(width: 8, height: 8) |
|
| 46 |
+ Text("Offline")
|
|
| 47 |
+ .font(.caption.weight(.semibold)) |
|
| 48 |
+ .foregroundColor(.secondary) |
|
| 49 |
+ } |
|
| 50 |
+ .padding(.horizontal, 10) |
|
| 51 |
+ .padding(.vertical, 6) |
|
| 52 |
+ .background( |
|
| 53 |
+ Capsule(style: .continuous) |
|
| 54 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 55 |
+ ) |
|
| 56 |
+ .overlay( |
|
| 57 |
+ Capsule(style: .continuous) |
|
| 58 |
+ .stroke(Color.secondary.opacity(0.22), lineWidth: 1) |
|
| 59 |
+ ) |
|
| 60 |
+ } |
|
| 61 |
+ } |
|
| 62 |
+ .padding(14) |
|
| 63 |
+ .meterCard( |
|
| 64 |
+ tint: .secondary, |
|
| 65 |
+ fillOpacity: 0.10, |
|
| 66 |
+ strokeOpacity: 0.16, |
|
| 67 |
+ cornerRadius: 18 |
|
| 68 |
+ ) |
|
| 69 |
+ } |
|
| 70 |
+} |
|
@@ -0,0 +1,63 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarAutoHelpResolver.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import Foundation |
|
| 7 |
+import CoreBluetooth |
|
| 8 |
+ |
|
| 9 |
+enum SidebarAutoHelpResolver {
|
|
| 10 |
+ static func activeReason( |
|
| 11 |
+ managerState: CBManagerState, |
|
| 12 |
+ cloudAvailability: MeterNameStore.CloudAvailability, |
|
| 13 |
+ hasLiveMeters: Bool, |
|
| 14 |
+ scanStartedAt: Date?, |
|
| 15 |
+ now: Date, |
|
| 16 |
+ noDevicesHelpDelay: TimeInterval |
|
| 17 |
+ ) -> SidebarHelpReason? {
|
|
| 18 |
+ if managerState == .unauthorized {
|
|
| 19 |
+ return .bluetoothPermission |
|
| 20 |
+ } |
|
| 21 |
+ if shouldPromptForCloudSync(cloudAvailability) {
|
|
| 22 |
+ return .cloudSyncUnavailable |
|
| 23 |
+ } |
|
| 24 |
+ if hasWaitedLongEnoughForDevices( |
|
| 25 |
+ managerState: managerState, |
|
| 26 |
+ hasLiveMeters: hasLiveMeters, |
|
| 27 |
+ scanStartedAt: scanStartedAt, |
|
| 28 |
+ now: now, |
|
| 29 |
+ noDevicesHelpDelay: noDevicesHelpDelay |
|
| 30 |
+ ) {
|
|
| 31 |
+ return .noDevicesDetected |
|
| 32 |
+ } |
|
| 33 |
+ return nil |
|
| 34 |
+ } |
|
| 35 |
+ |
|
| 36 |
+ private static func shouldPromptForCloudSync(_ cloudAvailability: MeterNameStore.CloudAvailability) -> Bool {
|
|
| 37 |
+ switch cloudAvailability {
|
|
| 38 |
+ case .noAccount, .error: |
|
| 39 |
+ return true |
|
| 40 |
+ case .unknown, .available: |
|
| 41 |
+ return false |
|
| 42 |
+ } |
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ private static func hasWaitedLongEnoughForDevices( |
|
| 46 |
+ managerState: CBManagerState, |
|
| 47 |
+ hasLiveMeters: Bool, |
|
| 48 |
+ scanStartedAt: Date?, |
|
| 49 |
+ now: Date, |
|
| 50 |
+ noDevicesHelpDelay: TimeInterval |
|
| 51 |
+ ) -> Bool {
|
|
| 52 |
+ guard managerState == .poweredOn else {
|
|
| 53 |
+ return false |
|
| 54 |
+ } |
|
| 55 |
+ guard hasLiveMeters == false else {
|
|
| 56 |
+ return false |
|
| 57 |
+ } |
|
| 58 |
+ guard let scanStartedAt else {
|
|
| 59 |
+ return false |
|
| 60 |
+ } |
|
| 61 |
+ return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay |
|
| 62 |
+ } |
|
| 63 |
+} |
|
@@ -0,0 +1,141 @@ |
||
| 1 |
+// |
|
| 2 |
+// ContentSidebarHelpSectionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct ContentSidebarHelpSectionView<BluetoothHelpDestination: View, DeviceHelpDestination: View>: View {
|
|
| 9 |
+ let activeReason: SidebarHelpReason? |
|
| 10 |
+ let isExpanded: Bool |
|
| 11 |
+ let bluetoothStatusTint: Color |
|
| 12 |
+ let bluetoothStatusText: String |
|
| 13 |
+ let cloudSyncHelpTitle: String |
|
| 14 |
+ let cloudSyncHelpMessage: String |
|
| 15 |
+ let onToggle: () -> Void |
|
| 16 |
+ let onOpenSettings: () -> Void |
|
| 17 |
+ let bluetoothHelpDestination: BluetoothHelpDestination |
|
| 18 |
+ let deviceHelpDestination: DeviceHelpDestination |
|
| 19 |
+ |
|
| 20 |
+ init( |
|
| 21 |
+ activeReason: SidebarHelpReason?, |
|
| 22 |
+ isExpanded: Bool, |
|
| 23 |
+ bluetoothStatusTint: Color, |
|
| 24 |
+ bluetoothStatusText: String, |
|
| 25 |
+ cloudSyncHelpTitle: String, |
|
| 26 |
+ cloudSyncHelpMessage: String, |
|
| 27 |
+ onToggle: @escaping () -> Void, |
|
| 28 |
+ onOpenSettings: @escaping () -> Void, |
|
| 29 |
+ @ViewBuilder bluetoothHelpDestination: () -> BluetoothHelpDestination, |
|
| 30 |
+ @ViewBuilder deviceHelpDestination: () -> DeviceHelpDestination |
|
| 31 |
+ ) {
|
|
| 32 |
+ self.activeReason = activeReason |
|
| 33 |
+ self.isExpanded = isExpanded |
|
| 34 |
+ self.bluetoothStatusTint = bluetoothStatusTint |
|
| 35 |
+ self.bluetoothStatusText = bluetoothStatusText |
|
| 36 |
+ self.cloudSyncHelpTitle = cloudSyncHelpTitle |
|
| 37 |
+ self.cloudSyncHelpMessage = cloudSyncHelpMessage |
|
| 38 |
+ self.onToggle = onToggle |
|
| 39 |
+ self.onOpenSettings = onOpenSettings |
|
| 40 |
+ self.bluetoothHelpDestination = bluetoothHelpDestination() |
|
| 41 |
+ self.deviceHelpDestination = deviceHelpDestination() |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ var body: some View {
|
|
| 45 |
+ Section(header: Text("Help & Troubleshooting").font(.headline)) {
|
|
| 46 |
+ Button(action: onToggle) {
|
|
| 47 |
+ HStack(spacing: 14) {
|
|
| 48 |
+ Image(systemName: sectionSymbol) |
|
| 49 |
+ .font(.system(size: 18, weight: .semibold)) |
|
| 50 |
+ .foregroundColor(sectionTint) |
|
| 51 |
+ .frame(width: 42, height: 42) |
|
| 52 |
+ .background(Circle().fill(sectionTint.opacity(0.18))) |
|
| 53 |
+ |
|
| 54 |
+ Text("Help")
|
|
| 55 |
+ .font(.headline) |
|
| 56 |
+ |
|
| 57 |
+ Spacer() |
|
| 58 |
+ |
|
| 59 |
+ if let activeReason {
|
|
| 60 |
+ Text(activeReason.badgeTitle) |
|
| 61 |
+ .font(.caption2.weight(.bold)) |
|
| 62 |
+ .foregroundColor(activeReason.tint) |
|
| 63 |
+ .padding(.horizontal, 10) |
|
| 64 |
+ .padding(.vertical, 6) |
|
| 65 |
+ .background( |
|
| 66 |
+ Capsule(style: .continuous) |
|
| 67 |
+ .fill(activeReason.tint.opacity(0.12)) |
|
| 68 |
+ ) |
|
| 69 |
+ .overlay( |
|
| 70 |
+ Capsule(style: .continuous) |
|
| 71 |
+ .stroke(activeReason.tint.opacity(0.22), lineWidth: 1) |
|
| 72 |
+ ) |
|
| 73 |
+ } |
|
| 74 |
+ |
|
| 75 |
+ Image(systemName: isExpanded ? "chevron.up" : "chevron.down") |
|
| 76 |
+ .font(.footnote.weight(.bold)) |
|
| 77 |
+ .foregroundColor(.secondary) |
|
| 78 |
+ } |
|
| 79 |
+ .padding(14) |
|
| 80 |
+ .meterCard(tint: sectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) |
|
| 81 |
+ } |
|
| 82 |
+ .buttonStyle(.plain) |
|
| 83 |
+ |
|
| 84 |
+ if isExpanded {
|
|
| 85 |
+ if let activeReason {
|
|
| 86 |
+ SidebarHelpNoticeCardView( |
|
| 87 |
+ reason: activeReason, |
|
| 88 |
+ cloudSyncHelpTitle: cloudSyncHelpTitle, |
|
| 89 |
+ cloudSyncHelpMessage: cloudSyncHelpMessage |
|
| 90 |
+ ) |
|
| 91 |
+ } |
|
| 92 |
+ |
|
| 93 |
+ SidebarBluetoothStatusCardView( |
|
| 94 |
+ tint: bluetoothStatusTint, |
|
| 95 |
+ statusText: bluetoothStatusText |
|
| 96 |
+ ) |
|
| 97 |
+ |
|
| 98 |
+ if activeReason == .cloudSyncUnavailable {
|
|
| 99 |
+ Button(action: onOpenSettings) {
|
|
| 100 |
+ SidebarLinkCardView( |
|
| 101 |
+ title: "Open Settings", |
|
| 102 |
+ subtitle: nil, |
|
| 103 |
+ symbol: "gearshape.fill", |
|
| 104 |
+ tint: .indigo |
|
| 105 |
+ ) |
|
| 106 |
+ } |
|
| 107 |
+ .buttonStyle(.plain) |
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ NavigationLink(destination: bluetoothHelpDestination) {
|
|
| 111 |
+ SidebarLinkCardView( |
|
| 112 |
+ title: "Bluetooth", |
|
| 113 |
+ subtitle: nil, |
|
| 114 |
+ symbol: "bolt.horizontal.circle.fill", |
|
| 115 |
+ tint: bluetoothStatusTint |
|
| 116 |
+ ) |
|
| 117 |
+ } |
|
| 118 |
+ .buttonStyle(.plain) |
|
| 119 |
+ |
|
| 120 |
+ NavigationLink(destination: deviceHelpDestination) {
|
|
| 121 |
+ SidebarLinkCardView( |
|
| 122 |
+ title: "Device", |
|
| 123 |
+ subtitle: nil, |
|
| 124 |
+ symbol: "questionmark.circle.fill", |
|
| 125 |
+ tint: .orange |
|
| 126 |
+ ) |
|
| 127 |
+ } |
|
| 128 |
+ .buttonStyle(.plain) |
|
| 129 |
+ } |
|
| 130 |
+ } |
|
| 131 |
+ .animation(.easeInOut(duration: 0.22), value: isExpanded) |
|
| 132 |
+ } |
|
| 133 |
+ |
|
| 134 |
+ private var sectionTint: Color {
|
|
| 135 |
+ activeReason?.tint ?? .secondary |
|
| 136 |
+ } |
|
| 137 |
+ |
|
| 138 |
+ private var sectionSymbol: String {
|
|
| 139 |
+ activeReason?.symbol ?? "questionmark.circle.fill" |
|
| 140 |
+ } |
|
| 141 |
+} |
|
@@ -0,0 +1,32 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarBluetoothStatusCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarBluetoothStatusCardView: View {
|
|
| 9 |
+ let tint: Color |
|
| 10 |
+ let statusText: String |
|
| 11 |
+ |
|
| 12 |
+ var body: some View {
|
|
| 13 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 14 |
+ HStack(spacing: 8) {
|
|
| 15 |
+ Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
|
|
| 16 |
+ .font(.footnote.weight(.semibold)) |
|
| 17 |
+ .foregroundColor(tint) |
|
| 18 |
+ ContextInfoButton( |
|
| 19 |
+ title: "Bluetooth", |
|
| 20 |
+ message: "Refer to this adapter state while walking through the Bluetooth and Device troubleshooting steps.", |
|
| 21 |
+ popoverWidth: 300 |
|
| 22 |
+ ) |
|
| 23 |
+ Spacer() |
|
| 24 |
+ Text(statusText) |
|
| 25 |
+ .font(.caption.weight(.semibold)) |
|
| 26 |
+ .foregroundColor(.secondary) |
|
| 27 |
+ } |
|
| 28 |
+ } |
|
| 29 |
+ .padding(14) |
|
| 30 |
+ .meterCard(tint: tint, fillOpacity: 0.22, strokeOpacity: 0.26, cornerRadius: 18) |
|
| 31 |
+ } |
|
| 32 |
+} |
|
@@ -0,0 +1,47 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarHelpNoticeCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarHelpNoticeCardView: View {
|
|
| 9 |
+ let reason: SidebarHelpReason |
|
| 10 |
+ let cloudSyncHelpTitle: String |
|
| 11 |
+ let cloudSyncHelpMessage: String |
|
| 12 |
+ |
|
| 13 |
+ var body: some View {
|
|
| 14 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 15 |
+ Text(helpNoticeTitle) |
|
| 16 |
+ .font(.subheadline.weight(.semibold)) |
|
| 17 |
+ Text(helpNoticeDetail) |
|
| 18 |
+ .font(.caption) |
|
| 19 |
+ .foregroundColor(.secondary) |
|
| 20 |
+ } |
|
| 21 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 22 |
+ .padding(14) |
|
| 23 |
+ .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 26 |
+ private var helpNoticeTitle: String {
|
|
| 27 |
+ switch reason {
|
|
| 28 |
+ case .bluetoothPermission: |
|
| 29 |
+ return "Bluetooth access needs attention" |
|
| 30 |
+ case .cloudSyncUnavailable: |
|
| 31 |
+ return cloudSyncHelpTitle |
|
| 32 |
+ case .noDevicesDetected: |
|
| 33 |
+ return "No supported meters found yet" |
|
| 34 |
+ } |
|
| 35 |
+ } |
|
| 36 |
+ |
|
| 37 |
+ private var helpNoticeDetail: String {
|
|
| 38 |
+ switch reason {
|
|
| 39 |
+ case .bluetoothPermission: |
|
| 40 |
+ return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked." |
|
| 41 |
+ case .cloudSyncUnavailable: |
|
| 42 |
+ return cloudSyncHelpMessage |
|
| 43 |
+ case .noDevicesDetected: |
|
| 44 |
+ return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone." |
|
| 45 |
+ } |
|
| 46 |
+ } |
|
| 47 |
+} |
|
@@ -0,0 +1,45 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarHelpReason.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+enum SidebarHelpReason: String {
|
|
| 9 |
+ case bluetoothPermission |
|
| 10 |
+ case cloudSyncUnavailable |
|
| 11 |
+ case noDevicesDetected |
|
| 12 |
+ |
|
| 13 |
+ var tint: Color {
|
|
| 14 |
+ switch self {
|
|
| 15 |
+ case .bluetoothPermission: |
|
| 16 |
+ return .orange |
|
| 17 |
+ case .cloudSyncUnavailable: |
|
| 18 |
+ return .indigo |
|
| 19 |
+ case .noDevicesDetected: |
|
| 20 |
+ return .yellow |
|
| 21 |
+ } |
|
| 22 |
+ } |
|
| 23 |
+ |
|
| 24 |
+ var symbol: String {
|
|
| 25 |
+ switch self {
|
|
| 26 |
+ case .bluetoothPermission: |
|
| 27 |
+ return "bolt.horizontal.circle.fill" |
|
| 28 |
+ case .cloudSyncUnavailable: |
|
| 29 |
+ return "icloud.slash.fill" |
|
| 30 |
+ case .noDevicesDetected: |
|
| 31 |
+ return "magnifyingglass.circle.fill" |
|
| 32 |
+ } |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ var badgeTitle: String {
|
|
| 36 |
+ switch self {
|
|
| 37 |
+ case .bluetoothPermission: |
|
| 38 |
+ return "Required" |
|
| 39 |
+ case .cloudSyncUnavailable: |
|
| 40 |
+ return "Sync Off" |
|
| 41 |
+ case .noDevicesDetected: |
|
| 42 |
+ return "Suggested" |
|
| 43 |
+ } |
|
| 44 |
+ } |
|
| 45 |
+} |
|
@@ -0,0 +1,22 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarDebugSectionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarDebugSectionView: View {
|
|
| 9 |
+ var body: some View {
|
|
| 10 |
+ Section(header: Text("Debug").font(.headline)) {
|
|
| 11 |
+ NavigationLink(destination: MeterMappingDebugView()) {
|
|
| 12 |
+ SidebarLinkCardView( |
|
| 13 |
+ title: "Meter Sync Debug", |
|
| 14 |
+ subtitle: "Inspect meter name sync data and iCloud KVS visibility as seen by this device.", |
|
| 15 |
+ symbol: "list.bullet.rectangle", |
|
| 16 |
+ tint: .purple |
|
| 17 |
+ ) |
|
| 18 |
+ } |
|
| 19 |
+ .buttonStyle(.plain) |
|
| 20 |
+ } |
|
| 21 |
+ } |
|
| 22 |
+} |
|
@@ -0,0 +1,141 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarHelpSectionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarHelpSectionView<BluetoothHelpDestination: View, DeviceHelpDestination: View>: View {
|
|
| 9 |
+ let activeReason: SidebarHelpReason? |
|
| 10 |
+ let isExpanded: Bool |
|
| 11 |
+ let bluetoothStatusTint: Color |
|
| 12 |
+ let bluetoothStatusText: String |
|
| 13 |
+ let cloudSyncHelpTitle: String |
|
| 14 |
+ let cloudSyncHelpMessage: String |
|
| 15 |
+ let onToggle: () -> Void |
|
| 16 |
+ let onOpenSettings: () -> Void |
|
| 17 |
+ let bluetoothHelpDestination: BluetoothHelpDestination |
|
| 18 |
+ let deviceHelpDestination: DeviceHelpDestination |
|
| 19 |
+ |
|
| 20 |
+ init( |
|
| 21 |
+ activeReason: SidebarHelpReason?, |
|
| 22 |
+ isExpanded: Bool, |
|
| 23 |
+ bluetoothStatusTint: Color, |
|
| 24 |
+ bluetoothStatusText: String, |
|
| 25 |
+ cloudSyncHelpTitle: String, |
|
| 26 |
+ cloudSyncHelpMessage: String, |
|
| 27 |
+ onToggle: @escaping () -> Void, |
|
| 28 |
+ onOpenSettings: @escaping () -> Void, |
|
| 29 |
+ @ViewBuilder bluetoothHelpDestination: () -> BluetoothHelpDestination, |
|
| 30 |
+ @ViewBuilder deviceHelpDestination: () -> DeviceHelpDestination |
|
| 31 |
+ ) {
|
|
| 32 |
+ self.activeReason = activeReason |
|
| 33 |
+ self.isExpanded = isExpanded |
|
| 34 |
+ self.bluetoothStatusTint = bluetoothStatusTint |
|
| 35 |
+ self.bluetoothStatusText = bluetoothStatusText |
|
| 36 |
+ self.cloudSyncHelpTitle = cloudSyncHelpTitle |
|
| 37 |
+ self.cloudSyncHelpMessage = cloudSyncHelpMessage |
|
| 38 |
+ self.onToggle = onToggle |
|
| 39 |
+ self.onOpenSettings = onOpenSettings |
|
| 40 |
+ self.bluetoothHelpDestination = bluetoothHelpDestination() |
|
| 41 |
+ self.deviceHelpDestination = deviceHelpDestination() |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ var body: some View {
|
|
| 45 |
+ Section(header: Text("Help & Troubleshooting").font(.headline)) {
|
|
| 46 |
+ Button(action: onToggle) {
|
|
| 47 |
+ HStack(spacing: 14) {
|
|
| 48 |
+ Image(systemName: sectionSymbol) |
|
| 49 |
+ .font(.system(size: 18, weight: .semibold)) |
|
| 50 |
+ .foregroundColor(sectionTint) |
|
| 51 |
+ .frame(width: 42, height: 42) |
|
| 52 |
+ .background(Circle().fill(sectionTint.opacity(0.18))) |
|
| 53 |
+ |
|
| 54 |
+ Text("Help")
|
|
| 55 |
+ .font(.headline) |
|
| 56 |
+ |
|
| 57 |
+ Spacer() |
|
| 58 |
+ |
|
| 59 |
+ if let activeReason {
|
|
| 60 |
+ Text(activeReason.badgeTitle) |
|
| 61 |
+ .font(.caption2.weight(.bold)) |
|
| 62 |
+ .foregroundColor(activeReason.tint) |
|
| 63 |
+ .padding(.horizontal, 10) |
|
| 64 |
+ .padding(.vertical, 6) |
|
| 65 |
+ .background( |
|
| 66 |
+ Capsule(style: .continuous) |
|
| 67 |
+ .fill(activeReason.tint.opacity(0.12)) |
|
| 68 |
+ ) |
|
| 69 |
+ .overlay( |
|
| 70 |
+ Capsule(style: .continuous) |
|
| 71 |
+ .stroke(activeReason.tint.opacity(0.22), lineWidth: 1) |
|
| 72 |
+ ) |
|
| 73 |
+ } |
|
| 74 |
+ |
|
| 75 |
+ Image(systemName: isExpanded ? "chevron.up" : "chevron.down") |
|
| 76 |
+ .font(.footnote.weight(.bold)) |
|
| 77 |
+ .foregroundColor(.secondary) |
|
| 78 |
+ } |
|
| 79 |
+ .padding(14) |
|
| 80 |
+ .meterCard(tint: sectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) |
|
| 81 |
+ } |
|
| 82 |
+ .buttonStyle(.plain) |
|
| 83 |
+ |
|
| 84 |
+ if isExpanded {
|
|
| 85 |
+ if let activeReason {
|
|
| 86 |
+ SidebarHelpNoticeCardView( |
|
| 87 |
+ reason: activeReason, |
|
| 88 |
+ cloudSyncHelpTitle: cloudSyncHelpTitle, |
|
| 89 |
+ cloudSyncHelpMessage: cloudSyncHelpMessage |
|
| 90 |
+ ) |
|
| 91 |
+ } |
|
| 92 |
+ |
|
| 93 |
+ SidebarBluetoothStatusCardView( |
|
| 94 |
+ tint: bluetoothStatusTint, |
|
| 95 |
+ statusText: bluetoothStatusText |
|
| 96 |
+ ) |
|
| 97 |
+ |
|
| 98 |
+ if activeReason == .cloudSyncUnavailable {
|
|
| 99 |
+ Button(action: onOpenSettings) {
|
|
| 100 |
+ SidebarLinkCardView( |
|
| 101 |
+ title: "Open Settings", |
|
| 102 |
+ subtitle: nil, |
|
| 103 |
+ symbol: "gearshape.fill", |
|
| 104 |
+ tint: .indigo |
|
| 105 |
+ ) |
|
| 106 |
+ } |
|
| 107 |
+ .buttonStyle(.plain) |
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ NavigationLink(destination: bluetoothHelpDestination) {
|
|
| 111 |
+ SidebarLinkCardView( |
|
| 112 |
+ title: "Bluetooth", |
|
| 113 |
+ subtitle: nil, |
|
| 114 |
+ symbol: "bolt.horizontal.circle.fill", |
|
| 115 |
+ tint: bluetoothStatusTint |
|
| 116 |
+ ) |
|
| 117 |
+ } |
|
| 118 |
+ .buttonStyle(.plain) |
|
| 119 |
+ |
|
| 120 |
+ NavigationLink(destination: deviceHelpDestination) {
|
|
| 121 |
+ SidebarLinkCardView( |
|
| 122 |
+ title: "Device", |
|
| 123 |
+ subtitle: nil, |
|
| 124 |
+ symbol: "questionmark.circle.fill", |
|
| 125 |
+ tint: .orange |
|
| 126 |
+ ) |
|
| 127 |
+ } |
|
| 128 |
+ .buttonStyle(.plain) |
|
| 129 |
+ } |
|
| 130 |
+ } |
|
| 131 |
+ .animation(.easeInOut(duration: 0.22), value: isExpanded) |
|
| 132 |
+ } |
|
| 133 |
+ |
|
| 134 |
+ private var sectionTint: Color {
|
|
| 135 |
+ activeReason?.tint ?? .secondary |
|
| 136 |
+ } |
|
| 137 |
+ |
|
| 138 |
+ private var sectionSymbol: String {
|
|
| 139 |
+ activeReason?.symbol ?? "questionmark.circle.fill" |
|
| 140 |
+ } |
|
| 141 |
+} |
|
@@ -0,0 +1,134 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarUSBMetersSectionView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+import CoreBluetooth |
|
| 8 |
+ |
|
| 9 |
+struct SidebarUSBMetersSectionView: View {
|
|
| 10 |
+ let meters: [AppData.MeterSummary] |
|
| 11 |
+ let managerState: CBManagerState |
|
| 12 |
+ let hasLiveMeters: Bool |
|
| 13 |
+ let scanStartedAt: Date? |
|
| 14 |
+ let now: Date |
|
| 15 |
+ let noDevicesHelpDelay: TimeInterval |
|
| 16 |
+ let isExpanded: Bool |
|
| 17 |
+ let onToggle: () -> Void |
|
| 18 |
+ let onAddMeter: () -> Void |
|
| 19 |
+ |
|
| 20 |
+ var body: some View {
|
|
| 21 |
+ Section(header: usbSectionHeader) {
|
|
| 22 |
+ if isExpanded {
|
|
| 23 |
+ if meters.isEmpty {
|
|
| 24 |
+ Text(devicesEmptyStateText) |
|
| 25 |
+ .font(.footnote) |
|
| 26 |
+ .foregroundColor(.secondary) |
|
| 27 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 28 |
+ .padding(18) |
|
| 29 |
+ .meterCard( |
|
| 30 |
+ tint: isWaitingForFirstDiscovery ? .blue : .secondary, |
|
| 31 |
+ fillOpacity: 0.14, |
|
| 32 |
+ strokeOpacity: 0.20 |
|
| 33 |
+ ) |
|
| 34 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 35 |
+ } else {
|
|
| 36 |
+ ForEach(meters) { meterSummary in
|
|
| 37 |
+ if let meter = meterSummary.meter {
|
|
| 38 |
+ NavigationLink(destination: MeterView().environmentObject(meter)) {
|
|
| 39 |
+ SidebarMeterCardView() |
|
| 40 |
+ .environmentObject(meter) |
|
| 41 |
+ } |
|
| 42 |
+ .buttonStyle(.plain) |
|
| 43 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 44 |
+ } else {
|
|
| 45 |
+ NavigationLink(destination: MeterView(offlineSummary: meterSummary)) {
|
|
| 46 |
+ SidebarOfflineMeterCardView(summary: meterSummary) |
|
| 47 |
+ } |
|
| 48 |
+ .buttonStyle(.plain) |
|
| 49 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 50 |
+ } |
|
| 51 |
+ } |
|
| 52 |
+ } |
|
| 53 |
+ } |
|
| 54 |
+ } |
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 57 |
+ private var isWaitingForFirstDiscovery: Bool {
|
|
| 58 |
+ guard managerState == .poweredOn else {
|
|
| 59 |
+ return false |
|
| 60 |
+ } |
|
| 61 |
+ guard hasLiveMeters == false else {
|
|
| 62 |
+ return false |
|
| 63 |
+ } |
|
| 64 |
+ guard let scanStartedAt else {
|
|
| 65 |
+ return false |
|
| 66 |
+ } |
|
| 67 |
+ return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay |
|
| 68 |
+ } |
|
| 69 |
+ |
|
| 70 |
+ private var devicesEmptyStateText: String {
|
|
| 71 |
+ if isWaitingForFirstDiscovery {
|
|
| 72 |
+ return "Scanning for nearby supported meters..." |
|
| 73 |
+ } |
|
| 74 |
+ return "No meters yet. Nearby supported meters will appear here and remain available after they disappear." |
|
| 75 |
+ } |
|
| 76 |
+ |
|
| 77 |
+ private var usbSectionHeader: some View {
|
|
| 78 |
+ HStack(alignment: .firstTextBaseline) {
|
|
| 79 |
+ Button(action: onToggle) {
|
|
| 80 |
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
|
|
| 81 |
+ Image(systemName: "chevron.right") |
|
| 82 |
+ .font(.caption.weight(.semibold)) |
|
| 83 |
+ .foregroundColor(.secondary) |
|
| 84 |
+ .rotationEffect(.degrees(isExpanded ? 90 : 0)) |
|
| 85 |
+ .animation(.easeInOut(duration: 0.22), value: isExpanded) |
|
| 86 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 87 |
+ Text("USB & Known Meters")
|
|
| 88 |
+ .font(.headline) |
|
| 89 |
+ if meters.isEmpty == false {
|
|
| 90 |
+ Text(sectionSubtitleText) |
|
| 91 |
+ .font(.caption) |
|
| 92 |
+ .foregroundColor(.secondary) |
|
| 93 |
+ .lineLimit(1) |
|
| 94 |
+ } |
|
| 95 |
+ } |
|
| 96 |
+ } |
|
| 97 |
+ } |
|
| 98 |
+ .buttonStyle(.plain) |
|
| 99 |
+ Spacer() |
|
| 100 |
+ Button(action: onAddMeter) {
|
|
| 101 |
+ Image(systemName: "plus.circle.fill") |
|
| 102 |
+ .font(.body.weight(.semibold)) |
|
| 103 |
+ .foregroundColor(.blue) |
|
| 104 |
+ } |
|
| 105 |
+ .buttonStyle(.plain) |
|
| 106 |
+ Text("\(meters.count)")
|
|
| 107 |
+ .font(.caption.weight(.bold)) |
|
| 108 |
+ .padding(.horizontal, 10) |
|
| 109 |
+ .padding(.vertical, 6) |
|
| 110 |
+ .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
|
| 111 |
+ } |
|
| 112 |
+ } |
|
| 113 |
+ |
|
| 114 |
+ private var sectionSubtitleText: String {
|
|
| 115 |
+ switch (liveMeterCount, offlineMeterCount) {
|
|
| 116 |
+ case let (live, offline) where live > 0 && offline > 0: |
|
| 117 |
+ return "\(live) live • \(offline) stored" |
|
| 118 |
+ case let (live, _) where live > 0: |
|
| 119 |
+ return "\(live) live meter\(live == 1 ? "" : "s")" |
|
| 120 |
+ case let (_, offline) where offline > 0: |
|
| 121 |
+ return "\(offline) known meter\(offline == 1 ? "" : "s")" |
|
| 122 |
+ default: |
|
| 123 |
+ return "" |
|
| 124 |
+ } |
|
| 125 |
+ } |
|
| 126 |
+ |
|
| 127 |
+ private var liveMeterCount: Int {
|
|
| 128 |
+ meters.filter { $0.meter != nil }.count
|
|
| 129 |
+ } |
|
| 130 |
+ |
|
| 131 |
+ private var offlineMeterCount: Int {
|
|
| 132 |
+ max(0, meters.count - liveMeterCount) |
|
| 133 |
+ } |
|
| 134 |
+} |
|
@@ -0,0 +1,53 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarListView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarListView<USBMetersSection: View, HelpSection: View, DebugSection: View>: View {
|
|
| 9 |
+ let backgroundTint: Color |
|
| 10 |
+ let usbMetersSection: USBMetersSection |
|
| 11 |
+ let helpSection: HelpSection |
|
| 12 |
+ let debugSection: DebugSection |
|
| 13 |
+ |
|
| 14 |
+ init( |
|
| 15 |
+ backgroundTint: Color, |
|
| 16 |
+ @ViewBuilder usbMetersSection: () -> USBMetersSection, |
|
| 17 |
+ @ViewBuilder helpSection: () -> HelpSection, |
|
| 18 |
+ @ViewBuilder debugSection: () -> DebugSection |
|
| 19 |
+ ) {
|
|
| 20 |
+ self.backgroundTint = backgroundTint |
|
| 21 |
+ self.usbMetersSection = usbMetersSection() |
|
| 22 |
+ self.helpSection = helpSection() |
|
| 23 |
+ self.debugSection = debugSection() |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 26 |
+ var body: some View {
|
|
| 27 |
+ if #available(iOS 16.0, *) {
|
|
| 28 |
+ listBody.scrollContentBackground(.hidden) |
|
| 29 |
+ } else {
|
|
| 30 |
+ listBody |
|
| 31 |
+ } |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 34 |
+ private var listBody: some View {
|
|
| 35 |
+ List {
|
|
| 36 |
+ usbMetersSection |
|
| 37 |
+ helpSection |
|
| 38 |
+ debugSection |
|
| 39 |
+ } |
|
| 40 |
+ .listStyle(SidebarListStyle()) |
|
| 41 |
+ .background( |
|
| 42 |
+ LinearGradient( |
|
| 43 |
+ colors: [ |
|
| 44 |
+ backgroundTint.opacity(0.18), |
|
| 45 |
+ Color.clear |
|
| 46 |
+ ], |
|
| 47 |
+ startPoint: .topLeading, |
|
| 48 |
+ endPoint: .bottomTrailing |
|
| 49 |
+ ) |
|
| 50 |
+ .ignoresSafeArea() |
|
| 51 |
+ ) |
|
| 52 |
+ } |
|
| 53 |
+} |
|
@@ -0,0 +1,293 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+import Combine |
|
| 8 |
+ |
|
| 9 |
+private enum SidebarCreationSheet: Identifiable {
|
|
| 10 |
+ case meter |
|
| 11 |
+ case device |
|
| 12 |
+ case charger |
|
| 13 |
+ |
|
| 14 |
+ var id: String {
|
|
| 15 |
+ switch self {
|
|
| 16 |
+ case .meter: |
|
| 17 |
+ return "meter" |
|
| 18 |
+ case .device: |
|
| 19 |
+ return "device" |
|
| 20 |
+ case .charger: |
|
| 21 |
+ return "charger" |
|
| 22 |
+ } |
|
| 23 |
+ } |
|
| 24 |
+} |
|
| 25 |
+ |
|
| 26 |
+struct SidebarView: View {
|
|
| 27 |
+ @EnvironmentObject private var appData: AppData |
|
| 28 |
+ @State private var isUSBMetersExpanded = true |
|
| 29 |
+ @State private var isDevicesExpanded = true |
|
| 30 |
+ @State private var isChargersExpanded = true |
|
| 31 |
+ @State private var isHelpExpanded = false |
|
| 32 |
+ @State private var dismissedAutoHelpReason: SidebarHelpReason? |
|
| 33 |
+ @State private var now = Date() |
|
| 34 |
+ @State private var creationSheet: SidebarCreationSheet? |
|
| 35 |
+ private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() |
|
| 36 |
+ private let noDevicesHelpDelay: TimeInterval = 12 |
|
| 37 |
+ |
|
| 38 |
+ var body: some View {
|
|
| 39 |
+ SidebarListView(backgroundTint: appData.bluetoothManager.managerState.color) {
|
|
| 40 |
+ usbMetersSection |
|
| 41 |
+ } helpSection: {
|
|
| 42 |
+ helpSection |
|
| 43 |
+ } debugSection: {
|
|
| 44 |
+ debugSection |
|
| 45 |
+ } |
|
| 46 |
+ .onAppear {
|
|
| 47 |
+ appData.bluetoothManager.start() |
|
| 48 |
+ now = Date() |
|
| 49 |
+ } |
|
| 50 |
+ .onReceive(helpRefreshTimer) { currentDate in
|
|
| 51 |
+ now = currentDate |
|
| 52 |
+ } |
|
| 53 |
+ .onChange(of: activeHelpAutoReason) { newReason in
|
|
| 54 |
+ if newReason == nil {
|
|
| 55 |
+ dismissedAutoHelpReason = nil |
|
| 56 |
+ } |
|
| 57 |
+ } |
|
| 58 |
+ .sheet(item: $creationSheet) { sheet in
|
|
| 59 |
+ switch sheet {
|
|
| 60 |
+ case .meter: |
|
| 61 |
+ MeterEditorSheetView() |
|
| 62 |
+ .environmentObject(appData) |
|
| 63 |
+ case .device: |
|
| 64 |
+ ChargedDeviceEditorSheetView( |
|
| 65 |
+ meterMACAddress: nil |
|
| 66 |
+ ) |
|
| 67 |
+ .environmentObject(appData) |
|
| 68 |
+ case .charger: |
|
| 69 |
+ ChargerEditorSheetView() |
|
| 70 |
+ .environmentObject(appData) |
|
| 71 |
+ } |
|
| 72 |
+ } |
|
| 73 |
+ } |
|
| 74 |
+ |
|
| 75 |
+ private var usbMetersSection: some View {
|
|
| 76 |
+ Group {
|
|
| 77 |
+ SidebarUSBMetersSectionView( |
|
| 78 |
+ meters: appData.meterSummaries, |
|
| 79 |
+ managerState: appData.bluetoothManager.managerState, |
|
| 80 |
+ hasLiveMeters: appData.meters.isEmpty == false, |
|
| 81 |
+ scanStartedAt: appData.bluetoothManager.scanStartedAt, |
|
| 82 |
+ now: now, |
|
| 83 |
+ noDevicesHelpDelay: noDevicesHelpDelay, |
|
| 84 |
+ isExpanded: isUSBMetersExpanded, |
|
| 85 |
+ onToggle: {
|
|
| 86 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 87 |
+ isUSBMetersExpanded.toggle() |
|
| 88 |
+ } |
|
| 89 |
+ }, |
|
| 90 |
+ onAddMeter: { creationSheet = .meter }
|
|
| 91 |
+ ) |
|
| 92 |
+ |
|
| 93 |
+ SidebarChargedDevicesSectionView( |
|
| 94 |
+ title: "Devices", |
|
| 95 |
+ mode: .device, |
|
| 96 |
+ chargedDevices: appData.deviceSummaries, |
|
| 97 |
+ emptyStateText: "No devices yet. Open Charge Record on a live meter or use the add button here to create one and start learning capacity.", |
|
| 98 |
+ tint: .orange, |
|
| 99 |
+ isExpanded: isDevicesExpanded, |
|
| 100 |
+ onToggle: {
|
|
| 101 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 102 |
+ isDevicesExpanded.toggle() |
|
| 103 |
+ } |
|
| 104 |
+ }, |
|
| 105 |
+ onAdd: { creationSheet = .device }
|
|
| 106 |
+ ) |
|
| 107 |
+ |
|
| 108 |
+ SidebarChargedDevicesSectionView( |
|
| 109 |
+ title: "Chargers", |
|
| 110 |
+ mode: .charger, |
|
| 111 |
+ chargedDevices: appData.chargerSummaries, |
|
| 112 |
+ emptyStateText: "No chargers yet. Add one here so wireless sessions can track both the charged device and the charger being used.", |
|
| 113 |
+ tint: .pink, |
|
| 114 |
+ isExpanded: isChargersExpanded, |
|
| 115 |
+ onToggle: {
|
|
| 116 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 117 |
+ isChargersExpanded.toggle() |
|
| 118 |
+ } |
|
| 119 |
+ }, |
|
| 120 |
+ onAdd: { creationSheet = .charger }
|
|
| 121 |
+ ) |
|
| 122 |
+ } |
|
| 123 |
+ } |
|
| 124 |
+ |
|
| 125 |
+ private var helpSection: some View {
|
|
| 126 |
+ SidebarHelpSectionView( |
|
| 127 |
+ activeReason: activeHelpAutoReason, |
|
| 128 |
+ isExpanded: helpIsExpanded, |
|
| 129 |
+ bluetoothStatusTint: appData.bluetoothManager.managerState.color, |
|
| 130 |
+ bluetoothStatusText: bluetoothStatusText, |
|
| 131 |
+ cloudSyncHelpTitle: appData.cloudAvailability.helpTitle, |
|
| 132 |
+ cloudSyncHelpMessage: appData.cloudAvailability.helpMessage, |
|
| 133 |
+ onToggle: toggleHelpSection, |
|
| 134 |
+ onOpenSettings: openSettings |
|
| 135 |
+ ) {
|
|
| 136 |
+ appData.bluetoothManager.managerState.helpView |
|
| 137 |
+ } deviceHelpDestination: {
|
|
| 138 |
+ DeviceHelpView() |
|
| 139 |
+ } |
|
| 140 |
+ } |
|
| 141 |
+ |
|
| 142 |
+ private var debugSection: some View {
|
|
| 143 |
+ SidebarDebugSectionView() |
|
| 144 |
+ } |
|
| 145 |
+ |
|
| 146 |
+ private var bluetoothStatusText: String {
|
|
| 147 |
+ switch appData.bluetoothManager.managerState {
|
|
| 148 |
+ case .poweredOff: |
|
| 149 |
+ return "Off" |
|
| 150 |
+ case .poweredOn: |
|
| 151 |
+ return "On" |
|
| 152 |
+ case .resetting: |
|
| 153 |
+ return "Resetting" |
|
| 154 |
+ case .unauthorized: |
|
| 155 |
+ return "Unauthorized" |
|
| 156 |
+ case .unknown: |
|
| 157 |
+ return "Unknown" |
|
| 158 |
+ case .unsupported: |
|
| 159 |
+ return "Unsupported" |
|
| 160 |
+ @unknown default: |
|
| 161 |
+ return "Other" |
|
| 162 |
+ } |
|
| 163 |
+ } |
|
| 164 |
+ |
|
| 165 |
+ private var helpIsExpanded: Bool {
|
|
| 166 |
+ isHelpExpanded || shouldAutoExpandHelp |
|
| 167 |
+ } |
|
| 168 |
+ |
|
| 169 |
+ private var shouldAutoExpandHelp: Bool {
|
|
| 170 |
+ guard let activeHelpAutoReason else {
|
|
| 171 |
+ return false |
|
| 172 |
+ } |
|
| 173 |
+ return dismissedAutoHelpReason != activeHelpAutoReason |
|
| 174 |
+ } |
|
| 175 |
+ |
|
| 176 |
+ private var activeHelpAutoReason: SidebarHelpReason? {
|
|
| 177 |
+ SidebarAutoHelpResolver.activeReason( |
|
| 178 |
+ managerState: appData.bluetoothManager.managerState, |
|
| 179 |
+ cloudAvailability: appData.cloudAvailability, |
|
| 180 |
+ hasLiveMeters: appData.meters.isEmpty == false, |
|
| 181 |
+ scanStartedAt: appData.bluetoothManager.scanStartedAt, |
|
| 182 |
+ now: now, |
|
| 183 |
+ noDevicesHelpDelay: noDevicesHelpDelay |
|
| 184 |
+ ) |
|
| 185 |
+ } |
|
| 186 |
+ |
|
| 187 |
+ private func toggleHelpSection() {
|
|
| 188 |
+ withAnimation(.easeInOut(duration: 0.22)) {
|
|
| 189 |
+ if shouldAutoExpandHelp {
|
|
| 190 |
+ dismissedAutoHelpReason = activeHelpAutoReason |
|
| 191 |
+ isHelpExpanded = false |
|
| 192 |
+ } else {
|
|
| 193 |
+ isHelpExpanded.toggle() |
|
| 194 |
+ } |
|
| 195 |
+ } |
|
| 196 |
+ } |
|
| 197 |
+ |
|
| 198 |
+ private func openSettings() {
|
|
| 199 |
+ guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
|
|
| 200 |
+ return |
|
| 201 |
+ } |
|
| 202 |
+ UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil) |
|
| 203 |
+ } |
|
| 204 |
+} |
|
| 205 |
+ |
|
| 206 |
+// MARK: - Meter Editor Sheet |
|
| 207 |
+ |
|
| 208 |
+struct MeterEditorSheetView: View {
|
|
| 209 |
+ @EnvironmentObject private var appData: AppData |
|
| 210 |
+ @Environment(\.dismiss) private var dismiss |
|
| 211 |
+ |
|
| 212 |
+ let existingMeterSummary: AppData.MeterSummary? |
|
| 213 |
+ |
|
| 214 |
+ @State private var customName: String |
|
| 215 |
+ @State private var macAddress: String |
|
| 216 |
+ @State private var advertisedName: String |
|
| 217 |
+ @State private var selectedModel: Model |
|
| 218 |
+ |
|
| 219 |
+ init(existingMeterSummary: AppData.MeterSummary? = nil) {
|
|
| 220 |
+ self.existingMeterSummary = existingMeterSummary |
|
| 221 |
+ _customName = State(initialValue: existingMeterSummary?.displayName ?? "") |
|
| 222 |
+ _macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "") |
|
| 223 |
+ _advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "") |
|
| 224 |
+ _selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary)) |
|
| 225 |
+ } |
|
| 226 |
+ |
|
| 227 |
+ var body: some View {
|
|
| 228 |
+ NavigationView {
|
|
| 229 |
+ Form {
|
|
| 230 |
+ Section( |
|
| 231 |
+ header: ContextInfoHeader( |
|
| 232 |
+ title: "Identity", |
|
| 233 |
+ message: "Use the real BLE MAC address format `AA:BB:CC:DD:EE:FF`. Saved meters remain visible in the sidebar even when they are currently offline." |
|
| 234 |
+ ) |
|
| 235 |
+ ) {
|
|
| 236 |
+ TextField("Display name", text: $customName)
|
|
| 237 |
+ TextField("MAC Address", text: $macAddress)
|
|
| 238 |
+ .textInputAutocapitalization(.characters) |
|
| 239 |
+ .disableAutocorrection(true) |
|
| 240 |
+ .disabled(existingMeterSummary != nil) |
|
| 241 |
+ |
|
| 242 |
+ Picker("Model", selection: $selectedModel) {
|
|
| 243 |
+ ForEach(Model.allCases, id: \.self) { model in
|
|
| 244 |
+ Text(model.canonicalName) |
|
| 245 |
+ .tag(model) |
|
| 246 |
+ } |
|
| 247 |
+ } |
|
| 248 |
+ |
|
| 249 |
+ TextField("Advertised name", text: $advertisedName)
|
|
| 250 |
+ } |
|
| 251 |
+ } |
|
| 252 |
+ .navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter") |
|
| 253 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 254 |
+ .toolbar {
|
|
| 255 |
+ ToolbarItem(placement: .cancellationAction) {
|
|
| 256 |
+ Button("Cancel") {
|
|
| 257 |
+ dismiss() |
|
| 258 |
+ } |
|
| 259 |
+ } |
|
| 260 |
+ ToolbarItem(placement: .confirmationAction) {
|
|
| 261 |
+ Button(existingMeterSummary == nil ? "Save" : "Update") {
|
|
| 262 |
+ let normalizedMAC = AppData.normalizedMACAddress(macAddress) |
|
| 263 |
+ let didSave = appData.createKnownMeter( |
|
| 264 |
+ macAddress: normalizedMAC, |
|
| 265 |
+ customName: customName, |
|
| 266 |
+ modelName: selectedModel.canonicalName, |
|
| 267 |
+ advertisedName: advertisedName |
|
| 268 |
+ ) |
|
| 269 |
+ if didSave {
|
|
| 270 |
+ dismiss() |
|
| 271 |
+ } |
|
| 272 |
+ } |
|
| 273 |
+ .disabled(isSaveDisabled) |
|
| 274 |
+ } |
|
| 275 |
+ } |
|
| 276 |
+ } |
|
| 277 |
+ .navigationViewStyle(StackNavigationViewStyle()) |
|
| 278 |
+ } |
|
| 279 |
+ |
|
| 280 |
+ private var isSaveDisabled: Bool {
|
|
| 281 |
+ AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false |
|
| 282 |
+ } |
|
| 283 |
+ |
|
| 284 |
+ private static func model(for summary: String?) -> Model {
|
|
| 285 |
+ if summary?.contains("UM34C") == true {
|
|
| 286 |
+ return .UM34C |
|
| 287 |
+ } |
|
| 288 |
+ if summary?.contains("TC66C") == true {
|
|
| 289 |
+ return .TC66C |
|
| 290 |
+ } |
|
| 291 |
+ return .UM25C |
|
| 292 |
+ } |
|
| 293 |
+} |
|