@@ -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). |
|
@@ -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/` |
@@ -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 */, |
@@ -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 {
|
@@ -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 |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
|