Showing 8 changed files with 742 additions and 28 deletions
+57 -0
Documentation/Charging While Off.md
@@ -0,0 +1,57 @@
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
+### Defaults
31
+
32
+- **Default assumption (for capacity learning):** if the user does not specify otherwise, a device is treated as **on/unknown-state** while charging.
33
+
34
+### Capacity estimation priority (conceptual rule)
35
+
36
+When we have both:
37
+
38
+- high-confidence capacity determinations from **off-state** charging, and
39
+- lower-confidence determinations from **on/unknown** charging,
40
+
41
+then:
42
+
43
+- off-state determinations are **preferred** for establishing the device’s capacity baseline
44
+- on/unknown determinations **must not overwrite** off-state determinations, **except** when they imply a **smaller capacity**
45
+  - 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
46
+
47
+### Current implementation note
48
+
49
+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.
50
+
51
+## Device-specific notes (practical guidance)
52
+
53
+- **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.
54
+- **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.
55
+- **AirPods case:** treat as “off” **only if the earbuds are not inside** the case while charging (otherwise the case is also charging the earbuds).
56
+- **Apple Watch:** cannot be reliably powered down for charging; treat as **always on**.
57
+- **Garmin Edge bike computers / some Garmin watches:** treat as **chargeable only while off** (they need to be powered down to charge).
+2 -0
Documentation/README.md
@@ -10,6 +10,8 @@ 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.
13 15
 - `Project Structure and Naming.md`
14 16
   Naming and file-organization rules for views, features, components, and subviews.
15 17
 - `Research Resources/`
+24 -0
USB Meter.xcodeproj/project.pbxproj
@@ -64,6 +64,8 @@
64 64
 		D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */; };
65 65
 		D28F11053C8E4A7A00A10015 /* MeterChartTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */; };
66 66
 		D28F11073C8E4A7A00A10017 /* MeterSettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */; };
67
+		D28F11413C8E4A7A00A10051 /* MeterChargeRecordTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11423C8E4A7A00A10052 /* MeterChargeRecordTabView.swift */; };
68
+		D28F11433C8E4A7A00A10053 /* MeterDataGroupsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */; };
67 69
 		D28F11113C8E4A7A00A10021 /* MeterInfoCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */; };
68 70
 		D28F11133C8E4A7A00A10023 /* MeterInfoRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */; };
69 71
 		D28F11153C8E4A7A00A10025 /* MeterNameEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */; };
@@ -177,6 +179,8 @@
177 179
 		D28F11043C8E4A7A00A10014 /* MeterLiveTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterLiveTabView.swift; sourceTree = "<group>"; };
178 180
 		D28F11063C8E4A7A00A10016 /* MeterChartTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterChartTabView.swift; sourceTree = "<group>"; };
179 181
 		D28F11083C8E4A7A00A10018 /* MeterSettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterSettingsTabView.swift; sourceTree = "<group>"; };
182
+		D28F11423C8E4A7A00A10052 /* MeterChargeRecordTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterChargeRecordTabView.swift; sourceTree = "<group>"; };
183
+		D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterDataGroupsTabView.swift; sourceTree = "<group>"; };
180 184
 		D28F11123C8E4A7A00A10022 /* MeterInfoCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterInfoCardView.swift; sourceTree = "<group>"; };
181 185
 		D28F11143C8E4A7A00A10024 /* MeterInfoRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterInfoRowView.swift; sourceTree = "<group>"; };
182 186
 		D28F11163C8E4A7A00A10026 /* MeterNameEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterNameEditorView.swift; sourceTree = "<group>"; };
@@ -515,6 +519,8 @@
515 519
 				D28F110A3C8E4A7A00A1001A /* Home */,
516 520
 				D28F110B3C8E4A7A00A1001B /* Live */,
517 521
 				D28F110C3C8E4A7A00A1001C /* Chart */,
522
+				D28F11453C8E4A7A00A10055 /* ChargeRecord */,
523
+				D28F11463C8E4A7A00A10056 /* DataGroups */,
518 524
 				D28F110D3C8E4A7A00A1001D /* Settings */,
519 525
 			);
520 526
 			path = Tabs;
@@ -555,6 +561,22 @@
555 561
 			path = Settings;
556 562
 			sourceTree = "<group>";
557 563
 		};
564
+		D28F11453C8E4A7A00A10055 /* ChargeRecord */ = {
565
+			isa = PBXGroup;
566
+			children = (
567
+				D28F11423C8E4A7A00A10052 /* MeterChargeRecordTabView.swift */,
568
+			);
569
+			path = ChargeRecord;
570
+			sourceTree = "<group>";
571
+		};
572
+		D28F11463C8E4A7A00A10056 /* DataGroups */ = {
573
+			isa = PBXGroup;
574
+			children = (
575
+				D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */,
576
+			);
577
+			path = DataGroups;
578
+			sourceTree = "<group>";
579
+		};
558 580
 		D28F111B3C8E4A7A00A1002B /* Subviews */ = {
559 581
 			isa = PBXGroup;
560 582
 			children = (
@@ -722,6 +744,8 @@
722 744
 				D28F11033C8E4A7A00A10013 /* MeterLiveTabView.swift in Sources */,
723 745
 				D28F11053C8E4A7A00A10015 /* MeterChartTabView.swift in Sources */,
724 746
 				D28F11073C8E4A7A00A10017 /* MeterSettingsTabView.swift in Sources */,
747
+				D28F11413C8E4A7A00A10051 /* MeterChargeRecordTabView.swift in Sources */,
748
+				D28F11433C8E4A7A00A10053 /* MeterDataGroupsTabView.swift in Sources */,
725 749
 				D28F11113C8E4A7A00A10021 /* MeterInfoCardView.swift in Sources */,
726 750
 				D28F11133C8E4A7A00A10023 /* MeterInfoRowView.swift in Sources */,
727 751
 				D28F11153C8E4A7A00A10025 /* MeterNameEditorView.swift in Sources */,
+67 -15
USB Meter/Views/Meter/MeterView.swift
@@ -90,22 +90,17 @@ struct MeterView: View {
90 90
         case home
91 91
         case live
92 92
         case chart
93
+        case chargeRecord
94
+        case dataGroups
93 95
         case settings
94 96
 
95
-        var title: String {
96
-            switch self {
97
-            case .home: return "Home"
98
-            case .live: return "Live"
99
-            case .chart: return "Chart"
100
-            case .settings: return "Settings"
101
-            }
102
-        }
103
-
104 97
         var systemImage: String {
105 98
             switch self {
106 99
             case .home: return "house.fill"
107 100
             case .live: return "waveform.path.ecg"
108 101
             case .chart: return "chart.xyaxis.line"
102
+            case .chargeRecord: return "gauge.with.dots.needle.50percent"
103
+            case .dataGroups: return "square.grid.2x2.fill"
109 104
             case .settings: return "gearshape.fill"
110 105
             }
111 106
         }
@@ -325,7 +320,7 @@ struct MeterView: View {
325 320
                             Image(systemName: tab.systemImage)
326 321
                                 .font(.subheadline.weight(.semibold))
327 322
                             if style.showsTitles {
328
-                                Text(tab.title)
323
+                                Text(title(for: tab))
329 324
                                     .font(.subheadline.weight(.semibold))
330 325
                                     .lineLimit(1)
331 326
                             }
@@ -348,7 +343,7 @@ struct MeterView: View {
348 343
                         )
349 344
                     }
350 345
                     .buttonStyle(.plain)
351
-                    .accessibilityLabel(tab.title)
346
+                    .accessibilityLabel(title(for: tab))
352 347
                 }
353 348
             }
354 349
             .frame(maxWidth: style.maxWidth)
@@ -436,11 +431,28 @@ struct MeterView: View {
436 431
     private func landscapeSegmentedContent(size: CGSize) -> some View {
437 432
         switch displayedMeterTab {
438 433
         case .home:
439
-            MeterHomeTabView(size: size, isLandscape: true)
434
+            MeterHomeTabView(
435
+                size: size,
436
+                isLandscape: true,
437
+                showChargeRecordTab: {
438
+                    withAnimation(.easeInOut(duration: 0.22)) {
439
+                        selectedMeterTab = .chargeRecord
440
+                    }
441
+                },
442
+                showDataGroupsTab: {
443
+                    withAnimation(.easeInOut(duration: 0.22)) {
444
+                        selectedMeterTab = .dataGroups
445
+                    }
446
+                }
447
+            )
440 448
         case .live:
441 449
             MeterLiveTabView(size: size, isLandscape: true)
442 450
         case .chart:
443 451
             MeterChartTabView(size: size, isLandscape: true)
452
+        case .chargeRecord:
453
+            MeterChargeRecordTabView()
454
+        case .dataGroups:
455
+            MeterDataGroupsTabView()
444 456
         case .settings:
445 457
             MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
446 458
                 withAnimation(.easeInOut(duration: 0.22)) {
@@ -454,11 +466,28 @@ struct MeterView: View {
454 466
     private func portraitSegmentedContent(size: CGSize) -> some View {
455 467
         switch displayedMeterTab {
456 468
         case .home:
457
-            MeterHomeTabView(size: size, isLandscape: false)
469
+            MeterHomeTabView(
470
+                size: size,
471
+                isLandscape: false,
472
+                showChargeRecordTab: {
473
+                    withAnimation(.easeInOut(duration: 0.22)) {
474
+                        selectedMeterTab = .chargeRecord
475
+                    }
476
+                },
477
+                showDataGroupsTab: {
478
+                    withAnimation(.easeInOut(duration: 0.22)) {
479
+                        selectedMeterTab = .dataGroups
480
+                    }
481
+                }
482
+            )
458 483
         case .live:
459 484
             MeterLiveTabView(size: size, isLandscape: false)
460 485
         case .chart:
461 486
             MeterChartTabView(size: size, isLandscape: false)
487
+        case .chargeRecord:
488
+            MeterChargeRecordTabView()
489
+        case .dataGroups:
490
+            MeterDataGroupsTabView()
462 491
         case .settings:
463 492
             MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
464 493
                 withAnimation(.easeInOut(duration: 0.22)) {
@@ -469,7 +498,13 @@ struct MeterView: View {
469 498
     }
470 499
 
471 500
     private var availableMeterTabs: [MeterTab] {
472
-        [.home, .live, .chart, .settings]
501
+        var tabs: [MeterTab] = [.home, .live, .chart]
502
+        if meter.supportsRecordingView {
503
+            tabs.append(.chargeRecord)
504
+        }
505
+        tabs.append(.dataGroups)
506
+        tabs.append(.settings)
507
+        return tabs
473 508
     }
474 509
 
475 510
     private var displayedMeterTab: MeterTab {
@@ -486,7 +521,7 @@ struct MeterView: View {
486 521
             return
487 522
         }
488 523
 
489
-        selectedMeterTab = restoredTab
524
+        selectedMeterTab = availableMeterTabs.contains(restoredTab) ? restoredTab : .home
490 525
     }
491 526
 
492 527
     private var meterBackground: some View {
@@ -530,6 +565,23 @@ struct MeterView: View {
530 565
         return max(landscapeTabBarHeight - 6, 0)
531 566
     }
532 567
 
568
+    private func title(for tab: MeterTab) -> String {
569
+        switch tab {
570
+        case .home:
571
+            return "Home"
572
+        case .live:
573
+            return "Live"
574
+        case .chart:
575
+            return "Chart"
576
+        case .chargeRecord:
577
+            return "Charge Record"
578
+        case .dataGroups:
579
+            return meter.dataGroupsTitle
580
+        case .settings:
581
+            return "Settings"
582
+        }
583
+    }
584
+
533 585
 }
534 586
 
535 587
 private struct MeterTabBarHeightPreferenceKey: PreferenceKey {
+1 -1
USB Meter/Views/Meter/Sheets/ChargeRecord/ChargeRecordSheetView.swift
@@ -530,7 +530,7 @@ struct ChargeRecordSheetView_Previews: PreviewProvider {
530 530
     }
531 531
 }
532 532
 
533
-private struct BatteryTargetNotificationEditorSheetView: View {
533
+struct BatteryTargetNotificationEditorSheetView: View {
534 534
     @Environment(\.dismiss) private var dismiss
535 535
     @EnvironmentObject private var appData: AppData
536 536
 
+515 -0
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -0,0 +1,515 @@
1
+//
2
+//  MeterChargeRecordTabView.swift
3
+//  USB Meter
4
+//
5
+
6
+import SwiftUI
7
+
8
+struct MeterChargeRecordTabView: View {
9
+    @EnvironmentObject private var appData: AppData
10
+    @EnvironmentObject private var usbMeter: Meter
11
+    @State private var chargedDeviceLibraryVisibility = false
12
+    @State private var chargerLibraryVisibility = false
13
+    @State private var checkpointEditorVisibility = false
14
+    @State private var editingChargedDevice: ChargedDeviceSummary?
15
+    @State private var targetNotificationEditorVisibility = false
16
+
17
+    var body: some View {
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
+                chargedDeviceSection
46
+
47
+                if let activeChargeSession {
48
+                    chargeMonitorSection(activeChargeSession)
49
+                }
50
+
51
+                ChargeRecordMetricsTableView(
52
+                    labels: ["Capacity", "Energy", "Duration", "Stop Threshold"],
53
+                    values: [
54
+                        "\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah",
55
+                        "\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh",
56
+                        usbMeter.chargeRecordDurationDescription,
57
+                        "\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A"
58
+                    ]
59
+                )
60
+                .padding(18)
61
+                .meterCard(tint: .pink, fillOpacity: 0.14, strokeOpacity: 0.20)
62
+
63
+                if usbMeter.chargeRecordTimeRange != nil {
64
+                    VStack(alignment: .leading, spacing: 12) {
65
+                        HStack {
66
+                            Text("Charge Curve")
67
+                                .font(.headline)
68
+                            Spacer()
69
+                            Button("Reset Graph") {
70
+                                usbMeter.resetChargeRecordGraph()
71
+                            }
72
+                            .foregroundColor(.red)
73
+                        }
74
+                        MeasurementChartView(timeRange: usbMeter.chargeRecordTimeRange)
75
+                            .environmentObject(usbMeter.measurements)
76
+                            .frame(minHeight: 220)
77
+                        Text("Reset Graph clears the current charge-record session and removes older shared samples that are no longer needed for this curve.")
78
+                            .font(.footnote)
79
+                            .foregroundColor(.secondary)
80
+                    }
81
+                    .padding(18)
82
+                    .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
83
+                }
84
+
85
+                VStack(alignment: .leading, spacing: 12) {
86
+                    Text("Stop Threshold")
87
+                        .font(.headline)
88
+                    Slider(value: $usbMeter.chargeRecordStopThreshold, in: 0...0.30, step: 0.01)
89
+                    Text("The app starts accumulating when current rises above this threshold and stops when it falls back to or below it.")
90
+                        .font(.footnote)
91
+                        .foregroundColor(.secondary)
92
+                    Button("Reset") {
93
+                        usbMeter.resetChargeRecord()
94
+                    }
95
+                    .frame(maxWidth: .infinity)
96
+                    .padding(.vertical, 10)
97
+                    .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
98
+                    .buttonStyle(.plain)
99
+                }
100
+                .padding(18)
101
+                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
102
+
103
+                if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
104
+                    VStack(alignment: .leading, spacing: 12) {
105
+                        Text("Meter Totals")
106
+                            .font(.headline)
107
+                        ChargeRecordMetricsTableView(
108
+                            labels: ["Capacity", "Energy", "Duration", "Meter Threshold"],
109
+                            values: [
110
+                                "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah",
111
+                                "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
112
+                                usbMeter.recordingDurationDescription,
113
+                                usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
114
+                            ]
115
+                        )
116
+                        Text("These values are reported by the meter for the active data group.")
117
+                            .font(.footnote)
118
+                            .foregroundColor(.secondary)
119
+                        if usbMeter.supportsDataGroupCommands {
120
+                            Button("Reset Active Group") {
121
+                                usbMeter.clear()
122
+                            }
123
+                            .frame(maxWidth: .infinity)
124
+                            .padding(.vertical, 10)
125
+                            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
126
+                            .buttonStyle(.plain)
127
+                        }
128
+                    }
129
+                    .padding(18)
130
+                    .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
131
+                }
132
+            }
133
+            .padding()
134
+        }
135
+        .background(
136
+            LinearGradient(
137
+                colors: [.pink.opacity(0.14), Color.clear],
138
+                startPoint: .topLeading,
139
+                endPoint: .bottomTrailing
140
+            )
141
+            .ignoresSafeArea()
142
+        )
143
+        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
144
+            ChargedDeviceLibrarySheetView(
145
+                visibility: $chargedDeviceLibraryVisibility,
146
+                meterMACAddress: usbMeter.btSerial.macAddress.description,
147
+                meterTint: usbMeter.color,
148
+                mode: .device
149
+            )
150
+            .environmentObject(appData)
151
+        }
152
+        .sheet(isPresented: $chargerLibraryVisibility) {
153
+            ChargedDeviceLibrarySheetView(
154
+                visibility: $chargerLibraryVisibility,
155
+                meterMACAddress: usbMeter.btSerial.macAddress.description,
156
+                meterTint: usbMeter.color,
157
+                mode: .charger
158
+            )
159
+            .environmentObject(appData)
160
+        }
161
+        .sheet(isPresented: $checkpointEditorVisibility) {
162
+            BatteryCheckpointEditorSheetView()
163
+                .environmentObject(appData)
164
+                .environmentObject(usbMeter)
165
+        }
166
+        .sheet(item: $editingChargedDevice) { chargedDevice in
167
+            ChargedDeviceEditorSheetView(
168
+                meterMACAddress: nil,
169
+                chargedDevice: chargedDevice
170
+            )
171
+            .environmentObject(appData)
172
+        }
173
+        .sheet(isPresented: $targetNotificationEditorVisibility) {
174
+            if let activeChargeSession {
175
+                BatteryTargetNotificationEditorSheetView(
176
+                    sessionID: activeChargeSession.id,
177
+                    initialTargetPercent: activeChargeSession.targetBatteryPercent
178
+                )
179
+                .environmentObject(appData)
180
+            }
181
+        }
182
+    }
183
+
184
+    private var selectedChargedDevice: ChargedDeviceSummary? {
185
+        appData.currentChargedDeviceSummary(for: usbMeter.btSerial.macAddress.description)
186
+    }
187
+
188
+    private var activeChargeSession: ChargeSessionSummary? {
189
+        appData.activeChargeSessionSummary(for: usbMeter.btSerial.macAddress.description)
190
+    }
191
+
192
+    private var selectedCharger: ChargedDeviceSummary? {
193
+        appData.currentChargerSummary(for: usbMeter.btSerial.macAddress.description)
194
+    }
195
+
196
+    private var chargedDeviceSection: some View {
197
+        VStack(alignment: .leading, spacing: 12) {
198
+            HStack {
199
+                Text("Device")
200
+                    .font(.headline)
201
+                Spacer()
202
+                Button("Library") {
203
+                    chargedDeviceLibraryVisibility = true
204
+                }
205
+            }
206
+
207
+            if let selectedChargedDevice {
208
+                HStack(alignment: .top, spacing: 14) {
209
+                    ChargedDeviceQRCodeView(
210
+                        qrIdentifier: selectedChargedDevice.qrIdentifier,
211
+                        side: 88
212
+                    )
213
+
214
+                    VStack(alignment: .leading, spacing: 8) {
215
+                        Label(selectedChargedDevice.name, systemImage: selectedChargedDevice.deviceClass.symbolName)
216
+                            .font(.headline)
217
+
218
+                        Text(selectedChargedDevice.deviceClass.title)
219
+                            .font(.caption.weight(.semibold))
220
+                            .foregroundColor(.secondary)
221
+
222
+                        Text(selectedChargedDevice.supportsChargingWhileOff ? "Can finish charging while off" : "Needs on-state sessions to estimate capacity carefully")
223
+                            .font(.caption2)
224
+                            .foregroundColor(.secondary)
225
+
226
+                        if selectedChargedDevice.supportedChargingModes.count == 1 {
227
+                            Label(
228
+                                "Charging via \(selectedChargedDevice.preferredChargingTransportMode.title)",
229
+                                systemImage: selectedChargedDevice.preferredChargingTransportMode.symbolName
230
+                            )
231
+                            .font(.caption2)
232
+                            .foregroundColor(.secondary)
233
+                        } else {
234
+                            Picker("Charging Type", selection: chargingTransportModeBinding(for: selectedChargedDevice)) {
235
+                                ForEach(selectedChargedDevice.supportedChargingModes) { chargingTransportMode in
236
+                                    Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
237
+                                        .tag(chargingTransportMode)
238
+                                }
239
+                            }
240
+                            .pickerStyle(.segmented)
241
+                        }
242
+
243
+                        if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
244
+                            Text("Estimated \(effectiveChargingTransportMode(for: selectedChargedDevice).title.lowercased()) capacity: \(capacity.format(decimalDigits: 2)) Wh")
245
+                                .font(.caption)
246
+                                .foregroundColor(.secondary)
247
+                        } else if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh {
248
+                            Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
249
+                                .font(.caption)
250
+                                .foregroundColor(.secondary)
251
+                        }
252
+
253
+                        if let minimumCurrent = selectedChargedDevice.resolvedCompletionCurrentAmps(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
254
+                            Text("\(effectiveChargingTransportMode(for: selectedChargedDevice).title) completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
255
+                                .font(.caption2)
256
+                                .foregroundColor(.secondary)
257
+                        } else if let minimumCurrent = selectedChargedDevice.minimumCurrentAmps {
258
+                            Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
259
+                                .font(.caption2)
260
+                                .foregroundColor(.secondary)
261
+                        }
262
+                    }
263
+
264
+                    Spacer(minLength: 0)
265
+                }
266
+
267
+                if shouldShowWirelessChargerSection(for: selectedChargedDevice) {
268
+                    Divider()
269
+
270
+                    VStack(alignment: .leading, spacing: 10) {
271
+                        HStack {
272
+                            Text("Wireless Charger")
273
+                                .font(.subheadline.weight(.semibold))
274
+                            Spacer()
275
+                            Button(selectedCharger == nil ? "Select" : "Change") {
276
+                                chargerLibraryVisibility = true
277
+                            }
278
+                        }
279
+
280
+                        if let selectedCharger {
281
+                            HStack(alignment: .top, spacing: 12) {
282
+                                ChargedDeviceQRCodeView(
283
+                                    qrIdentifier: selectedCharger.qrIdentifier,
284
+                                    side: 62
285
+                                )
286
+
287
+                                VStack(alignment: .leading, spacing: 6) {
288
+                                    Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName)
289
+                                        .font(.subheadline.weight(.semibold))
290
+
291
+                                    if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
292
+                                        Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
293
+                                            .font(.caption)
294
+                                            .foregroundColor(.secondary)
295
+                                    }
296
+
297
+                                    if !selectedCharger.chargerObservedVoltageSelections.isEmpty {
298
+                                        Text(
299
+                                            "Observed voltages: " + selectedCharger.chargerObservedVoltageSelections
300
+                                                .map { "\($0.format(decimalDigits: 1)) V" }
301
+                                                .joined(separator: ", ")
302
+                                        )
303
+                                        .font(.caption2)
304
+                                        .foregroundColor(.secondary)
305
+                                    }
306
+                                }
307
+                            }
308
+                        } else {
309
+                            Text("Wireless sessions need a selected charger in addition to the charged device.")
310
+                                .font(.caption)
311
+                                .foregroundColor(.secondary)
312
+                        }
313
+                    }
314
+                }
315
+
316
+                Button("Add Battery Checkpoint") {
317
+                    checkpointEditorVisibility = true
318
+                }
319
+                .frame(maxWidth: .infinity)
320
+                .padding(.vertical, 10)
321
+                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
322
+                .buttonStyle(.plain)
323
+
324
+                Button("Edit Device") {
325
+                    editingChargedDevice = selectedChargedDevice
326
+                }
327
+                .frame(maxWidth: .infinity)
328
+                .padding(.vertical, 10)
329
+                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
330
+                .buttonStyle(.plain)
331
+            } else {
332
+                Text("Select or create the device you are charging. New sessions, checkpoints, QR identity, capacity tracking, and curve learning are all anchored to that device.")
333
+                    .font(.footnote)
334
+                    .foregroundColor(.secondary)
335
+            }
336
+        }
337
+        .padding(18)
338
+        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
339
+    }
340
+
341
+    private func chargeMonitorSection(_ activeChargeSession: ChargeSessionSummary) -> some View {
342
+        VStack(alignment: .leading, spacing: 12) {
343
+            Text("Charging Monitor")
344
+                .font(.headline)
345
+
346
+            ChargeRecordMetricsTableView(
347
+                labels: ["Source", "Energy", "Charge", "Stop Threshold"],
348
+                values: [
349
+                    activeChargeSession.sourceMode.title,
350
+                    "\(activeChargeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh",
351
+                    "\(activeChargeSession.measuredChargeAh.format(decimalDigits: 3)) Ah",
352
+                    "\(activeChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A"
353
+                ]
354
+            )
355
+
356
+            if let selectedChargedDevice,
357
+               let batteryPrediction = selectedChargedDevice.batteryLevelPrediction(for: activeChargeSession) {
358
+                VStack(alignment: .leading, spacing: 4) {
359
+                    Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
360
+                        .font(.caption.weight(.semibold))
361
+                    Text(
362
+                        "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
363
+                    )
364
+                    .font(.caption2)
365
+                    .foregroundColor(.secondary)
366
+                }
367
+            }
368
+
369
+            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
370
+                Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
371
+                    .font(.caption.weight(.semibold))
372
+            } else {
373
+                Text("No target battery notification configured.")
374
+                    .font(.caption)
375
+                    .foregroundColor(.secondary)
376
+            }
377
+
378
+            Button(activeChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
379
+                targetNotificationEditorVisibility = true
380
+            }
381
+            .frame(maxWidth: .infinity)
382
+            .padding(.vertical, 10)
383
+            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
384
+            .buttonStyle(.plain)
385
+
386
+            if activeChargeSession.targetBatteryPercent != nil {
387
+                Button("Clear Target Notification") {
388
+                    _ = appData.setTargetBatteryPercent(nil, for: activeChargeSession.id)
389
+                }
390
+                .frame(maxWidth: .infinity)
391
+                .padding(.vertical, 10)
392
+                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
393
+                .buttonStyle(.plain)
394
+            }
395
+
396
+            if activeChargeSession.requiresCompletionConfirmation {
397
+                completionConfirmationCard(activeChargeSession)
398
+            }
399
+
400
+            if let capacityEstimateWh = activeChargeSession.capacityEstimateWh {
401
+                Text("Current \(activeChargeSession.chargingTransportMode.title.lowercased()) capacity estimate: \(capacityEstimateWh.format(decimalDigits: 2)) Wh")
402
+                    .font(.caption.weight(.semibold))
403
+            }
404
+
405
+            Label(
406
+                "Session charging type: \(activeChargeSession.chargingTransportMode.title)",
407
+                systemImage: activeChargeSession.chargingTransportMode.symbolName
408
+            )
409
+            .font(.caption)
410
+            .foregroundColor(.secondary)
411
+
412
+            if activeChargeSession.chargingTransportMode == .wireless {
413
+                if let chargerID = activeChargeSession.chargerID,
414
+                   let charger = appData.chargedDeviceSummary(id: chargerID) {
415
+                    Label("Wireless charger: \(charger.name)", systemImage: "bolt.badge.clock")
416
+                        .font(.caption)
417
+                        .foregroundColor(.secondary)
418
+                } else {
419
+                    Text("No wireless charger is currently selected for this session.")
420
+                        .font(.caption)
421
+                        .foregroundColor(.orange)
422
+                }
423
+            }
424
+
425
+            if activeChargeSession.checkpoints.isEmpty == false {
426
+                VStack(alignment: .leading, spacing: 8) {
427
+                    Text("Battery Checkpoints")
428
+                        .font(.subheadline.weight(.semibold))
429
+
430
+                    ForEach(activeChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in
431
+                        HStack {
432
+                            Text(checkpoint.timestamp.format())
433
+                                .font(.caption2)
434
+                                .foregroundColor(.secondary)
435
+                            Spacer()
436
+                            Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
437
+                                .font(.caption.weight(.semibold))
438
+                            Text("•")
439
+                                .foregroundColor(.secondary)
440
+                            Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
441
+                                .font(.caption2)
442
+                                .foregroundColor(.secondary)
443
+                        }
444
+                    }
445
+                }
446
+            }
447
+
448
+            Text("The monitor prefers the meter's offline counters when available, then blends them with live samples so reconnects do not lose the real transferred energy.")
449
+                .font(.footnote)
450
+                .foregroundColor(.secondary)
451
+        }
452
+        .padding(18)
453
+        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
454
+    }
455
+
456
+    private func completionConfirmationCard(_ activeChargeSession: ChargeSessionSummary) -> some View {
457
+        VStack(alignment: .leading, spacing: 10) {
458
+            Text("Completion Needs Confirmation")
459
+                .font(.subheadline.weight(.semibold))
460
+
461
+            if let contradictionPercent = activeChargeSession.completionContradictionPercent {
462
+                Text("Current dropped below the stop threshold, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
463
+                    .font(.caption)
464
+                    .foregroundColor(.secondary)
465
+            } else {
466
+                Text("Current dropped below the stop threshold, but the estimated battery level does not match a normal charge end.")
467
+                    .font(.caption)
468
+                    .foregroundColor(.secondary)
469
+            }
470
+
471
+            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
472
+                Text("The active target is \(targetBatteryPercent.format(decimalDigits: 0))%.")
473
+                    .font(.caption2)
474
+                    .foregroundColor(.secondary)
475
+            }
476
+
477
+            Button("Finish Session") {
478
+                _ = appData.confirmChargeSessionCompletion(sessionID: activeChargeSession.id)
479
+            }
480
+            .frame(maxWidth: .infinity)
481
+            .padding(.vertical, 10)
482
+            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
483
+            .buttonStyle(.plain)
484
+
485
+            Button("Keep Monitoring") {
486
+                _ = appData.continueChargeSessionMonitoring(sessionID: activeChargeSession.id)
487
+            }
488
+            .frame(maxWidth: .infinity)
489
+            .padding(.vertical, 10)
490
+            .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
491
+            .buttonStyle(.plain)
492
+        }
493
+        .padding(14)
494
+        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
495
+    }
496
+
497
+    private func chargingTransportModeBinding(for chargedDevice: ChargedDeviceSummary) -> Binding<ChargingTransportMode> {
498
+        Binding(
499
+            get: {
500
+                effectiveChargingTransportMode(for: chargedDevice)
501
+            },
502
+            set: { newValue in
503
+                _ = appData.setChargingTransportMode(newValue, for: usbMeter)
504
+            }
505
+        )
506
+    }
507
+
508
+    private func effectiveChargingTransportMode(for chargedDevice: ChargedDeviceSummary) -> ChargingTransportMode {
509
+        activeChargeSession?.chargingTransportMode ?? chargedDevice.preferredChargingTransportMode
510
+    }
511
+
512
+    private func shouldShowWirelessChargerSection(for chargedDevice: ChargedDeviceSummary) -> Bool {
513
+        effectiveChargingTransportMode(for: chargedDevice) == .wireless
514
+    }
515
+}
+72 -0
USB Meter/Views/Meter/Tabs/DataGroups/MeterDataGroupsTabView.swift
@@ -0,0 +1,72 @@
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", "Ah"]
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
+                        Text(usbMeter.dataGroupsTitle)
22
+                            .font(.system(.title3, design: .rounded).weight(.bold))
23
+                        if let hint = usbMeter.dataGroupsHint {
24
+                            Text(hint)
25
+                                .font(.footnote)
26
+                                .foregroundColor(.secondary)
27
+                        }
28
+                    }
29
+                    .padding(18)
30
+                    .meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24)
31
+
32
+                    HStack(spacing: 8) {
33
+                        ForEach(columnTitles, id: \.self) { text in
34
+                            headerCell(text: text, width: columnWidth)
35
+                        }
36
+                    }
37
+
38
+                    VStack(spacing: 10) {
39
+                        ForEach(usbMeter.availableDataGroupIDs, id: \.self) { groupId in
40
+                            DataGroupRowView(
41
+                                id: groupId,
42
+                                width: columnWidth,
43
+                                opacity: groupId.isMultiple(of: 2) ? 0.1 : 0.2,
44
+                                showsCommands: usbMeter.supportsDataGroupCommands,
45
+                                showsEnergy: usbMeter.showsDataGroupEnergy,
46
+                                highlightsSelection: usbMeter.highlightsActiveDataGroup
47
+                            )
48
+                        }
49
+                    }
50
+                }
51
+                .padding()
52
+            }
53
+            .background(
54
+                LinearGradient(
55
+                    colors: [.teal.opacity(0.14), Color.clear],
56
+                    startPoint: .topLeading,
57
+                    endPoint: .bottomTrailing
58
+                )
59
+                .ignoresSafeArea()
60
+            )
61
+        }
62
+    }
63
+
64
+    private func headerCell(text: String, width: CGFloat) -> some View {
65
+        Text(text)
66
+            .font(.footnote.weight(.bold))
67
+            .frame(width: width)
68
+            .frame(minHeight: 38)
69
+            .foregroundColor(.secondary)
70
+            .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 14)
71
+    }
72
+}
+4 -12
USB Meter/Views/Meter/Tabs/Home/MeterHomeTabView.swift
@@ -10,9 +10,9 @@ struct MeterHomeTabView: View {
10 10
 
11 11
     let size: CGSize
12 12
     let isLandscape: Bool
13
+    let showChargeRecordTab: () -> Void
14
+    let showDataGroupsTab: () -> Void
13 15
 
14
-    @State private var dataGroupsViewVisibility = false
15
-    @State private var recordingViewVisibility = false
16 16
     @State private var measurementsViewVisibility = false
17 17
 
18 18
     private let actionStripPadding: CGFloat = 10
@@ -120,21 +120,13 @@ struct MeterHomeTabView: View {
120 120
             let stripWidth = actionStripWidth(for: buttonWidth)
121 121
             let stripContent = HStack(spacing: 0) {
122 122
                 meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
123
-                    dataGroupsViewVisibility.toggle()
124
-                }
125
-                .sheet(isPresented: $dataGroupsViewVisibility) {
126
-                    DataGroupsSheetView(visibility: $dataGroupsViewVisibility)
127
-                        .environmentObject(meter)
123
+                    showDataGroupsTab()
128 124
                 }
129 125
 
130 126
                 if meter.supportsRecordingView {
131 127
                     actionStripDivider(height: currentActionHeight)
132 128
                     meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
133
-                        recordingViewVisibility.toggle()
134
-                    }
135
-                    .sheet(isPresented: $recordingViewVisibility) {
136
-                        ChargeRecordSheetView(visibility: $recordingViewVisibility)
137
-                            .environmentObject(meter)
129
+                        showChargeRecordTab()
138 130
                     }
139 131
                 }
140 132