Compare changes
Compare changes across branches, commits, tags, and more below. If you need to, you can also compare across forks.

...
Commits not after 2026-04-24
Showing 109 changed files with 26155 additions and 2402 deletions
+128 -0
Documentation/Charge Session Integrity and Conflict Healing.md
@@ -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.
+70 -0
Documentation/Charging While Off.md
@@ -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).
+199 -0
Documentation/External Contributions.md
@@ -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.
+139 -0
Documentation/Project Structure and Naming.md
@@ -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.
+8 -0
Documentation/README.md
@@ -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
 
+500 -62
USB Meter.xcodeproj/project.pbxproj
@@ -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;
+191 -37
USB Meter/AppDelegate.swift
@@ -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
-
+4 -0
USB Meter/Info.plist
@@ -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>
+1418 -36
USB Meter/Model/AppData.swift
@@ -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
 }
+22 -4
USB Meter/Model/BluetoothManager.swift
@@ -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
 }
+38 -0
USB Meter/Model/BluetoothSerial.swift
@@ -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
+1 -1
USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion
@@ -3,6 +3,6 @@
3 3
 <plist version="1.0">
4 4
 <dict>
5 5
 	<key>_XCCurrentVersionName</key>
6
-	<string>USB_Meter.xcdatamodel</string>
6
+	<string>USB_Meter 16.xcdatamodel</string>
7 7
 </dict>
8 8
 </plist>
+121 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 10.xcdatamodel/contents
@@ -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>
+123 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 11.xcdatamodel/contents
@@ -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>
+124 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 12.xcdatamodel/contents
@@ -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>
+125 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 13.xcdatamodel/contents
@@ -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>
+124 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 14.xcdatamodel/contents
@@ -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>
+126 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 15.xcdatamodel/contents
@@ -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>
+127 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 16.xcdatamodel/contents
@@ -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>
+67 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 4.xcdatamodel/contents
@@ -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>
+75 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 5.xcdatamodel/contents
@@ -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>
+91 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 6.xcdatamodel/contents
@@ -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>
+97 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 7.xcdatamodel/contents
@@ -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>
+106 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 8.xcdatamodel/contents
@@ -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>
+114 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 9.xcdatamodel/contents
@@ -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>
+0 -7
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter.xcdatamodel/contents
@@ -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>
+1595 -0
USB Meter/Model/ChargeInsightsModel.swift
@@ -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
+}
+3548 -0
USB Meter/Model/ChargeInsightsStore.swift
@@ -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
+}
+460 -0
USB Meter/Model/ChargerStandbyPowerStore.swift
@@ -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
+}
+94 -0
USB Meter/Model/ChargingWindowDetector.swift
@@ -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
+}
+10 -0
USB Meter/Model/ChartContext.swift
@@ -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)
+724 -34
USB Meter/Model/Measurements.swift
@@ -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
 }
+293 -15
USB Meter/Model/Meter.swift
@@ -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
+497 -0
USB Meter/Model/MeterNameStore.swift
@@ -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
+}
+2 -10
USB Meter/SceneDelegate.swift
@@ -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
-
+191 -0
USB Meter/Templates/ChargedDeviceTemplates.json
@@ -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
+}
+0 -29
USB Meter/Templates/ICloudDefault.swift
@@ -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
-}
+11 -1
USB Meter/USB Meter.entitlements
@@ -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>
+0 -32
USB Meter/Views/BorderView.swift
@@ -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
-}
+82 -0
USB Meter/Views/ChargedDevices/Components/ChargedDeviceDetailTabBarView.swift
@@ -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
+}
+47 -0
USB Meter/Views/ChargedDevices/Components/ChargedDeviceEditorScaffoldView.swift
@@ -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
+}
+83 -0
USB Meter/Views/ChargedDevices/Components/ChargedDeviceIdentityViews.swift
@@ -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
+}
+92 -0
USB Meter/Views/ChargedDevices/Components/ChargedDeviceLibraryRowView.swift
@@ -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
+}
+60 -0
USB Meter/Views/ChargedDevices/Components/ChargedDeviceQRCodeView.swift
@@ -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
+}
+73 -0
USB Meter/Views/ChargedDevices/Components/ChargedDeviceSidebarCardView.swift
@@ -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
+}
+1102 -0
USB Meter/Views/ChargedDevices/Details/ChargedDeviceDetailView.swift
@@ -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
+}
+1756 -0
USB Meter/Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift
@@ -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
+}
+410 -0
USB Meter/Views/ChargedDevices/Sessions/ChargedDeviceSessionsView.swift
@@ -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
+}
+323 -0
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/BatteryCheckpointEditorSheetView.swift
@@ -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
+}
+266 -0
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/ChargeSessionCompletionSheetView.swift
@@ -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
+}
+500 -0
USB Meter/Views/ChargedDevices/Sheets/Editors/ChargedDeviceEditorSheetView.swift
@@ -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
+}
+111 -0
USB Meter/Views/ChargedDevices/Sheets/Editors/ChargerEditorSheetView.swift
@@ -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
+}
+229 -0
USB Meter/Views/ChargedDevices/Sheets/Library/ChargedDeviceLibrarySheetView.swift
@@ -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
+}
+158 -0
USB Meter/Views/ChargedDevices/Sidebar/SidebarChargedDeviceLibraryView.swift
@@ -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
+}
+93 -0
USB Meter/Views/ChargedDevices/Sidebar/SidebarChargedDevicesSectionView.swift
@@ -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
+}
+422 -0
USB Meter/Views/Components/Charts/TimeSeriesChart.swift
@@ -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
+}
+29 -0
USB Meter/Views/Components/Generic/AdaptiveTabBarPresentation.swift
@@ -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
+}
+80 -0
USB Meter/Views/Components/Generic/ChevronView.swift
@@ -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
+}
+2 -2
USB Meter/Views/Meter/RSSIView.swift → USB Meter/Views/Components/Generic/RSSIView.swift
@@ -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
     }
+21 -368
USB Meter/Views/ContentView.swift
@@ -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
 }
+0 -26
USB Meter/Views/Meter/ChevronView.swift
@@ -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
-}
+2795 -0
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -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
+}
+46 -0
USB Meter/Views/Meter/Components/MeterInfoCardView.swift
@@ -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
+}
+22 -0
USB Meter/Views/Meter/Components/MeterInfoRowView.swift
@@ -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
+}
+0 -298
USB Meter/Views/Meter/LiveView.swift
@@ -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
-}
+0 -457
USB Meter/Views/Meter/Measurements/Chart/MeasurementChartView.swift
@@ -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
-}
+0 -257
USB Meter/Views/Meter/MeterSettingsView.swift
@@ -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
-}
+698 -462
USB Meter/Views/Meter/MeterView.swift
@@ -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
         }
+0 -168
USB Meter/Views/Meter/RecordingView.swift
@@ -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
-}
+85 -0
USB Meter/Views/Meter/Sheets/ChargeRecord/ChargeRecordSheetView.swift
@@ -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
+}
+33 -0
USB Meter/Views/Meter/Sheets/ChargeRecord/Subviews/ChargeRecordMetricsTableView.swift
@@ -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
+}
+13 -9
USB Meter/Views/Meter/Data Groups/DataGroupsView.swift → USB Meter/Views/Meter/Sheets/DataGroups/DataGroupsSheetView.swift
@@ -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)
+2 -7
USB Meter/Views/Meter/Data Groups/DataGroupRowView.swift → USB Meter/Views/Meter/Sheets/DataGroups/Subviews/DataGroupRowView.swift
@@ -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))")
+26 -15
USB Meter/Views/Meter/Measurements/MeasurementsView.swift → USB Meter/Views/Meter/Sheets/MeasurementSeries/MeasurementSeriesSheetView.swift
@@ -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
 }
+6 -2
USB Meter/Views/Meter/Measurements/MeasurementPointView.swift → USB Meter/Views/Meter/Sheets/MeasurementSeries/Subviews/MeasurementSeriesSampleView.swift
@@ -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
         }
+736 -0
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -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
+}
+66 -0
USB Meter/Views/Meter/Tabs/Chart/MeterChartTabView.swift
@@ -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
+}
+75 -0
USB Meter/Views/Meter/Tabs/DataGroups/MeterDataGroupsTabView.swift
@@ -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
+}
+235 -0
USB Meter/Views/Meter/Tabs/Home/MeterHomeTabView.swift
@@ -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
+}
+80 -0
USB Meter/Views/Meter/Tabs/Home/Subviews/MeterConnectionActionView.swift
@@ -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
+}
+23 -0
USB Meter/Views/Meter/Tabs/Home/Subviews/MeterConnectionStatusBadgeView.swift
@@ -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
+}
+63 -0
USB Meter/Views/Meter/Tabs/Home/Subviews/MeterOverviewSectionView.swift
@@ -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
+}
+991 -0
USB Meter/Views/Meter/Tabs/Live/ChargerStandbyPowerWizardView.swift
@@ -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
+}
+59 -0
USB Meter/Views/Meter/Tabs/Live/MeterLiveTabView.swift
@@ -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
+}
+60 -0
USB Meter/Views/Meter/Tabs/Live/Subviews/LoadResistanceIconView.swift
@@ -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
+}
+909 -0
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveContentView.swift
@@ -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
+}
+13 -0
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveMetricRange.swift
@@ -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
+}
+201 -0
USB Meter/Views/Meter/Tabs/Settings/MeterSettingsTabView.swift
@@ -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
+}
+30 -0
USB Meter/Views/Meter/Tabs/Settings/Subviews/MeterCurrentScreenSummaryView.swift
@@ -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
+}
+24 -0
USB Meter/Views/Meter/Tabs/Settings/Subviews/MeterNameEditorView.swift
@@ -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
+}
+36 -0
USB Meter/Views/Meter/Tabs/Settings/Subviews/MeterScreenControlButtonView.swift
@@ -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
+}
+45 -53
USB Meter/Views/Meter/ControlView.swift → USB Meter/Views/Meter/Tabs/Settings/Subviews/MeterScreenControlsView.swift
@@ -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
 }
+19 -0
USB Meter/Views/Meter/Tabs/Settings/Subviews/ScreenBrightnessEditorView.swift
@@ -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
+}
+20 -0
USB Meter/Views/Meter/Tabs/Settings/Subviews/ScreenTimeoutEditorView.swift
@@ -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
+}
+82 -0
USB Meter/Views/MeterMappingDebugView.swift
@@ -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
+}
+75 -0
USB Meter/Views/Sidebar/SidebarList/Components/MeterCardView.swift
@@ -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
+}
+41 -0
USB Meter/Views/Sidebar/SidebarList/Components/SidebarLinkCardView.swift
@@ -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
+}
+3 -10
USB Meter/Views/MeterRowView.swift → USB Meter/Views/Sidebar/SidebarList/Components/SidebarMeterCardView.swift
@@ -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)
+70 -0
USB Meter/Views/Sidebar/SidebarList/Components/SidebarOfflineMeterCardView.swift
@@ -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
+}
+63 -0
USB Meter/Views/Sidebar/SidebarList/Logic/SidebarAutoHelpResolver.swift
@@ -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
+}
+141 -0
USB Meter/Views/Sidebar/SidebarList/Sections/ContentSidebarHelpSectionView.swift
@@ -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
+}
+32 -0
USB Meter/Views/Sidebar/SidebarList/Sections/Help/Components/SidebarBluetoothStatusCardView.swift
@@ -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
+}
+47 -0
USB Meter/Views/Sidebar/SidebarList/Sections/Help/Components/SidebarHelpNoticeCardView.swift
@@ -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
+}
+45 -0
USB Meter/Views/Sidebar/SidebarList/Sections/Help/SidebarHelpReason.swift
@@ -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
+}
+22 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarDebugSectionView.swift
@@ -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
+}
+141 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarHelpSectionView.swift
@@ -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
+}
+134 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarUSBMetersSectionView.swift
@@ -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
+}
+53 -0
USB Meter/Views/Sidebar/SidebarList/SidebarListView.swift
@@ -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
+}
+293 -0
USB Meter/Views/Sidebar/SidebarView.swift
@@ -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
+}