@@ -42,6 +42,7 @@ final class AppData : ObservableObject {
|
||
| 42 | 42 |
private var chargeInsightsStoreObserver: AnyCancellable? |
| 43 | 43 |
private var chargeInsightsRemoteObserver: AnyCancellable? |
| 44 | 44 |
private var chargerStandbyPowerStoreObserver: AnyCancellable? |
| 45 |
+ private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem? |
|
| 45 | 46 |
private let meterStore = MeterNameStore.shared |
| 46 | 47 |
private var chargeInsightsStore: ChargeInsightsStore? |
| 47 | 48 |
private let chargerStandbyPowerStore = ChargerStandbyPowerStore() |
@@ -103,7 +104,7 @@ final class AppData : ObservableObject {
|
||
| 103 | 104 |
) |
| 104 | 105 |
.receive(on: DispatchQueue.main) |
| 105 | 106 |
.sink { [weak self] _ in
|
| 106 |
- self?.reloadChargedDevices() |
|
| 107 |
+ self?.scheduleChargedDevicesReload() |
|
| 107 | 108 |
} |
| 108 | 109 |
|
| 109 | 110 |
chargeInsightsRemoteObserver = NotificationCenter.default.publisher( |
@@ -112,7 +113,7 @@ final class AppData : ObservableObject {
|
||
| 112 | 113 |
) |
| 113 | 114 |
.receive(on: DispatchQueue.main) |
| 114 | 115 |
.sink { [weak self] _ in
|
| 115 |
- self?.reloadChargedDevices() |
|
| 116 |
+ self?.scheduleChargedDevicesReload() |
|
| 116 | 117 |
} |
| 117 | 118 |
|
| 118 | 119 |
chargeNotificationCoordinator.ensureAuthorizationIfNeeded() |
@@ -194,7 +195,7 @@ final class AppData : ObservableObject {
|
||
| 194 | 195 |
func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
|
| 195 | 196 |
let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() |
| 196 | 197 |
|
| 197 |
- if let activeSession = activeChargeSessionSummary(for: normalizedMAC), |
|
| 198 |
+ if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC), |
|
| 198 | 199 |
let liveDevice = chargedDevices.first(where: {
|
| 199 | 200 |
$0.id == activeSession.chargedDeviceID && $0.isCharger == false |
| 200 | 201 |
}) {
|
@@ -209,7 +210,7 @@ final class AppData : ObservableObject {
|
||
| 209 | 210 |
func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
|
| 210 | 211 |
let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() |
| 211 | 212 |
|
| 212 |
- if let activeSession = activeChargeSessionSummary(for: normalizedMAC), |
|
| 213 |
+ if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC), |
|
| 213 | 214 |
let chargerID = activeSession.chargerID, |
| 214 | 215 |
let liveCharger = chargedDevices.first(where: {
|
| 215 | 216 |
$0.id == chargerID && $0.isCharger |
@@ -223,7 +224,10 @@ final class AppData : ObservableObject {
|
||
| 223 | 224 |
} |
| 224 | 225 |
|
| 225 | 226 |
func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
|
| 226 |
- chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress) |
|
| 227 |
+ if let cachedSummary = cachedActiveChargeSessionSummary(for: meterMACAddress) {
|
|
| 228 |
+ return cachedSummary |
|
| 229 |
+ } |
|
| 230 |
+ return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress) |
|
| 227 | 231 |
} |
| 228 | 232 |
|
| 229 | 233 |
func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
|
@@ -557,14 +561,42 @@ final class AppData : ObservableObject {
|
||
| 557 | 561 |
return didSave |
| 558 | 562 |
} |
| 559 | 563 |
|
| 564 |
+ @discardableResult |
|
| 565 |
+ func addBatteryCheckpoint( |
|
| 566 |
+ percent: Double, |
|
| 567 |
+ label: String?, |
|
| 568 |
+ for sessionID: UUID, |
|
| 569 |
+ measuredEnergyWh: Double?, |
|
| 570 |
+ measuredChargeAh: Double? |
|
| 571 |
+ ) -> Bool {
|
|
| 572 |
+ let didSave = chargeInsightsStore?.addBatteryCheckpoint( |
|
| 573 |
+ percent: percent, |
|
| 574 |
+ label: label, |
|
| 575 |
+ for: sessionID, |
|
| 576 |
+ measuredEnergyWh: measuredEnergyWh, |
|
| 577 |
+ measuredChargeAh: measuredChargeAh |
|
| 578 |
+ ) ?? false |
|
| 579 |
+ |
|
| 580 |
+ if didSave {
|
|
| 581 |
+ reloadChargedDevices() |
|
| 582 |
+ } |
|
| 583 |
+ |
|
| 584 |
+ return didSave |
|
| 585 |
+ } |
|
| 586 |
+ |
|
| 560 | 587 |
func batteryCheckpointPlausibilityWarning( |
| 561 | 588 |
percent: Double, |
| 562 |
- for sessionID: UUID |
|
| 589 |
+ for sessionID: UUID, |
|
| 590 |
+ effectiveEnergyWhOverride: Double? = nil |
|
| 563 | 591 |
) -> BatteryCheckpointPlausibilityWarning? {
|
| 564 | 592 |
guard let session = chargeSessionSummary(id: sessionID) else {
|
| 565 | 593 |
return nil |
| 566 | 594 |
} |
| 567 |
- return batteryCheckpointPlausibilityWarning(percent: percent, for: session) |
|
| 595 |
+ return batteryCheckpointPlausibilityWarning( |
|
| 596 |
+ percent: percent, |
|
| 597 |
+ for: session, |
|
| 598 |
+ effectiveEnergyWhOverride: effectiveEnergyWhOverride |
|
| 599 |
+ ) |
|
| 568 | 600 |
} |
| 569 | 601 |
|
| 570 | 602 |
@discardableResult |
@@ -575,7 +607,7 @@ final class AppData : ObservableObject {
|
||
| 575 | 607 |
) ?? false |
| 576 | 608 |
|
| 577 | 609 |
if didDelete {
|
| 578 |
- reloadChargedDevices() |
|
| 610 |
+ scheduleChargedDevicesReload(delay: 0) |
|
| 579 | 611 |
} |
| 580 | 612 |
|
| 581 | 613 |
return didDelete |
@@ -756,7 +788,32 @@ final class AppData : ObservableObject {
|
||
| 756 | 788 |
} |
| 757 | 789 |
} |
| 758 | 790 |
|
| 791 |
+ private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
|
|
| 792 |
+ pendingChargedDevicesReloadWorkItem?.cancel() |
|
| 793 |
+ |
|
| 794 |
+ let workItem = DispatchWorkItem { [weak self] in
|
|
| 795 |
+ self?.reloadChargedDevices() |
|
| 796 |
+ } |
|
| 797 |
+ pendingChargedDevicesReloadWorkItem = workItem |
|
| 798 |
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) |
|
| 799 |
+ } |
|
| 800 |
+ |
|
| 801 |
+ private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
|
|
| 802 |
+ let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) |
|
| 803 |
+ guard !normalizedMAC.isEmpty else {
|
|
| 804 |
+ return nil |
|
| 805 |
+ } |
|
| 806 |
+ |
|
| 807 |
+ return chargedDevices |
|
| 808 |
+ .lazy |
|
| 809 |
+ .compactMap(\.activeSession) |
|
| 810 |
+ .first(where: { $0.status == .active && $0.meterMACAddress == normalizedMAC })
|
|
| 811 |
+ } |
|
| 812 |
+ |
|
| 759 | 813 |
private func reloadChargedDevices() {
|
| 814 |
+ pendingChargedDevicesReloadWorkItem?.cancel() |
|
| 815 |
+ pendingChargedDevicesReloadWorkItem = nil |
|
| 816 |
+ |
|
| 760 | 817 |
let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID() |
| 761 | 818 |
chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
|
| 762 | 819 |
chargedDevice.withStandbyPowerMeasurements( |
@@ -823,7 +880,8 @@ final class AppData : ObservableObject {
|
||
| 823 | 880 |
|
| 824 | 881 |
private func batteryCheckpointPlausibilityWarning( |
| 825 | 882 |
percent: Double, |
| 826 |
- for session: ChargeSessionSummary |
|
| 883 |
+ for session: ChargeSessionSummary, |
|
| 884 |
+ effectiveEnergyWhOverride: Double? = nil |
|
| 827 | 885 |
) -> BatteryCheckpointPlausibilityWarning? {
|
| 828 | 886 |
guard percent.isFinite, percent >= 0, percent <= 100 else {
|
| 829 | 887 |
return nil |
@@ -847,8 +905,46 @@ final class AppData : ObservableObject {
|
||
| 847 | 905 |
) |
| 848 | 906 |
} |
| 849 | 907 |
|
| 908 |
+ let effectiveEnergyWh = effectiveEnergyWhOverride |
|
| 909 |
+ ?? session.effectiveBatteryEnergyWh |
|
| 910 |
+ ?? session.measuredEnergyWh |
|
| 911 |
+ |
|
| 912 |
+ if let lastCheckpoint = sortedCheckpoints.last, |
|
| 913 |
+ let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
|
|
| 914 |
+ let estimatedCapacityWh = session.capacityEstimateWh |
|
| 915 |
+ ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode) |
|
| 916 |
+ ?? chargedDevice.estimatedBatteryCapacityWh |
|
| 917 |
+ |
|
| 918 |
+ if let estimatedCapacityWh, estimatedCapacityWh > 0 {
|
|
| 919 |
+ let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0) |
|
| 920 |
+ let expectedPercent = min( |
|
| 921 |
+ 100, |
|
| 922 |
+ max( |
|
| 923 |
+ lastCheckpoint.batteryPercent, |
|
| 924 |
+ lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100 |
|
| 925 |
+ ) |
|
| 926 |
+ ) |
|
| 927 |
+ let predictionGap = percent - expectedPercent |
|
| 928 |
+ guard abs(predictionGap) >= 4 else {
|
|
| 929 |
+ return nil |
|
| 930 |
+ } |
|
| 931 |
+ |
|
| 932 |
+ let direction = predictionGap > 0 ? "above" : "below" |
|
| 933 |
+ let gapText = abs(predictionGap).format(decimalDigits: 0) |
|
| 934 |
+ let expectedText = expectedPercent.format(decimalDigits: 0) |
|
| 935 |
+ |
|
| 936 |
+ return BatteryCheckpointPlausibilityWarning( |
|
| 937 |
+ title: "Checkpoint Looks Implausible", |
|
| 938 |
+ 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." |
|
| 939 |
+ ) |
|
| 940 |
+ } |
|
| 941 |
+ } |
|
| 942 |
+ |
|
| 850 | 943 |
guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID), |
| 851 |
- let prediction = chargedDevice.batteryLevelPrediction(for: session) |
|
| 944 |
+ let prediction = chargedDevice.batteryLevelPrediction( |
|
| 945 |
+ for: session, |
|
| 946 |
+ effectiveEnergyWhOverride: effectiveEnergyWh |
|
| 947 |
+ ) |
|
| 852 | 948 |
else {
|
| 853 | 949 |
return nil |
| 854 | 950 |
} |
@@ -863,7 +959,6 @@ final class AppData : ObservableObject {
|
||
| 863 | 959 |
let predictedText = prediction.predictedPercent.format(decimalDigits: 0) |
| 864 | 960 |
|
| 865 | 961 |
if let lastCheckpoint = sortedCheckpoints.last {
|
| 866 |
- let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh |
|
| 867 | 962 |
let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0) |
| 868 | 963 |
return BatteryCheckpointPlausibilityWarning( |
| 869 | 964 |
title: "Checkpoint Looks Implausible", |
@@ -581,7 +581,9 @@ final class ChargeInsightsStore {
|
||
| 581 | 581 |
func addBatteryCheckpoint( |
| 582 | 582 |
percent: Double, |
| 583 | 583 |
label: String?, |
| 584 |
- for sessionID: UUID |
|
| 584 |
+ for sessionID: UUID, |
|
| 585 |
+ measuredEnergyWh: Double? = nil, |
|
| 586 |
+ measuredChargeAh: Double? = nil |
|
| 585 | 587 |
) -> Bool {
|
| 586 | 588 |
guard percent.isFinite, percent >= 0, percent <= 100 else {
|
| 587 | 589 |
return false |
@@ -593,7 +595,13 @@ final class ChargeInsightsStore {
|
||
| 593 | 595 |
return |
| 594 | 596 |
} |
| 595 | 597 |
|
| 596 |
- didSave = addBatteryCheckpoint(percent: percent, label: label, to: session) |
|
| 598 |
+ didSave = addBatteryCheckpoint( |
|
| 599 |
+ percent: percent, |
|
| 600 |
+ label: label, |
|
| 601 |
+ measuredEnergyWh: measuredEnergyWh, |
|
| 602 |
+ measuredChargeAh: measuredChargeAh, |
|
| 603 |
+ to: session |
|
| 604 |
+ ) |
|
| 597 | 605 |
} |
| 598 | 606 |
return didSave |
| 599 | 607 |
} |
@@ -617,16 +625,11 @@ final class ChargeInsightsStore {
|
||
| 617 | 625 |
context.delete(checkpoint) |
| 618 | 626 |
refreshCheckpointDerivedValues(for: session) |
| 619 | 627 |
|
| 620 |
- guard saveContext() else {
|
|
| 621 |
- return |
|
| 622 |
- } |
|
| 623 |
- |
|
| 624 | 628 |
if let chargedDeviceID {
|
| 625 | 629 |
refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
| 626 |
- didSave = saveContext() |
|
| 627 |
- } else {
|
|
| 628 |
- didSave = true |
|
| 629 | 630 |
} |
| 631 |
+ |
|
| 632 |
+ didSave = saveContext() |
|
| 630 | 633 |
} |
| 631 | 634 |
return didSave |
| 632 | 635 |
} |
@@ -856,12 +859,18 @@ final class ChargeInsightsStore {
|
||
| 856 | 859 |
let devices = fetchObjects(entityName: EntityName.chargedDevice) |
| 857 | 860 |
let sessions = fetchObjects(entityName: EntityName.chargeSession) |
| 858 | 861 |
let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint) |
| 859 |
- let sessionSamples = fetchObjects(entityName: EntityName.chargeSessionSample) |
|
| 860 | 862 |
|
| 861 | 863 |
let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
|
| 862 |
- let samplesBySessionID = Dictionary(grouping: sessionSamples) { stringValue($0, key: "sessionID") ?? "" }
|
|
| 863 | 864 |
let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
|
| 864 | 865 |
let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
|
| 866 |
+ let sampleBackedSessionIDs = sampleBackedSessionIDs( |
|
| 867 |
+ devices: devices, |
|
| 868 |
+ sessionsByDeviceID: sessionsByDeviceID, |
|
| 869 |
+ sessionsByChargerID: sessionsByChargerID |
|
| 870 |
+ ) |
|
| 871 |
+ let samplesBySessionID = Dictionary( |
|
| 872 |
+ grouping: fetchSessionSampleObjects(forSessionIDs: Array(sampleBackedSessionIDs)) |
|
| 873 |
+ ) { stringValue($0, key: "sessionID") ?? "" }
|
|
| 865 | 874 |
|
| 866 | 875 |
summaries = devices.compactMap { device in
|
| 867 | 876 |
guard |
@@ -2111,6 +2120,16 @@ final class ChargeInsightsStore {
|
||
| 2111 | 2120 |
return (try? context.fetch(request)) ?? [] |
| 2112 | 2121 |
} |
| 2113 | 2122 |
|
| 2123 |
+ private func fetchSessionSampleObjects(forSessionIDs sessionIDs: [String]) -> [NSManagedObject] {
|
|
| 2124 |
+ guard !sessionIDs.isEmpty else {
|
|
| 2125 |
+ return [] |
|
| 2126 |
+ } |
|
| 2127 |
+ |
|
| 2128 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample) |
|
| 2129 |
+ request.predicate = NSPredicate(format: "sessionID IN %@", sessionIDs) |
|
| 2130 |
+ return (try? context.fetch(request)) ?? [] |
|
| 2131 |
+ } |
|
| 2132 |
+ |
|
| 2114 | 2133 |
private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
|
| 2115 | 2134 |
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint) |
| 2116 | 2135 |
request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID) |
@@ -2139,6 +2158,68 @@ final class ChargeInsightsStore {
|
||
| 2139 | 2158 |
return (try? context.fetch(request)) ?? [] |
| 2140 | 2159 |
} |
| 2141 | 2160 |
|
| 2161 |
+ private func sampleBackedSessionIDs( |
|
| 2162 |
+ devices: [NSManagedObject], |
|
| 2163 |
+ sessionsByDeviceID: [String: [NSManagedObject]], |
|
| 2164 |
+ sessionsByChargerID: [String: [NSManagedObject]] |
|
| 2165 |
+ ) -> Set<String> {
|
|
| 2166 |
+ var sessionIDs: Set<String> = [] |
|
| 2167 |
+ |
|
| 2168 |
+ for device in devices {
|
|
| 2169 |
+ guard |
|
| 2170 |
+ let deviceID = stringValue(device, key: "id"), |
|
| 2171 |
+ let rawClass = stringValue(device, key: "deviceClassRawValue"), |
|
| 2172 |
+ let deviceClass = ChargedDeviceClass(rawValue: rawClass) |
|
| 2173 |
+ else {
|
|
| 2174 |
+ continue |
|
| 2175 |
+ } |
|
| 2176 |
+ |
|
| 2177 |
+ let relevantSessions = relevantSessionObjects( |
|
| 2178 |
+ for: deviceID, |
|
| 2179 |
+ deviceClass: deviceClass, |
|
| 2180 |
+ sessionsByDeviceID: sessionsByDeviceID, |
|
| 2181 |
+ sessionsByChargerID: sessionsByChargerID |
|
| 2182 |
+ ) |
|
| 2183 |
+ .sorted { lhs, rhs in
|
|
| 2184 |
+ let lhsStatus = statusValue(lhs, key: "statusRawValue") ?? .completed |
|
| 2185 |
+ let rhsStatus = statusValue(rhs, key: "statusRawValue") ?? .completed |
|
| 2186 |
+ |
|
| 2187 |
+ if lhsStatus.isOpen && !rhsStatus.isOpen {
|
|
| 2188 |
+ return true |
|
| 2189 |
+ } |
|
| 2190 |
+ if !lhsStatus.isOpen && rhsStatus.isOpen {
|
|
| 2191 |
+ return false |
|
| 2192 |
+ } |
|
| 2193 |
+ |
|
| 2194 |
+ return (dateValue(lhs, key: "startedAt") ?? .distantPast) |
|
| 2195 |
+ > (dateValue(rhs, key: "startedAt") ?? .distantPast) |
|
| 2196 |
+ } |
|
| 2197 |
+ |
|
| 2198 |
+ var recentCompletedSamplesIncluded = 0 |
|
| 2199 |
+ |
|
| 2200 |
+ for session in relevantSessions {
|
|
| 2201 |
+ guard let sessionID = stringValue(session, key: "id"), |
|
| 2202 |
+ let status = statusValue(session, key: "statusRawValue") else {
|
|
| 2203 |
+ continue |
|
| 2204 |
+ } |
|
| 2205 |
+ |
|
| 2206 |
+ if status.isOpen {
|
|
| 2207 |
+ sessionIDs.insert(sessionID) |
|
| 2208 |
+ continue |
|
| 2209 |
+ } |
|
| 2210 |
+ |
|
| 2211 |
+ guard recentCompletedSamplesIncluded < 2 else {
|
|
| 2212 |
+ continue |
|
| 2213 |
+ } |
|
| 2214 |
+ |
|
| 2215 |
+ sessionIDs.insert(sessionID) |
|
| 2216 |
+ recentCompletedSamplesIncluded += 1 |
|
| 2217 |
+ } |
|
| 2218 |
+ } |
|
| 2219 |
+ |
|
| 2220 |
+ return sessionIDs |
|
| 2221 |
+ } |
|
| 2222 |
+ |
|
| 2142 | 2223 |
private func relevantSessionObjects( |
| 2143 | 2224 |
for chargedDeviceID: String, |
| 2144 | 2225 |
deviceClass: ChargedDeviceClass, |
@@ -7,99 +7,169 @@ |
||
| 7 | 7 |
|
| 8 | 8 |
import SwiftUI |
| 9 | 9 |
|
| 10 |
-struct BatteryCheckpointEditorSheetView: View {
|
|
| 10 |
+struct BatteryCheckpointEditorContentView: View {
|
|
| 11 | 11 |
@EnvironmentObject private var appData: AppData |
| 12 |
- @EnvironmentObject private var meter: Meter |
|
| 13 |
- @Environment(\.dismiss) private var dismiss |
|
| 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)? |
|
| 14 | 19 |
|
| 15 | 20 |
@State private var batteryPercent = "" |
| 16 | 21 |
@State private var label = "" |
| 17 |
- @State private var confirmationWarning: BatteryCheckpointPlausibilityWarning? |
|
| 18 |
- |
|
| 19 |
- private var activeSession: ChargeSessionSummary? {
|
|
| 20 |
- appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description) |
|
| 21 |
- } |
|
| 22 |
+ @State private var showsWarningPopover = false |
|
| 22 | 23 |
|
| 23 | 24 |
private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
|
| 24 |
- guard let percent = Double(batteryPercent), |
|
| 25 |
- let activeSession else {
|
|
| 25 |
+ guard let percent = normalizedBatteryPercent else {
|
|
| 26 | 26 |
return nil |
| 27 | 27 |
} |
| 28 |
- return appData.batteryCheckpointPlausibilityWarning(percent: percent, for: activeSession.id) |
|
| 28 |
+ return appData.batteryCheckpointPlausibilityWarning( |
|
| 29 |
+ percent: percent, |
|
| 30 |
+ for: sessionID, |
|
| 31 |
+ effectiveEnergyWhOverride: effectiveEnergyWhOverride |
|
| 32 |
+ ) |
|
| 29 | 33 |
} |
| 30 | 34 |
|
| 31 |
- var body: some View {
|
|
| 32 |
- NavigationView {
|
|
| 33 |
- Form {
|
|
| 34 |
- Section( |
|
| 35 |
- header: ContextInfoHeader( |
|
| 36 |
- title: "Checkpoint", |
|
| 37 |
- message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve." |
|
| 38 |
- ) |
|
| 39 |
- ) {
|
|
| 40 |
- TextField("Battery %", text: $batteryPercent)
|
|
| 41 |
- .keyboardType(.decimalPad) |
|
| 42 |
- TextField("Label (optional)", text: $label)
|
|
| 43 |
- } |
|
| 35 |
+ private var normalizedBatteryPercent: Double? {
|
|
| 36 |
+ let normalized = batteryPercent |
|
| 37 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 38 |
+ .replacingOccurrences(of: ",", with: ".") |
|
| 39 |
+ return Double(normalized) |
|
| 40 |
+ } |
|
| 44 | 41 |
|
| 42 |
+ private var canSave: Bool {
|
|
| 43 |
+ guard let percent = normalizedBatteryPercent else {
|
|
| 44 |
+ return false |
|
| 45 |
+ } |
|
| 46 |
+ return percent >= 0 && percent <= 100 |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ var body: some View {
|
|
| 50 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 51 |
+ HStack(spacing: 8) {
|
|
| 52 |
+ Text("Checkpoint")
|
|
| 53 |
+ Spacer(minLength: 0) |
|
| 45 | 54 |
if let plausibilityWarning {
|
| 46 |
- Section(header: Text(plausibilityWarning.title)) {
|
|
| 47 |
- Text(plausibilityWarning.message) |
|
| 48 |
- .font(.footnote) |
|
| 55 |
+ Button {
|
|
| 56 |
+ showsWarningPopover.toggle() |
|
| 57 |
+ } label: {
|
|
| 58 |
+ Image(systemName: "exclamationmark.triangle.fill") |
|
| 59 |
+ .font(.body.weight(.semibold)) |
|
| 49 | 60 |
.foregroundColor(.orange) |
| 50 | 61 |
} |
| 62 |
+ .buttonStyle(.plain) |
|
| 63 |
+ .accessibilityLabel(plausibilityWarning.title) |
|
| 64 |
+ .popover(isPresented: $showsWarningPopover, arrowEdge: .top) {
|
|
| 65 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 66 |
+ Text(plausibilityWarning.title) |
|
| 67 |
+ .font(.headline) |
|
| 68 |
+ Text(plausibilityWarning.message) |
|
| 69 |
+ .font(.body) |
|
| 70 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 71 |
+ } |
|
| 72 |
+ .padding(16) |
|
| 73 |
+ .frame(width: 320, alignment: .leading) |
|
| 74 |
+ } |
|
| 51 | 75 |
} |
| 76 |
+ ContextInfoButton( |
|
| 77 |
+ title: "Checkpoint", |
|
| 78 |
+ message: message |
|
| 79 |
+ ) |
|
| 52 | 80 |
} |
| 53 |
- .navigationTitle("Battery Checkpoint")
|
|
| 54 |
- .navigationBarTitleDisplayMode(.inline) |
|
| 55 |
- .toolbar {
|
|
| 56 |
- ToolbarItem(placement: .cancellationAction) {
|
|
| 81 |
+ |
|
| 82 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 83 |
+ TextField("Battery %", text: $batteryPercent)
|
|
| 84 |
+ .keyboardType(.decimalPad) |
|
| 85 |
+ .textFieldStyle(.roundedBorder) |
|
| 86 |
+ |
|
| 87 |
+ TextField("Label (optional)", text: $label)
|
|
| 88 |
+ .textFieldStyle(.roundedBorder) |
|
| 89 |
+ } |
|
| 90 |
+ |
|
| 91 |
+ HStack(spacing: 10) {
|
|
| 92 |
+ if let onCancel {
|
|
| 57 | 93 |
Button("Cancel") {
|
| 58 |
- dismiss() |
|
| 94 |
+ onCancel() |
|
| 59 | 95 |
} |
| 96 |
+ .frame(maxWidth: .infinity) |
|
| 97 |
+ .padding(.vertical, 10) |
|
| 98 |
+ .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14) |
|
| 99 |
+ .buttonStyle(.plain) |
|
| 60 | 100 |
} |
| 61 |
- ToolbarItem(placement: .confirmationAction) {
|
|
| 62 |
- Button("Save") {
|
|
| 63 |
- saveCheckpoint() |
|
| 64 |
- } |
|
| 65 |
- .disabled( |
|
| 66 |
- (Double(batteryPercent) ?? -1) < 0 |
|
| 67 |
- || (Double(batteryPercent) ?? 101) > 100 |
|
| 68 |
- || appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description) == nil |
|
| 69 |
- ) |
|
| 101 |
+ |
|
| 102 |
+ Button("Save Checkpoint") {
|
|
| 103 |
+ saveCheckpoint() |
|
| 70 | 104 |
} |
| 105 |
+ .frame(maxWidth: .infinity) |
|
| 106 |
+ .padding(.vertical, 10) |
|
| 107 |
+ .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 108 |
+ .buttonStyle(.plain) |
|
| 109 |
+ .disabled(!canSave) |
|
| 110 |
+ .opacity(canSave ? 1 : 0.6) |
|
| 71 | 111 |
} |
| 72 | 112 |
} |
| 73 |
- .navigationViewStyle(StackNavigationViewStyle()) |
|
| 74 |
- .alert(item: $confirmationWarning) { warning in
|
|
| 75 |
- Alert( |
|
| 76 |
- title: Text(warning.title), |
|
| 77 |
- message: Text(warning.message), |
|
| 78 |
- primaryButton: .destructive(Text("Save Anyway")) {
|
|
| 79 |
- saveCheckpoint(forceOverride: true) |
|
| 80 |
- }, |
|
| 81 |
- secondaryButton: .cancel() |
|
| 82 |
- ) |
|
| 83 |
- } |
|
| 84 | 113 |
} |
| 85 | 114 |
|
| 86 |
- private func saveCheckpoint(forceOverride: Bool = false) {
|
|
| 87 |
- guard let percent = Double(batteryPercent) else {
|
|
| 88 |
- return |
|
| 89 |
- } |
|
| 90 |
- |
|
| 91 |
- if !forceOverride, let plausibilityWarning {
|
|
| 92 |
- confirmationWarning = plausibilityWarning |
|
| 115 |
+ private func saveCheckpoint() {
|
|
| 116 |
+ guard let percent = normalizedBatteryPercent else {
|
|
| 93 | 117 |
return |
| 94 | 118 |
} |
| 95 | 119 |
|
| 96 |
- let didSave = appData.addBatteryCheckpoint( |
|
| 120 |
+ if appData.addBatteryCheckpoint( |
|
| 97 | 121 |
percent: percent, |
| 98 | 122 |
label: label, |
| 99 |
- for: meter |
|
| 100 |
- ) |
|
| 101 |
- if didSave {
|
|
| 102 |
- dismiss() |
|
| 123 |
+ for: sessionID, |
|
| 124 |
+ measuredEnergyWh: effectiveEnergyWhOverride, |
|
| 125 |
+ measuredChargeAh: measuredChargeAhOverride |
|
| 126 |
+ ) {
|
|
| 127 |
+ onSaved?() |
|
| 128 |
+ } |
|
| 129 |
+ } |
|
| 130 |
+} |
|
| 131 |
+ |
|
| 132 |
+struct BatteryCheckpointEditorSheetView: View {
|
|
| 133 |
+ @EnvironmentObject private var appData: AppData |
|
| 134 |
+ @EnvironmentObject private var meter: Meter |
|
| 135 |
+ @Environment(\.dismiss) private var dismiss |
|
| 136 |
+ |
|
| 137 |
+ private var activeSession: ChargeSessionSummary? {
|
|
| 138 |
+ appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description) |
|
| 139 |
+ } |
|
| 140 |
+ |
|
| 141 |
+ var body: some View {
|
|
| 142 |
+ NavigationView {
|
|
| 143 |
+ Group {
|
|
| 144 |
+ if let activeSession {
|
|
| 145 |
+ Form {
|
|
| 146 |
+ BatteryCheckpointEditorContentView( |
|
| 147 |
+ sessionID: activeSession.id, |
|
| 148 |
+ message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.", |
|
| 149 |
+ effectiveEnergyWhOverride: nil, |
|
| 150 |
+ measuredChargeAhOverride: nil, |
|
| 151 |
+ onCancel: { dismiss() },
|
|
| 152 |
+ onSaved: { dismiss() }
|
|
| 153 |
+ ) |
|
| 154 |
+ } |
|
| 155 |
+ } else {
|
|
| 156 |
+ VStack(spacing: 12) {
|
|
| 157 |
+ Image(systemName: "bolt.slash") |
|
| 158 |
+ .font(.title2) |
|
| 159 |
+ .foregroundColor(.secondary) |
|
| 160 |
+ Text("No Active Session")
|
|
| 161 |
+ .font(.headline) |
|
| 162 |
+ Text("Start a charging session before adding a battery checkpoint.")
|
|
| 163 |
+ .font(.footnote) |
|
| 164 |
+ .foregroundColor(.secondary) |
|
| 165 |
+ .multilineTextAlignment(.center) |
|
| 166 |
+ } |
|
| 167 |
+ .padding(24) |
|
| 168 |
+ } |
|
| 169 |
+ } |
|
| 170 |
+ .navigationTitle("Battery Checkpoint")
|
|
| 171 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 103 | 172 |
} |
| 173 |
+ .navigationViewStyle(StackNavigationViewStyle()) |
|
| 104 | 174 |
} |
| 105 | 175 |
} |
@@ -11,11 +11,12 @@ struct ChargedDeviceDetailView: View {
|
||
| 11 | 11 |
@EnvironmentObject private var appData: AppData |
| 12 | 12 |
@Environment(\.dismiss) private var dismiss |
| 13 | 13 |
@State private var editorVisibility = false |
| 14 |
- @State private var checkpointEditorVisibility = false |
|
| 15 | 14 |
@State private var targetNotificationEditorVisibility = false |
| 16 | 15 |
@State private var pendingSessionDeletion: ChargeSessionSummary? |
| 16 |
+ @State private var pendingCheckpointDeletion: ChargeCheckpointSummary? |
|
| 17 | 17 |
@State private var pendingSessionStopRequest: DeviceSessionStopRequest? |
| 18 | 18 |
@State private var deleteConfirmationVisibility = false |
| 19 |
+ @State private var showsInlineCheckpointEditor = false |
|
| 19 | 20 |
|
| 20 | 21 |
let chargedDeviceID: UUID |
| 21 | 22 |
|
@@ -90,12 +91,6 @@ struct ChargedDeviceDetailView: View {
|
||
| 90 | 91 |
.environmentObject(appData) |
| 91 | 92 |
} |
| 92 | 93 |
} |
| 93 |
- .sheet(isPresented: $checkpointEditorVisibility) {
|
|
| 94 |
- if let sessionID = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession?.id {
|
|
| 95 |
- ChargedDeviceCheckpointEditorSheetView(sessionID: sessionID) |
|
| 96 |
- .environmentObject(appData) |
|
| 97 |
- } |
|
| 98 |
- } |
|
| 99 | 94 |
.sheet(isPresented: $targetNotificationEditorVisibility) {
|
| 100 | 95 |
if let activeSession = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession {
|
| 101 | 96 |
ChargedDeviceTargetNotificationEditorSheetView( |
@@ -124,6 +119,19 @@ struct ChargedDeviceDetailView: View {
|
||
| 124 | 119 |
secondaryButton: .cancel() |
| 125 | 120 |
) |
| 126 | 121 |
} |
| 122 |
+ .alert(item: $pendingCheckpointDeletion) { checkpoint in
|
|
| 123 |
+ Alert( |
|
| 124 |
+ title: Text("Delete Battery Checkpoint"),
|
|
| 125 |
+ message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
|
|
| 126 |
+ primaryButton: .destructive(Text("Delete")) {
|
|
| 127 |
+ _ = appData.deleteBatteryCheckpoint( |
|
| 128 |
+ checkpointID: checkpoint.id, |
|
| 129 |
+ for: checkpoint.sessionID |
|
| 130 |
+ ) |
|
| 131 |
+ }, |
|
| 132 |
+ secondaryButton: .cancel() |
|
| 133 |
+ ) |
|
| 134 |
+ } |
|
| 127 | 135 |
.confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
|
| 128 | 136 |
Button("Delete", role: .destructive) {
|
| 129 | 137 |
if appData.deleteChargedDevice(id: chargedDeviceID) {
|
@@ -134,6 +142,9 @@ struct ChargedDeviceDetailView: View {
|
||
| 134 | 142 |
} message: {
|
| 135 | 143 |
Text(deletionMessage) |
| 136 | 144 |
} |
| 145 |
+ .onChange(of: appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession?.id) { _ in
|
|
| 146 |
+ showsInlineCheckpointEditor = false |
|
| 147 |
+ } |
|
| 137 | 148 |
} |
| 138 | 149 |
|
| 139 | 150 |
private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
@@ -425,14 +436,31 @@ struct ChargedDeviceDetailView: View {
|
||
| 425 | 436 |
.foregroundColor(.secondary) |
| 426 | 437 |
} |
| 427 | 438 |
|
| 428 |
- Button("Add Battery Checkpoint") {
|
|
| 429 |
- checkpointEditorVisibility = true |
|
| 439 |
+ if !activeSession.checkpoints.isEmpty {
|
|
| 440 |
+ checkpointList( |
|
| 441 |
+ checkpoints: Array(activeSession.checkpoints.suffix(6).reversed()) |
|
| 442 |
+ ) |
|
| 443 |
+ } |
|
| 444 |
+ |
|
| 445 |
+ Button(showsInlineCheckpointEditor ? "Hide Checkpoint Editor" : "Add Battery Checkpoint") {
|
|
| 446 |
+ showsInlineCheckpointEditor.toggle() |
|
| 430 | 447 |
} |
| 431 | 448 |
.frame(maxWidth: .infinity) |
| 432 | 449 |
.padding(.vertical, 10) |
| 433 | 450 |
.meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
| 434 | 451 |
.buttonStyle(.plain) |
| 435 | 452 |
|
| 453 |
+ if showsInlineCheckpointEditor {
|
|
| 454 |
+ BatteryCheckpointEditorContentView( |
|
| 455 |
+ sessionID: activeSession.id, |
|
| 456 |
+ message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.", |
|
| 457 |
+ effectiveEnergyWhOverride: nil, |
|
| 458 |
+ measuredChargeAhOverride: nil, |
|
| 459 |
+ onCancel: { showsInlineCheckpointEditor = false },
|
|
| 460 |
+ onSaved: { showsInlineCheckpointEditor = false }
|
|
| 461 |
+ ) |
|
| 462 |
+ } |
|
| 463 |
+ |
|
| 436 | 464 |
Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
|
| 437 | 465 |
targetNotificationEditorVisibility = true |
| 438 | 466 |
} |
@@ -531,6 +559,38 @@ struct ChargedDeviceDetailView: View {
|
||
| 531 | 559 |
.meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) |
| 532 | 560 |
} |
| 533 | 561 |
|
| 562 |
+ private func checkpointList(checkpoints: [ChargeCheckpointSummary]) -> some View {
|
|
| 563 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 564 |
+ Text("Battery Checkpoints")
|
|
| 565 |
+ .font(.subheadline.weight(.semibold)) |
|
| 566 |
+ |
|
| 567 |
+ ForEach(checkpoints, id: \.id) { checkpoint in
|
|
| 568 |
+ HStack {
|
|
| 569 |
+ Text(checkpoint.timestamp.format()) |
|
| 570 |
+ .font(.caption2) |
|
| 571 |
+ .foregroundColor(.secondary) |
|
| 572 |
+ Spacer() |
|
| 573 |
+ Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
|
|
| 574 |
+ .font(.caption.weight(.semibold)) |
|
| 575 |
+ Text("•")
|
|
| 576 |
+ .foregroundColor(.secondary) |
|
| 577 |
+ Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
|
|
| 578 |
+ .font(.caption2) |
|
| 579 |
+ .foregroundColor(.secondary) |
|
| 580 |
+ Button {
|
|
| 581 |
+ pendingCheckpointDeletion = checkpoint |
|
| 582 |
+ } label: {
|
|
| 583 |
+ Image(systemName: "trash") |
|
| 584 |
+ .font(.caption.weight(.semibold)) |
|
| 585 |
+ .foregroundColor(.red) |
|
| 586 |
+ } |
|
| 587 |
+ .buttonStyle(.plain) |
|
| 588 |
+ .help("Delete checkpoint")
|
|
| 589 |
+ } |
|
| 590 |
+ } |
|
| 591 |
+ } |
|
| 592 |
+ } |
|
| 593 |
+ |
|
| 534 | 594 |
private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
|
| 535 | 595 |
if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
|
| 536 | 596 |
return activeSession |
@@ -1000,94 +1060,6 @@ private struct StoredSeriesSnapshot {
|
||
| 1000 | 1060 |
} |
| 1001 | 1061 |
} |
| 1002 | 1062 |
|
| 1003 |
-private struct ChargedDeviceCheckpointEditorSheetView: View {
|
|
| 1004 |
- @Environment(\.dismiss) private var dismiss |
|
| 1005 |
- @EnvironmentObject private var appData: AppData |
|
| 1006 |
- |
|
| 1007 |
- let sessionID: UUID |
|
| 1008 |
- |
|
| 1009 |
- @State private var batteryPercent = "" |
|
| 1010 |
- @State private var label = "" |
|
| 1011 |
- @State private var confirmationWarning: BatteryCheckpointPlausibilityWarning? |
|
| 1012 |
- |
|
| 1013 |
- private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
|
|
| 1014 |
- guard let percent = Double(batteryPercent) else {
|
|
| 1015 |
- return nil |
|
| 1016 |
- } |
|
| 1017 |
- return appData.batteryCheckpointPlausibilityWarning(percent: percent, for: sessionID) |
|
| 1018 |
- } |
|
| 1019 |
- |
|
| 1020 |
- var body: some View {
|
|
| 1021 |
- NavigationView {
|
|
| 1022 |
- Form {
|
|
| 1023 |
- Section( |
|
| 1024 |
- header: ContextInfoHeader( |
|
| 1025 |
- title: "Checkpoint", |
|
| 1026 |
- message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction." |
|
| 1027 |
- ) |
|
| 1028 |
- ) {
|
|
| 1029 |
- TextField("Battery %", text: $batteryPercent)
|
|
| 1030 |
- .keyboardType(.decimalPad) |
|
| 1031 |
- TextField("Label (optional)", text: $label)
|
|
| 1032 |
- } |
|
| 1033 |
- |
|
| 1034 |
- if let plausibilityWarning {
|
|
| 1035 |
- Section(header: Text(plausibilityWarning.title)) {
|
|
| 1036 |
- Text(plausibilityWarning.message) |
|
| 1037 |
- .font(.footnote) |
|
| 1038 |
- .foregroundColor(.orange) |
|
| 1039 |
- } |
|
| 1040 |
- } |
|
| 1041 |
- } |
|
| 1042 |
- .navigationTitle("Battery Checkpoint")
|
|
| 1043 |
- .navigationBarTitleDisplayMode(.inline) |
|
| 1044 |
- .toolbar {
|
|
| 1045 |
- ToolbarItem(placement: .cancellationAction) {
|
|
| 1046 |
- Button("Cancel") {
|
|
| 1047 |
- dismiss() |
|
| 1048 |
- } |
|
| 1049 |
- } |
|
| 1050 |
- |
|
| 1051 |
- ToolbarItem(placement: .confirmationAction) {
|
|
| 1052 |
- Button("Save") {
|
|
| 1053 |
- saveCheckpoint() |
|
| 1054 |
- } |
|
| 1055 |
- .disabled( |
|
| 1056 |
- (Double(batteryPercent) ?? -1) < 0 |
|
| 1057 |
- || (Double(batteryPercent) ?? 101) > 100 |
|
| 1058 |
- ) |
|
| 1059 |
- } |
|
| 1060 |
- } |
|
| 1061 |
- } |
|
| 1062 |
- .navigationViewStyle(StackNavigationViewStyle()) |
|
| 1063 |
- .alert(item: $confirmationWarning) { warning in
|
|
| 1064 |
- Alert( |
|
| 1065 |
- title: Text(warning.title), |
|
| 1066 |
- message: Text(warning.message), |
|
| 1067 |
- primaryButton: .destructive(Text("Save Anyway")) {
|
|
| 1068 |
- saveCheckpoint(forceOverride: true) |
|
| 1069 |
- }, |
|
| 1070 |
- secondaryButton: .cancel() |
|
| 1071 |
- ) |
|
| 1072 |
- } |
|
| 1073 |
- } |
|
| 1074 |
- |
|
| 1075 |
- private func saveCheckpoint(forceOverride: Bool = false) {
|
|
| 1076 |
- guard let percent = Double(batteryPercent) else {
|
|
| 1077 |
- return |
|
| 1078 |
- } |
|
| 1079 |
- |
|
| 1080 |
- if !forceOverride, let plausibilityWarning {
|
|
| 1081 |
- confirmationWarning = plausibilityWarning |
|
| 1082 |
- return |
|
| 1083 |
- } |
|
| 1084 |
- |
|
| 1085 |
- if appData.addBatteryCheckpoint(percent: percent, label: label, for: sessionID) {
|
|
| 1086 |
- dismiss() |
|
| 1087 |
- } |
|
| 1088 |
- } |
|
| 1089 |
-} |
|
| 1090 |
- |
|
| 1091 | 1063 |
private struct ChargedDeviceTargetNotificationEditorSheetView: View {
|
| 1092 | 1064 |
@Environment(\.dismiss) private var dismiss |
| 1093 | 1065 |
@EnvironmentObject private var appData: AppData |
@@ -36,7 +36,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 36 | 36 |
|
| 37 | 37 |
@State private var chargedDeviceLibraryVisibility = false |
| 38 | 38 |
@State private var chargerLibraryVisibility = false |
| 39 |
- @State private var checkpointEditorVisibility = false |
|
| 40 | 39 |
@State private var editingChargedDevice: ChargedDeviceSummary? |
| 41 | 40 |
@State private var targetNotificationEditorVisibility = false |
| 42 | 41 |
@State private var pendingStopRequest: ChargeSessionStopRequest? |
@@ -46,6 +45,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 46 | 45 |
@State private var initialCheckpointMode: InitialCheckpointMode = .known |
| 47 | 46 |
@State private var initialCheckpoint = "" |
| 48 | 47 |
@State private var showsMeterTotalsInfo = false |
| 48 |
+ @State private var showsInlineCheckpointEditor = false |
|
| 49 | 49 |
|
| 50 | 50 |
private enum SessionStartRequirement: Identifiable {
|
| 51 | 51 |
case existingSession |
@@ -143,11 +143,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 143 | 143 |
) |
| 144 | 144 |
.environmentObject(appData) |
| 145 | 145 |
} |
| 146 |
- .sheet(isPresented: $checkpointEditorVisibility) {
|
|
| 147 |
- BatteryCheckpointEditorSheetView() |
|
| 148 |
- .environmentObject(appData) |
|
| 149 |
- .environmentObject(usbMeter) |
|
| 150 |
- } |
|
| 151 | 146 |
.sheet(item: $editingChargedDevice) { chargedDevice in
|
| 152 | 147 |
ChargedDeviceEditorSheetView( |
| 153 | 148 |
meterMACAddress: nil, |
@@ -197,6 +192,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 197 | 192 |
} |
| 198 | 193 |
.onChange(of: openChargeSession?.id) { _ in
|
| 199 | 194 |
syncDraftSelections() |
| 195 |
+ showsInlineCheckpointEditor = false |
|
| 200 | 196 |
} |
| 201 | 197 |
} |
| 202 | 198 |
|
@@ -730,6 +726,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 730 | 726 |
|
| 731 | 727 |
private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
|
| 732 | 728 |
let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession) |
| 729 |
+ let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession) |
|
| 733 | 730 |
return VStack(alignment: .leading, spacing: 12) {
|
| 734 | 731 |
HStack(spacing: 8) {
|
| 735 | 732 |
Text("Charging Monitor")
|
@@ -797,14 +794,31 @@ struct MeterChargeRecordContentView: View {
|
||
| 797 | 794 |
.foregroundColor(.secondary) |
| 798 | 795 |
} |
| 799 | 796 |
|
| 800 |
- Button("Add Battery Checkpoint") {
|
|
| 801 |
- checkpointEditorVisibility = true |
|
| 797 |
+ if !openChargeSession.checkpoints.isEmpty {
|
|
| 798 |
+ checkpointList( |
|
| 799 |
+ checkpoints: Array(openChargeSession.checkpoints.suffix(6).reversed()) |
|
| 800 |
+ ) |
|
| 801 |
+ } |
|
| 802 |
+ |
|
| 803 |
+ Button(showsInlineCheckpointEditor ? "Hide Checkpoint Editor" : "Add Battery Checkpoint") {
|
|
| 804 |
+ showsInlineCheckpointEditor.toggle() |
|
| 802 | 805 |
} |
| 803 | 806 |
.frame(maxWidth: .infinity) |
| 804 | 807 |
.padding(.vertical, 10) |
| 805 | 808 |
.meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
| 806 | 809 |
.buttonStyle(.plain) |
| 807 | 810 |
|
| 811 |
+ if showsInlineCheckpointEditor {
|
|
| 812 |
+ BatteryCheckpointEditorContentView( |
|
| 813 |
+ sessionID: openChargeSession.id, |
|
| 814 |
+ message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.", |
|
| 815 |
+ effectiveEnergyWhOverride: displayedEnergyWh, |
|
| 816 |
+ measuredChargeAhOverride: displayedChargeAh, |
|
| 817 |
+ onCancel: { showsInlineCheckpointEditor = false },
|
|
| 818 |
+ onSaved: { showsInlineCheckpointEditor = false }
|
|
| 819 |
+ ) |
|
| 820 |
+ } |
|
| 821 |
+ |
|
| 808 | 822 |
Button(openChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
|
| 809 | 823 |
targetNotificationEditorVisibility = true |
| 810 | 824 |
} |
@@ -854,37 +868,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 854 | 868 |
.meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
| 855 | 869 |
.buttonStyle(.plain) |
| 856 | 870 |
|
| 857 |
- if !openChargeSession.checkpoints.isEmpty {
|
|
| 858 |
- let recentCheckpoints = Array(openChargeSession.checkpoints.suffix(6).reversed()) |
|
| 859 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 860 |
- Text("Battery Checkpoints")
|
|
| 861 |
- .font(.subheadline.weight(.semibold)) |
|
| 862 |
- |
|
| 863 |
- ForEach(recentCheckpoints, id: \.id) { checkpoint in
|
|
| 864 |
- HStack {
|
|
| 865 |
- Text(checkpoint.timestamp.format()) |
|
| 866 |
- .font(.caption2) |
|
| 867 |
- .foregroundColor(.secondary) |
|
| 868 |
- Spacer() |
|
| 869 |
- Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
|
|
| 870 |
- .font(.caption.weight(.semibold)) |
|
| 871 |
- Text("•")
|
|
| 872 |
- .foregroundColor(.secondary) |
|
| 873 |
- Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
|
|
| 874 |
- .font(.caption2) |
|
| 875 |
- .foregroundColor(.secondary) |
|
| 876 |
- Button {
|
|
| 877 |
- pendingCheckpointDeletion = checkpoint |
|
| 878 |
- } label: {
|
|
| 879 |
- Image(systemName: "trash") |
|
| 880 |
- .font(.caption.weight(.semibold)) |
|
| 881 |
- .foregroundColor(.red) |
|
| 882 |
- } |
|
| 883 |
- .buttonStyle(.plain) |
|
| 884 |
- } |
|
| 885 |
- } |
|
| 886 |
- } |
|
| 887 |
- } |
|
| 888 | 871 |
} |
| 889 | 872 |
.padding(18) |
| 890 | 873 |
.meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20) |
@@ -1049,6 +1032,55 @@ struct MeterChargeRecordContentView: View {
|
||
| 1049 | 1032 |
return storedEnergyWh |
| 1050 | 1033 |
} |
| 1051 | 1034 |
|
| 1035 |
+ private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
|
|
| 1036 |
+ let storedChargeAh = session.measuredChargeAh |
|
| 1037 |
+ guard session.status.isOpen else {
|
|
| 1038 |
+ return storedChargeAh |
|
| 1039 |
+ } |
|
| 1040 |
+ |
|
| 1041 |
+ guard session.meterMACAddress == meterMACAddress else {
|
|
| 1042 |
+ return storedChargeAh |
|
| 1043 |
+ } |
|
| 1044 |
+ |
|
| 1045 |
+ if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
| 1046 |
+ return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0)) |
|
| 1047 |
+ } |
|
| 1048 |
+ |
|
| 1049 |
+ return storedChargeAh |
|
| 1050 |
+ } |
|
| 1051 |
+ |
|
| 1052 |
+ private func checkpointList(checkpoints: [ChargeCheckpointSummary]) -> some View {
|
|
| 1053 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 1054 |
+ Text("Battery Checkpoints")
|
|
| 1055 |
+ .font(.subheadline.weight(.semibold)) |
|
| 1056 |
+ |
|
| 1057 |
+ ForEach(checkpoints, id: \.id) { checkpoint in
|
|
| 1058 |
+ HStack {
|
|
| 1059 |
+ Text(checkpoint.timestamp.format()) |
|
| 1060 |
+ .font(.caption2) |
|
| 1061 |
+ .foregroundColor(.secondary) |
|
| 1062 |
+ Spacer() |
|
| 1063 |
+ Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
|
|
| 1064 |
+ .font(.caption.weight(.semibold)) |
|
| 1065 |
+ Text("•")
|
|
| 1066 |
+ .foregroundColor(.secondary) |
|
| 1067 |
+ Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
|
|
| 1068 |
+ .font(.caption2) |
|
| 1069 |
+ .foregroundColor(.secondary) |
|
| 1070 |
+ Button {
|
|
| 1071 |
+ pendingCheckpointDeletion = checkpoint |
|
| 1072 |
+ } label: {
|
|
| 1073 |
+ Image(systemName: "trash") |
|
| 1074 |
+ .font(.caption.weight(.semibold)) |
|
| 1075 |
+ .foregroundColor(.red) |
|
| 1076 |
+ } |
|
| 1077 |
+ .buttonStyle(.plain) |
|
| 1078 |
+ .help("Delete checkpoint")
|
|
| 1079 |
+ } |
|
| 1080 |
+ } |
|
| 1081 |
+ } |
|
| 1082 |
+ } |
|
| 1083 |
+ |
|
| 1052 | 1084 |
private func formatDuration(_ duration: TimeInterval) -> String {
|
| 1053 | 1085 |
let totalSeconds = Int(duration.rounded(.down)) |
| 1054 | 1086 |
let hours = totalSeconds / 3600 |