@@ -130,6 +130,7 @@ |
||
| 130 | 130 |
C100001C3C8E4A7A00A1001C /* USB_Meter 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 7.xcdatamodel"; sourceTree = "<group>"; };
|
| 131 | 131 |
C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 8.xcdatamodel"; sourceTree = "<group>"; };
|
| 132 | 132 |
C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 9.xcdatamodel"; sourceTree = "<group>"; };
|
| 133 |
+ C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 10.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 133 | 134 |
430CB4FB245E07EB006525C2 /* ChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronView.swift; sourceTree = "<group>"; };
|
| 134 | 135 |
4311E639241384960080EA59 /* DeviceHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelpView.swift; sourceTree = "<group>"; };
|
| 135 | 136 |
4327461A24619CED0009BE4B /* MeterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterRowView.swift; sourceTree = "<group>"; };
|
@@ -829,8 +830,9 @@ |
||
| 829 | 830 |
C100001C3C8E4A7A00A1001C /* USB_Meter 7.xcdatamodel */, |
| 830 | 831 |
C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */, |
| 831 | 832 |
C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */, |
| 833 |
+ C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */, |
|
| 832 | 834 |
); |
| 833 |
- currentVersion = C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */; |
|
| 835 |
+ currentVersion = C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */; |
|
| 834 | 836 |
path = CKModel.xcdatamodeld; |
| 835 | 837 |
sourceTree = "<group>"; |
| 836 | 838 |
versionGroupType = wrapper.xcdatamodel; |
@@ -233,7 +233,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD |
||
| 233 | 233 |
} |
| 234 | 234 |
|
| 235 | 235 |
container.viewContext.automaticallyMergesChangesFromParent = true |
| 236 |
- container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy |
|
| 236 |
+ container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy |
|
| 237 | 237 |
return container |
| 238 | 238 |
}() |
| 239 | 239 |
|
@@ -12,6 +12,15 @@ import CoreBluetooth |
||
| 12 | 12 |
import CoreData |
| 13 | 13 |
import UserNotifications |
| 14 | 14 |
|
| 15 |
+struct BatteryCheckpointPlausibilityWarning: Identifiable, Hashable {
|
|
| 16 |
+ let title: String |
|
| 17 |
+ let message: String |
|
| 18 |
+ |
|
| 19 |
+ var id: String {
|
|
| 20 |
+ "\(title)\n\(message)" |
|
| 21 |
+ } |
|
| 22 |
+} |
|
| 23 |
+ |
|
| 15 | 24 |
final class AppData : ObservableObject {
|
| 16 | 25 |
struct MeterSummary: Identifiable {
|
| 17 | 26 |
let macAddress: String |
@@ -77,7 +86,7 @@ final class AppData : ObservableObject {
|
||
| 77 | 86 |
} |
| 78 | 87 |
|
| 79 | 88 |
context.automaticallyMergesChangesFromParent = true |
| 80 |
- context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy |
|
| 89 |
+ context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy |
|
| 81 | 90 |
chargeInsightsStore = ChargeInsightsStore(context: context) |
| 82 | 91 |
|
| 83 | 92 |
chargeInsightsStoreObserver = NotificationCenter.default.publisher( |
@@ -143,6 +152,15 @@ final class AppData : ObservableObject {
|
||
| 143 | 152 |
chargedDevices.first(where: { $0.id == id })
|
| 144 | 153 |
} |
| 145 | 154 |
|
| 155 |
+ func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
|
|
| 156 |
+ for chargedDevice in chargedDevices {
|
|
| 157 |
+ if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
|
|
| 158 |
+ return session |
|
| 159 |
+ } |
|
| 160 |
+ } |
|
| 161 |
+ return nil |
|
| 162 |
+ } |
|
| 163 |
+ |
|
| 146 | 164 |
func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
|
| 147 | 165 |
let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() |
| 148 | 166 |
return chargedDevices.filter { chargedDevice in
|
@@ -201,25 +219,23 @@ final class AppData : ObservableObject {
|
||
| 201 | 219 |
} |
| 202 | 220 |
|
| 203 | 221 |
@discardableResult |
| 204 |
- func createChargedDevice( |
|
| 222 |
+ func createDevice( |
|
| 205 | 223 |
name: String, |
| 206 | 224 |
deviceClass: ChargedDeviceClass, |
| 207 | 225 |
chargingStateAvailability: ChargingStateAvailability, |
| 208 | 226 |
supportsWiredCharging: Bool, |
| 209 | 227 |
supportsWirelessCharging: Bool, |
| 210 |
- preferredChargingTransportMode: ChargingTransportMode, |
|
| 211 | 228 |
wirelessChargingProfile: WirelessChargingProfile, |
| 212 | 229 |
configuredCompletionCurrents: [ChargeSessionKind: Double], |
| 213 | 230 |
notes: String?, |
| 214 | 231 |
meterMACAddress: String? |
| 215 | 232 |
) -> Bool {
|
| 216 |
- let didSave = chargeInsightsStore?.createChargedDevice( |
|
| 233 |
+ let didSave = chargeInsightsStore?.createDevice( |
|
| 217 | 234 |
name: name, |
| 218 | 235 |
deviceClass: deviceClass, |
| 219 | 236 |
chargingStateAvailability: chargingStateAvailability, |
| 220 | 237 |
supportsWiredCharging: supportsWiredCharging, |
| 221 | 238 |
supportsWirelessCharging: supportsWirelessCharging, |
| 222 |
- preferredChargingTransportMode: preferredChargingTransportMode, |
|
| 223 | 239 |
wirelessChargingProfile: wirelessChargingProfile, |
| 224 | 240 |
configuredCompletionCurrents: configuredCompletionCurrents, |
| 225 | 241 |
notes: notes, |
@@ -234,26 +250,43 @@ final class AppData : ObservableObject {
|
||
| 234 | 250 |
} |
| 235 | 251 |
|
| 236 | 252 |
@discardableResult |
| 237 |
- func updateChargedDevice( |
|
| 253 |
+ func createCharger( |
|
| 254 |
+ name: String, |
|
| 255 |
+ notes: String?, |
|
| 256 |
+ meterMACAddress: String? |
|
| 257 |
+ ) -> Bool {
|
|
| 258 |
+ let didSave = chargeInsightsStore?.createCharger( |
|
| 259 |
+ name: name, |
|
| 260 |
+ notes: notes, |
|
| 261 |
+ assignTo: meterMACAddress |
|
| 262 |
+ ) ?? false |
|
| 263 |
+ |
|
| 264 |
+ if didSave {
|
|
| 265 |
+ reloadChargedDevices() |
|
| 266 |
+ } |
|
| 267 |
+ |
|
| 268 |
+ return didSave |
|
| 269 |
+ } |
|
| 270 |
+ |
|
| 271 |
+ @discardableResult |
|
| 272 |
+ func updateDevice( |
|
| 238 | 273 |
id: UUID, |
| 239 | 274 |
name: String, |
| 240 | 275 |
deviceClass: ChargedDeviceClass, |
| 241 | 276 |
chargingStateAvailability: ChargingStateAvailability, |
| 242 | 277 |
supportsWiredCharging: Bool, |
| 243 | 278 |
supportsWirelessCharging: Bool, |
| 244 |
- preferredChargingTransportMode: ChargingTransportMode, |
|
| 245 | 279 |
wirelessChargingProfile: WirelessChargingProfile, |
| 246 | 280 |
configuredCompletionCurrents: [ChargeSessionKind: Double], |
| 247 | 281 |
notes: String? |
| 248 | 282 |
) -> Bool {
|
| 249 |
- let didSave = chargeInsightsStore?.updateChargedDevice( |
|
| 283 |
+ let didSave = chargeInsightsStore?.updateDevice( |
|
| 250 | 284 |
id: id, |
| 251 | 285 |
name: name, |
| 252 | 286 |
deviceClass: deviceClass, |
| 253 | 287 |
chargingStateAvailability: chargingStateAvailability, |
| 254 | 288 |
supportsWiredCharging: supportsWiredCharging, |
| 255 | 289 |
supportsWirelessCharging: supportsWirelessCharging, |
| 256 |
- preferredChargingTransportMode: preferredChargingTransportMode, |
|
| 257 | 290 |
wirelessChargingProfile: wirelessChargingProfile, |
| 258 | 291 |
configuredCompletionCurrents: configuredCompletionCurrents, |
| 259 | 292 |
notes: notes |
@@ -267,10 +300,15 @@ final class AppData : ObservableObject {
|
||
| 267 | 300 |
} |
| 268 | 301 |
|
| 269 | 302 |
@discardableResult |
| 270 |
- func setChargingTransportMode(_ chargingTransportMode: ChargingTransportMode, for meter: Meter) -> Bool {
|
|
| 271 |
- let didSave = chargeInsightsStore?.setChargingTransportMode( |
|
| 272 |
- chargingTransportMode, |
|
| 273 |
- for: meter.btSerial.macAddress.description |
|
| 303 |
+ func updateCharger( |
|
| 304 |
+ id: UUID, |
|
| 305 |
+ name: String, |
|
| 306 |
+ notes: String? |
|
| 307 |
+ ) -> Bool {
|
|
| 308 |
+ let didSave = chargeInsightsStore?.updateCharger( |
|
| 309 |
+ id: id, |
|
| 310 |
+ name: name, |
|
| 311 |
+ notes: notes |
|
| 274 | 312 |
) ?? false |
| 275 | 313 |
|
| 276 | 314 |
if didSave {
|
@@ -298,6 +336,16 @@ final class AppData : ObservableObject {
|
||
| 298 | 336 |
return didSave |
| 299 | 337 |
} |
| 300 | 338 |
|
| 339 |
+ func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
|
|
| 340 |
+ guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
|
|
| 341 |
+ return |
|
| 342 |
+ } |
|
| 343 |
+ guard activeSession.status == .active else {
|
|
| 344 |
+ return |
|
| 345 |
+ } |
|
| 346 |
+ meter.restoreChargeMonitoringIfNeeded(from: activeSession) |
|
| 347 |
+ } |
|
| 348 |
+ |
|
| 301 | 349 |
@discardableResult |
| 302 | 350 |
func startChargeSession( |
| 303 | 351 |
for meter: Meter, |
@@ -306,7 +354,8 @@ final class AppData : ObservableObject {
|
||
| 306 | 354 |
chargingTransportMode: ChargingTransportMode, |
| 307 | 355 |
chargingStateMode: ChargingStateMode, |
| 308 | 356 |
autoStopEnabled: Bool, |
| 309 |
- initialBatteryPercent: Double |
|
| 357 |
+ initialBatteryPercent: Double?, |
|
| 358 |
+ startsFromFlatBattery: Bool |
|
| 310 | 359 |
) -> Bool {
|
| 311 | 360 |
guard let snapshot = meter.chargingMonitorSnapshot else {
|
| 312 | 361 |
return false |
@@ -319,10 +368,18 @@ final class AppData : ObservableObject {
|
||
| 319 | 368 |
chargingTransportMode: chargingTransportMode, |
| 320 | 369 |
chargingStateMode: chargingStateMode, |
| 321 | 370 |
autoStopEnabled: autoStopEnabled, |
| 322 |
- initialBatteryPercent: initialBatteryPercent |
|
| 371 |
+ initialBatteryPercent: initialBatteryPercent, |
|
| 372 |
+ startsFromFlatBattery: startsFromFlatBattery |
|
| 323 | 373 |
) ?? false |
| 324 | 374 |
if didSave {
|
| 325 | 375 |
reloadChargedDevices() |
| 376 |
+ meter.resetChargeRecordGraph() |
|
| 377 |
+ if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description), |
|
| 378 |
+ meter.supportsRecordingThreshold, |
|
| 379 |
+ activeSession.stopThresholdAmps > 0 {
|
|
| 380 |
+ meter.recordingTreshold = activeSession.stopThresholdAmps |
|
| 381 |
+ } |
|
| 382 |
+ restoreChargeMonitoringStateIfNeeded(for: meter) |
|
| 326 | 383 |
} |
| 327 | 384 |
return didSave |
| 328 | 385 |
} |
@@ -404,6 +461,30 @@ final class AppData : ObservableObject {
|
||
| 404 | 461 |
return didSave |
| 405 | 462 |
} |
| 406 | 463 |
|
| 464 |
+ func batteryCheckpointPlausibilityWarning( |
|
| 465 |
+ percent: Double, |
|
| 466 |
+ for sessionID: UUID |
|
| 467 |
+ ) -> BatteryCheckpointPlausibilityWarning? {
|
|
| 468 |
+ guard let session = chargeSessionSummary(id: sessionID) else {
|
|
| 469 |
+ return nil |
|
| 470 |
+ } |
|
| 471 |
+ return batteryCheckpointPlausibilityWarning(percent: percent, for: session) |
|
| 472 |
+ } |
|
| 473 |
+ |
|
| 474 |
+ @discardableResult |
|
| 475 |
+ func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
|
|
| 476 |
+ let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint( |
|
| 477 |
+ id: checkpointID, |
|
| 478 |
+ from: sessionID |
|
| 479 |
+ ) ?? false |
|
| 480 |
+ |
|
| 481 |
+ if didDelete {
|
|
| 482 |
+ reloadChargedDevices() |
|
| 483 |
+ } |
|
| 484 |
+ |
|
| 485 |
+ return didDelete |
|
| 486 |
+ } |
|
| 487 |
+ |
|
| 407 | 488 |
@discardableResult |
| 408 | 489 |
func flushChargeInsights() -> Bool {
|
| 409 | 490 |
let didSave = chargeInsightsStore?.flushPendingChanges() ?? false |
@@ -531,16 +612,6 @@ final class AppData : ObservableObject {
|
||
| 531 | 612 |
return didDelete |
| 532 | 613 |
} |
| 533 | 614 |
|
| 534 |
- func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
|
|
| 535 |
- guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
|
|
| 536 |
- return |
|
| 537 |
- } |
|
| 538 |
- guard activeSession.status == .active else {
|
|
| 539 |
- return |
|
| 540 |
- } |
|
| 541 |
- meter.restoreChargeMonitoringIfNeeded(from: activeSession) |
|
| 542 |
- } |
|
| 543 |
- |
|
| 544 | 615 |
var meterSummaries: [MeterSummary] {
|
| 545 | 616 |
let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
|
| 546 | 617 |
let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
|
@@ -619,6 +690,62 @@ final class AppData : ObservableObject {
|
||
| 619 | 690 |
} |
| 620 | 691 |
} |
| 621 | 692 |
} |
| 693 |
+ |
|
| 694 |
+ private func batteryCheckpointPlausibilityWarning( |
|
| 695 |
+ percent: Double, |
|
| 696 |
+ for session: ChargeSessionSummary |
|
| 697 |
+ ) -> BatteryCheckpointPlausibilityWarning? {
|
|
| 698 |
+ guard percent.isFinite, percent >= 0, percent <= 100 else {
|
|
| 699 |
+ return nil |
|
| 700 |
+ } |
|
| 701 |
+ |
|
| 702 |
+ let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
|
|
| 703 |
+ if lhs.timestamp != rhs.timestamp {
|
|
| 704 |
+ return lhs.timestamp < rhs.timestamp |
|
| 705 |
+ } |
|
| 706 |
+ if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
|
|
| 707 |
+ return lhs.measuredEnergyWh < rhs.measuredEnergyWh |
|
| 708 |
+ } |
|
| 709 |
+ return lhs.id.uuidString < rhs.id.uuidString |
|
| 710 |
+ } |
|
| 711 |
+ |
|
| 712 |
+ if let lastCheckpoint = sortedCheckpoints.last, |
|
| 713 |
+ percent < lastCheckpoint.batteryPercent - 1.5 {
|
|
| 714 |
+ return BatteryCheckpointPlausibilityWarning( |
|
| 715 |
+ title: "Checkpoint Goes Backwards", |
|
| 716 |
+ message: "The latest checkpoint is \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.timestamp.format()). A new value of \(percent.format(decimalDigits: 0))% is unexpectedly lower while the session is still charging." |
|
| 717 |
+ ) |
|
| 718 |
+ } |
|
| 719 |
+ |
|
| 720 |
+ guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID), |
|
| 721 |
+ let prediction = chargedDevice.batteryLevelPrediction(for: session) |
|
| 722 |
+ else {
|
|
| 723 |
+ return nil |
|
| 724 |
+ } |
|
| 725 |
+ |
|
| 726 |
+ let predictionGap = percent - prediction.predictedPercent |
|
| 727 |
+ guard abs(predictionGap) >= 4 else {
|
|
| 728 |
+ return nil |
|
| 729 |
+ } |
|
| 730 |
+ |
|
| 731 |
+ let direction = predictionGap > 0 ? "above" : "below" |
|
| 732 |
+ let gapText = abs(predictionGap).format(decimalDigits: 0) |
|
| 733 |
+ let predictedText = prediction.predictedPercent.format(decimalDigits: 0) |
|
| 734 |
+ |
|
| 735 |
+ if let lastCheckpoint = sortedCheckpoints.last {
|
|
| 736 |
+ let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh |
|
| 737 |
+ let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0) |
|
| 738 |
+ return BatteryCheckpointPlausibilityWarning( |
|
| 739 |
+ title: "Checkpoint Looks Implausible", |
|
| 740 |
+ message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Since the last checkpoint at \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))%, only \(energyDeltaWh.format(decimalDigits: 2)) Wh were added." |
|
| 741 |
+ ) |
|
| 742 |
+ } |
|
| 743 |
+ |
|
| 744 |
+ return BatteryCheckpointPlausibilityWarning( |
|
| 745 |
+ title: "Checkpoint Looks Implausible", |
|
| 746 |
+ message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Save it only if the device gauge really jumped that much." |
|
| 747 |
+ ) |
|
| 748 |
+ } |
|
| 622 | 749 |
} |
| 623 | 750 |
|
| 624 | 751 |
extension AppData.MeterSummary {
|
@@ -7,6 +7,40 @@ |
||
| 7 | 7 |
|
| 8 | 8 |
import Foundation |
| 9 | 9 |
|
| 10 |
+enum ChargedDeviceKind: String, Identifiable {
|
|
| 11 |
+ case device |
|
| 12 |
+ case charger |
|
| 13 |
+ |
|
| 14 |
+ var id: String { rawValue }
|
|
| 15 |
+ |
|
| 16 |
+ var title: String {
|
|
| 17 |
+ switch self {
|
|
| 18 |
+ case .device: |
|
| 19 |
+ return "Device" |
|
| 20 |
+ case .charger: |
|
| 21 |
+ return "Charger" |
|
| 22 |
+ } |
|
| 23 |
+ } |
|
| 24 |
+ |
|
| 25 |
+ var pluralTitle: String {
|
|
| 26 |
+ switch self {
|
|
| 27 |
+ case .device: |
|
| 28 |
+ return "Devices" |
|
| 29 |
+ case .charger: |
|
| 30 |
+ return "Chargers" |
|
| 31 |
+ } |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 34 |
+ var symbolName: String {
|
|
| 35 |
+ switch self {
|
|
| 36 |
+ case .device: |
|
| 37 |
+ return "iphone" |
|
| 38 |
+ case .charger: |
|
| 39 |
+ return "bolt.horizontal.circle" |
|
| 40 |
+ } |
|
| 41 |
+ } |
|
| 42 |
+} |
|
| 43 |
+ |
|
| 10 | 44 |
enum ChargedDeviceClass: String, CaseIterable, Identifiable {
|
| 11 | 45 |
case iphone |
| 12 | 46 |
case watch |
@@ -16,6 +50,14 @@ enum ChargedDeviceClass: String, CaseIterable, Identifiable {
|
||
| 16 | 50 |
|
| 17 | 51 |
var id: String { rawValue }
|
| 18 | 52 |
|
| 53 |
+ static var deviceCases: [ChargedDeviceClass] {
|
|
| 54 |
+ allCases.filter { $0 != .charger }
|
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 57 |
+ var kind: ChargedDeviceKind {
|
|
| 58 |
+ self == .charger ? .charger : .device |
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 19 | 61 |
var title: String {
|
| 20 | 62 |
switch self {
|
| 21 | 63 |
case .iphone: |
@@ -318,6 +360,8 @@ struct ChargeSessionSummary: Identifiable, Hashable {
|
||
| 318 | 360 |
let measuredEnergyWh: Double |
| 319 | 361 |
let effectiveBatteryEnergyWh: Double? |
| 320 | 362 |
let measuredChargeAh: Double |
| 363 |
+ let meterEnergyBaselineWh: Double? |
|
| 364 |
+ let meterChargeBaselineAh: Double? |
|
| 321 | 365 |
let minimumObservedCurrentAmps: Double? |
| 322 | 366 |
let maximumObservedCurrentAmps: Double? |
| 323 | 367 |
let maximumObservedPowerWatts: Double? |
@@ -358,7 +402,8 @@ struct ChargeSessionSummary: Identifiable, Hashable {
|
||
| 358 | 402 |
} |
| 359 | 403 |
|
| 360 | 404 |
var batteryDeltaPercent: Double? {
|
| 361 |
- guard let startBatteryPercent, let endBatteryPercent else { return nil }
|
|
| 405 |
+ guard let startBatteryPercent, let endBatteryPercent, |
|
| 406 |
+ startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
|
|
| 362 | 407 |
return endBatteryPercent - startBatteryPercent |
| 363 | 408 |
} |
| 364 | 409 |
|
@@ -383,6 +428,40 @@ struct BatteryLevelPrediction: Hashable {
|
||
| 383 | 428 |
let anchorDescription: String |
| 384 | 429 |
} |
| 385 | 430 |
|
| 431 |
+enum BatteryLevelPredictionTuning {
|
|
| 432 |
+ static let checkpointSettleDuration: TimeInterval = 10 * 60 |
|
| 433 |
+ |
|
| 434 |
+ static func predictedPercent( |
|
| 435 |
+ anchorPercent: Double, |
|
| 436 |
+ anchorEnergyWh: Double, |
|
| 437 |
+ anchorTimestamp: Date, |
|
| 438 |
+ anchorIsCheckpoint: Bool, |
|
| 439 |
+ effectiveEnergyWh: Double, |
|
| 440 |
+ referenceTimestamp: Date, |
|
| 441 |
+ estimatedCapacityWh: Double |
|
| 442 |
+ ) -> Double {
|
|
| 443 |
+ let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0) |
|
| 444 |
+ let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100 |
|
| 445 |
+ let stabilizedGainPercent: Double |
|
| 446 |
+ |
|
| 447 |
+ if anchorIsCheckpoint {
|
|
| 448 |
+ let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0) |
|
| 449 |
+ let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1) |
|
| 450 |
+ stabilizedGainPercent = rawGainPercent * settleProgress |
|
| 451 |
+ } else {
|
|
| 452 |
+ stabilizedGainPercent = rawGainPercent |
|
| 453 |
+ } |
|
| 454 |
+ |
|
| 455 |
+ return min( |
|
| 456 |
+ 100, |
|
| 457 |
+ max( |
|
| 458 |
+ 0, |
|
| 459 |
+ anchorPercent + stabilizedGainPercent |
|
| 460 |
+ ) |
|
| 461 |
+ ) |
|
| 462 |
+ } |
|
| 463 |
+} |
|
| 464 |
+ |
|
| 386 | 465 |
struct CapacityTrendPoint: Identifiable, Hashable {
|
| 387 | 466 |
let sessionID: UUID |
| 388 | 467 |
let timestamp: Date |
@@ -410,7 +489,6 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 410 | 489 |
let chargingStateAvailability: ChargingStateAvailability |
| 411 | 490 |
let supportsWiredCharging: Bool |
| 412 | 491 |
let supportsWirelessCharging: Bool |
| 413 |
- let preferredChargingTransportMode: ChargingTransportMode |
|
| 414 | 492 |
let wirelessChargingProfile: WirelessChargingProfile |
| 415 | 493 |
let configuredCompletionCurrents: [ChargeSessionKind: Double] |
| 416 | 494 |
let learnedCompletionCurrents: [ChargeSessionKind: Double] |
@@ -439,6 +517,18 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 439 | 517 |
deviceClass == .charger |
| 440 | 518 |
} |
| 441 | 519 |
|
| 520 |
+ var kind: ChargedDeviceKind {
|
|
| 521 |
+ deviceClass.kind |
|
| 522 |
+ } |
|
| 523 |
+ |
|
| 524 |
+ var identityTitle: String {
|
|
| 525 |
+ isCharger ? kind.title : deviceClass.title |
|
| 526 |
+ } |
|
| 527 |
+ |
|
| 528 |
+ var identitySymbolName: String {
|
|
| 529 |
+ isCharger ? kind.symbolName : deviceClass.symbolName |
|
| 530 |
+ } |
|
| 531 |
+ |
|
| 442 | 532 |
var activeSession: ChargeSessionSummary? {
|
| 443 | 533 |
sessions.first(where: \.isOpen) |
| 444 | 534 |
} |
@@ -459,7 +549,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 459 | 549 |
if supportsWirelessCharging {
|
| 460 | 550 |
modes.append(.wireless) |
| 461 | 551 |
} |
| 462 |
- return modes.isEmpty ? [preferredChargingTransportMode] : modes |
|
| 552 |
+ return modes |
|
| 463 | 553 |
} |
| 464 | 554 |
|
| 465 | 555 |
var supportedChargingStateModes: [ChargingStateMode] {
|
@@ -544,7 +634,10 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 544 | 634 |
?? minimumCurrentAmps |
| 545 | 635 |
} |
| 546 | 636 |
|
| 547 |
- func batteryLevelPrediction(for session: ChargeSessionSummary) -> BatteryLevelPrediction? {
|
|
| 637 |
+ func batteryLevelPrediction( |
|
| 638 |
+ for session: ChargeSessionSummary, |
|
| 639 |
+ effectiveEnergyWhOverride: Double? = nil |
|
| 640 |
+ ) -> BatteryLevelPrediction? {
|
|
| 548 | 641 |
let estimatedCapacityWh = session.capacityEstimateWh |
| 549 | 642 |
?? estimatedBatteryCapacityWh(for: session.chargingTransportMode) |
| 550 | 643 |
?? estimatedBatteryCapacityWh |
@@ -553,22 +646,28 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 553 | 646 |
return nil |
| 554 | 647 |
} |
| 555 | 648 |
|
| 556 |
- let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh |
|
| 649 |
+ let effectiveEnergyWh = effectiveEnergyWhOverride |
|
| 650 |
+ ?? session.effectiveBatteryEnergyWh |
|
| 651 |
+ ?? session.measuredEnergyWh |
|
| 557 | 652 |
|
| 558 | 653 |
struct Anchor {
|
| 559 | 654 |
let percent: Double |
| 560 | 655 |
let energyWh: Double |
| 656 |
+ let timestamp: Date |
|
| 561 | 657 |
let description: String |
| 658 |
+ let isCheckpoint: Bool |
|
| 562 | 659 |
} |
| 563 | 660 |
|
| 564 | 661 |
var anchors: [Anchor] = [] |
| 565 | 662 |
|
| 566 |
- if let startBatteryPercent = session.startBatteryPercent {
|
|
| 663 |
+ if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
|
|
| 567 | 664 |
anchors.append( |
| 568 | 665 |
Anchor( |
| 569 | 666 |
percent: startBatteryPercent, |
| 570 | 667 |
energyWh: 0, |
| 571 |
- description: "session start" |
|
| 668 |
+ timestamp: session.startedAt, |
|
| 669 |
+ description: "session start", |
|
| 670 |
+ isCheckpoint: false |
|
| 572 | 671 |
) |
| 573 | 672 |
) |
| 574 | 673 |
} |
@@ -581,12 +680,17 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 581 | 680 |
} |
| 582 | 681 |
return lhs.timestamp < rhs.timestamp |
| 583 | 682 |
} |
| 683 |
+ .filter { checkpoint in
|
|
| 684 |
+ checkpoint.batteryPercent >= 0 |
|
| 685 |
+ } |
|
| 584 | 686 |
.map { checkpoint in
|
| 585 | 687 |
let trimmedLabel = checkpoint.label?.trimmingCharacters(in: .whitespacesAndNewlines) |
| 586 | 688 |
return Anchor( |
| 587 | 689 |
percent: checkpoint.batteryPercent, |
| 588 | 690 |
energyWh: checkpoint.measuredEnergyWh, |
| 589 |
- description: trimmedLabel.map { "checkpoint \($0)" } ?? "last checkpoint"
|
|
| 691 |
+ timestamp: checkpoint.timestamp, |
|
| 692 |
+ description: trimmedLabel.map { "checkpoint \($0)" } ?? "last checkpoint",
|
|
| 693 |
+ isCheckpoint: true |
|
| 590 | 694 |
) |
| 591 | 695 |
} |
| 592 | 696 |
) |
@@ -597,13 +701,14 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 597 | 701 |
|
| 598 | 702 |
let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
|
| 599 | 703 |
let anchor = eligibleAnchors.last ?? anchors.first! |
| 600 |
- let energyDeltaWh = max(effectiveEnergyWh - anchor.energyWh, 0) |
|
| 601 |
- let predictedPercent = min( |
|
| 602 |
- 100, |
|
| 603 |
- max( |
|
| 604 |
- 0, |
|
| 605 |
- anchor.percent + ((energyDeltaWh / estimatedCapacityWh) * 100) |
|
| 606 |
- ) |
|
| 704 |
+ let predictedPercent = BatteryLevelPredictionTuning.predictedPercent( |
|
| 705 |
+ anchorPercent: anchor.percent, |
|
| 706 |
+ anchorEnergyWh: anchor.energyWh, |
|
| 707 |
+ anchorTimestamp: anchor.timestamp, |
|
| 708 |
+ anchorIsCheckpoint: anchor.isCheckpoint, |
|
| 709 |
+ effectiveEnergyWh: effectiveEnergyWh, |
|
| 710 |
+ referenceTimestamp: session.lastObservedAt, |
|
| 711 |
+ estimatedCapacityWh: estimatedCapacityWh |
|
| 607 | 712 |
) |
| 608 | 713 |
|
| 609 | 714 |
return BatteryLevelPrediction( |
@@ -37,6 +37,7 @@ final class ChargeInsightsStore {
|
||
| 37 | 37 |
private let stopDetectionHoldDuration: TimeInterval = 20 |
| 38 | 38 |
private let maximumLiveIntegrationGap: TimeInterval = 20 |
| 39 | 39 |
private let activeSessionSaveInterval: TimeInterval = 15 |
| 40 |
+ private let aggregatedSampleSaveInterval: TimeInterval = 5 |
|
| 40 | 41 |
private let counterDecreaseTolerance = 0.002 |
| 41 | 42 |
private let completionConfirmationCooldown: TimeInterval = 15 * 60 |
| 42 | 43 |
private let pausedSessionTimeout: TimeInterval = 10 * 60 |
@@ -45,6 +46,7 @@ final class ChargeInsightsStore {
|
||
| 45 | 46 |
private let minimumWirelessEfficiencyFactor = 0.35 |
| 46 | 47 |
private let maximumWirelessEfficiencyFactor = 0.95 |
| 47 | 48 |
private let lowWirelessEfficiencyThreshold = 0.72 |
| 49 |
+ private let unresolvedFlatBatteryPercent = -1.0 |
|
| 48 | 50 |
|
| 49 | 51 |
init(context: NSManagedObjectContext) {
|
| 50 | 52 |
self.context = context |
@@ -67,18 +69,18 @@ final class ChargeInsightsStore {
|
||
| 67 | 69 |
} |
| 68 | 70 |
|
| 69 | 71 |
@discardableResult |
| 70 |
- func createChargedDevice( |
|
| 72 |
+ func createDevice( |
|
| 71 | 73 |
name: String, |
| 72 | 74 |
deviceClass: ChargedDeviceClass, |
| 73 | 75 |
chargingStateAvailability: ChargingStateAvailability, |
| 74 | 76 |
supportsWiredCharging: Bool, |
| 75 | 77 |
supportsWirelessCharging: Bool, |
| 76 |
- preferredChargingTransportMode: ChargingTransportMode, |
|
| 77 | 78 |
wirelessChargingProfile: WirelessChargingProfile, |
| 78 | 79 |
configuredCompletionCurrents: [ChargeSessionKind: Double], |
| 79 | 80 |
notes: String?, |
| 80 | 81 |
assignTo meterMACAddress: String? |
| 81 | 82 |
) -> Bool {
|
| 83 |
+ guard deviceClass.kind == .device else { return false }
|
|
| 82 | 84 |
let normalizedName = normalizedText(name) |
| 83 | 85 |
guard !normalizedName.isEmpty else { return false }
|
| 84 | 86 |
guard supportsWiredCharging || supportsWirelessCharging else { return false }
|
@@ -98,14 +100,6 @@ final class ChargeInsightsStore {
|
||
| 98 | 100 |
object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff") |
| 99 | 101 |
object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging") |
| 100 | 102 |
object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging") |
| 101 |
- object.setValue( |
|
| 102 |
- resolvedPreferredChargingTransportMode( |
|
| 103 |
- preferredChargingTransportMode, |
|
| 104 |
- supportsWiredCharging: supportsWiredCharging, |
|
| 105 |
- supportsWirelessCharging: supportsWirelessCharging |
|
| 106 |
- ).rawValue, |
|
| 107 |
- forKey: "preferredChargingTransportRawValue" |
|
| 108 |
- ) |
|
| 109 | 103 |
object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue") |
| 110 | 104 |
object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue") |
| 111 | 105 |
object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps") |
@@ -121,18 +115,56 @@ final class ChargeInsightsStore {
|
||
| 121 | 115 |
} |
| 122 | 116 |
|
| 123 | 117 |
@discardableResult |
| 124 |
- func updateChargedDevice( |
|
| 118 |
+ func createCharger( |
|
| 119 |
+ name: String, |
|
| 120 |
+ notes: String?, |
|
| 121 |
+ assignTo meterMACAddress: String? |
|
| 122 |
+ ) -> Bool {
|
|
| 123 |
+ let normalizedName = normalizedText(name) |
|
| 124 |
+ guard !normalizedName.isEmpty else { return false }
|
|
| 125 |
+ |
|
| 126 |
+ var didSave = false |
|
| 127 |
+ context.performAndWait {
|
|
| 128 |
+ guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
|
|
| 129 |
+ return |
|
| 130 |
+ } |
|
| 131 |
+ |
|
| 132 |
+ let object = NSManagedObject(entity: entity, insertInto: context) |
|
| 133 |
+ let now = Date() |
|
| 134 |
+ object.setValue(UUID().uuidString, forKey: "id") |
|
| 135 |
+ object.setValue(normalizedName, forKey: "name") |
|
| 136 |
+ object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue") |
|
| 137 |
+ object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue") |
|
| 138 |
+ object.setValue(false, forKey: "supportsChargingWhileOff") |
|
| 139 |
+ object.setValue(false, forKey: "supportsWiredCharging") |
|
| 140 |
+ object.setValue(true, forKey: "supportsWirelessCharging") |
|
| 141 |
+ object.setValue(WirelessChargingProfile.genericQi.rawValue, forKey: "wirelessChargingProfileRawValue") |
|
| 142 |
+ object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue") |
|
| 143 |
+ object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps") |
|
| 144 |
+ object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps") |
|
| 145 |
+ object.setValue(normalizedOptionalText(notes), forKey: "notes") |
|
| 146 |
+ object.setValue(generateQRIdentifier(), forKey: "qrIdentifier") |
|
| 147 |
+ object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC") |
|
| 148 |
+ object.setValue(now, forKey: "createdAt") |
|
| 149 |
+ object.setValue(now, forKey: "updatedAt") |
|
| 150 |
+ didSave = saveContext() |
|
| 151 |
+ } |
|
| 152 |
+ return didSave |
|
| 153 |
+ } |
|
| 154 |
+ |
|
| 155 |
+ @discardableResult |
|
| 156 |
+ func updateDevice( |
|
| 125 | 157 |
id: UUID, |
| 126 | 158 |
name: String, |
| 127 | 159 |
deviceClass: ChargedDeviceClass, |
| 128 | 160 |
chargingStateAvailability: ChargingStateAvailability, |
| 129 | 161 |
supportsWiredCharging: Bool, |
| 130 | 162 |
supportsWirelessCharging: Bool, |
| 131 |
- preferredChargingTransportMode: ChargingTransportMode, |
|
| 132 | 163 |
wirelessChargingProfile: WirelessChargingProfile, |
| 133 | 164 |
configuredCompletionCurrents: [ChargeSessionKind: Double], |
| 134 | 165 |
notes: String? |
| 135 | 166 |
) -> Bool {
|
| 167 |
+ guard deviceClass.kind == .device else { return false }
|
|
| 136 | 168 |
let normalizedName = normalizedText(name) |
| 137 | 169 |
guard !normalizedName.isEmpty else { return false }
|
| 138 | 170 |
guard supportsWiredCharging || supportsWirelessCharging else { return false }
|
@@ -142,17 +174,14 @@ final class ChargeInsightsStore {
|
||
| 142 | 174 |
guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
|
| 143 | 175 |
return |
| 144 | 176 |
} |
| 177 |
+ guard isChargerObject(object) == false else {
|
|
| 178 |
+ return |
|
| 179 |
+ } |
|
| 145 | 180 |
|
| 146 | 181 |
let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff") |
| 147 | 182 |
let previousChargingStateAvailability = self.chargingStateAvailability(for: object) |
| 148 | 183 |
let previousSupportsWiredCharging = self.supportsWiredCharging(for: object) |
| 149 | 184 |
let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object) |
| 150 |
- let previousPreferredChargingTransportMode = self.preferredChargingTransportMode(for: object) |
|
| 151 |
- let resolvedPreferredTransportMode = resolvedPreferredChargingTransportMode( |
|
| 152 |
- preferredChargingTransportMode, |
|
| 153 |
- supportsWiredCharging: supportsWiredCharging, |
|
| 154 |
- supportsWirelessCharging: supportsWirelessCharging |
|
| 155 |
- ) |
|
| 156 | 185 |
let now = Date() |
| 157 | 186 |
|
| 158 | 187 |
object.setValue(normalizedName, forKey: "name") |
@@ -161,7 +190,6 @@ final class ChargeInsightsStore {
|
||
| 161 | 190 |
object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff") |
| 162 | 191 |
object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging") |
| 163 | 192 |
object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging") |
| 164 |
- object.setValue(resolvedPreferredTransportMode.rawValue, forKey: "preferredChargingTransportRawValue") |
|
| 165 | 193 |
object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue") |
| 166 | 194 |
object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue") |
| 167 | 195 |
object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps") |
@@ -175,7 +203,6 @@ final class ChargeInsightsStore {
|
||
| 175 | 203 |
|| previousChargingStateAvailability != chargingStateAvailability |
| 176 | 204 |
|| previousSupportsWiredCharging != supportsWiredCharging |
| 177 | 205 |
|| previousSupportsWirelessCharging != supportsWirelessCharging |
| 178 |
- || previousPreferredChargingTransportMode != resolvedPreferredTransportMode |
|
| 179 | 206 |
|
| 180 | 207 |
if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
|
| 181 | 208 |
let sessions = fetchSessions(forChargedDeviceID: id.uuidString) |
@@ -227,6 +254,33 @@ final class ChargeInsightsStore {
|
||
| 227 | 254 |
return didSave |
| 228 | 255 |
} |
| 229 | 256 |
|
| 257 |
+ @discardableResult |
|
| 258 |
+ func updateCharger( |
|
| 259 |
+ id: UUID, |
|
| 260 |
+ name: String, |
|
| 261 |
+ notes: String? |
|
| 262 |
+ ) -> Bool {
|
|
| 263 |
+ let normalizedName = normalizedText(name) |
|
| 264 |
+ guard !normalizedName.isEmpty else { return false }
|
|
| 265 |
+ |
|
| 266 |
+ var didSave = false |
|
| 267 |
+ context.performAndWait {
|
|
| 268 |
+ guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
|
|
| 269 |
+ return |
|
| 270 |
+ } |
|
| 271 |
+ guard isChargerObject(object) else {
|
|
| 272 |
+ return |
|
| 273 |
+ } |
|
| 274 |
+ |
|
| 275 |
+ object.setValue(normalizedName, forKey: "name") |
|
| 276 |
+ object.setValue(normalizedOptionalText(notes), forKey: "notes") |
|
| 277 |
+ object.setValue(Date(), forKey: "updatedAt") |
|
| 278 |
+ didSave = saveContext() |
|
| 279 |
+ } |
|
| 280 |
+ |
|
| 281 |
+ return didSave |
|
| 282 |
+ } |
|
| 283 |
+ |
|
| 230 | 284 |
@discardableResult |
| 231 | 285 |
func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
|
| 232 | 286 |
assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice) |
@@ -288,60 +342,6 @@ final class ChargeInsightsStore {
|
||
| 288 | 342 |
return didSave |
| 289 | 343 |
} |
| 290 | 344 |
|
| 291 |
- @discardableResult |
|
| 292 |
- func setChargingTransportMode(_ chargingTransportMode: ChargingTransportMode, for meterMACAddress: String) -> Bool {
|
|
| 293 |
- let normalizedMAC = normalizedMACAddress(meterMACAddress) |
|
| 294 |
- guard !normalizedMAC.isEmpty else { return false }
|
|
| 295 |
- |
|
| 296 |
- var didSave = false |
|
| 297 |
- context.performAndWait {
|
|
| 298 |
- let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC) |
|
| 299 |
- let device = (openSession.flatMap { stringValue($0, key: "chargedDeviceID") }.flatMap(fetchChargedDeviceObject(id:)))
|
|
| 300 |
- ?? resolvedDeviceObject(for: normalizedMAC) |
|
| 301 |
- |
|
| 302 |
- guard let device else {
|
|
| 303 |
- return |
|
| 304 |
- } |
|
| 305 |
- |
|
| 306 |
- let resolvedMode = resolvedPreferredChargingTransportMode( |
|
| 307 |
- chargingTransportMode, |
|
| 308 |
- supportsWiredCharging: supportsWiredCharging(for: device), |
|
| 309 |
- supportsWirelessCharging: supportsWirelessCharging(for: device) |
|
| 310 |
- ) |
|
| 311 |
- let charger = resolvedMode == .wireless ? resolvedChargerObject(for: normalizedMAC) : nil |
|
| 312 |
- guard resolvedMode == .wired || charger != nil else {
|
|
| 313 |
- return |
|
| 314 |
- } |
|
| 315 |
- |
|
| 316 |
- device.setValue(resolvedMode.rawValue, forKey: "preferredChargingTransportRawValue") |
|
| 317 |
- device.setValue(Date(), forKey: "updatedAt") |
|
| 318 |
- |
|
| 319 |
- if let openSession {
|
|
| 320 |
- let chargingStateMode = resolvedChargingStateMode( |
|
| 321 |
- chargingStateMode(for: openSession), |
|
| 322 |
- availability: chargingStateAvailability(for: device) |
|
| 323 |
- ) |
|
| 324 |
- openSession.setValue(resolvedMode.rawValue, forKey: "chargingTransportRawValue") |
|
| 325 |
- openSession.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
|
|
| 326 |
- openSession.setValue( |
|
| 327 |
- resolvedStopThreshold( |
|
| 328 |
- for: device, |
|
| 329 |
- chargingTransportMode: resolvedMode, |
|
| 330 |
- chargingStateMode: chargingStateMode, |
|
| 331 |
- charger: charger, |
|
| 332 |
- fallback: optionalDoubleValue(openSession, key: "stopThresholdAmps") |
|
| 333 |
- ) ?? 0, |
|
| 334 |
- forKey: "stopThresholdAmps" |
|
| 335 |
- ) |
|
| 336 |
- openSession.setValue(Date(), forKey: "updatedAt") |
|
| 337 |
- } |
|
| 338 |
- |
|
| 339 |
- didSave = saveContext() |
|
| 340 |
- } |
|
| 341 |
- |
|
| 342 |
- return didSave |
|
| 343 |
- } |
|
| 344 |
- |
|
| 345 | 345 |
@discardableResult |
| 346 | 346 |
func startSession( |
| 347 | 347 |
for snapshot: ChargingMonitorSnapshot, |
@@ -350,9 +350,11 @@ final class ChargeInsightsStore {
|
||
| 350 | 350 |
chargingTransportMode: ChargingTransportMode, |
| 351 | 351 |
chargingStateMode: ChargingStateMode, |
| 352 | 352 |
autoStopEnabled: Bool, |
| 353 |
- initialBatteryPercent: Double |
|
| 353 |
+ initialBatteryPercent: Double?, |
|
| 354 |
+ startsFromFlatBattery: Bool |
|
| 354 | 355 |
) -> Bool {
|
| 355 |
- guard initialBatteryPercent.isFinite, initialBatteryPercent >= 0, initialBatteryPercent <= 100 else {
|
|
| 356 |
+ if let initialBatteryPercent, |
|
| 357 |
+ (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
|
|
| 356 | 358 |
return false |
| 357 | 359 |
} |
| 358 | 360 |
|
@@ -361,6 +363,9 @@ final class ChargeInsightsStore {
|
||
| 361 | 363 |
guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
|
| 362 | 364 |
return |
| 363 | 365 |
} |
| 366 |
+ guard isChargerObject(chargedDevice) == false else {
|
|
| 367 |
+ return |
|
| 368 |
+ } |
|
| 364 | 369 |
|
| 365 | 370 |
guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
|
| 366 | 371 |
return |
@@ -378,16 +383,19 @@ final class ChargeInsightsStore {
|
||
| 378 | 383 |
let charger = resolvedChargingTransportMode == .wireless |
| 379 | 384 |
? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
|
| 380 | 385 |
: nil |
| 386 |
+ if let charger, isChargerObject(charger) == false {
|
|
| 387 |
+ return |
|
| 388 |
+ } |
|
| 381 | 389 |
guard resolvedChargingTransportMode == .wired || charger != nil else {
|
| 382 | 390 |
return |
| 383 | 391 |
} |
| 384 |
- let stopThreshold = autoStopEnabled ? resolvedStopThreshold( |
|
| 392 |
+ let stopThreshold = resolvedStopThreshold( |
|
| 385 | 393 |
for: chargedDevice, |
| 386 | 394 |
chargingTransportMode: resolvedChargingTransportMode, |
| 387 | 395 |
chargingStateMode: resolvedChargingStateMode, |
| 388 | 396 |
charger: charger, |
| 389 |
- fallback: nil |
|
| 390 |
- ) : nil |
|
| 397 |
+ fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil |
|
| 398 |
+ ) |
|
| 391 | 399 |
guard let session = createSessionObject( |
| 392 | 400 |
for: chargedDevice, |
| 393 | 401 |
charger: charger, |
@@ -400,13 +408,18 @@ final class ChargeInsightsStore {
|
||
| 400 | 408 |
return |
| 401 | 409 |
} |
| 402 | 410 |
|
| 403 |
- guard insertBatteryCheckpoint( |
|
| 404 |
- percent: initialBatteryPercent, |
|
| 405 |
- label: "Start", |
|
| 406 |
- timestamp: snapshot.observedAt, |
|
| 407 |
- to: session |
|
| 408 |
- ) != nil else {
|
|
| 409 |
- return |
|
| 411 |
+ if startsFromFlatBattery {
|
|
| 412 |
+ session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent") |
|
| 413 |
+ session.setValue(nil, forKey: "endBatteryPercent") |
|
| 414 |
+ } else if let initialBatteryPercent {
|
|
| 415 |
+ guard insertBatteryCheckpoint( |
|
| 416 |
+ percent: initialBatteryPercent, |
|
| 417 |
+ label: "Start", |
|
| 418 |
+ timestamp: snapshot.observedAt, |
|
| 419 |
+ to: session |
|
| 420 |
+ ) != nil else {
|
|
| 421 |
+ return |
|
| 422 |
+ } |
|
| 410 | 423 |
} |
| 411 | 424 |
didSave = saveContext() |
| 412 | 425 |
} |
@@ -577,6 +590,39 @@ final class ChargeInsightsStore {
|
||
| 577 | 590 |
return didSave |
| 578 | 591 |
} |
| 579 | 592 |
|
| 593 |
+ @discardableResult |
|
| 594 |
+ func deleteBatteryCheckpoint( |
|
| 595 |
+ id checkpointID: UUID, |
|
| 596 |
+ from sessionID: UUID |
|
| 597 |
+ ) -> Bool {
|
|
| 598 |
+ var didSave = false |
|
| 599 |
+ context.performAndWait {
|
|
| 600 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString), |
|
| 601 |
+ let checkpoint = fetchCheckpointObject( |
|
| 602 |
+ id: checkpointID.uuidString, |
|
| 603 |
+ sessionID: sessionID.uuidString |
|
| 604 |
+ ) else {
|
|
| 605 |
+ return |
|
| 606 |
+ } |
|
| 607 |
+ |
|
| 608 |
+ let chargedDeviceID = stringValue(session, key: "chargedDeviceID") |
|
| 609 |
+ context.delete(checkpoint) |
|
| 610 |
+ refreshCheckpointDerivedValues(for: session) |
|
| 611 |
+ |
|
| 612 |
+ guard saveContext() else {
|
|
| 613 |
+ return |
|
| 614 |
+ } |
|
| 615 |
+ |
|
| 616 |
+ if let chargedDeviceID {
|
|
| 617 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 618 |
+ didSave = saveContext() |
|
| 619 |
+ } else {
|
|
| 620 |
+ didSave = true |
|
| 621 |
+ } |
|
| 622 |
+ } |
|
| 623 |
+ return didSave |
|
| 624 |
+ } |
|
| 625 |
+ |
|
| 580 | 626 |
@discardableResult |
| 581 | 627 |
func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
|
| 582 | 628 |
if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
|
@@ -769,10 +815,14 @@ final class ChargeInsightsStore {
|
||
| 769 | 815 |
) |
| 770 | 816 |
|
| 771 | 817 |
update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger) |
| 772 |
- updateAggregatedSample(session: session, with: snapshot) |
|
| 818 |
+ let aggregatedSample = updateAggregatedSample(session: session, with: snapshot) |
|
| 773 | 819 |
|
| 774 | 820 |
let saveReason = saveReason(for: session, observedAt: snapshot.observedAt) |
| 775 |
- guard saveReason != .none else {
|
|
| 821 |
+ let shouldPersistAggregatedCurve = aggregatedSample.map {
|
|
| 822 |
+ shouldPersistAggregatedSample($0, observedAt: snapshot.observedAt) |
|
| 823 |
+ } ?? false |
|
| 824 |
+ |
|
| 825 |
+ guard saveReason != .none || shouldPersistAggregatedCurve else {
|
|
| 776 | 826 |
return |
| 777 | 827 |
} |
| 778 | 828 |
|
@@ -855,7 +905,6 @@ final class ChargeInsightsStore {
|
||
| 855 | 905 |
chargingStateAvailability: chargingStateAvailability(for: device), |
| 856 | 906 |
supportsWiredCharging: supportsWiredCharging(for: device), |
| 857 | 907 |
supportsWirelessCharging: supportsWirelessCharging(for: device), |
| 858 |
- preferredChargingTransportMode: preferredChargingTransportMode(for: device), |
|
| 859 | 908 |
wirelessChargingProfile: wirelessChargingProfile(for: device), |
| 860 | 909 |
configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"), |
| 861 | 910 |
learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"), |
@@ -917,11 +966,22 @@ final class ChargeInsightsStore {
|
||
| 917 | 966 |
let normalizedMAC = normalizedMACAddress(meterMACAddress) |
| 918 | 967 |
guard !normalizedMAC.isEmpty else { return nil }
|
| 919 | 968 |
|
| 920 |
- return fetchChargedDeviceSummaries() |
|
| 921 |
- .flatMap(\.sessions) |
|
| 922 |
- .first(where: {
|
|
| 923 |
- $0.status.isOpen && $0.meterMACAddress == normalizedMAC |
|
| 924 |
- }) |
|
| 969 |
+ var summary: ChargeSessionSummary? |
|
| 970 |
+ |
|
| 971 |
+ context.performAndWait {
|
|
| 972 |
+ guard let session = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC), |
|
| 973 |
+ let sessionID = stringValue(session, key: "id") else {
|
|
| 974 |
+ return |
|
| 975 |
+ } |
|
| 976 |
+ |
|
| 977 |
+ summary = makeSessionSummary( |
|
| 978 |
+ from: session, |
|
| 979 |
+ checkpoints: fetchCheckpointObjects(forSessionID: sessionID), |
|
| 980 |
+ samples: fetchSessionSampleObjects(forSessionID: sessionID) |
|
| 981 |
+ ) |
|
| 982 |
+ } |
|
| 983 |
+ |
|
| 984 |
+ return summary |
|
| 925 | 985 |
} |
| 926 | 986 |
|
| 927 | 987 |
private func createSessionObject( |
@@ -951,7 +1011,11 @@ final class ChargeInsightsStore {
|
||
| 951 | 1011 |
session.setValue(now, forKey: "startedAt") |
| 952 | 1012 |
session.setValue(now, forKey: "lastObservedAt") |
| 953 | 1013 |
session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue") |
| 954 |
- session.setValue(ChargeSessionSourceMode.live.rawValue, forKey: "sourceModeRawValue") |
|
| 1014 |
+ let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil |
|
| 1015 |
+ session.setValue( |
|
| 1016 |
+ (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue, |
|
| 1017 |
+ forKey: "sourceModeRawValue" |
|
| 1018 |
+ ) |
|
| 955 | 1019 |
session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue") |
| 956 | 1020 |
session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue") |
| 957 | 1021 |
session.setValue(autoStopEnabled, forKey: "autoStopEnabled") |
@@ -995,7 +1059,6 @@ final class ChargeInsightsStore {
|
||
| 995 | 1059 |
session.setValue(now, forKey: "updatedAt") |
| 996 | 1060 |
|
| 997 | 1061 |
chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC") |
| 998 |
- chargedDevice.setValue(chargingTransportMode.rawValue, forKey: "preferredChargingTransportRawValue") |
|
| 999 | 1062 |
chargedDevice.setValue(now, forKey: "updatedAt") |
| 1000 | 1063 |
return session |
| 1001 | 1064 |
} |
@@ -1043,11 +1106,9 @@ final class ChargeInsightsStore {
|
||
| 1043 | 1106 |
|
| 1044 | 1107 |
if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
|
| 1045 | 1108 |
let offlineEnergy = meterEnergyCounterWh - baselineEnergy |
| 1046 |
- if offlineEnergy > measuredEnergyWh {
|
|
| 1047 |
- measuredEnergyWh = offlineEnergy |
|
| 1048 |
- } |
|
| 1109 |
+ measuredEnergyWh = max(offlineEnergy, 0) |
|
| 1049 | 1110 |
usedOfflineMeterCounters = true |
| 1050 |
- sourceMode = sourceMode == .live && measuredEnergyWh > 0 ? .blended : .offline |
|
| 1111 |
+ sourceMode = .offline |
|
| 1051 | 1112 |
} else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
|
| 1052 | 1113 |
let delta = meterEnergyCounterWh - lastEnergy |
| 1053 | 1114 |
if delta > 0 {
|
@@ -1068,9 +1129,7 @@ final class ChargeInsightsStore {
|
||
| 1068 | 1129 |
|
| 1069 | 1130 |
if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
|
| 1070 | 1131 |
let offlineCharge = meterChargeCounterAh - baselineCharge |
| 1071 |
- if offlineCharge > measuredChargeAh {
|
|
| 1072 |
- measuredChargeAh = offlineCharge |
|
| 1073 |
- } |
|
| 1132 |
+ measuredChargeAh = max(offlineCharge, 0) |
|
| 1074 | 1133 |
usedOfflineMeterCounters = true |
| 1075 | 1134 |
} else if let lastCharge, meterChargeCounterAh > lastCharge {
|
| 1076 | 1135 |
let delta = meterChargeCounterAh - lastCharge |
@@ -1171,14 +1230,14 @@ final class ChargeInsightsStore {
|
||
| 1171 | 1230 |
private func updateAggregatedSample( |
| 1172 | 1231 |
session: NSManagedObject, |
| 1173 | 1232 |
with snapshot: ChargingMonitorSnapshot |
| 1174 |
- ) {
|
|
| 1233 |
+ ) -> NSManagedObject? {
|
|
| 1175 | 1234 |
guard |
| 1176 | 1235 |
let sessionID = stringValue(session, key: "id"), |
| 1177 | 1236 |
let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), |
| 1178 | 1237 |
let startedAt = dateValue(session, key: "startedAt"), |
| 1179 | 1238 |
let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context) |
| 1180 | 1239 |
else {
|
| 1181 |
- return |
|
| 1240 |
+ return nil |
|
| 1182 | 1241 |
} |
| 1183 | 1242 |
|
| 1184 | 1243 |
let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0) |
@@ -1229,6 +1288,7 @@ final class ChargeInsightsStore {
|
||
| 1229 | 1288 |
sample.setValue(Int16(updatedCount), forKey: "sampleCount") |
| 1230 | 1289 |
sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt") |
| 1231 | 1290 |
sample.setValue(snapshot.observedAt, forKey: "updatedAt") |
| 1291 |
+ return sample |
|
| 1232 | 1292 |
} |
| 1233 | 1293 |
|
| 1234 | 1294 |
private func maybeTriggerTargetBatteryAlert(for session: NSManagedObject, observedAt: Date) {
|
@@ -1398,11 +1458,21 @@ final class ChargeInsightsStore {
|
||
| 1398 | 1458 |
struct Anchor {
|
| 1399 | 1459 |
let percent: Double |
| 1400 | 1460 |
let energyWh: Double |
| 1461 |
+ let timestamp: Date |
|
| 1462 |
+ let isCheckpoint: Bool |
|
| 1401 | 1463 |
} |
| 1402 | 1464 |
|
| 1403 | 1465 |
var anchors: [Anchor] = [] |
| 1404 |
- if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") {
|
|
| 1405 |
- anchors.append(Anchor(percent: startBatteryPercent, energyWh: 0)) |
|
| 1466 |
+ if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), |
|
| 1467 |
+ startBatteryPercent >= 0 {
|
|
| 1468 |
+ anchors.append( |
|
| 1469 |
+ Anchor( |
|
| 1470 |
+ percent: startBatteryPercent, |
|
| 1471 |
+ energyWh: 0, |
|
| 1472 |
+ timestamp: dateValue(session, key: "startedAt") ?? Date.distantPast, |
|
| 1473 |
+ isCheckpoint: false |
|
| 1474 |
+ ) |
|
| 1475 |
+ ) |
|
| 1406 | 1476 |
} |
| 1407 | 1477 |
|
| 1408 | 1478 |
let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID) |
@@ -1413,7 +1483,15 @@ final class ChargeInsightsStore {
|
||
| 1413 | 1483 |
} |
| 1414 | 1484 |
return lhs.timestamp < rhs.timestamp |
| 1415 | 1485 |
} |
| 1416 |
- .map { Anchor(percent: $0.batteryPercent, energyWh: $0.measuredEnergyWh) }
|
|
| 1486 |
+ .filter { $0.batteryPercent >= 0 }
|
|
| 1487 |
+ .map {
|
|
| 1488 |
+ Anchor( |
|
| 1489 |
+ percent: $0.batteryPercent, |
|
| 1490 |
+ energyWh: $0.measuredEnergyWh, |
|
| 1491 |
+ timestamp: $0.timestamp, |
|
| 1492 |
+ isCheckpoint: true |
|
| 1493 |
+ ) |
|
| 1494 |
+ } |
|
| 1417 | 1495 |
anchors.append(contentsOf: checkpointAnchors) |
| 1418 | 1496 |
|
| 1419 | 1497 |
guard !anchors.isEmpty else {
|
@@ -1421,12 +1499,14 @@ final class ChargeInsightsStore {
|
||
| 1421 | 1499 |
} |
| 1422 | 1500 |
|
| 1423 | 1501 |
let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
|
| 1424 |
- return min( |
|
| 1425 |
- 100, |
|
| 1426 |
- max( |
|
| 1427 |
- 0, |
|
| 1428 |
- anchor.percent + (((measuredEnergyWh - anchor.energyWh) / estimatedCapacityWh) * 100) |
|
| 1429 |
- ) |
|
| 1502 |
+ return BatteryLevelPredictionTuning.predictedPercent( |
|
| 1503 |
+ anchorPercent: anchor.percent, |
|
| 1504 |
+ anchorEnergyWh: anchor.energyWh, |
|
| 1505 |
+ anchorTimestamp: anchor.timestamp, |
|
| 1506 |
+ anchorIsCheckpoint: anchor.isCheckpoint, |
|
| 1507 |
+ effectiveEnergyWh: measuredEnergyWh, |
|
| 1508 |
+ referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp, |
|
| 1509 |
+ estimatedCapacityWh: estimatedCapacityWh |
|
| 1430 | 1510 |
) |
| 1431 | 1511 |
} |
| 1432 | 1512 |
|
@@ -1481,6 +1561,11 @@ final class ChargeInsightsStore {
|
||
| 1481 | 1561 |
return |
| 1482 | 1562 |
} |
| 1483 | 1563 |
|
| 1564 |
+ guard startBatteryPercent >= 0, endBatteryPercent >= 0 else {
|
|
| 1565 |
+ session.setValue(nil, forKey: "capacityEstimateWh") |
|
| 1566 |
+ return |
|
| 1567 |
+ } |
|
| 1568 |
+ |
|
| 1484 | 1569 |
let percentDelta = endBatteryPercent - startBatteryPercent |
| 1485 | 1570 |
let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff") |
| 1486 | 1571 |
|
@@ -1531,16 +1616,38 @@ final class ChargeInsightsStore {
|
||
| 1531 | 1616 |
checkpoint.setValue(normalizedOptionalText(label), forKey: "label") |
| 1532 | 1617 |
checkpoint.setValue(timestamp, forKey: "createdAt") |
| 1533 | 1618 |
|
| 1534 |
- if session.value(forKey: "startBatteryPercent") == nil {
|
|
| 1619 |
+ let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") |
|
| 1620 |
+ if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
|
|
| 1535 | 1621 |
session.setValue(percent, forKey: "startBatteryPercent") |
| 1536 | 1622 |
} |
| 1537 |
- session.setValue(percent, forKey: "endBatteryPercent") |
|
| 1623 |
+ if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
|
|
| 1624 |
+ session.setValue(percent, forKey: "endBatteryPercent") |
|
| 1625 |
+ } |
|
| 1538 | 1626 |
session.setValue(timestamp, forKey: "updatedAt") |
| 1539 | 1627 |
updateCapacityEstimate(for: session) |
| 1540 | 1628 |
|
| 1541 | 1629 |
return chargedDeviceID |
| 1542 | 1630 |
} |
| 1543 | 1631 |
|
| 1632 |
+ private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
|
|
| 1633 |
+ guard let sessionID = stringValue(session, key: "id") else {
|
|
| 1634 |
+ return |
|
| 1635 |
+ } |
|
| 1636 |
+ |
|
| 1637 |
+ let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID) |
|
| 1638 |
+ if let latestCheckpoint = remainingCheckpoints.last {
|
|
| 1639 |
+ session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent") |
|
| 1640 |
+ } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), |
|
| 1641 |
+ startBatteryPercent >= 0 {
|
|
| 1642 |
+ session.setValue(startBatteryPercent, forKey: "endBatteryPercent") |
|
| 1643 |
+ } else {
|
|
| 1644 |
+ session.setValue(nil, forKey: "endBatteryPercent") |
|
| 1645 |
+ } |
|
| 1646 |
+ |
|
| 1647 |
+ session.setValue(Date(), forKey: "updatedAt") |
|
| 1648 |
+ updateCapacityEstimate(for: session) |
|
| 1649 |
+ } |
|
| 1650 |
+ |
|
| 1544 | 1651 |
@discardableResult |
| 1545 | 1652 |
private func addBatteryCheckpoint( |
| 1546 | 1653 |
percent: Double, |
@@ -1603,7 +1710,7 @@ final class ChargeInsightsStore {
|
||
| 1603 | 1710 |
} |
| 1604 | 1711 |
|
| 1605 | 1712 |
guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh") |
| 1606 |
- ?? ((preferredChargingTransportMode(for: chargedDevice) == .wired) |
|
| 1713 |
+ ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired) |
|
| 1607 | 1714 |
? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh") |
| 1608 | 1715 |
: nil), |
| 1609 | 1716 |
wiredCapacityWh > 0 |
@@ -1670,7 +1777,7 @@ final class ChargeInsightsStore {
|
||
| 1670 | 1777 |
let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil |
| 1671 | 1778 |
let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil |
| 1672 | 1779 |
|
| 1673 |
- let preferredChargingTransportMode = preferredChargingTransportMode(for: chargedDevice) |
|
| 1780 |
+ let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice) |
|
| 1674 | 1781 |
let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on |
| 1675 | 1782 |
let preferredMinimumCurrent: Double? |
| 1676 | 1783 |
let preferredCapacity: Double? |
@@ -1691,19 +1798,19 @@ final class ChargeInsightsStore {
|
||
| 1691 | 1798 |
preferredCapacity = wirelessCapacity ?? wiredCapacity |
| 1692 | 1799 |
} |
| 1693 | 1800 |
|
| 1694 |
- chargedDevice.setValue(wiredMinimumCurrent, forKey: "wiredMinimumCurrentAmps") |
|
| 1695 |
- chargedDevice.setValue(wirelessMinimumCurrent, forKey: "wirelessMinimumCurrentAmps") |
|
| 1696 |
- chargedDevice.setValue(encodedCompletionCurrents(learnedCompletionCurrents), forKey: "learnedCompletionCurrentsRawValue") |
|
| 1697 |
- chargedDevice.setValue(wiredCapacity, forKey: "wiredEstimatedBatteryCapacityWh") |
|
| 1698 |
- chargedDevice.setValue(wirelessCapacity, forKey: "wirelessEstimatedBatteryCapacityWh") |
|
| 1699 |
- chargedDevice.setValue(wirelessEfficiency, forKey: "wirelessChargerEfficiencyFactor") |
|
| 1700 |
- chargedDevice.setValue(encodedObservedVoltageSelections(chargerObservedVoltages), forKey: "chargerObservedVoltageSelectionsRawValue") |
|
| 1701 |
- chargedDevice.setValue(chargerIdleCurrent, forKey: "chargerIdleCurrentAmps") |
|
| 1702 |
- chargedDevice.setValue(chargerEfficiency, forKey: "chargerEfficiencyFactor") |
|
| 1703 |
- chargedDevice.setValue(chargerMaximumPower, forKey: "chargerMaximumPowerWatts") |
|
| 1704 |
- chargedDevice.setValue(preferredMinimumCurrent, forKey: "minimumCurrentAmps") |
|
| 1705 |
- chargedDevice.setValue(preferredCapacity, forKey: "estimatedBatteryCapacityWh") |
|
| 1706 |
- chargedDevice.setValue(Date(), forKey: "updatedAt") |
|
| 1801 |
+ setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps") |
|
| 1802 |
+ setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps") |
|
| 1803 |
+ setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue") |
|
| 1804 |
+ setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh") |
|
| 1805 |
+ setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh") |
|
| 1806 |
+ setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor") |
|
| 1807 |
+ setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue") |
|
| 1808 |
+ setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps") |
|
| 1809 |
+ setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor") |
|
| 1810 |
+ setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts") |
|
| 1811 |
+ setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps") |
|
| 1812 |
+ setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh") |
|
| 1813 |
+ setValue(Date(), on: chargedDevice, key: "updatedAt") |
|
| 1707 | 1814 |
} |
| 1708 | 1815 |
|
| 1709 | 1816 |
private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
|
@@ -1836,6 +1943,8 @@ final class ChargeInsightsStore {
|
||
| 1836 | 1943 |
measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"), |
| 1837 | 1944 |
effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"), |
| 1838 | 1945 |
measuredChargeAh: doubleValue(object, key: "measuredChargeAh"), |
| 1946 |
+ meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"), |
|
| 1947 |
+ meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"), |
|
| 1839 | 1948 |
minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"), |
| 1840 | 1949 |
maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"), |
| 1841 | 1950 |
maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"), |
@@ -1963,6 +2072,13 @@ final class ChargeInsightsStore {
|
||
| 1963 | 2072 |
return (try? context.fetch(request)) ?? [] |
| 1964 | 2073 |
} |
| 1965 | 2074 |
|
| 2075 |
+ private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
|
|
| 2076 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint) |
|
| 2077 |
+ request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID) |
|
| 2078 |
+ request.fetchLimit = 1 |
|
| 2079 |
+ return (try? context.fetch(request))?.first |
|
| 2080 |
+ } |
|
| 2081 |
+ |
|
| 1966 | 2082 |
private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
|
| 1967 | 2083 |
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint) |
| 1968 | 2084 |
request.predicate = NSPredicate(format: "sessionID == %@", sessionID) |
@@ -2028,11 +2144,14 @@ final class ChargeInsightsStore {
|
||
| 2028 | 2144 |
request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)] |
| 2029 | 2145 |
let matches = (try? context.fetch(request)) ?? [] |
| 2030 | 2146 |
return matches.first { object in
|
| 2031 |
- let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger |
|
| 2032 |
- return isCharger == expectsChargerClass |
|
| 2147 |
+ isChargerObject(object) == expectsChargerClass |
|
| 2033 | 2148 |
} |
| 2034 | 2149 |
} |
| 2035 | 2150 |
|
| 2151 |
+ private func isChargerObject(_ object: NSManagedObject) -> Bool {
|
|
| 2152 |
+ ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger |
|
| 2153 |
+ } |
|
| 2154 |
+ |
|
| 2036 | 2155 |
private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
|
| 2037 | 2156 |
let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice) |
| 2038 | 2157 |
request.predicate = NSPredicate(format: "id == %@", id) |
@@ -2090,12 +2209,11 @@ final class ChargeInsightsStore {
|
||
| 2090 | 2209 |
return max(resolvedCurrent, 0.01) |
| 2091 | 2210 |
} |
| 2092 | 2211 |
|
| 2093 |
- private func preferredChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
|
|
| 2212 |
+ private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
|
|
| 2094 | 2213 |
let supportsWiredCharging = supportsWiredCharging(for: chargedDevice) |
| 2095 | 2214 |
let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice) |
| 2096 |
- let persistedMode = chargingTransportModeValue(chargedDevice, key: "preferredChargingTransportRawValue") ?? .wired |
|
| 2097 | 2215 |
return resolvedPreferredChargingTransportMode( |
| 2098 |
- persistedMode, |
|
| 2216 |
+ .wired, |
|
| 2099 | 2217 |
supportsWiredCharging: supportsWiredCharging, |
| 2100 | 2218 |
supportsWirelessCharging: supportsWirelessCharging |
| 2101 | 2219 |
) |
@@ -2425,7 +2543,7 @@ final class ChargeInsightsStore {
|
||
| 2425 | 2543 |
|
| 2426 | 2544 |
if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"), |
| 2427 | 2545 |
let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
|
| 2428 |
- return preferredChargingTransportMode(for: chargedDevice) |
|
| 2546 |
+ return fallbackChargingTransportMode(for: chargedDevice) |
|
| 2429 | 2547 |
} |
| 2430 | 2548 |
|
| 2431 | 2549 |
return .wired |
@@ -2477,6 +2595,22 @@ final class ChargeInsightsStore {
|
||
| 2477 | 2595 |
return .none |
| 2478 | 2596 |
} |
| 2479 | 2597 |
|
| 2598 |
+ private func shouldPersistAggregatedSample( |
|
| 2599 |
+ _ sample: NSManagedObject, |
|
| 2600 |
+ observedAt: Date |
|
| 2601 |
+ ) -> Bool {
|
|
| 2602 |
+ if sample.isInserted {
|
|
| 2603 |
+ return true |
|
| 2604 |
+ } |
|
| 2605 |
+ |
|
| 2606 |
+ let committedValues = sample.committedValues(forKeys: ["updatedAt"]) |
|
| 2607 |
+ let lastPersistedAt = (committedValues["updatedAt"] as? Date) |
|
| 2608 |
+ ?? dateValue(sample, key: "createdAt") |
|
| 2609 |
+ ?? observedAt |
|
| 2610 |
+ |
|
| 2611 |
+ return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval |
|
| 2612 |
+ } |
|
| 2613 |
+ |
|
| 2480 | 2614 |
private func generateQRIdentifier() -> String {
|
| 2481 | 2615 |
"device:\(UUID().uuidString)" |
| 2482 | 2616 |
} |
@@ -2508,59 +2642,73 @@ final class ChargeInsightsStore {
|
||
| 2508 | 2642 |
normalizedText(macAddress).uppercased() |
| 2509 | 2643 |
} |
| 2510 | 2644 |
|
| 2645 |
+ private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
|
|
| 2646 |
+ guard object.entity.propertiesByName[key] != nil else {
|
|
| 2647 |
+ return nil |
|
| 2648 |
+ } |
|
| 2649 |
+ return object.value(forKey: key) |
|
| 2650 |
+ } |
|
| 2651 |
+ |
|
| 2652 |
+ private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
|
|
| 2653 |
+ guard object.entity.propertiesByName[key] != nil else {
|
|
| 2654 |
+ return |
|
| 2655 |
+ } |
|
| 2656 |
+ object.setValue(value, forKey: key) |
|
| 2657 |
+ } |
|
| 2658 |
+ |
|
| 2511 | 2659 |
private func stringValue(_ object: NSManagedObject, key: String) -> String? {
|
| 2512 |
- guard let value = object.value(forKey: key) as? String else { return nil }
|
|
| 2660 |
+ guard let value = rawValue(object, key: key) as? String else { return nil }
|
|
| 2513 | 2661 |
let normalized = normalizedOptionalText(value) |
| 2514 | 2662 |
return normalized |
| 2515 | 2663 |
} |
| 2516 | 2664 |
|
| 2517 | 2665 |
private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
|
| 2518 |
- object.value(forKey: key) as? Date |
|
| 2666 |
+ rawValue(object, key: key) as? Date |
|
| 2519 | 2667 |
} |
| 2520 | 2668 |
|
| 2521 | 2669 |
private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
|
| 2522 |
- if let value = object.value(forKey: key) as? Double {
|
|
| 2670 |
+ if let value = rawValue(object, key: key) as? Double {
|
|
| 2523 | 2671 |
return value |
| 2524 | 2672 |
} |
| 2525 |
- if let value = object.value(forKey: key) as? NSNumber {
|
|
| 2673 |
+ if let value = rawValue(object, key: key) as? NSNumber {
|
|
| 2526 | 2674 |
return value.doubleValue |
| 2527 | 2675 |
} |
| 2528 | 2676 |
return 0 |
| 2529 | 2677 |
} |
| 2530 | 2678 |
|
| 2531 | 2679 |
private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
|
| 2532 |
- let rawValue = object.value(forKey: key) |
|
| 2533 |
- if rawValue == nil {
|
|
| 2680 |
+ let value = rawValue(object, key: key) |
|
| 2681 |
+ if value == nil {
|
|
| 2534 | 2682 |
return nil |
| 2535 | 2683 |
} |
| 2536 | 2684 |
return doubleValue(object, key: key) |
| 2537 | 2685 |
} |
| 2538 | 2686 |
|
| 2539 | 2687 |
private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
|
| 2540 |
- if let value = object.value(forKey: key) as? Int16 {
|
|
| 2688 |
+ if let value = rawValue(object, key: key) as? Int16 {
|
|
| 2541 | 2689 |
return value |
| 2542 | 2690 |
} |
| 2543 |
- if let value = object.value(forKey: key) as? NSNumber {
|
|
| 2691 |
+ if let value = rawValue(object, key: key) as? NSNumber {
|
|
| 2544 | 2692 |
return value.int16Value |
| 2545 | 2693 |
} |
| 2546 | 2694 |
return nil |
| 2547 | 2695 |
} |
| 2548 | 2696 |
|
| 2549 | 2697 |
private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
|
| 2550 |
- if let value = object.value(forKey: key) as? Int32 {
|
|
| 2698 |
+ if let value = rawValue(object, key: key) as? Int32 {
|
|
| 2551 | 2699 |
return value |
| 2552 | 2700 |
} |
| 2553 |
- if let value = object.value(forKey: key) as? NSNumber {
|
|
| 2701 |
+ if let value = rawValue(object, key: key) as? NSNumber {
|
|
| 2554 | 2702 |
return value.int32Value |
| 2555 | 2703 |
} |
| 2556 | 2704 |
return nil |
| 2557 | 2705 |
} |
| 2558 | 2706 |
|
| 2559 | 2707 |
private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
|
| 2560 |
- if let value = object.value(forKey: key) as? Bool {
|
|
| 2708 |
+ if let value = rawValue(object, key: key) as? Bool {
|
|
| 2561 | 2709 |
return value |
| 2562 | 2710 |
} |
| 2563 |
- if let value = object.value(forKey: key) as? NSNumber {
|
|
| 2711 |
+ if let value = rawValue(object, key: key) as? NSNumber {
|
|
| 2564 | 2712 |
return value.boolValue |
| 2565 | 2713 |
} |
| 2566 | 2714 |
return false |
@@ -405,6 +405,9 @@ class Measurements : ObservableObject {
|
||
| 405 | 405 |
let delta = value - lastEnergyCounterValue |
| 406 | 406 |
if delta > energyResetEpsilon {
|
| 407 | 407 |
accumulatedEnergyValue += delta |
| 408 |
+ } else if delta < -energyResetEpsilon {
|
|
| 409 |
+ energy.addDiscontinuity(timestamp: timestamp) |
|
| 410 |
+ accumulatedEnergyValue = 0 |
|
| 408 | 411 |
} |
| 409 | 412 |
} |
| 410 | 413 |
|
@@ -620,7 +620,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 620 | 620 |
} |
| 621 | 621 |
|
| 622 | 622 |
func chargingMonitorSnapshot(at observedAt: Date) -> ChargingMonitorSnapshot? {
|
| 623 |
- ChargingMonitorSnapshot( |
|
| 623 |
+ let usesNativeRecordingCounters = supportsRecordingView |
|
| 624 |
+ let nativeChargeCounter = usesNativeRecordingCounters ? recordedAH : nil |
|
| 625 |
+ let nativeEnergyCounter = usesNativeRecordingCounters ? recordedWH : nil |
|
| 626 |
+ |
|
| 627 |
+ return ChargingMonitorSnapshot( |
|
| 624 | 628 |
meterMACAddress: btSerial.macAddress.description, |
| 625 | 629 |
meterName: name, |
| 626 | 630 |
meterModel: deviceModelSummary, |
@@ -628,10 +632,10 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 628 | 632 |
voltageVolts: voltage, |
| 629 | 633 |
currentAmps: current, |
| 630 | 634 |
powerWatts: power, |
| 631 |
- selectedDataGroup: currentEnergySample()?.groupID ?? currentChargeSample()?.groupID, |
|
| 632 |
- meterChargeCounterAh: currentChargeSample()?.value, |
|
| 633 |
- meterEnergyCounterWh: currentEnergySample()?.value, |
|
| 634 |
- fallbackStopThresholdAmps: chargeRecordStopThreshold |
|
| 635 |
+ selectedDataGroup: usesNativeRecordingCounters ? nil : (currentEnergySample()?.groupID ?? currentChargeSample()?.groupID), |
|
| 636 |
+ meterChargeCounterAh: nativeChargeCounter ?? currentChargeSample()?.value, |
|
| 637 |
+ meterEnergyCounterWh: nativeEnergyCounter ?? currentEnergySample()?.value, |
|
| 638 |
+ fallbackStopThresholdAmps: supportsRecordingThreshold ? recordingTreshold : chargeRecordStopThreshold |
|
| 635 | 639 |
) |
| 636 | 640 |
} |
| 637 | 641 |
|
@@ -788,7 +792,13 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 788 | 792 |
} |
| 789 | 793 |
} |
| 790 | 794 |
updateChargeRecord(at: dataDumpRequestTimestamp) |
| 791 |
- if let energySample = currentEnergySample() {
|
|
| 795 |
+ if supportsRecordingView {
|
|
| 796 |
+ measurements.captureEnergyValue( |
|
| 797 |
+ timestamp: dataDumpRequestTimestamp, |
|
| 798 |
+ value: recordedWH, |
|
| 799 |
+ groupID: .max |
|
| 800 |
+ ) |
|
| 801 |
+ } else if let energySample = currentEnergySample() {
|
|
| 792 | 802 |
measurements.captureEnergyValue( |
| 793 | 803 |
timestamp: dataDumpRequestTimestamp, |
| 794 | 804 |
value: energySample.value, |
@@ -14,20 +14,40 @@ struct BatteryCheckpointEditorSheetView: View {
|
||
| 14 | 14 |
|
| 15 | 15 |
@State private var batteryPercent = "" |
| 16 | 16 |
@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 |
+ |
|
| 23 |
+ private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
|
|
| 24 |
+ guard let percent = Double(batteryPercent), |
|
| 25 |
+ let activeSession else {
|
|
| 26 |
+ return nil |
|
| 27 |
+ } |
|
| 28 |
+ return appData.batteryCheckpointPlausibilityWarning(percent: percent, for: activeSession.id) |
|
| 29 |
+ } |
|
| 17 | 30 |
|
| 18 | 31 |
var body: some View {
|
| 19 | 32 |
NavigationView {
|
| 20 | 33 |
Form {
|
| 21 |
- Section(header: Text("Checkpoint")) {
|
|
| 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 |
+ ) {
|
|
| 22 | 40 |
TextField("Battery %", text: $batteryPercent)
|
| 23 | 41 |
.keyboardType(.decimalPad) |
| 24 | 42 |
TextField("Label (optional)", text: $label)
|
| 25 | 43 |
} |
| 26 | 44 |
|
| 27 |
- Section {
|
|
| 28 |
- Text("The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.")
|
|
| 29 |
- .font(.footnote) |
|
| 30 |
- .foregroundColor(.secondary) |
|
| 45 |
+ if let plausibilityWarning {
|
|
| 46 |
+ Section(header: Text(plausibilityWarning.title)) {
|
|
| 47 |
+ Text(plausibilityWarning.message) |
|
| 48 |
+ .font(.footnote) |
|
| 49 |
+ .foregroundColor(.orange) |
|
| 50 |
+ } |
|
| 31 | 51 |
} |
| 32 | 52 |
} |
| 33 | 53 |
.navigationTitle("Battery Checkpoint")
|
@@ -40,18 +60,7 @@ struct BatteryCheckpointEditorSheetView: View {
|
||
| 40 | 60 |
} |
| 41 | 61 |
ToolbarItem(placement: .confirmationAction) {
|
| 42 | 62 |
Button("Save") {
|
| 43 |
- guard let percent = Double(batteryPercent) else {
|
|
| 44 |
- return |
|
| 45 |
- } |
|
| 46 |
- |
|
| 47 |
- let didSave = appData.addBatteryCheckpoint( |
|
| 48 |
- percent: percent, |
|
| 49 |
- label: label, |
|
| 50 |
- for: meter |
|
| 51 |
- ) |
|
| 52 |
- if didSave {
|
|
| 53 |
- dismiss() |
|
| 54 |
- } |
|
| 63 |
+ saveCheckpoint() |
|
| 55 | 64 |
} |
| 56 | 65 |
.disabled( |
| 57 | 66 |
(Double(batteryPercent) ?? -1) < 0 |
@@ -62,5 +71,35 @@ struct BatteryCheckpointEditorSheetView: View {
|
||
| 62 | 71 |
} |
| 63 | 72 |
} |
| 64 | 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 |
+ } |
|
| 85 |
+ |
|
| 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 |
|
| 93 |
+ return |
|
| 94 |
+ } |
|
| 95 |
+ |
|
| 96 |
+ let didSave = appData.addBatteryCheckpoint( |
|
| 97 |
+ percent: percent, |
|
| 98 |
+ label: label, |
|
| 99 |
+ for: meter |
|
| 100 |
+ ) |
|
| 101 |
+ if didSave {
|
|
| 102 |
+ dismiss() |
|
| 103 |
+ } |
|
| 65 | 104 |
} |
| 66 | 105 |
} |
@@ -80,6 +80,7 @@ struct ChargedDeviceDetailView: View {
|
||
| 80 | 80 |
if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
|
| 81 | 81 |
ChargedDeviceEditorSheetView( |
| 82 | 82 |
meterMACAddress: nil, |
| 83 |
+ kind: chargedDevice.kind, |
|
| 83 | 84 |
chargedDevice: chargedDevice |
| 84 | 85 |
) |
| 85 | 86 |
.environmentObject(appData) |
@@ -136,10 +137,10 @@ struct ChargedDeviceDetailView: View {
|
||
| 136 | 137 |
ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118) |
| 137 | 138 |
|
| 138 | 139 |
VStack(alignment: .leading, spacing: 10) {
|
| 139 |
- Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName) |
|
| 140 |
+ Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName) |
|
| 140 | 141 |
.font(.title3.weight(.bold)) |
| 141 | 142 |
|
| 142 |
- Text(chargedDevice.deviceClass.title) |
|
| 143 |
+ Text(chargedDevice.identityTitle) |
|
| 143 | 144 |
.font(.subheadline.weight(.semibold)) |
| 144 | 145 |
.foregroundColor(.secondary) |
| 145 | 146 |
|
@@ -164,103 +165,114 @@ struct ChargedDeviceDetailView: View {
|
||
| 164 | 165 |
|
| 165 | 166 |
private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
| 166 | 167 |
MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
|
| 168 |
+ if chargedDevice.isCharger {
|
|
| 169 |
+ chargerInsights(chargedDevice) |
|
| 170 |
+ } else {
|
|
| 171 |
+ deviceInsights(chargedDevice) |
|
| 172 |
+ } |
|
| 173 |
+ |
|
| 174 |
+ if let notes = chargedDevice.notes, !notes.isEmpty {
|
|
| 175 |
+ Divider() |
|
| 176 |
+ Text(notes) |
|
| 177 |
+ .font(.footnote) |
|
| 178 |
+ .foregroundColor(.secondary) |
|
| 179 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 180 |
+ } |
|
| 181 |
+ } |
|
| 182 |
+ } |
|
| 183 |
+ |
|
| 184 |
+ @ViewBuilder |
|
| 185 |
+ private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 186 |
+ MeterInfoRowView( |
|
| 187 |
+ label: "Charge Modes", |
|
| 188 |
+ value: chargedDevice.chargingStateAvailability.title |
|
| 189 |
+ ) |
|
| 190 |
+ MeterInfoRowView( |
|
| 191 |
+ label: "Charging Support", |
|
| 192 |
+ value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ") |
|
| 193 |
+ ) |
|
| 194 |
+ if chargedDevice.supportsWirelessCharging {
|
|
| 167 | 195 |
MeterInfoRowView( |
| 168 |
- label: "Charge Modes", |
|
| 169 |
- value: chargedDevice.chargingStateAvailability.title |
|
| 196 |
+ label: "Wireless Profile", |
|
| 197 |
+ value: chargedDevice.wirelessChargingProfile.title |
|
| 170 | 198 |
) |
| 199 |
+ } |
|
| 200 |
+ |
|
| 201 |
+ ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
|
|
| 171 | 202 |
MeterInfoRowView( |
| 172 |
- label: "Charging Support", |
|
| 173 |
- value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ") |
|
| 203 |
+ label: "\(sessionKind.shortTitle) Stop Current", |
|
| 204 |
+ value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind) |
|
| 174 | 205 |
) |
| 206 |
+ } |
|
| 207 |
+ MeterInfoRowView( |
|
| 208 |
+ label: "Estimated Capacity", |
|
| 209 |
+ value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
|
|
| 210 |
+ ) |
|
| 211 |
+ if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
|
|
| 175 | 212 |
MeterInfoRowView( |
| 176 |
- label: "Preferred Session Type", |
|
| 177 |
- value: chargedDevice.preferredChargingTransportMode.title |
|
| 213 |
+ label: "Wired Capacity", |
|
| 214 |
+ value: "\(wiredCapacity.format(decimalDigits: 2)) Wh" |
|
| 178 | 215 |
) |
| 179 |
- if chargedDevice.supportsWirelessCharging {
|
|
| 180 |
- MeterInfoRowView( |
|
| 181 |
- label: "Wireless Profile", |
|
| 182 |
- value: chargedDevice.wirelessChargingProfile.title |
|
| 183 |
- ) |
|
| 184 |
- } |
|
| 216 |
+ } |
|
| 217 |
+ if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
|
|
| 218 |
+ MeterInfoRowView( |
|
| 219 |
+ label: "Wireless Capacity", |
|
| 220 |
+ value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh" |
|
| 221 |
+ ) |
|
| 222 |
+ } |
|
| 223 |
+ if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor {
|
|
| 224 |
+ MeterInfoRowView( |
|
| 225 |
+ label: "Wireless Efficiency", |
|
| 226 |
+ value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%" |
|
| 227 |
+ ) |
|
| 228 |
+ } |
|
| 229 |
+ MeterInfoRowView( |
|
| 230 |
+ label: "End-of-Charge Current", |
|
| 231 |
+ value: chargedDevice.minimumCurrentAmps.map { "\($0.format(decimalDigits: 2)) A" } ?? "Learning"
|
|
| 232 |
+ ) |
|
| 233 |
+ MeterInfoRowView( |
|
| 234 |
+ label: "Charge Sessions", |
|
| 235 |
+ value: "\(chargedDevice.sessionCount)" |
|
| 236 |
+ ) |
|
| 237 |
+ } |
|
| 185 | 238 |
|
| 186 |
- ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
|
|
| 187 |
- MeterInfoRowView( |
|
| 188 |
- label: "\(sessionKind.shortTitle) Stop Current", |
|
| 189 |
- value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind) |
|
| 190 |
- ) |
|
| 191 |
- } |
|
| 239 |
+ @ViewBuilder |
|
| 240 |
+ private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 241 |
+ if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
|
|
| 192 | 242 |
MeterInfoRowView( |
| 193 |
- label: "Estimated Capacity", |
|
| 194 |
- value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
|
|
| 243 |
+ label: "Observed Voltages", |
|
| 244 |
+ value: chargedDevice.chargerObservedVoltageSelections |
|
| 245 |
+ .map { "\($0.format(decimalDigits: 1)) V" }
|
|
| 246 |
+ .joined(separator: ", ") |
|
| 195 | 247 |
) |
| 196 |
- if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
|
|
| 197 |
- MeterInfoRowView( |
|
| 198 |
- label: "Wired Capacity", |
|
| 199 |
- value: "\(wiredCapacity.format(decimalDigits: 2)) Wh" |
|
| 200 |
- ) |
|
| 201 |
- } |
|
| 202 |
- if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
|
|
| 203 |
- MeterInfoRowView( |
|
| 204 |
- label: "Wireless Capacity", |
|
| 205 |
- value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh" |
|
| 206 |
- ) |
|
| 207 |
- } |
|
| 208 |
- if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor {
|
|
| 209 |
- MeterInfoRowView( |
|
| 210 |
- label: "Wireless Efficiency", |
|
| 211 |
- value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%" |
|
| 212 |
- ) |
|
| 213 |
- } |
|
| 214 |
- if chargedDevice.isCharger {
|
|
| 215 |
- Divider() |
|
| 216 |
- if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
|
|
| 217 |
- MeterInfoRowView( |
|
| 218 |
- label: "Observed Voltages", |
|
| 219 |
- value: chargedDevice.chargerObservedVoltageSelections |
|
| 220 |
- .map { "\($0.format(decimalDigits: 1)) V" }
|
|
| 221 |
- .joined(separator: ", ") |
|
| 222 |
- ) |
|
| 223 |
- } |
|
| 224 |
- if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
|
|
| 225 |
- MeterInfoRowView( |
|
| 226 |
- label: "Idle Current", |
|
| 227 |
- value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A" |
|
| 228 |
- ) |
|
| 229 |
- } |
|
| 230 |
- if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
|
|
| 231 |
- MeterInfoRowView( |
|
| 232 |
- label: "Efficiency", |
|
| 233 |
- value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%" |
|
| 234 |
- ) |
|
| 235 |
- } |
|
| 236 |
- if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
|
|
| 237 |
- MeterInfoRowView( |
|
| 238 |
- label: "Max Power", |
|
| 239 |
- value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W" |
|
| 240 |
- ) |
|
| 241 |
- } |
|
| 242 |
- if chargedDevice.chargerIdleCurrentAmps == nil {
|
|
| 243 |
- Text("Idle current is missing. Wireless sessions that use this charger can still be recorded, but they cannot learn or auto-apply the wireless stop threshold yet.")
|
|
| 244 |
- .font(.caption2) |
|
| 245 |
- .foregroundColor(.orange) |
|
| 246 |
- } |
|
| 247 |
- } |
|
| 248 |
+ } |
|
| 249 |
+ if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
|
|
| 248 | 250 |
MeterInfoRowView( |
| 249 |
- label: "End-of-Charge Current", |
|
| 250 |
- value: chargedDevice.minimumCurrentAmps.map { "\($0.format(decimalDigits: 2)) A" } ?? "Learning"
|
|
| 251 |
+ label: "Idle Current", |
|
| 252 |
+ value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A" |
|
| 251 | 253 |
) |
| 254 |
+ } |
|
| 255 |
+ if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
|
|
| 252 | 256 |
MeterInfoRowView( |
| 253 |
- label: "Charge Sessions", |
|
| 254 |
- value: "\(chargedDevice.sessionCount)" |
|
| 257 |
+ label: "Efficiency", |
|
| 258 |
+ value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%" |
|
| 255 | 259 |
) |
| 260 |
+ } |
|
| 261 |
+ if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
|
|
| 262 |
+ MeterInfoRowView( |
|
| 263 |
+ label: "Max Power", |
|
| 264 |
+ value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W" |
|
| 265 |
+ ) |
|
| 266 |
+ } |
|
| 267 |
+ MeterInfoRowView( |
|
| 268 |
+ label: "Wireless Sessions", |
|
| 269 |
+ value: "\(chargedDevice.sessionCount)" |
|
| 270 |
+ ) |
|
| 256 | 271 |
|
| 257 |
- if let notes = chargedDevice.notes, !notes.isEmpty {
|
|
| 258 |
- Divider() |
|
| 259 |
- Text(notes) |
|
| 260 |
- .font(.footnote) |
|
| 261 |
- .foregroundColor(.secondary) |
|
| 262 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 263 |
- } |
|
| 272 |
+ if chargedDevice.chargerIdleCurrentAmps == nil {
|
|
| 273 |
+ Text("Idle current is missing. Wireless sessions that use this charger can still be recorded, but they cannot learn or auto-apply the wireless stop threshold yet.")
|
|
| 274 |
+ .font(.caption2) |
|
| 275 |
+ .foregroundColor(.orange) |
|
| 264 | 276 |
} |
| 265 | 277 |
} |
| 266 | 278 |
|
@@ -279,7 +291,6 @@ struct ChargedDeviceDetailView: View {
|
||
| 279 | 291 |
abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 {
|
| 280 | 292 |
MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh") |
| 281 | 293 |
} |
| 282 |
- MeterInfoRowView(label: "Charge", value: "\(activeSession.measuredChargeAh.format(decimalDigits: 3)) Ah") |
|
| 283 | 294 |
MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: activeSession)) |
| 284 | 295 |
MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title) |
| 285 | 296 |
if chargedDevice.isCharger == false, |
@@ -455,8 +466,14 @@ struct ChargedDeviceDetailView: View {
|
||
| 455 | 466 |
return VStack(alignment: .leading, spacing: 14) {
|
| 456 | 467 |
HStack(alignment: .firstTextBaseline) {
|
| 457 | 468 |
VStack(alignment: .leading, spacing: 4) {
|
| 458 |
- Text("Stored Session Curve")
|
|
| 459 |
- .font(.headline) |
|
| 469 |
+ HStack(spacing: 8) {
|
|
| 470 |
+ Text("Stored Session Curve")
|
|
| 471 |
+ .font(.headline) |
|
| 472 |
+ ContextInfoButton( |
|
| 473 |
+ title: "Stored Session Curve", |
|
| 474 |
+ message: "Database storage and iCloud sync use 300 aggregated points per hour. Active sessions persist their partial aggregated curve continuously, so a reconnect or restart can restore the stored progress." |
|
| 475 |
+ ) |
|
| 476 |
+ } |
|
| 460 | 477 |
Text(session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.") |
| 461 | 478 |
.font(.caption) |
| 462 | 479 |
.foregroundColor(.secondary) |
@@ -488,9 +505,6 @@ struct ChargedDeviceDetailView: View {
|
||
| 488 | 505 |
) |
| 489 | 506 |
} |
| 490 | 507 |
|
| 491 |
- Text("Database storage and iCloud sync use 300 aggregated points per hour. The live recording session still keeps the original in-memory samples while charging is in progress.")
|
|
| 492 |
- .font(.caption) |
|
| 493 |
- .foregroundColor(.secondary) |
|
| 494 | 508 |
} |
| 495 | 509 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 496 | 510 |
.padding(18) |
@@ -524,12 +538,14 @@ struct ChargedDeviceDetailView: View {
|
||
| 524 | 538 |
|
| 525 | 539 |
private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
|
| 526 | 540 |
VStack(alignment: .leading, spacing: 12) {
|
| 527 |
- Text("Charge Sessions")
|
|
| 528 |
- .font(.headline) |
|
| 529 |
- |
|
| 530 |
- Text("Use these summaries to spot odd sessions quickly before they influence device estimates.")
|
|
| 531 |
- .font(.caption) |
|
| 532 |
- .foregroundColor(.secondary) |
|
| 541 |
+ HStack(spacing: 8) {
|
|
| 542 |
+ Text("Charge Sessions")
|
|
| 543 |
+ .font(.headline) |
|
| 544 |
+ ContextInfoButton( |
|
| 545 |
+ title: "Charge Sessions", |
|
| 546 |
+ message: "Use these summaries to spot odd sessions quickly before they influence device estimates." |
|
| 547 |
+ ) |
|
| 548 |
+ } |
|
| 533 | 549 |
|
| 534 | 550 |
ForEach(chargedDevice.sessions, id: \.id) { session in
|
| 535 | 551 |
VStack(alignment: .leading, spacing: 6) {
|
@@ -605,12 +621,6 @@ struct ChargedDeviceDetailView: View {
|
||
| 605 | 621 |
value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V" |
| 606 | 622 |
) |
| 607 | 623 |
} |
| 608 |
- if let selectedDataGroup = session.selectedDataGroup {
|
|
| 609 |
- MeterInfoRowView( |
|
| 610 |
- label: "Data Group", |
|
| 611 |
- value: "#\(selectedDataGroup)" |
|
| 612 |
- ) |
|
| 613 |
- } |
|
| 614 | 624 |
if chargedDevice.isCharger == false, |
| 615 | 625 |
let chargerID = session.chargerID, |
| 616 | 626 |
let charger = appData.chargedDeviceSummary(id: chargerID) {
|
@@ -900,20 +910,35 @@ private struct ChargedDeviceCheckpointEditorSheetView: View {
|
||
| 900 | 910 |
|
| 901 | 911 |
@State private var batteryPercent = "" |
| 902 | 912 |
@State private var label = "" |
| 913 |
+ @State private var confirmationWarning: BatteryCheckpointPlausibilityWarning? |
|
| 914 |
+ |
|
| 915 |
+ private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
|
|
| 916 |
+ guard let percent = Double(batteryPercent) else {
|
|
| 917 |
+ return nil |
|
| 918 |
+ } |
|
| 919 |
+ return appData.batteryCheckpointPlausibilityWarning(percent: percent, for: sessionID) |
|
| 920 |
+ } |
|
| 903 | 921 |
|
| 904 | 922 |
var body: some View {
|
| 905 | 923 |
NavigationView {
|
| 906 | 924 |
Form {
|
| 907 |
- Section(header: Text("Checkpoint")) {
|
|
| 925 |
+ Section( |
|
| 926 |
+ header: ContextInfoHeader( |
|
| 927 |
+ title: "Checkpoint", |
|
| 928 |
+ message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction." |
|
| 929 |
+ ) |
|
| 930 |
+ ) {
|
|
| 908 | 931 |
TextField("Battery %", text: $batteryPercent)
|
| 909 | 932 |
.keyboardType(.decimalPad) |
| 910 | 933 |
TextField("Label (optional)", text: $label)
|
| 911 | 934 |
} |
| 912 | 935 |
|
| 913 |
- Section {
|
|
| 914 |
- Text("The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.")
|
|
| 915 |
- .font(.footnote) |
|
| 916 |
- .foregroundColor(.secondary) |
|
| 936 |
+ if let plausibilityWarning {
|
|
| 937 |
+ Section(header: Text(plausibilityWarning.title)) {
|
|
| 938 |
+ Text(plausibilityWarning.message) |
|
| 939 |
+ .font(.footnote) |
|
| 940 |
+ .foregroundColor(.orange) |
|
| 941 |
+ } |
|
| 917 | 942 |
} |
| 918 | 943 |
} |
| 919 | 944 |
.navigationTitle("Battery Checkpoint")
|
@@ -927,13 +952,7 @@ private struct ChargedDeviceCheckpointEditorSheetView: View {
|
||
| 927 | 952 |
|
| 928 | 953 |
ToolbarItem(placement: .confirmationAction) {
|
| 929 | 954 |
Button("Save") {
|
| 930 |
- guard let percent = Double(batteryPercent) else {
|
|
| 931 |
- return |
|
| 932 |
- } |
|
| 933 |
- |
|
| 934 |
- if appData.addBatteryCheckpoint(percent: percent, label: label, for: sessionID) {
|
|
| 935 |
- dismiss() |
|
| 936 |
- } |
|
| 955 |
+ saveCheckpoint() |
|
| 937 | 956 |
} |
| 938 | 957 |
.disabled( |
| 939 | 958 |
(Double(batteryPercent) ?? -1) < 0 |
@@ -943,6 +962,31 @@ private struct ChargedDeviceCheckpointEditorSheetView: View {
|
||
| 943 | 962 |
} |
| 944 | 963 |
} |
| 945 | 964 |
.navigationViewStyle(StackNavigationViewStyle()) |
| 965 |
+ .alert(item: $confirmationWarning) { warning in
|
|
| 966 |
+ Alert( |
|
| 967 |
+ title: Text(warning.title), |
|
| 968 |
+ message: Text(warning.message), |
|
| 969 |
+ primaryButton: .destructive(Text("Save Anyway")) {
|
|
| 970 |
+ saveCheckpoint(forceOverride: true) |
|
| 971 |
+ }, |
|
| 972 |
+ secondaryButton: .cancel() |
|
| 973 |
+ ) |
|
| 974 |
+ } |
|
| 975 |
+ } |
|
| 976 |
+ |
|
| 977 |
+ private func saveCheckpoint(forceOverride: Bool = false) {
|
|
| 978 |
+ guard let percent = Double(batteryPercent) else {
|
|
| 979 |
+ return |
|
| 980 |
+ } |
|
| 981 |
+ |
|
| 982 |
+ if !forceOverride, let plausibilityWarning {
|
|
| 983 |
+ confirmationWarning = plausibilityWarning |
|
| 984 |
+ return |
|
| 985 |
+ } |
|
| 986 |
+ |
|
| 987 |
+ if appData.addBatteryCheckpoint(percent: percent, label: label, for: sessionID) {
|
|
| 988 |
+ dismiss() |
|
| 989 |
+ } |
|
| 946 | 990 |
} |
| 947 | 991 |
} |
| 948 | 992 |
|
@@ -964,14 +1008,16 @@ private struct ChargedDeviceTargetNotificationEditorSheetView: View {
|
||
| 964 | 1008 |
var body: some View {
|
| 965 | 1009 |
NavigationView {
|
| 966 | 1010 |
Form {
|
| 967 |
- Section(header: Text("Target Level")) {
|
|
| 1011 |
+ Section( |
|
| 1012 |
+ header: ContextInfoHeader( |
|
| 1013 |
+ title: "Target Level", |
|
| 1014 |
+ message: "A local notification will be generated on synced devices when the estimated battery level reaches this target." |
|
| 1015 |
+ ) |
|
| 1016 |
+ ) {
|
|
| 968 | 1017 |
VStack(alignment: .leading, spacing: 12) {
|
| 969 | 1018 |
Text("\(targetPercent.format(decimalDigits: 0))%")
|
| 970 | 1019 |
.font(.title3.weight(.bold)) |
| 971 | 1020 |
Slider(value: $targetPercent, in: 20...100, step: 1) |
| 972 |
- Text("A local notification will be generated on synced devices when the estimated battery level reaches this target.")
|
|
| 973 |
- .font(.footnote) |
|
| 974 |
- .foregroundColor(.secondary) |
|
| 975 | 1021 |
} |
| 976 | 1022 |
} |
| 977 | 1023 |
} |
@@ -13,132 +13,55 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 13 | 13 |
|
| 14 | 14 |
let meterMACAddress: String? |
| 15 | 15 |
let chargedDevice: ChargedDeviceSummary? |
| 16 |
- let suggestedDeviceClass: ChargedDeviceClass? |
|
| 16 |
+ let kind: ChargedDeviceKind |
|
| 17 | 17 |
|
| 18 | 18 |
@State private var name: String |
| 19 | 19 |
@State private var deviceClass: ChargedDeviceClass |
| 20 | 20 |
@State private var chargingStateAvailability: ChargingStateAvailability |
| 21 | 21 |
@State private var supportsWiredCharging: Bool |
| 22 | 22 |
@State private var supportsWirelessCharging: Bool |
| 23 |
- @State private var preferredChargingTransportMode: ChargingTransportMode |
|
| 24 | 23 |
@State private var wirelessChargingProfile: WirelessChargingProfile |
| 25 | 24 |
@State private var completionCurrentTexts: [ChargeSessionKind: String] |
| 26 | 25 |
@State private var notes: String |
| 27 | 26 |
|
| 28 | 27 |
init( |
| 29 | 28 |
meterMACAddress: String?, |
| 30 |
- chargedDevice: ChargedDeviceSummary? = nil, |
|
| 31 |
- suggestedDeviceClass: ChargedDeviceClass? = nil |
|
| 29 |
+ kind: ChargedDeviceKind, |
|
| 30 |
+ chargedDevice: ChargedDeviceSummary? = nil |
|
| 32 | 31 |
) {
|
| 33 | 32 |
self.meterMACAddress = meterMACAddress |
| 34 | 33 |
self.chargedDevice = chargedDevice |
| 35 |
- self.suggestedDeviceClass = suggestedDeviceClass |
|
| 36 | 34 |
|
| 37 |
- let initialDeviceClass = chargedDevice?.deviceClass ?? suggestedDeviceClass ?? .iphone |
|
| 35 |
+ let resolvedKind = chargedDevice?.kind ?? kind |
|
| 36 |
+ self.kind = resolvedKind |
|
| 37 |
+ |
|
| 38 |
+ let initialDeviceClass = chargedDevice?.deviceClass ?? (resolvedKind == .charger ? .charger : .iphone) |
|
| 38 | 39 |
_name = State(initialValue: chargedDevice?.name ?? "") |
| 39 | 40 |
_deviceClass = State(initialValue: initialDeviceClass) |
| 40 |
- _chargingStateAvailability = State(initialValue: chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass)) |
|
| 41 |
+ _chargingStateAvailability = State( |
|
| 42 |
+ initialValue: chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass) |
|
| 43 |
+ ) |
|
| 41 | 44 |
_supportsWiredCharging = State(initialValue: chargedDevice?.supportsWiredCharging ?? true) |
| 42 | 45 |
_supportsWirelessCharging = State(initialValue: chargedDevice?.supportsWirelessCharging ?? true) |
| 43 |
- _preferredChargingTransportMode = State(initialValue: chargedDevice?.preferredChargingTransportMode ?? .wired) |
|
| 44 | 46 |
_wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi) |
| 45 |
- _completionCurrentTexts = State( |
|
| 46 |
- initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice) |
|
| 47 |
- ) |
|
| 47 |
+ _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice)) |
|
| 48 | 48 |
_notes = State(initialValue: chargedDevice?.notes ?? "") |
| 49 | 49 |
} |
| 50 | 50 |
|
| 51 | 51 |
var body: some View {
|
| 52 | 52 |
NavigationView {
|
| 53 | 53 |
Form {
|
| 54 |
- Section(header: Text("Identity")) {
|
|
| 55 |
- TextField("Name", text: $name)
|
|
| 56 |
- |
|
| 57 |
- Picker("Class", selection: $deviceClass) {
|
|
| 58 |
- ForEach(ChargedDeviceClass.allCases) { deviceClass in
|
|
| 59 |
- Label(deviceClass.title, systemImage: deviceClass.symbolName) |
|
| 60 |
- .tag(deviceClass) |
|
| 61 |
- } |
|
| 62 |
- } |
|
| 63 |
- |
|
| 64 |
- if let chargedDevice {
|
|
| 65 |
- Text(chargedDevice.qrIdentifier) |
|
| 66 |
- .font(.caption.monospaced()) |
|
| 67 |
- .foregroundColor(.secondary) |
|
| 68 |
- .textSelection(.enabled) |
|
| 69 |
- } |
|
| 70 |
- } |
|
| 71 |
- |
|
| 72 |
- Section(header: Text("Charge Behaviour")) {
|
|
| 73 |
- Picker("Session Modes", selection: $chargingStateAvailability) {
|
|
| 74 |
- ForEach(ChargingStateAvailability.allCases) { availability in
|
|
| 75 |
- Text(availability.title) |
|
| 76 |
- .tag(availability) |
|
| 77 |
- } |
|
| 78 |
- } |
|
| 79 |
- |
|
| 80 |
- Text(chargingStateAvailability.description) |
|
| 81 |
- .font(.footnote) |
|
| 82 |
- .foregroundColor(.secondary) |
|
| 83 |
- } |
|
| 84 |
- |
|
| 85 |
- Section(header: Text("Charging Support")) {
|
|
| 86 |
- Toggle("Supports wired charging", isOn: $supportsWiredCharging)
|
|
| 87 |
- Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
|
|
| 88 |
- |
|
| 89 |
- if supportsWirelessCharging {
|
|
| 90 |
- Picker("Wireless profile", selection: $wirelessChargingProfile) {
|
|
| 91 |
- ForEach(WirelessChargingProfile.allCases) { profile in
|
|
| 92 |
- Text(profile.title) |
|
| 93 |
- .tag(profile) |
|
| 94 |
- } |
|
| 95 |
- } |
|
| 96 |
- |
|
| 97 |
- Text(wirelessChargingProfile.description) |
|
| 98 |
- .font(.footnote) |
|
| 99 |
- .foregroundColor(.secondary) |
|
| 100 |
- } |
|
| 101 |
- |
|
| 102 |
- if !supportedChargingModes.isEmpty {
|
|
| 103 |
- Picker("Default session type", selection: preferredChargingTransportBinding) {
|
|
| 104 |
- ForEach(supportedChargingModes) { chargingTransportMode in
|
|
| 105 |
- Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName) |
|
| 106 |
- .tag(chargingTransportMode) |
|
| 107 |
- } |
|
| 108 |
- } |
|
| 109 |
- } else {
|
|
| 110 |
- Text("Enable at least one charging method.")
|
|
| 111 |
- .font(.footnote) |
|
| 112 |
- .foregroundColor(.secondary) |
|
| 113 |
- } |
|
| 54 |
+ identitySection |
|
| 55 |
+ |
|
| 56 |
+ if kind == .device {
|
|
| 57 |
+ deviceChargeBehaviourSection |
|
| 58 |
+ deviceChargingSupportSection |
|
| 59 |
+ deviceCompletionSection |
|
| 60 |
+ } else {
|
|
| 61 |
+ chargerInformationSection |
|
| 114 | 62 |
} |
| 115 | 63 |
|
| 116 |
- Section(header: Text("Charge Completion")) {
|
|
| 117 |
- if applicableSessionKinds.isEmpty {
|
|
| 118 |
- Text("Enable at least one charging method to configure stop currents.")
|
|
| 119 |
- .font(.footnote) |
|
| 120 |
- .foregroundColor(.secondary) |
|
| 121 |
- } else {
|
|
| 122 |
- ForEach(applicableSessionKinds) { sessionKind in
|
|
| 123 |
- VStack(alignment: .leading, spacing: 6) {
|
|
| 124 |
- TextField( |
|
| 125 |
- "\(sessionKind.shortTitle) completion current (A)", |
|
| 126 |
- text: completionCurrentTextBinding(for: sessionKind) |
|
| 127 |
- ) |
|
| 128 |
- .keyboardType(.decimalPad) |
|
| 129 |
- |
|
| 130 |
- Text("Leave empty to keep learning this threshold from sessions of the same type.")
|
|
| 131 |
- .font(.caption) |
|
| 132 |
- .foregroundColor(.secondary) |
|
| 133 |
- } |
|
| 134 |
- .padding(.vertical, 2) |
|
| 135 |
- } |
|
| 136 |
- } |
|
| 137 |
- } |
|
| 138 |
- |
|
| 139 |
- Section(header: Text("Notes")) {
|
|
| 140 |
- TextField("Optional notes", text: $notes)
|
|
| 141 |
- } |
|
| 64 |
+ notesSection |
|
| 142 | 65 |
} |
| 143 | 66 |
.navigationTitle(editorTitle) |
| 144 | 67 |
.navigationBarTitleDisplayMode(.inline) |
@@ -150,71 +73,157 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 150 | 73 |
} |
| 151 | 74 |
ToolbarItem(placement: .confirmationAction) {
|
| 152 | 75 |
Button(saveButtonTitle) {
|
| 153 |
- let configuredCompletionCurrents = parsedCompletionCurrents |
|
| 154 |
- let didSave: Bool |
|
| 155 |
- if let chargedDevice {
|
|
| 156 |
- didSave = appData.updateChargedDevice( |
|
| 157 |
- id: chargedDevice.id, |
|
| 158 |
- name: name, |
|
| 159 |
- deviceClass: deviceClass, |
|
| 160 |
- chargingStateAvailability: chargingStateAvailability, |
|
| 161 |
- supportsWiredCharging: supportsWiredCharging, |
|
| 162 |
- supportsWirelessCharging: supportsWirelessCharging, |
|
| 163 |
- preferredChargingTransportMode: resolvedPreferredChargingTransportMode, |
|
| 164 |
- wirelessChargingProfile: wirelessChargingProfile, |
|
| 165 |
- configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 166 |
- notes: notes |
|
| 167 |
- ) |
|
| 168 |
- } else {
|
|
| 169 |
- didSave = appData.createChargedDevice( |
|
| 170 |
- name: name, |
|
| 171 |
- deviceClass: deviceClass, |
|
| 172 |
- chargingStateAvailability: chargingStateAvailability, |
|
| 173 |
- supportsWiredCharging: supportsWiredCharging, |
|
| 174 |
- supportsWirelessCharging: supportsWirelessCharging, |
|
| 175 |
- preferredChargingTransportMode: resolvedPreferredChargingTransportMode, |
|
| 176 |
- wirelessChargingProfile: wirelessChargingProfile, |
|
| 177 |
- configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 178 |
- notes: notes, |
|
| 179 |
- meterMACAddress: meterMACAddress |
|
| 180 |
- ) |
|
| 181 |
- } |
|
| 182 |
- |
|
| 183 |
- if didSave {
|
|
| 184 |
- dismiss() |
|
| 185 |
- } |
|
| 76 |
+ save() |
|
| 186 | 77 |
} |
| 187 |
- .disabled( |
|
| 188 |
- name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
|
| 189 |
- || (!supportsWiredCharging && !supportsWirelessCharging) |
|
| 190 |
- || hasInvalidCompletionCurrentEntry |
|
| 191 |
- ) |
|
| 78 |
+ .disabled(!canSave) |
|
| 192 | 79 |
} |
| 193 | 80 |
} |
| 194 | 81 |
} |
| 195 | 82 |
.navigationViewStyle(StackNavigationViewStyle()) |
| 196 | 83 |
.onChange(of: deviceClass) { newValue in
|
| 84 |
+ guard kind == .device else {
|
|
| 85 |
+ return |
|
| 86 |
+ } |
|
| 197 | 87 |
applySuggestedChargingSupport(for: newValue) |
| 198 | 88 |
} |
| 199 | 89 |
.onAppear {
|
| 200 |
- guard chargedDevice == nil else {
|
|
| 90 |
+ guard kind == .device, chargedDevice == nil else {
|
|
| 201 | 91 |
return |
| 202 | 92 |
} |
| 203 | 93 |
applySuggestedChargingSupport(for: deviceClass) |
| 204 | 94 |
} |
| 205 | 95 |
} |
| 206 | 96 |
|
| 97 |
+ private var identitySection: some View {
|
|
| 98 |
+ Section(header: Text("Identity")) {
|
|
| 99 |
+ TextField(kind == .charger ? "Charger name" : "Name", text: $name) |
|
| 100 |
+ |
|
| 101 |
+ if kind == .device {
|
|
| 102 |
+ Picker("Class", selection: $deviceClass) {
|
|
| 103 |
+ ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
|
|
| 104 |
+ Label(deviceClass.title, systemImage: deviceClass.symbolName) |
|
| 105 |
+ .tag(deviceClass) |
|
| 106 |
+ } |
|
| 107 |
+ } |
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ if let chargedDevice {
|
|
| 111 |
+ Text(chargedDevice.qrIdentifier) |
|
| 112 |
+ .font(.caption.monospaced()) |
|
| 113 |
+ .foregroundColor(.secondary) |
|
| 114 |
+ .textSelection(.enabled) |
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 117 |
+ } |
|
| 118 |
+ |
|
| 119 |
+ private var deviceChargeBehaviourSection: some View {
|
|
| 120 |
+ Section( |
|
| 121 |
+ header: ContextInfoHeader( |
|
| 122 |
+ title: "Charge Behaviour", |
|
| 123 |
+ message: "Choose whether sessions for this device are recorded only while it is on, only while it is off, or explicitly in either state." |
|
| 124 |
+ ) |
|
| 125 |
+ ) {
|
|
| 126 |
+ Picker("Session Modes", selection: $chargingStateAvailability) {
|
|
| 127 |
+ ForEach(ChargingStateAvailability.allCases) { availability in
|
|
| 128 |
+ Text(availability.title) |
|
| 129 |
+ .tag(availability) |
|
| 130 |
+ } |
|
| 131 |
+ } |
|
| 132 |
+ } |
|
| 133 |
+ } |
|
| 134 |
+ |
|
| 135 |
+ private var deviceChargingSupportSection: some View {
|
|
| 136 |
+ Section( |
|
| 137 |
+ header: ContextInfoHeader( |
|
| 138 |
+ title: "Charging Support", |
|
| 139 |
+ message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate." |
|
| 140 |
+ ) |
|
| 141 |
+ ) {
|
|
| 142 |
+ Toggle("Supports wired charging", isOn: $supportsWiredCharging)
|
|
| 143 |
+ Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
|
|
| 144 |
+ |
|
| 145 |
+ if supportsWirelessCharging {
|
|
| 146 |
+ Picker("Wireless profile", selection: $wirelessChargingProfile) {
|
|
| 147 |
+ ForEach(WirelessChargingProfile.allCases) { profile in
|
|
| 148 |
+ Text(profile.title) |
|
| 149 |
+ .tag(profile) |
|
| 150 |
+ } |
|
| 151 |
+ } |
|
| 152 |
+ |
|
| 153 |
+ } |
|
| 154 |
+ |
|
| 155 |
+ if supportedChargingModes.isEmpty {
|
|
| 156 |
+ Text("Enable at least one charging method.")
|
|
| 157 |
+ .font(.footnote) |
|
| 158 |
+ .foregroundColor(.secondary) |
|
| 159 |
+ } |
|
| 160 |
+ } |
|
| 161 |
+ } |
|
| 162 |
+ |
|
| 163 |
+ private var deviceCompletionSection: some View {
|
|
| 164 |
+ Section( |
|
| 165 |
+ header: ContextInfoHeader( |
|
| 166 |
+ title: "Charge Completion", |
|
| 167 |
+ message: "Completion currents can be set per session type. Leave a value empty to keep learning that threshold automatically from sessions of the same type." |
|
| 168 |
+ ) |
|
| 169 |
+ ) {
|
|
| 170 |
+ if applicableSessionKinds.isEmpty {
|
|
| 171 |
+ Text("Enable at least one charging method to configure stop currents.")
|
|
| 172 |
+ .font(.footnote) |
|
| 173 |
+ .foregroundColor(.secondary) |
|
| 174 |
+ } else {
|
|
| 175 |
+ ForEach(applicableSessionKinds) { sessionKind in
|
|
| 176 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 177 |
+ TextField( |
|
| 178 |
+ "\(sessionKind.shortTitle) completion current (A)", |
|
| 179 |
+ text: completionCurrentTextBinding(for: sessionKind) |
|
| 180 |
+ ) |
|
| 181 |
+ .keyboardType(.decimalPad) |
|
| 182 |
+ } |
|
| 183 |
+ .padding(.vertical, 2) |
|
| 184 |
+ } |
|
| 185 |
+ } |
|
| 186 |
+ } |
|
| 187 |
+ } |
|
| 188 |
+ |
|
| 189 |
+ private var chargerInformationSection: some View {
|
|
| 190 |
+ Section( |
|
| 191 |
+ header: ContextInfoHeader( |
|
| 192 |
+ title: "Charger", |
|
| 193 |
+ message: "Chargers are edited separately from devices. Their charge-session metrics are learned automatically from wireless sessions." |
|
| 194 |
+ ) |
|
| 195 |
+ ) {
|
|
| 196 |
+ EmptyView() |
|
| 197 |
+ } |
|
| 198 |
+ } |
|
| 199 |
+ |
|
| 200 |
+ private var notesSection: some View {
|
|
| 201 |
+ Section(header: Text("Notes")) {
|
|
| 202 |
+ TextField("Optional notes", text: $notes)
|
|
| 203 |
+ } |
|
| 204 |
+ } |
|
| 205 |
+ |
|
| 207 | 206 |
private var editorTitle: String {
|
| 208 | 207 |
if chargedDevice == nil {
|
| 209 |
- return deviceClass == .charger ? "New Charger" : "New Device" |
|
| 208 |
+ return "New \(kind.title)" |
|
| 210 | 209 |
} |
| 211 |
- return chargedDevice?.isCharger == true ? "Edit Charger" : "Edit Device" |
|
| 210 |
+ return "Edit \(kind.title)" |
|
| 212 | 211 |
} |
| 213 | 212 |
|
| 214 | 213 |
private var saveButtonTitle: String {
|
| 215 | 214 |
chargedDevice == nil ? "Save" : "Update" |
| 216 | 215 |
} |
| 217 | 216 |
|
| 217 |
+ private var canSave: Bool {
|
|
| 218 |
+ let hasValidName = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false |
|
| 219 |
+ guard kind == .device else {
|
|
| 220 |
+ return hasValidName |
|
| 221 |
+ } |
|
| 222 |
+ return hasValidName |
|
| 223 |
+ && (supportsWiredCharging || supportsWirelessCharging) |
|
| 224 |
+ && !hasInvalidCompletionCurrentEntry |
|
| 225 |
+ } |
|
| 226 |
+ |
|
| 218 | 227 |
private var supportedChargingModes: [ChargingTransportMode] {
|
| 219 | 228 |
var modes: [ChargingTransportMode] = [] |
| 220 | 229 |
if supportsWiredCharging {
|
@@ -254,20 +263,6 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 254 | 263 |
} |
| 255 | 264 |
} |
| 256 | 265 |
|
| 257 |
- private var resolvedPreferredChargingTransportMode: ChargingTransportMode {
|
|
| 258 |
- if supportedChargingModes.contains(preferredChargingTransportMode) {
|
|
| 259 |
- return preferredChargingTransportMode |
|
| 260 |
- } |
|
| 261 |
- return supportsWiredCharging ? .wired : .wireless |
|
| 262 |
- } |
|
| 263 |
- |
|
| 264 |
- private var preferredChargingTransportBinding: Binding<ChargingTransportMode> {
|
|
| 265 |
- Binding( |
|
| 266 |
- get: { resolvedPreferredChargingTransportMode },
|
|
| 267 |
- set: { preferredChargingTransportMode = $0 }
|
|
| 268 |
- ) |
|
| 269 |
- } |
|
| 270 |
- |
|
| 271 | 266 |
private func completionCurrentTextBinding(for sessionKind: ChargeSessionKind) -> Binding<String> {
|
| 272 | 267 |
Binding( |
| 273 | 268 |
get: { completionCurrentTexts[sessionKind] ?? "" },
|
@@ -275,6 +270,57 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 275 | 270 |
) |
| 276 | 271 |
} |
| 277 | 272 |
|
| 273 |
+ private func save() {
|
|
| 274 |
+ let didSave: Bool |
|
| 275 |
+ |
|
| 276 |
+ if kind == .charger {
|
|
| 277 |
+ if let chargedDevice {
|
|
| 278 |
+ didSave = appData.updateCharger( |
|
| 279 |
+ id: chargedDevice.id, |
|
| 280 |
+ name: name, |
|
| 281 |
+ notes: notes |
|
| 282 |
+ ) |
|
| 283 |
+ } else {
|
|
| 284 |
+ didSave = appData.createCharger( |
|
| 285 |
+ name: name, |
|
| 286 |
+ notes: notes, |
|
| 287 |
+ meterMACAddress: meterMACAddress |
|
| 288 |
+ ) |
|
| 289 |
+ } |
|
| 290 |
+ } else {
|
|
| 291 |
+ let configuredCompletionCurrents = parsedCompletionCurrents |
|
| 292 |
+ if let chargedDevice {
|
|
| 293 |
+ didSave = appData.updateDevice( |
|
| 294 |
+ id: chargedDevice.id, |
|
| 295 |
+ name: name, |
|
| 296 |
+ deviceClass: deviceClass, |
|
| 297 |
+ chargingStateAvailability: chargingStateAvailability, |
|
| 298 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 299 |
+ supportsWirelessCharging: supportsWirelessCharging, |
|
| 300 |
+ wirelessChargingProfile: wirelessChargingProfile, |
|
| 301 |
+ configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 302 |
+ notes: notes |
|
| 303 |
+ ) |
|
| 304 |
+ } else {
|
|
| 305 |
+ didSave = appData.createDevice( |
|
| 306 |
+ name: name, |
|
| 307 |
+ deviceClass: deviceClass, |
|
| 308 |
+ chargingStateAvailability: chargingStateAvailability, |
|
| 309 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 310 |
+ supportsWirelessCharging: supportsWirelessCharging, |
|
| 311 |
+ wirelessChargingProfile: wirelessChargingProfile, |
|
| 312 |
+ configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 313 |
+ notes: notes, |
|
| 314 |
+ meterMACAddress: meterMACAddress |
|
| 315 |
+ ) |
|
| 316 |
+ } |
|
| 317 |
+ } |
|
| 318 |
+ |
|
| 319 |
+ if didSave {
|
|
| 320 |
+ dismiss() |
|
| 321 |
+ } |
|
| 322 |
+ } |
|
| 323 |
+ |
|
| 278 | 324 |
private func applySuggestedChargingSupport(for deviceClass: ChargedDeviceClass) {
|
| 279 | 325 |
if chargedDevice != nil {
|
| 280 | 326 |
return |
@@ -286,23 +332,18 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 286 | 332 |
case .iphone: |
| 287 | 333 |
supportsWiredCharging = true |
| 288 | 334 |
supportsWirelessCharging = true |
| 289 |
- preferredChargingTransportMode = .wired |
|
| 290 | 335 |
case .watch: |
| 291 | 336 |
supportsWiredCharging = false |
| 292 | 337 |
supportsWirelessCharging = true |
| 293 |
- preferredChargingTransportMode = .wireless |
|
| 294 | 338 |
case .powerbank: |
| 295 | 339 |
supportsWiredCharging = true |
| 296 | 340 |
supportsWirelessCharging = false |
| 297 |
- preferredChargingTransportMode = .wired |
|
| 298 | 341 |
case .charger: |
| 299 |
- supportsWiredCharging = true |
|
| 342 |
+ supportsWiredCharging = false |
|
| 300 | 343 |
supportsWirelessCharging = true |
| 301 |
- preferredChargingTransportMode = .wireless |
|
| 302 | 344 |
case .other: |
| 303 | 345 |
supportsWiredCharging = true |
| 304 | 346 |
supportsWirelessCharging = false |
| 305 |
- preferredChargingTransportMode = .wired |
|
| 306 | 347 |
} |
| 307 | 348 |
} |
| 308 | 349 |
|
@@ -346,9 +387,7 @@ struct ChargedDeviceEditorSheetView: View {
|
||
| 346 | 387 |
return .onOnly |
| 347 | 388 |
case .powerbank: |
| 348 | 389 |
return .offOnly |
| 349 |
- case .charger: |
|
| 350 |
- return .onOnly |
|
| 351 |
- case .other: |
|
| 390 |
+ case .charger, .other: |
|
| 352 | 391 |
return .onOnly |
| 353 | 392 |
} |
| 354 | 393 |
} |
@@ -11,30 +11,30 @@ enum ChargedDeviceLibraryMode {
|
||
| 11 | 11 |
case device |
| 12 | 12 |
case charger |
| 13 | 13 |
|
| 14 |
- var title: String {
|
|
| 14 |
+ var kind: ChargedDeviceKind {
|
|
| 15 | 15 |
switch self {
|
| 16 | 16 |
case .device: |
| 17 |
- return "Devices" |
|
| 17 |
+ return .device |
|
| 18 | 18 |
case .charger: |
| 19 |
- return "Chargers" |
|
| 19 |
+ return .charger |
|
| 20 | 20 |
} |
| 21 | 21 |
} |
| 22 | 22 |
|
| 23 |
- var singularTitle: String {
|
|
| 23 |
+ var title: String {
|
|
| 24 | 24 |
switch self {
|
| 25 | 25 |
case .device: |
| 26 |
- return "Device" |
|
| 26 |
+ return "Devices" |
|
| 27 | 27 |
case .charger: |
| 28 |
- return "Charger" |
|
| 28 |
+ return "Chargers" |
|
| 29 | 29 |
} |
| 30 | 30 |
} |
| 31 | 31 |
|
| 32 |
- var suggestedClass: ChargedDeviceClass {
|
|
| 32 |
+ var singularTitle: String {
|
|
| 33 | 33 |
switch self {
|
| 34 | 34 |
case .device: |
| 35 |
- return .iphone |
|
| 35 |
+ return "Device" |
|
| 36 | 36 |
case .charger: |
| 37 |
- return .charger |
|
| 37 |
+ return "Charger" |
|
| 38 | 38 |
} |
| 39 | 39 |
} |
| 40 | 40 |
} |
@@ -56,11 +56,14 @@ struct ChargedDeviceLibrarySheetView: View {
|
||
| 56 | 56 |
List {
|
| 57 | 57 |
if displayedChargedDevices.isEmpty {
|
| 58 | 58 |
VStack(alignment: .leading, spacing: 10) {
|
| 59 |
- Text("No \(mode.title.lowercased()) yet.")
|
|
| 60 |
- .font(.headline) |
|
| 61 |
- Text(emptyStateDescription) |
|
| 62 |
- .font(.footnote) |
|
| 63 |
- .foregroundColor(.secondary) |
|
| 59 |
+ HStack(spacing: 8) {
|
|
| 60 |
+ Text("No \(mode.title.lowercased()) yet.")
|
|
| 61 |
+ .font(.headline) |
|
| 62 |
+ ContextInfoButton( |
|
| 63 |
+ title: mode.title, |
|
| 64 |
+ message: emptyStateDescription |
|
| 65 |
+ ) |
|
| 66 |
+ } |
|
| 64 | 67 |
} |
| 65 | 68 |
.padding(.vertical, 10) |
| 66 | 69 |
.listRowBackground(Color.clear) |
@@ -88,7 +91,7 @@ struct ChargedDeviceLibrarySheetView: View {
|
||
| 88 | 91 |
Button {
|
| 89 | 92 |
editingChargedDevice = chargedDevice |
| 90 | 93 |
} label: {
|
| 91 |
- Label("Edit Device", systemImage: "pencil")
|
|
| 94 |
+ Label("Edit \(mode.singularTitle)", systemImage: "pencil")
|
|
| 92 | 95 |
} |
| 93 | 96 |
} |
| 94 | 97 |
} |
@@ -122,15 +125,15 @@ struct ChargedDeviceLibrarySheetView: View {
|
||
| 122 | 125 |
.sheet(isPresented: $editorVisibility) {
|
| 123 | 126 |
ChargedDeviceEditorSheetView( |
| 124 | 127 |
meterMACAddress: meterMACAddress, |
| 125 |
- suggestedDeviceClass: mode.suggestedClass |
|
| 128 |
+ kind: mode.kind |
|
| 126 | 129 |
) |
| 127 | 130 |
.environmentObject(appData) |
| 128 | 131 |
} |
| 129 | 132 |
.sheet(item: $editingChargedDevice) { chargedDevice in
|
| 130 | 133 |
ChargedDeviceEditorSheetView( |
| 131 | 134 |
meterMACAddress: nil, |
| 132 |
- chargedDevice: chargedDevice, |
|
| 133 |
- suggestedDeviceClass: mode.suggestedClass |
|
| 135 |
+ kind: mode.kind, |
|
| 136 |
+ chargedDevice: chargedDevice |
|
| 134 | 137 |
) |
| 135 | 138 |
.environmentObject(appData) |
| 136 | 139 |
} |
@@ -183,7 +186,7 @@ private struct ChargedDeviceLibraryRowView: View {
|
||
| 183 | 186 |
|
| 184 | 187 |
VStack(alignment: .leading, spacing: 6) {
|
| 185 | 188 |
HStack {
|
| 186 |
- Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName) |
|
| 189 |
+ Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName) |
|
| 187 | 190 |
.font(.headline) |
| 188 | 191 |
.foregroundColor(.primary) |
| 189 | 192 |
Spacer() |
@@ -193,28 +196,44 @@ private struct ChargedDeviceLibraryRowView: View {
|
||
| 193 | 196 |
} |
| 194 | 197 |
} |
| 195 | 198 |
|
| 196 |
- Text(chargedDevice.deviceClass.title) |
|
| 199 |
+ Text(chargedDevice.identityTitle) |
|
| 197 | 200 |
.font(.caption.weight(.semibold)) |
| 198 | 201 |
.foregroundColor(.secondary) |
| 199 | 202 |
|
| 200 |
- Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")) |
|
| 201 |
- .font(.caption2) |
|
| 202 |
- .foregroundColor(.secondary) |
|
| 203 |
- |
|
| 204 |
- if let capacity = chargedDevice.estimatedBatteryCapacityWh {
|
|
| 205 |
- Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
|
|
| 206 |
- .font(.caption) |
|
| 207 |
- .foregroundColor(.secondary) |
|
| 208 |
- } else if chargedDevice.isCharger, let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
|
|
| 209 |
- Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
|
|
| 210 |
- .font(.caption) |
|
| 203 |
+ if chargedDevice.isCharger {
|
|
| 204 |
+ if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
|
|
| 205 |
+ Text( |
|
| 206 |
+ chargedDevice.chargerObservedVoltageSelections |
|
| 207 |
+ .map { "\($0.format(decimalDigits: 1)) V" }
|
|
| 208 |
+ .joined(separator: ", ") |
|
| 209 |
+ ) |
|
| 210 |
+ .font(.caption2) |
|
| 211 | 211 |
.foregroundColor(.secondary) |
| 212 |
- } |
|
| 213 |
- |
|
| 214 |
- if let minimumCurrent = chargedDevice.resolvedCompletionCurrentAmps(for: chargedDevice.preferredChargingTransportMode) {
|
|
| 215 |
- Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
|
|
| 212 |
+ } else if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
|
|
| 213 |
+ Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
|
|
| 214 |
+ .font(.caption2) |
|
| 215 |
+ .foregroundColor(.secondary) |
|
| 216 |
+ } else {
|
|
| 217 |
+ Text("Wireless charger")
|
|
| 218 |
+ .font(.caption2) |
|
| 219 |
+ .foregroundColor(.secondary) |
|
| 220 |
+ } |
|
| 221 |
+ } else {
|
|
| 222 |
+ Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")) |
|
| 216 | 223 |
.font(.caption2) |
| 217 | 224 |
.foregroundColor(.secondary) |
| 225 |
+ |
|
| 226 |
+ if let capacity = chargedDevice.estimatedBatteryCapacityWh {
|
|
| 227 |
+ Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
|
|
| 228 |
+ .font(.caption) |
|
| 229 |
+ .foregroundColor(.secondary) |
|
| 230 |
+ } |
|
| 231 |
+ |
|
| 232 |
+ if let minimumCurrent = chargedDevice.minimumCurrentAmps {
|
|
| 233 |
+ Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
|
|
| 234 |
+ .font(.caption2) |
|
| 235 |
+ .foregroundColor(.secondary) |
|
| 236 |
+ } |
|
| 218 | 237 |
} |
| 219 | 238 |
} |
| 220 | 239 |
} |
@@ -63,7 +63,7 @@ private struct ChargedDeviceSidebarCardView: View {
|
||
| 63 | 63 |
|
| 64 | 64 |
VStack(alignment: .leading, spacing: 6) {
|
| 65 | 65 |
HStack {
|
| 66 |
- Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName) |
|
| 66 |
+ Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName) |
|
| 67 | 67 |
.font(.headline) |
| 68 | 68 |
if chargedDevice.activeSession != nil {
|
| 69 | 69 |
Spacer() |
@@ -73,26 +73,34 @@ private struct ChargedDeviceSidebarCardView: View {
|
||
| 73 | 73 |
} |
| 74 | 74 |
} |
| 75 | 75 |
|
| 76 |
- Text(chargedDevice.deviceClass.title) |
|
| 76 |
+ Text(chargedDevice.identityTitle) |
|
| 77 | 77 |
.font(.caption.weight(.semibold)) |
| 78 | 78 |
.foregroundColor(.secondary) |
| 79 | 79 |
|
| 80 |
- Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")) |
|
| 81 |
- .font(.caption2) |
|
| 82 |
- .foregroundColor(.secondary) |
|
| 83 |
- |
|
| 84 |
- if let estimatedCapacityWh = chargedDevice.estimatedBatteryCapacityWh {
|
|
| 85 |
- Text("Capacity: \(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
|
|
| 86 |
- .font(.caption2) |
|
| 87 |
- .foregroundColor(.secondary) |
|
| 88 |
- } else if chargedDevice.isCharger, let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
|
|
| 89 |
- Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
|
|
| 90 |
- .font(.caption2) |
|
| 91 |
- .foregroundColor(.secondary) |
|
| 80 |
+ if chargedDevice.isCharger {
|
|
| 81 |
+ if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
|
|
| 82 |
+ Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
|
|
| 83 |
+ .font(.caption2) |
|
| 84 |
+ .foregroundColor(.secondary) |
|
| 85 |
+ } else {
|
|
| 86 |
+ Text("Wireless charger")
|
|
| 87 |
+ .font(.caption2) |
|
| 88 |
+ .foregroundColor(.secondary) |
|
| 89 |
+ } |
|
| 92 | 90 |
} else {
|
| 93 |
- Text("Capacity: learning")
|
|
| 91 |
+ Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")) |
|
| 94 | 92 |
.font(.caption2) |
| 95 | 93 |
.foregroundColor(.secondary) |
| 94 |
+ |
|
| 95 |
+ if let estimatedCapacityWh = chargedDevice.estimatedBatteryCapacityWh {
|
|
| 96 |
+ Text("Capacity: \(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
|
|
| 97 |
+ .font(.caption2) |
|
| 98 |
+ .foregroundColor(.secondary) |
|
| 99 |
+ } else {
|
|
| 100 |
+ Text("Capacity: learning")
|
|
| 101 |
+ .font(.caption2) |
|
| 102 |
+ .foregroundColor(.secondary) |
|
| 103 |
+ } |
|
| 96 | 104 |
} |
| 97 | 105 |
} |
| 98 | 106 |
} |
@@ -24,3 +24,57 @@ struct ChevronView: View {
|
||
| 24 | 24 |
} |
| 25 | 25 |
} |
| 26 | 26 |
} |
| 27 |
+ |
|
| 28 |
+struct ContextInfoButton: View {
|
|
| 29 |
+ let title: String |
|
| 30 |
+ let message: String |
|
| 31 |
+ let popoverWidth: CGFloat |
|
| 32 |
+ |
|
| 33 |
+ @State private var showsPopover = false |
|
| 34 |
+ |
|
| 35 |
+ init( |
|
| 36 |
+ title: String, |
|
| 37 |
+ message: String, |
|
| 38 |
+ popoverWidth: CGFloat = 280 |
|
| 39 |
+ ) {
|
|
| 40 |
+ self.title = title |
|
| 41 |
+ self.message = message |
|
| 42 |
+ self.popoverWidth = popoverWidth |
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ var body: some View {
|
|
| 46 |
+ Button {
|
|
| 47 |
+ showsPopover.toggle() |
|
| 48 |
+ } label: {
|
|
| 49 |
+ Image(systemName: "info.circle") |
|
| 50 |
+ .font(.body.weight(.semibold)) |
|
| 51 |
+ .foregroundColor(.secondary) |
|
| 52 |
+ } |
|
| 53 |
+ .buttonStyle(.plain) |
|
| 54 |
+ .accessibilityLabel("\(title) info")
|
|
| 55 |
+ .popover(isPresented: $showsPopover, arrowEdge: .top) {
|
|
| 56 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 57 |
+ Text(title) |
|
| 58 |
+ .font(.headline) |
|
| 59 |
+ Text(message) |
|
| 60 |
+ .font(.body) |
|
| 61 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 62 |
+ } |
|
| 63 |
+ .padding(16) |
|
| 64 |
+ .frame(width: popoverWidth, alignment: .leading) |
|
| 65 |
+ } |
|
| 66 |
+ } |
|
| 67 |
+} |
|
| 68 |
+ |
|
| 69 |
+struct ContextInfoHeader: View {
|
|
| 70 |
+ let title: String |
|
| 71 |
+ let message: String |
|
| 72 |
+ |
|
| 73 |
+ var body: some View {
|
|
| 74 |
+ HStack(spacing: 8) {
|
|
| 75 |
+ Text(title) |
|
| 76 |
+ Spacer(minLength: 0) |
|
| 77 |
+ ContextInfoButton(title: title, message: message) |
|
| 78 |
+ } |
|
| 79 |
+ } |
|
| 80 |
+} |
|
@@ -97,6 +97,8 @@ struct MeasurementChartView: View {
|
||
| 97 | 97 |
|
| 98 | 98 |
let compactLayout: Bool |
| 99 | 99 |
let availableSize: CGSize |
| 100 |
+ let showsRangeSelector: Bool |
|
| 101 |
+ let rebasesEnergyToVisibleRangeStart: Bool |
|
| 100 | 102 |
|
| 101 | 103 |
@EnvironmentObject private var measurements: Measurements |
| 102 | 104 |
@Environment(\.horizontalSizeClass) private var horizontalSizeClass |
@@ -128,11 +130,15 @@ struct MeasurementChartView: View {
|
||
| 128 | 130 |
init( |
| 129 | 131 |
compactLayout: Bool = false, |
| 130 | 132 |
availableSize: CGSize = .zero, |
| 131 |
- timeRange: ClosedRange<Date>? = nil |
|
| 133 |
+ timeRange: ClosedRange<Date>? = nil, |
|
| 134 |
+ showsRangeSelector: Bool = true, |
|
| 135 |
+ rebasesEnergyToVisibleRangeStart: Bool = false |
|
| 132 | 136 |
) {
|
| 133 | 137 |
self.compactLayout = compactLayout |
| 134 | 138 |
self.availableSize = availableSize |
| 135 | 139 |
self.timeRange = timeRange |
| 140 |
+ self.showsRangeSelector = showsRangeSelector |
|
| 141 |
+ self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart |
|
| 136 | 142 |
} |
| 137 | 143 |
|
| 138 | 144 |
private var axisColumnWidth: CGFloat {
|
@@ -153,6 +159,16 @@ struct MeasurementChartView: View {
|
||
| 153 | 159 |
return isLargeDisplay ? 36 : 28 |
| 154 | 160 |
} |
| 155 | 161 |
|
| 162 |
+ private var belowXAxisControlsHeight: CGFloat {
|
|
| 163 |
+ if usesCompactLandscapeOriginControls {
|
|
| 164 |
+ return 40 |
|
| 165 |
+ } |
|
| 166 |
+ if compactLayout {
|
|
| 167 |
+ return 46 |
|
| 168 |
+ } |
|
| 169 |
+ return isLargeDisplay ? 58 : 50 |
|
| 170 |
+ } |
|
| 171 |
+ |
|
| 156 | 172 |
private var isPortraitLayout: Bool {
|
| 157 | 173 |
guard availableSize != .zero else { return verticalSizeClass != .compact }
|
| 158 | 174 |
return availableSize.height >= availableSize.width |
@@ -280,7 +296,13 @@ struct MeasurementChartView: View {
|
||
| 280 | 296 |
chartToggleBar() |
| 281 | 297 |
|
| 282 | 298 |
GeometryReader { geometry in
|
| 283 |
- let plotHeight = max(geometry.size.height - xAxisHeight, compactLayout ? 180 : 220) |
|
| 299 |
+ let reservedBottomHeight = |
|
| 300 |
+ xAxisHeight |
|
| 301 |
+ + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0) |
|
| 302 |
+ let plotHeight = max( |
|
| 303 |
+ geometry.size.height - reservedBottomHeight, |
|
| 304 |
+ compactLayout ? 180 : 220 |
|
| 305 |
+ ) |
|
| 284 | 306 |
|
| 285 | 307 |
VStack(spacing: 6) {
|
| 286 | 308 |
HStack(spacing: chartSectionSpacing) {
|
@@ -363,7 +385,8 @@ struct MeasurementChartView: View {
|
||
| 363 | 385 |
} |
| 364 | 386 |
} |
| 365 | 387 |
|
| 366 |
- if let availableTimeRange, |
|
| 388 |
+ if showsRangeSelector, |
|
| 389 |
+ let availableTimeRange, |
|
| 367 | 390 |
let selectorSeries, |
| 368 | 391 |
shouldShowRangeSelector( |
| 369 | 392 |
availableTimeRange: availableTimeRange, |
@@ -879,7 +902,8 @@ struct MeasurementChartView: View {
|
||
| 879 | 902 |
measurement, |
| 880 | 903 |
visibleTimeRange: visibleTimeRange |
| 881 | 904 |
) |
| 882 |
- let points = smoothedPoints(from: rawPoints) |
|
| 905 |
+ let normalizedRawPoints = normalizedPoints(rawPoints, for: kind) |
|
| 906 |
+ let points = smoothedPoints(from: normalizedRawPoints) |
|
| 883 | 907 |
let samplePoints = points.filter { $0.isSample }
|
| 884 | 908 |
let context = ChartContext() |
| 885 | 909 |
|
@@ -921,6 +945,28 @@ struct MeasurementChartView: View {
|
||
| 921 | 945 |
) |
| 922 | 946 |
} |
| 923 | 947 |
|
| 948 |
+ private func normalizedPoints( |
|
| 949 |
+ _ points: [Measurements.Measurement.Point], |
|
| 950 |
+ for kind: SeriesKind |
|
| 951 |
+ ) -> [Measurements.Measurement.Point] {
|
|
| 952 |
+ guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
|
|
| 953 |
+ return points |
|
| 954 |
+ } |
|
| 955 |
+ |
|
| 956 |
+ guard let baseline = points.first(where: \.isSample)?.value else {
|
|
| 957 |
+ return points |
|
| 958 |
+ } |
|
| 959 |
+ |
|
| 960 |
+ return points.enumerated().map { index, point in
|
|
| 961 |
+ Measurements.Measurement.Point( |
|
| 962 |
+ id: point.id == index ? point.id : index, |
|
| 963 |
+ timestamp: point.timestamp, |
|
| 964 |
+ value: point.value - baseline, |
|
| 965 |
+ kind: point.kind |
|
| 966 |
+ ) |
|
| 967 |
+ } |
|
| 968 |
+ } |
|
| 969 |
+ |
|
| 924 | 970 |
private func overviewSeries(for kind: SeriesKind) -> SeriesData {
|
| 925 | 971 |
series( |
| 926 | 972 |
for: measurement(for: kind), |
@@ -7,17 +7,35 @@ import SwiftUI |
||
| 7 | 7 |
|
| 8 | 8 |
struct MeterInfoCardView<Content: View>: View {
|
| 9 | 9 |
let title: String |
| 10 |
+ let infoMessage: String? |
|
| 10 | 11 |
let tint: Color |
| 11 | 12 |
@ViewBuilder var content: Content |
| 12 | 13 |
|
| 14 |
+ init( |
|
| 15 |
+ title: String, |
|
| 16 |
+ infoMessage: String? = nil, |
|
| 17 |
+ tint: Color, |
|
| 18 |
+ @ViewBuilder content: () -> Content |
|
| 19 |
+ ) {
|
|
| 20 |
+ self.title = title |
|
| 21 |
+ self.infoMessage = infoMessage |
|
| 22 |
+ self.tint = tint |
|
| 23 |
+ self.content = content() |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 13 | 26 |
var body: some View {
|
| 14 | 27 |
VStack(alignment: .leading, spacing: 12) {
|
| 15 |
- Text(title) |
|
| 16 |
- .font(.headline) |
|
| 28 |
+ HStack(spacing: 8) {
|
|
| 29 |
+ Text(title) |
|
| 30 |
+ .font(.headline) |
|
| 31 |
+ if let infoMessage {
|
|
| 32 |
+ ContextInfoButton(title: title, message: infoMessage) |
|
| 33 |
+ } |
|
| 34 |
+ } |
|
| 17 | 35 |
content |
| 18 | 36 |
} |
| 19 | 37 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 20 | 38 |
.padding(18) |
| 21 | 39 |
.meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
| 22 | 40 |
} |
| 23 |
-} |
|
| 41 |
+} |
|
@@ -46,14 +46,16 @@ struct BatteryTargetNotificationEditorSheetView: View {
|
||
| 46 | 46 |
var body: some View {
|
| 47 | 47 |
NavigationView {
|
| 48 | 48 |
Form {
|
| 49 |
- Section(header: Text("Target Level")) {
|
|
| 49 |
+ Section( |
|
| 50 |
+ header: ContextInfoHeader( |
|
| 51 |
+ title: "Target Level", |
|
| 52 |
+ message: "A local notification will be generated on synced devices when the estimated battery level reaches this target." |
|
| 53 |
+ ) |
|
| 54 |
+ ) {
|
|
| 50 | 55 |
VStack(alignment: .leading, spacing: 12) {
|
| 51 | 56 |
Text("\(targetPercent.format(decimalDigits: 0))%")
|
| 52 | 57 |
.font(.title3.weight(.bold)) |
| 53 | 58 |
Slider(value: $targetPercent, in: 20...100, step: 1) |
| 54 |
- Text("A local notification will be generated on synced devices when the estimated battery level reaches this target.")
|
|
| 55 |
- .font(.footnote) |
|
| 56 |
- .foregroundColor(.secondary) |
|
| 57 | 59 |
} |
| 58 | 60 |
} |
| 59 | 61 |
} |
@@ -16,7 +16,7 @@ struct DataGroupsSheetView: View {
|
||
| 16 | 16 |
var body: some View {
|
| 17 | 17 |
NavigationView {
|
| 18 | 18 |
GeometryReader { box in
|
| 19 |
- let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory", "Ah"] |
|
| 19 |
+ let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory"] |
|
| 20 | 20 |
+ (usbMeter.showsDataGroupEnergy ? ["Wh"] : []) |
| 21 | 21 |
+ (usbMeter.supportsDataGroupCommands ? ["Clear"] : []) |
| 22 | 22 |
let columnWidth = (box.size.width - 60) / CGFloat(columnTitles.count) |
@@ -24,12 +24,16 @@ struct DataGroupsSheetView: View {
|
||
| 24 | 24 |
ScrollView {
|
| 25 | 25 |
VStack(alignment: .leading, spacing: 14) {
|
| 26 | 26 |
VStack(alignment: .leading, spacing: 8) {
|
| 27 |
- Text(usbMeter.dataGroupsTitle) |
|
| 28 |
- .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 29 |
- if let hint = usbMeter.dataGroupsHint {
|
|
| 30 |
- Text(hint) |
|
| 31 |
- .font(.footnote) |
|
| 32 |
- .foregroundColor(.secondary) |
|
| 27 |
+ HStack {
|
|
| 28 |
+ Text(usbMeter.dataGroupsTitle) |
|
| 29 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 30 |
+ if let hint = usbMeter.dataGroupsHint {
|
|
| 31 |
+ ContextInfoButton( |
|
| 32 |
+ title: usbMeter.dataGroupsTitle, |
|
| 33 |
+ message: hint |
|
| 34 |
+ ) |
|
| 35 |
+ } |
|
| 36 |
+ Spacer(minLength: 0) |
|
| 33 | 37 |
} |
| 34 | 38 |
} |
| 35 | 39 |
.padding(18) |
@@ -34,12 +34,7 @@ struct DataGroupRowView: View {
|
||
| 34 | 34 |
.fontWeight(.semibold) |
| 35 | 35 |
} |
| 36 | 36 |
} |
| 37 |
- |
|
| 38 |
- cell(width: width) {
|
|
| 39 |
- Text("\(usbMeter.dataGroupRecords[Int(id)]!.ah.format(decimalDigits: 3))")
|
|
| 40 |
- .monospacedDigit() |
|
| 41 |
- } |
|
| 42 |
- |
|
| 37 |
+ |
|
| 43 | 38 |
if showsEnergy {
|
| 44 | 39 |
cell(width: width) {
|
| 45 | 40 |
Text("\(usbMeter.dataGroupRecords[Int(id)]!.wh.format(decimalDigits: 3))")
|
@@ -21,11 +21,15 @@ struct MeasurementSeriesSheetView: View {
|
||
| 21 | 21 |
ScrollView {
|
| 22 | 22 |
VStack(alignment: .leading, spacing: 14) {
|
| 23 | 23 |
VStack(alignment: .leading, spacing: 8) {
|
| 24 |
- Text("Measurement Series")
|
|
| 25 |
- .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 26 |
- Text("Buffered measurement series captured from the meter for analysis, charts, and correlations.")
|
|
| 27 |
- .font(.footnote) |
|
| 28 |
- .foregroundColor(.secondary) |
|
| 24 |
+ HStack {
|
|
| 25 |
+ Text("Measurement Series")
|
|
| 26 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 27 |
+ ContextInfoButton( |
|
| 28 |
+ title: "Measurement Series", |
|
| 29 |
+ message: "Buffered measurement series captured from the meter for analysis, charts, and correlations." |
|
| 30 |
+ ) |
|
| 31 |
+ Spacer(minLength: 0) |
|
| 32 |
+ } |
|
| 29 | 33 |
} |
| 30 | 34 |
.padding(18) |
| 31 | 35 |
.meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24) |
@@ -12,6 +12,25 @@ struct MeterChargeRecordTabView: View {
|
||
| 12 | 12 |
} |
| 13 | 13 |
|
| 14 | 14 |
struct MeterChargeRecordContentView: View {
|
| 15 |
+ private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
|
|
| 16 |
+ case known |
|
| 17 |
+ case unknown |
|
| 18 |
+ case flat |
|
| 19 |
+ |
|
| 20 |
+ var id: String { rawValue }
|
|
| 21 |
+ |
|
| 22 |
+ var title: String {
|
|
| 23 |
+ switch self {
|
|
| 24 |
+ case .known: |
|
| 25 |
+ return "Known" |
|
| 26 |
+ case .unknown: |
|
| 27 |
+ return "Unknown" |
|
| 28 |
+ case .flat: |
|
| 29 |
+ return "Flat" |
|
| 30 |
+ } |
|
| 31 |
+ } |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 15 | 34 |
@EnvironmentObject private var appData: AppData |
| 16 | 35 |
@EnvironmentObject private var usbMeter: Meter |
| 17 | 36 |
|
@@ -21,10 +40,60 @@ struct MeterChargeRecordContentView: View {
|
||
| 21 | 40 |
@State private var editingChargedDevice: ChargedDeviceSummary? |
| 22 | 41 |
@State private var targetNotificationEditorVisibility = false |
| 23 | 42 |
@State private var pendingStopRequest: ChargeSessionStopRequest? |
| 43 |
+ @State private var pendingCheckpointDeletion: ChargeCheckpointSummary? |
|
| 24 | 44 |
@State private var draftChargingTransportMode: ChargingTransportMode? |
| 25 | 45 |
@State private var draftChargingStateMode: ChargingStateMode? |
| 26 |
- @State private var draftAutoStopEnabled = true |
|
| 46 |
+ @State private var initialCheckpointMode: InitialCheckpointMode = .known |
|
| 27 | 47 |
@State private var initialCheckpoint = "" |
| 48 |
+ @State private var showsMeterTotalsInfo = false |
|
| 49 |
+ |
|
| 50 |
+ private enum SessionStartRequirement: Identifiable {
|
|
| 51 |
+ case existingSession |
|
| 52 |
+ case device |
|
| 53 |
+ case chargingType |
|
| 54 |
+ case chargingMode |
|
| 55 |
+ case charger |
|
| 56 |
+ case initialCheckpointEmpty |
|
| 57 |
+ case initialCheckpointInvalid |
|
| 58 |
+ |
|
| 59 |
+ var id: String {
|
|
| 60 |
+ switch self {
|
|
| 61 |
+ case .existingSession: |
|
| 62 |
+ return "existing-session" |
|
| 63 |
+ case .device: |
|
| 64 |
+ return "device" |
|
| 65 |
+ case .chargingType: |
|
| 66 |
+ return "charging-type" |
|
| 67 |
+ case .chargingMode: |
|
| 68 |
+ return "charging-mode" |
|
| 69 |
+ case .charger: |
|
| 70 |
+ return "charger" |
|
| 71 |
+ case .initialCheckpointEmpty: |
|
| 72 |
+ return "initial-checkpoint-empty" |
|
| 73 |
+ case .initialCheckpointInvalid: |
|
| 74 |
+ return "initial-checkpoint-invalid" |
|
| 75 |
+ } |
|
| 76 |
+ } |
|
| 77 |
+ |
|
| 78 |
+ var message: String {
|
|
| 79 |
+ switch self {
|
|
| 80 |
+ case .existingSession: |
|
| 81 |
+ return "Stop or pause the current session before starting another one." |
|
| 82 |
+ case .device: |
|
| 83 |
+ return "Select the device that is charging." |
|
| 84 |
+ case .chargingType: |
|
| 85 |
+ return "Choose the charging type for this session." |
|
| 86 |
+ case .chargingMode: |
|
| 87 |
+ return "Choose whether the device is on or off for this session." |
|
| 88 |
+ case .charger: |
|
| 89 |
+ return "Select the wireless charger used in this session." |
|
| 90 |
+ case .initialCheckpointEmpty: |
|
| 91 |
+ return "Enter the initial battery percentage." |
|
| 92 |
+ case .initialCheckpointInvalid: |
|
| 93 |
+ return "Initial battery percentage must be between 0 and 100." |
|
| 94 |
+ } |
|
| 95 |
+ } |
|
| 96 |
+ } |
|
| 28 | 97 |
|
| 29 | 98 |
var body: some View {
|
| 30 | 99 |
ScrollView {
|
@@ -35,12 +104,14 @@ struct MeterChargeRecordContentView: View {
|
||
| 35 | 104 |
if let openChargeSession {
|
| 36 | 105 |
chargingMonitorCard(openChargeSession) |
| 37 | 106 |
|
| 107 |
+ if showsMeterTotalsCard {
|
|
| 108 |
+ meterTotalsCard |
|
| 109 |
+ } |
|
| 110 |
+ |
|
| 38 | 111 |
if let sessionChartTimeRange {
|
| 39 | 112 |
sessionChartCard(timeRange: sessionChartTimeRange, session: openChargeSession) |
| 40 | 113 |
} |
| 41 |
- } |
|
| 42 |
- |
|
| 43 |
- if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
|
|
| 114 |
+ } else if showsMeterTotalsCard {
|
|
| 44 | 115 |
meterTotalsCard |
| 45 | 116 |
} |
| 46 | 117 |
} |
@@ -80,6 +151,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 80 | 151 |
.sheet(item: $editingChargedDevice) { chargedDevice in
|
| 81 | 152 |
ChargedDeviceEditorSheetView( |
| 82 | 153 |
meterMACAddress: nil, |
| 154 |
+ kind: chargedDevice.kind, |
|
| 83 | 155 |
chargedDevice: chargedDevice |
| 84 | 156 |
) |
| 85 | 157 |
.environmentObject(appData) |
@@ -102,6 +174,21 @@ struct MeterChargeRecordContentView: View {
|
||
| 102 | 174 |
) |
| 103 | 175 |
.environmentObject(appData) |
| 104 | 176 |
} |
| 177 |
+ .alert(item: $pendingCheckpointDeletion) { checkpoint in
|
|
| 178 |
+ Alert( |
|
| 179 |
+ title: Text("Delete Battery Checkpoint"),
|
|
| 180 |
+ message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
|
|
| 181 |
+ primaryButton: .destructive(Text("Delete")) {
|
|
| 182 |
+ if let openChargeSession {
|
|
| 183 |
+ _ = appData.deleteBatteryCheckpoint( |
|
| 184 |
+ checkpointID: checkpoint.id, |
|
| 185 |
+ for: openChargeSession.id |
|
| 186 |
+ ) |
|
| 187 |
+ } |
|
| 188 |
+ }, |
|
| 189 |
+ secondaryButton: .cancel() |
|
| 190 |
+ ) |
|
| 191 |
+ } |
|
| 105 | 192 |
.onAppear {
|
| 106 | 193 |
syncDraftSelections() |
| 107 | 194 |
} |
@@ -129,6 +216,14 @@ struct MeterChargeRecordContentView: View {
|
||
| 129 | 216 |
appData.activeChargeSessionSummary(for: meterMACAddress) |
| 130 | 217 |
} |
| 131 | 218 |
|
| 219 |
+ private var showsMeterTotalsCard: Bool {
|
|
| 220 |
+ usbMeter.supportsRecordingView |
|
| 221 |
+ || usbMeter.supportsDataGroupCommands |
|
| 222 |
+ || usbMeter.recordedAH > 0 |
|
| 223 |
+ || usbMeter.recordedWH > 0 |
|
| 224 |
+ || usbMeter.recordingDuration > 0 |
|
| 225 |
+ } |
|
| 226 |
+ |
|
| 132 | 227 |
private var selectedDraftTransportMode: ChargingTransportMode? {
|
| 133 | 228 |
openChargeSession?.chargingTransportMode ?? draftChargingTransportMode |
| 134 | 229 |
} |
@@ -137,31 +232,10 @@ struct MeterChargeRecordContentView: View {
|
||
| 137 | 232 |
openChargeSession?.chargingStateMode ?? draftChargingStateMode |
| 138 | 233 |
} |
| 139 | 234 |
|
| 140 |
- private var selectedDraftSessionKind: ChargeSessionKind? {
|
|
| 141 |
- guard let chargingTransportMode = selectedDraftTransportMode, |
|
| 142 |
- let chargingStateMode = selectedDraftChargingStateMode else {
|
|
| 143 |
- return nil |
|
| 144 |
- } |
|
| 145 |
- |
|
| 146 |
- return ChargeSessionKind( |
|
| 147 |
- chargingTransportMode: chargingTransportMode, |
|
| 148 |
- chargingStateMode: chargingStateMode |
|
| 149 |
- ) |
|
| 150 |
- } |
|
| 151 |
- |
|
| 152 |
- private var selectedDraftStopThreshold: Double? {
|
|
| 153 |
- guard let selectedChargedDevice, |
|
| 154 |
- let chargingTransportMode = selectedDraftTransportMode else {
|
|
| 235 |
+ private var initialCheckpointValue: Double? {
|
|
| 236 |
+ guard initialCheckpointMode == .known else {
|
|
| 155 | 237 |
return nil |
| 156 | 238 |
} |
| 157 |
- |
|
| 158 |
- return selectedChargedDevice.resolvedCompletionCurrentAmps( |
|
| 159 |
- for: chargingTransportMode, |
|
| 160 |
- chargingStateMode: selectedDraftChargingStateMode |
|
| 161 |
- ) |
|
| 162 |
- } |
|
| 163 |
- |
|
| 164 |
- private var initialCheckpointValue: Double? {
|
|
| 165 | 239 |
let normalized = initialCheckpoint |
| 166 | 240 |
.trimmingCharacters(in: .whitespacesAndNewlines) |
| 167 | 241 |
.replacingOccurrences(of: ",", with: ".") |
@@ -171,6 +245,14 @@ struct MeterChargeRecordContentView: View {
|
||
| 171 | 245 |
return value |
| 172 | 246 |
} |
| 173 | 247 |
|
| 248 |
+ private var hasInitialCheckpointInput: Bool {
|
|
| 249 |
+ initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false |
|
| 250 |
+ } |
|
| 251 |
+ |
|
| 252 |
+ private var shouldRequireInitialCheckpoint: Bool {
|
|
| 253 |
+ initialCheckpointMode == .known |
|
| 254 |
+ } |
|
| 255 |
+ |
|
| 174 | 256 |
private var requiresExplicitTransportSelection: Bool {
|
| 175 | 257 |
(selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1 |
| 176 | 258 |
} |
@@ -179,28 +261,53 @@ struct MeterChargeRecordContentView: View {
|
||
| 179 | 261 |
(selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1 |
| 180 | 262 |
} |
| 181 | 263 |
|
| 182 |
- private var canStartSession: Bool {
|
|
| 183 |
- guard openChargeSession == nil, |
|
| 184 |
- let selectedChargedDevice, |
|
| 185 |
- let chargingTransportMode = selectedDraftTransportMode, |
|
| 186 |
- let chargingStateMode = selectedDraftChargingStateMode, |
|
| 187 |
- let initialCheckpointValue else {
|
|
| 188 |
- return false |
|
| 264 |
+ private var startRequirements: [SessionStartRequirement] {
|
|
| 265 |
+ var requirements: [SessionStartRequirement] = [] |
|
| 266 |
+ |
|
| 267 |
+ if openChargeSession != nil {
|
|
| 268 |
+ requirements.append(.existingSession) |
|
| 189 | 269 |
} |
| 190 | 270 |
|
| 191 |
- guard selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) else {
|
|
| 192 |
- return false |
|
| 271 |
+ guard let selectedChargedDevice else {
|
|
| 272 |
+ requirements.append(.device) |
|
| 273 |
+ return requirements |
|
| 193 | 274 |
} |
| 194 | 275 |
|
| 195 |
- guard selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) else {
|
|
| 196 |
- return false |
|
| 276 |
+ guard let chargingTransportMode = selectedDraftTransportMode else {
|
|
| 277 |
+ requirements.append(.chargingType) |
|
| 278 |
+ return requirements |
|
| 197 | 279 |
} |
| 198 | 280 |
|
| 199 |
- if chargingTransportMode == .wireless {
|
|
| 200 |
- return selectedCharger != nil |
|
| 281 |
+ if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
|
|
| 282 |
+ requirements.append(.chargingType) |
|
| 201 | 283 |
} |
| 202 | 284 |
|
| 203 |
- return true |
|
| 285 |
+ guard let chargingStateMode = selectedDraftChargingStateMode else {
|
|
| 286 |
+ requirements.append(.chargingMode) |
|
| 287 |
+ return requirements |
|
| 288 |
+ } |
|
| 289 |
+ |
|
| 290 |
+ if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
|
|
| 291 |
+ requirements.append(.chargingMode) |
|
| 292 |
+ } |
|
| 293 |
+ |
|
| 294 |
+ if chargingTransportMode == .wireless, selectedCharger == nil {
|
|
| 295 |
+ requirements.append(.charger) |
|
| 296 |
+ } |
|
| 297 |
+ |
|
| 298 |
+ if shouldRequireInitialCheckpoint {
|
|
| 299 |
+ if hasInitialCheckpointInput == false {
|
|
| 300 |
+ requirements.append(.initialCheckpointEmpty) |
|
| 301 |
+ } else if initialCheckpointValue == nil {
|
|
| 302 |
+ requirements.append(.initialCheckpointInvalid) |
|
| 303 |
+ } |
|
| 304 |
+ } |
|
| 305 |
+ |
|
| 306 |
+ return requirements |
|
| 307 |
+ } |
|
| 308 |
+ |
|
| 309 |
+ private var canStartSession: Bool {
|
|
| 310 |
+ startRequirements.isEmpty |
|
| 204 | 311 |
} |
| 205 | 312 |
|
| 206 | 313 |
private var headerStatusTitle: String {
|
@@ -236,51 +343,15 @@ struct MeterChargeRecordContentView: View {
|
||
| 236 | 343 |
return openChargeSession.startedAt...max(end, openChargeSession.startedAt) |
| 237 | 344 |
} |
| 238 | 345 |
|
| 239 |
- private var draftAutoStopDescription: String {
|
|
| 240 |
- guard let chargingTransportMode = selectedDraftTransportMode else {
|
|
| 241 |
- return "Choose the charging type before starting the session." |
|
| 242 |
- } |
|
| 243 |
- |
|
| 244 |
- if chargingTransportMode == .wireless, selectedCharger == nil {
|
|
| 245 |
- return "Wireless sessions need a selected charger before they can start." |
|
| 246 |
- } |
|
| 247 |
- |
|
| 248 |
- if draftAutoStopEnabled == false {
|
|
| 249 |
- return "The session starts open-ended and will stop only when you pause or stop it manually." |
|
| 250 |
- } |
|
| 251 |
- |
|
| 252 |
- if let setupWarning = setupWirelessThresholdWarning {
|
|
| 253 |
- return setupWarning |
|
| 254 |
- } |
|
| 255 |
- |
|
| 256 |
- if let selectedDraftSessionKind, let selectedDraftStopThreshold {
|
|
| 257 |
- return "Auto-stop is ready for \(selectedDraftSessionKind.shortTitle.lowercased()) sessions at about \(selectedDraftStopThreshold.format(decimalDigits: 2)) A." |
|
| 258 |
- } |
|
| 259 |
- |
|
| 260 |
- return "No stop threshold is known for this charging type yet, so the session starts open-ended." |
|
| 261 |
- } |
|
| 262 |
- |
|
| 263 |
- private var setupWirelessThresholdWarning: String? {
|
|
| 264 |
- guard selectedDraftTransportMode == .wireless else {
|
|
| 265 |
- return nil |
|
| 266 |
- } |
|
| 267 |
- |
|
| 268 |
- guard let selectedCharger else {
|
|
| 269 |
- return nil |
|
| 270 |
- } |
|
| 271 |
- |
|
| 272 |
- guard selectedCharger.chargerIdleCurrentAmps == nil else {
|
|
| 273 |
- return nil |
|
| 274 |
- } |
|
| 275 |
- |
|
| 276 |
- return "This charger has no idle-current measurement. Wireless sessions can still be recorded, but they cannot learn or auto-apply the final stop threshold yet." |
|
| 277 |
- } |
|
| 278 |
- |
|
| 279 | 346 |
private var headerCard: some View {
|
| 280 | 347 |
VStack(alignment: .leading, spacing: 8) {
|
| 281 | 348 |
HStack {
|
| 282 | 349 |
Text("Charging Session")
|
| 283 | 350 |
.font(.system(.title3, design: .rounded).weight(.bold)) |
| 351 |
+ ContextInfoButton( |
|
| 352 |
+ title: "Charging Session", |
|
| 353 |
+ message: "Recording starts only after the device, charging type, charging mode, and initial checkpoint are explicit." |
|
| 354 |
+ ) |
|
| 284 | 355 |
Spacer() |
| 285 | 356 |
Text(headerStatusTitle) |
| 286 | 357 |
.font(.caption.weight(.bold)) |
@@ -295,9 +366,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 295 | 366 |
) |
| 296 | 367 |
} |
| 297 | 368 |
|
| 298 |
- Text("Recording starts only after the device, charging type, charging mode, and initial checkpoint are explicit.")
|
|
| 299 |
- .font(.footnote) |
|
| 300 |
- .foregroundColor(.secondary) |
|
| 301 | 369 |
} |
| 302 | 370 |
.frame(maxWidth: .infinity) |
| 303 | 371 |
.padding(18) |
@@ -309,6 +377,10 @@ struct MeterChargeRecordContentView: View {
|
||
| 309 | 377 |
HStack {
|
| 310 | 378 |
Text(openChargeSession == nil ? "Session Setup" : "Session Context") |
| 311 | 379 |
.font(.headline) |
| 380 |
+ ContextInfoButton( |
|
| 381 |
+ title: openChargeSession == nil ? "Session Setup" : "Session Context", |
|
| 382 |
+ message: "Select or create the device you are charging. Sessions, checkpoints, curve learning, and wireless charger warnings all depend on that explicit selection." |
|
| 383 |
+ ) |
|
| 312 | 384 |
Spacer() |
| 313 | 385 |
Button("Library") {
|
| 314 | 386 |
chargedDeviceLibraryVisibility = true |
@@ -331,9 +403,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 331 | 403 |
.meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
| 332 | 404 |
.buttonStyle(.plain) |
| 333 | 405 |
} else {
|
| 334 |
- Text("Select or create the device you are charging. Sessions, checkpoints, curve learning, and wireless charger warnings all depend on that explicit selection.")
|
|
| 335 |
- .font(.footnote) |
|
| 336 |
- .foregroundColor(.secondary) |
|
| 406 |
+ EmptyView() |
|
| 337 | 407 |
} |
| 338 | 408 |
} |
| 339 | 409 |
.padding(18) |
@@ -349,10 +419,10 @@ struct MeterChargeRecordContentView: View {
|
||
| 349 | 419 |
) |
| 350 | 420 |
|
| 351 | 421 |
VStack(alignment: .leading, spacing: 8) {
|
| 352 |
- Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName) |
|
| 422 |
+ Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName) |
|
| 353 | 423 |
.font(.headline) |
| 354 | 424 |
|
| 355 |
- Text(chargedDevice.deviceClass.title) |
|
| 425 |
+ Text(chargedDevice.identityTitle) |
|
| 356 | 426 |
.font(.caption.weight(.semibold)) |
| 357 | 427 |
.foregroundColor(.secondary) |
| 358 | 428 |
|
@@ -364,18 +434,8 @@ struct MeterChargeRecordContentView: View {
|
||
| 364 | 434 |
.font(.caption2) |
| 365 | 435 |
.foregroundColor(.secondary) |
| 366 | 436 |
|
| 367 |
- if let selectedDraftSessionKind, |
|
| 368 |
- let threshold = chargedDevice.resolvedCompletionCurrentAmps( |
|
| 369 |
- for: selectedDraftSessionKind.chargingTransportMode, |
|
| 370 |
- chargingStateMode: selectedDraftSessionKind.chargingStateMode |
|
| 371 |
- ) {
|
|
| 372 |
- Text("\(selectedDraftSessionKind.shortTitle) stop current: \(threshold.format(decimalDigits: 2)) A")
|
|
| 373 |
- .font(.caption2) |
|
| 374 |
- .foregroundColor(.secondary) |
|
| 375 |
- } else if let estimatedCapacity = chargedDevice.estimatedBatteryCapacityWh( |
|
| 376 |
- for: chargedDevice.preferredChargingTransportMode |
|
| 377 |
- ) {
|
|
| 378 |
- Text("Estimated \(chargedDevice.preferredChargingTransportMode.title.lowercased()) capacity: \(estimatedCapacity.format(decimalDigits: 2)) Wh")
|
|
| 437 |
+ if let estimatedCapacity = chargedDevice.estimatedBatteryCapacityWh {
|
|
| 438 |
+ Text("Estimated capacity: \(estimatedCapacity.format(decimalDigits: 2)) Wh")
|
|
| 379 | 439 |
.font(.caption2) |
| 380 | 440 |
.foregroundColor(.secondary) |
| 381 | 441 |
} |
@@ -398,13 +458,17 @@ struct MeterChargeRecordContentView: View {
|
||
| 398 | 458 |
Text("Charging Type")
|
| 399 | 459 |
.font(.subheadline.weight(.semibold)) |
| 400 | 460 |
|
| 401 |
- Picker("Charging Type", selection: $draftChargingTransportMode) {
|
|
| 402 |
- ForEach(chargedDevice.supportedChargingModes) { chargingTransportMode in
|
|
| 403 |
- Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName) |
|
| 404 |
- .tag(Optional(chargingTransportMode)) |
|
| 461 |
+ compactSelectionMenu( |
|
| 462 |
+ title: draftChargingTransportMode?.title ?? "Choose", |
|
| 463 |
+ options: chargedDevice.supportedChargingModes.map { chargingTransportMode in
|
|
| 464 |
+ CompactSelectionOption( |
|
| 465 |
+ id: chargingTransportMode.id, |
|
| 466 |
+ title: chargingTransportMode.title, |
|
| 467 |
+ isSelected: draftChargingTransportMode == chargingTransportMode, |
|
| 468 |
+ action: { draftChargingTransportMode = chargingTransportMode }
|
|
| 469 |
+ ) |
|
| 405 | 470 |
} |
| 406 |
- } |
|
| 407 |
- .pickerStyle(.segmented) |
|
| 471 |
+ ) |
|
| 408 | 472 |
|
| 409 | 473 |
if draftChargingTransportMode == nil {
|
| 410 | 474 |
Text("Pick the charging type explicitly before starting.")
|
@@ -425,13 +489,17 @@ struct MeterChargeRecordContentView: View {
|
||
| 425 | 489 |
Text("Charging Mode")
|
| 426 | 490 |
.font(.subheadline.weight(.semibold)) |
| 427 | 491 |
|
| 428 |
- Picker("Charging Mode", selection: $draftChargingStateMode) {
|
|
| 429 |
- ForEach(chargedDevice.supportedChargingStateModes) { chargingStateMode in
|
|
| 430 |
- Text(chargingStateMode.title) |
|
| 431 |
- .tag(Optional(chargingStateMode)) |
|
| 492 |
+ compactSelectionMenu( |
|
| 493 |
+ title: draftChargingStateMode?.title ?? "Choose", |
|
| 494 |
+ options: chargedDevice.supportedChargingStateModes.map { chargingStateMode in
|
|
| 495 |
+ CompactSelectionOption( |
|
| 496 |
+ id: chargingStateMode.id, |
|
| 497 |
+ title: chargingStateMode.title, |
|
| 498 |
+ isSelected: draftChargingStateMode == chargingStateMode, |
|
| 499 |
+ action: { draftChargingStateMode = chargingStateMode }
|
|
| 500 |
+ ) |
|
| 432 | 501 |
} |
| 433 |
- } |
|
| 434 |
- .pickerStyle(.segmented) |
|
| 502 |
+ ) |
|
| 435 | 503 |
|
| 436 | 504 |
if draftChargingStateMode == nil {
|
| 437 | 505 |
Text("Pick whether the device is on or off for this session.")
|
@@ -448,22 +516,84 @@ struct MeterChargeRecordContentView: View {
|
||
| 448 | 516 |
} |
| 449 | 517 |
|
| 450 | 518 |
VStack(alignment: .leading, spacing: 8) {
|
| 451 |
- Text("Initial Checkpoint")
|
|
| 452 |
- .font(.subheadline.weight(.semibold)) |
|
| 519 |
+ HStack(spacing: 8) {
|
|
| 520 |
+ Text("Initial Checkpoint")
|
|
| 521 |
+ .font(.subheadline.weight(.semibold)) |
|
| 522 |
+ ContextInfoButton( |
|
| 523 |
+ title: "Initial Checkpoint", |
|
| 524 |
+ message: "Use the battery level shown by the device right now when it is known. A known checkpoint improves battery prediction and capacity learning, but the session can also start without one when the level is unavailable." |
|
| 525 |
+ ) |
|
| 526 |
+ } |
|
| 453 | 527 |
|
| 454 |
- TextField("Battery %", text: $initialCheckpoint)
|
|
| 455 |
- .keyboardType(.decimalPad) |
|
| 528 |
+ compactSelectionMenu( |
|
| 529 |
+ title: initialCheckpointMode.title, |
|
| 530 |
+ options: InitialCheckpointMode.allCases.map { mode in
|
|
| 531 |
+ CompactSelectionOption( |
|
| 532 |
+ id: mode.id, |
|
| 533 |
+ title: mode.title, |
|
| 534 |
+ isSelected: initialCheckpointMode == mode, |
|
| 535 |
+ action: { initialCheckpointMode = mode }
|
|
| 536 |
+ ) |
|
| 537 |
+ } |
|
| 538 |
+ ) |
|
| 539 |
+ |
|
| 540 |
+ if initialCheckpointMode == .known {
|
|
| 541 |
+ HStack(spacing: 10) {
|
|
| 542 |
+ Button {
|
|
| 543 |
+ adjustInitialCheckpoint(by: -1) |
|
| 544 |
+ } label: {
|
|
| 545 |
+ Image(systemName: "minus.circle") |
|
| 546 |
+ .font(.title3) |
|
| 547 |
+ } |
|
| 548 |
+ .buttonStyle(.plain) |
|
| 549 |
+ |
|
| 550 |
+ TextField("Battery %", text: $initialCheckpoint)
|
|
| 551 |
+ .keyboardType(.decimalPad) |
|
| 552 |
+ .textFieldStyle(.roundedBorder) |
|
| 553 |
+ .frame(width: 92) |
|
| 554 |
+ |
|
| 555 |
+ Text("%")
|
|
| 556 |
+ .font(.subheadline.weight(.semibold)) |
|
| 557 |
+ .foregroundColor(.secondary) |
|
| 558 |
+ |
|
| 559 |
+ Button {
|
|
| 560 |
+ adjustInitialCheckpoint(by: 1) |
|
| 561 |
+ } label: {
|
|
| 562 |
+ Image(systemName: "plus.circle") |
|
| 563 |
+ .font(.title3) |
|
| 564 |
+ } |
|
| 565 |
+ .buttonStyle(.plain) |
|
| 566 |
+ |
|
| 567 |
+ Spacer() |
|
| 568 |
+ } |
|
| 569 |
+ } else {
|
|
| 570 |
+ Text( |
|
| 571 |
+ initialCheckpointMode == .flat |
|
| 572 |
+ ? "Use Flat when the device does not turn on yet. Predictions and capacity estimates stay off until you record a positive battery level." |
|
| 573 |
+ : "Start without an initial battery checkpoint only when the level cannot be read reliably, for example on a device without display." |
|
| 574 |
+ ) |
|
| 575 |
+ .font(.caption2) |
|
| 576 |
+ .foregroundColor(.orange) |
|
| 577 |
+ } |
|
| 456 | 578 |
|
| 457 |
- Text("The session starts only after this first checkpoint is recorded.")
|
|
| 458 |
- .font(.caption2) |
|
| 459 |
- .foregroundColor(.secondary) |
|
| 460 | 579 |
} |
| 461 | 580 |
|
| 462 |
- Toggle("Auto-stop when the type already has a stop threshold", isOn: $draftAutoStopEnabled)
|
|
| 581 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 582 |
+ Text("Start Requirements")
|
|
| 583 |
+ .font(.subheadline.weight(.semibold)) |
|
| 463 | 584 |
|
| 464 |
- Text(draftAutoStopDescription) |
|
| 465 |
- .font(.footnote) |
|
| 466 |
- .foregroundColor(setupWirelessThresholdWarning == nil ? .secondary : .orange) |
|
| 585 |
+ if startRequirements.isEmpty {
|
|
| 586 |
+ Label("Everything needed to start is ready.", systemImage: "checkmark.circle.fill")
|
|
| 587 |
+ .font(.caption) |
|
| 588 |
+ .foregroundColor(.green) |
|
| 589 |
+ } else {
|
|
| 590 |
+ ForEach(startRequirements) { requirement in
|
|
| 591 |
+ Label(requirement.message, systemImage: "exclamationmark.circle") |
|
| 592 |
+ .font(.caption) |
|
| 593 |
+ .foregroundColor(.orange) |
|
| 594 |
+ } |
|
| 595 |
+ } |
|
| 596 |
+ } |
|
| 467 | 597 |
|
| 468 | 598 |
Button("Start Session") {
|
| 469 | 599 |
startSession() |
@@ -496,7 +626,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 496 | 626 |
) |
| 497 | 627 |
|
| 498 | 628 |
VStack(alignment: .leading, spacing: 6) {
|
| 499 |
- Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName) |
|
| 629 |
+ Label(selectedCharger.name, systemImage: selectedCharger.identitySymbolName) |
|
| 500 | 630 |
.font(.subheadline.weight(.semibold)) |
| 501 | 631 |
|
| 502 | 632 |
if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
|
@@ -525,21 +655,38 @@ struct MeterChargeRecordContentView: View {
|
||
| 525 | 655 |
} |
| 526 | 656 |
|
| 527 | 657 |
private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
|
| 528 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 529 |
- Text("Charging Monitor")
|
|
| 530 |
- .font(.headline) |
|
| 658 |
+ let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession) |
|
| 659 |
+ |
|
| 660 |
+ return VStack(alignment: .leading, spacing: 12) {
|
|
| 661 |
+ HStack(spacing: 8) {
|
|
| 662 |
+ Text("Charging Monitor")
|
|
| 663 |
+ .font(.headline) |
|
| 664 |
+ ContextInfoButton( |
|
| 665 |
+ title: "Charging Monitor", |
|
| 666 |
+ message: "The monitor keeps the session explicit: it never auto-selects another device or charger, and it never starts a new session on its own." |
|
| 667 |
+ ) |
|
| 668 |
+ } |
|
| 531 | 669 |
|
| 532 | 670 |
ChargeRecordMetricsTableView( |
| 533 | 671 |
labels: ["Type", "Mode", "Energy", "Auto Stop"], |
| 534 | 672 |
values: [ |
| 535 | 673 |
openChargeSession.chargingTransportMode.title, |
| 536 | 674 |
openChargeSession.chargingStateMode.title, |
| 537 |
- "\(openChargeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh", |
|
| 675 |
+ "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", |
|
| 538 | 676 |
autoStopLabel(for: openChargeSession) |
| 539 | 677 |
] |
| 540 | 678 |
) |
| 541 | 679 |
|
| 542 |
- if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(for: openChargeSession) {
|
|
| 680 |
+ if openChargeSession.stopThresholdAmps > 0 {
|
|
| 681 |
+ Text("Meter monitor threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
|
|
| 682 |
+ .font(.caption) |
|
| 683 |
+ .foregroundColor(.secondary) |
|
| 684 |
+ } |
|
| 685 |
+ |
|
| 686 |
+ if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction( |
|
| 687 |
+ for: openChargeSession, |
|
| 688 |
+ effectiveEnergyWhOverride: displayedEnergyWh |
|
| 689 |
+ ) {
|
|
| 543 | 690 |
VStack(alignment: .leading, spacing: 4) {
|
| 544 | 691 |
Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
|
| 545 | 692 |
.font(.caption.weight(.semibold)) |
@@ -634,11 +781,12 @@ struct MeterChargeRecordContentView: View {
|
||
| 634 | 781 |
.buttonStyle(.plain) |
| 635 | 782 |
|
| 636 | 783 |
if !openChargeSession.checkpoints.isEmpty {
|
| 784 |
+ let recentCheckpoints = Array(openChargeSession.checkpoints.suffix(6).reversed()) |
|
| 637 | 785 |
VStack(alignment: .leading, spacing: 8) {
|
| 638 | 786 |
Text("Battery Checkpoints")
|
| 639 | 787 |
.font(.subheadline.weight(.semibold)) |
| 640 | 788 |
|
| 641 |
- ForEach(openChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in
|
|
| 789 |
+ ForEach(recentCheckpoints, id: \.id) { checkpoint in
|
|
| 642 | 790 |
HStack {
|
| 643 | 791 |
Text(checkpoint.timestamp.format()) |
| 644 | 792 |
.font(.caption2) |
@@ -651,14 +799,18 @@ struct MeterChargeRecordContentView: View {
|
||
| 651 | 799 |
Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
|
| 652 | 800 |
.font(.caption2) |
| 653 | 801 |
.foregroundColor(.secondary) |
| 802 |
+ Button {
|
|
| 803 |
+ pendingCheckpointDeletion = checkpoint |
|
| 804 |
+ } label: {
|
|
| 805 |
+ Image(systemName: "trash") |
|
| 806 |
+ .font(.caption.weight(.semibold)) |
|
| 807 |
+ .foregroundColor(.red) |
|
| 808 |
+ } |
|
| 809 |
+ .buttonStyle(.plain) |
|
| 654 | 810 |
} |
| 655 | 811 |
} |
| 656 | 812 |
} |
| 657 | 813 |
} |
| 658 |
- |
|
| 659 |
- Text("The monitor keeps the session explicit: it never auto-selects another device or charger, and it never starts a new session on its own.")
|
|
| 660 |
- .font(.footnote) |
|
| 661 |
- .foregroundColor(.secondary) |
|
| 662 | 814 |
} |
| 663 | 815 |
.padding(18) |
| 664 | 816 |
.meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20) |
@@ -709,16 +861,31 @@ struct MeterChargeRecordContentView: View {
|
||
| 709 | 861 |
session: ChargeSessionSummary |
| 710 | 862 |
) -> some View {
|
| 711 | 863 |
VStack(alignment: .leading, spacing: 12) {
|
| 712 |
- Text("Session Chart")
|
|
| 713 |
- .font(.headline) |
|
| 864 |
+ HStack(spacing: 8) {
|
|
| 865 |
+ Text("Session Chart")
|
|
| 866 |
+ .font(.headline) |
|
| 867 |
+ ContextInfoButton( |
|
| 868 |
+ title: "Session Chart", |
|
| 869 |
+ message: "The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging." |
|
| 870 |
+ ) |
|
| 871 |
+ } |
|
| 714 | 872 |
|
| 715 |
- MeasurementChartView(timeRange: timeRange) |
|
| 873 |
+ GeometryReader { geometry in
|
|
| 874 |
+ let chartWidth = max(geometry.size.width, 1) |
|
| 875 |
+ let compactChartLayout = chartWidth < 760 |
|
| 876 |
+ let chartHeight = compactChartLayout ? 290.0 : 350.0 |
|
| 877 |
+ |
|
| 878 |
+ MeasurementChartView( |
|
| 879 |
+ compactLayout: compactChartLayout, |
|
| 880 |
+ availableSize: CGSize(width: chartWidth, height: chartHeight), |
|
| 881 |
+ timeRange: timeRange, |
|
| 882 |
+ showsRangeSelector: false, |
|
| 883 |
+ rebasesEnergyToVisibleRangeStart: true |
|
| 884 |
+ ) |
|
| 716 | 885 |
.environmentObject(usbMeter.measurements) |
| 717 |
- .frame(minHeight: 220) |
|
| 718 |
- |
|
| 719 |
- Text("The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging.")
|
|
| 720 |
- .font(.footnote) |
|
| 721 |
- .foregroundColor(.secondary) |
|
| 886 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 887 |
+ } |
|
| 888 |
+ .frame(height: 350) |
|
| 722 | 889 |
} |
| 723 | 890 |
.padding(18) |
| 724 | 891 |
.meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20) |
@@ -726,32 +893,42 @@ struct MeterChargeRecordContentView: View {
|
||
| 726 | 893 |
|
| 727 | 894 |
private var meterTotalsCard: some View {
|
| 728 | 895 |
VStack(alignment: .leading, spacing: 12) {
|
| 729 |
- Text("Meter Totals")
|
|
| 730 |
- .font(.headline) |
|
| 896 |
+ HStack(spacing: 8) {
|
|
| 897 |
+ Text("Meter Recorder")
|
|
| 898 |
+ .font(.headline) |
|
| 899 |
+ |
|
| 900 |
+ Spacer(minLength: 0) |
|
| 901 |
+ |
|
| 902 |
+ Button {
|
|
| 903 |
+ showsMeterTotalsInfo.toggle() |
|
| 904 |
+ } label: {
|
|
| 905 |
+ Image(systemName: "info.circle") |
|
| 906 |
+ .font(.body.weight(.semibold)) |
|
| 907 |
+ .foregroundColor(.secondary) |
|
| 908 |
+ } |
|
| 909 |
+ .buttonStyle(.plain) |
|
| 910 |
+ .accessibilityLabel("Meter recorder info")
|
|
| 911 |
+ .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
|
|
| 912 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 913 |
+ Text("Meter Recorder")
|
|
| 914 |
+ .font(.headline) |
|
| 915 |
+ Text("These values come directly from the meter's built-in recorder. Keep them visible while comparing the app session against what the meter captured on its own.")
|
|
| 916 |
+ .font(.body) |
|
| 917 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 918 |
+ } |
|
| 919 |
+ .padding(16) |
|
| 920 |
+ .frame(width: 280, alignment: .leading) |
|
| 921 |
+ } |
|
| 922 |
+ } |
|
| 731 | 923 |
|
| 732 | 924 |
ChargeRecordMetricsTableView( |
| 733 |
- labels: ["Capacity", "Energy", "Duration", "Meter Threshold"], |
|
| 925 |
+ labels: ["Energy", "Duration", "Meter Threshold"], |
|
| 734 | 926 |
values: [ |
| 735 |
- "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah", |
|
| 736 | 927 |
"\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh", |
| 737 | 928 |
usbMeter.recordingDurationDescription, |
| 738 | 929 |
usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only" |
| 739 | 930 |
] |
| 740 | 931 |
) |
| 741 |
- |
|
| 742 |
- Text("These values come directly from the meter and remain separate from the explicit app session controls.")
|
|
| 743 |
- .font(.footnote) |
|
| 744 |
- .foregroundColor(.secondary) |
|
| 745 |
- |
|
| 746 |
- if usbMeter.supportsDataGroupCommands {
|
|
| 747 |
- Button("Reset Active Group") {
|
|
| 748 |
- usbMeter.clear() |
|
| 749 |
- } |
|
| 750 |
- .frame(maxWidth: .infinity) |
|
| 751 |
- .padding(.vertical, 10) |
|
| 752 |
- .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 753 |
- .buttonStyle(.plain) |
|
| 754 |
- } |
|
| 755 | 932 |
} |
| 756 | 933 |
.padding(18) |
| 757 | 934 |
.meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20) |
@@ -775,6 +952,23 @@ struct MeterChargeRecordContentView: View {
|
||
| 775 | 952 |
return "Learning" |
| 776 | 953 |
} |
| 777 | 954 |
|
| 955 |
+ private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
|
|
| 956 |
+ let storedEnergyWh = session.effectiveOrMeasuredEnergyWh |
|
| 957 |
+ guard session.status.isOpen else {
|
|
| 958 |
+ return storedEnergyWh |
|
| 959 |
+ } |
|
| 960 |
+ |
|
| 961 |
+ guard session.meterMACAddress == meterMACAddress else {
|
|
| 962 |
+ return storedEnergyWh |
|
| 963 |
+ } |
|
| 964 |
+ |
|
| 965 |
+ if let baselineEnergyWh = session.meterEnergyBaselineWh {
|
|
| 966 |
+ return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0)) |
|
| 967 |
+ } |
|
| 968 |
+ |
|
| 969 |
+ return storedEnergyWh |
|
| 970 |
+ } |
|
| 971 |
+ |
|
| 778 | 972 |
private func sessionWarning(for session: ChargeSessionSummary) -> String? {
|
| 779 | 973 |
guard session.chargingTransportMode == .wireless, |
| 780 | 974 |
let chargerID = session.chargerID, |
@@ -792,8 +986,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 792 | 986 |
private func startSession() {
|
| 793 | 987 |
guard let selectedChargedDevice, |
| 794 | 988 |
let chargingTransportMode = selectedDraftTransportMode, |
| 795 |
- let chargingStateMode = selectedDraftChargingStateMode, |
|
| 796 |
- let initialCheckpointValue else {
|
|
| 989 |
+ let chargingStateMode = selectedDraftChargingStateMode else {
|
|
| 797 | 990 |
return |
| 798 | 991 |
} |
| 799 | 992 |
|
@@ -804,27 +997,37 @@ struct MeterChargeRecordContentView: View {
|
||
| 804 | 997 |
chargerID: chargerID, |
| 805 | 998 |
chargingTransportMode: chargingTransportMode, |
| 806 | 999 |
chargingStateMode: chargingStateMode, |
| 807 |
- autoStopEnabled: draftAutoStopEnabled, |
|
| 808 |
- initialBatteryPercent: initialCheckpointValue |
|
| 1000 |
+ autoStopEnabled: false, |
|
| 1001 |
+ initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil, |
|
| 1002 |
+ startsFromFlatBattery: initialCheckpointMode == .flat |
|
| 809 | 1003 |
) |
| 810 | 1004 |
|
| 811 | 1005 |
if didStart {
|
| 812 | 1006 |
initialCheckpoint = "" |
| 1007 |
+ initialCheckpointMode = .known |
|
| 1008 |
+ } |
|
| 1009 |
+ } |
|
| 1010 |
+ |
|
| 1011 |
+ private func adjustInitialCheckpoint(by delta: Double) {
|
|
| 1012 |
+ guard initialCheckpointMode == .known else {
|
|
| 1013 |
+ return |
|
| 813 | 1014 |
} |
| 1015 |
+ |
|
| 1016 |
+ let currentValue = initialCheckpointValue ?? 0 |
|
| 1017 |
+ let nextValue = min(max(currentValue + delta, 0), 100) |
|
| 1018 |
+ initialCheckpoint = nextValue.format(decimalDigits: 0) |
|
| 814 | 1019 |
} |
| 815 | 1020 |
|
| 816 | 1021 |
private func syncDraftSelections() {
|
| 817 | 1022 |
guard let selectedChargedDevice else {
|
| 818 | 1023 |
draftChargingTransportMode = nil |
| 819 | 1024 |
draftChargingStateMode = nil |
| 820 |
- draftAutoStopEnabled = true |
|
| 821 | 1025 |
return |
| 822 | 1026 |
} |
| 823 | 1027 |
|
| 824 | 1028 |
if let openChargeSession {
|
| 825 | 1029 |
draftChargingTransportMode = openChargeSession.chargingTransportMode |
| 826 | 1030 |
draftChargingStateMode = openChargeSession.chargingStateMode |
| 827 |
- draftAutoStopEnabled = openChargeSession.autoStopEnabled |
|
| 828 | 1031 |
return |
| 829 | 1032 |
} |
| 830 | 1033 |
|
@@ -842,10 +1045,53 @@ struct MeterChargeRecordContentView: View {
|
||
| 842 | 1045 |
draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first |
| 843 | 1046 |
} |
| 844 | 1047 |
|
| 845 |
- if selectedChargedDevice.supportedChargingStateModes.count == 1 {
|
|
| 1048 |
+ if let draftChargingTransportMode {
|
|
| 1049 |
+ draftChargingStateMode = draftChargingStateMode |
|
| 1050 |
+ ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode) |
|
| 1051 |
+ } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
|
|
| 846 | 1052 |
draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first |
| 847 | 1053 |
} |
| 848 | 1054 |
} |
| 1055 |
+ |
|
| 1056 |
+ private struct CompactSelectionOption: Identifiable {
|
|
| 1057 |
+ let id: String |
|
| 1058 |
+ let title: String |
|
| 1059 |
+ let isSelected: Bool |
|
| 1060 |
+ let action: () -> Void |
|
| 1061 |
+ } |
|
| 1062 |
+ |
|
| 1063 |
+ private func compactSelectionMenu( |
|
| 1064 |
+ title: String, |
|
| 1065 |
+ options: [CompactSelectionOption] |
|
| 1066 |
+ ) -> some View {
|
|
| 1067 |
+ Menu {
|
|
| 1068 |
+ ForEach(options) { option in
|
|
| 1069 |
+ Button {
|
|
| 1070 |
+ option.action() |
|
| 1071 |
+ } label: {
|
|
| 1072 |
+ if option.isSelected {
|
|
| 1073 |
+ Label(option.title, systemImage: "checkmark") |
|
| 1074 |
+ } else {
|
|
| 1075 |
+ Text(option.title) |
|
| 1076 |
+ } |
|
| 1077 |
+ } |
|
| 1078 |
+ } |
|
| 1079 |
+ } label: {
|
|
| 1080 |
+ HStack(spacing: 8) {
|
|
| 1081 |
+ Text(title) |
|
| 1082 |
+ .foregroundColor(.primary) |
|
| 1083 |
+ Spacer() |
|
| 1084 |
+ Image(systemName: "chevron.up.chevron.down") |
|
| 1085 |
+ .font(.caption.weight(.semibold)) |
|
| 1086 |
+ .foregroundColor(.secondary) |
|
| 1087 |
+ } |
|
| 1088 |
+ .padding(.horizontal, 12) |
|
| 1089 |
+ .padding(.vertical, 9) |
|
| 1090 |
+ .frame(width: 180, alignment: .leading) |
|
| 1091 |
+ .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12) |
|
| 1092 |
+ } |
|
| 1093 |
+ .buttonStyle(.plain) |
|
| 1094 |
+ } |
|
| 849 | 1095 |
} |
| 850 | 1096 |
|
| 851 | 1097 |
struct ChargeSessionCompletionSheetView: View {
|
@@ -863,17 +1109,18 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 863 | 1109 |
var body: some View {
|
| 864 | 1110 |
NavigationView {
|
| 865 | 1111 |
Form {
|
| 866 |
- Section(header: Text("Final Checkpoint")) {
|
|
| 1112 |
+ Section( |
|
| 1113 |
+ header: ContextInfoHeader( |
|
| 1114 |
+ title: "Final Checkpoint", |
|
| 1115 |
+ message: explanation |
|
| 1116 |
+ ) |
|
| 1117 |
+ ) {
|
|
| 867 | 1118 |
TextField("Battery %", text: $batteryPercent)
|
| 868 | 1119 |
.keyboardType(.decimalPad) |
| 869 | 1120 |
TextField("Label", text: $label)
|
| 870 | 1121 |
} |
| 871 | 1122 |
|
| 872 | 1123 |
Section {
|
| 873 |
- Text(explanation) |
|
| 874 |
- .font(.footnote) |
|
| 875 |
- .foregroundColor(.secondary) |
|
| 876 |
- |
|
| 877 | 1124 |
if let sessionWarning {
|
| 878 | 1125 |
Text(sessionWarning) |
| 879 | 1126 |
.font(.footnote) |
@@ -10,7 +10,7 @@ struct MeterDataGroupsTabView: View {
|
||
| 10 | 10 |
|
| 11 | 11 |
var body: some View {
|
| 12 | 12 |
GeometryReader { box in
|
| 13 |
- let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory", "Ah"] |
|
| 13 |
+ let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory"] |
|
| 14 | 14 |
+ (usbMeter.showsDataGroupEnergy ? ["Wh"] : []) |
| 15 | 15 |
+ (usbMeter.supportsDataGroupCommands ? ["Clear"] : []) |
| 16 | 16 |
let columnWidth = (box.size.width - 60) / CGFloat(columnTitles.count) |
@@ -18,12 +18,15 @@ struct MeterDataGroupsTabView: View {
|
||
| 18 | 18 |
ScrollView {
|
| 19 | 19 |
VStack(alignment: .leading, spacing: 14) {
|
| 20 | 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) |
|
| 21 |
+ HStack(spacing: 8) {
|
|
| 22 |
+ Text(usbMeter.dataGroupsTitle) |
|
| 23 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 24 |
+ if let hint = usbMeter.dataGroupsHint {
|
|
| 25 |
+ ContextInfoButton( |
|
| 26 |
+ title: usbMeter.dataGroupsTitle, |
|
| 27 |
+ message: hint |
|
| 28 |
+ ) |
|
| 29 |
+ } |
|
| 27 | 30 |
} |
| 28 | 31 |
} |
| 29 | 32 |
.padding(18) |
@@ -12,9 +12,20 @@ import SwiftUI |
||
| 12 | 12 |
struct MeterOverviewSectionView: View {
|
| 13 | 13 |
let meter: Meter |
| 14 | 14 |
|
| 15 |
+ private var overviewInfoMessage: String? {
|
|
| 16 |
+ guard meter.operationalState != .dataIsAvailable else {
|
|
| 17 |
+ return nil |
|
| 18 |
+ } |
|
| 19 |
+ return "Connect to the meter to load firmware, serial, and boot details." |
|
| 20 |
+ } |
|
| 21 |
+ |
|
| 15 | 22 |
var body: some View {
|
| 16 | 23 |
VStack(spacing: 14) {
|
| 17 |
- MeterInfoCardView(title: "Overview", tint: meter.color) {
|
|
| 24 |
+ MeterInfoCardView( |
|
| 25 |
+ title: "Overview", |
|
| 26 |
+ infoMessage: overviewInfoMessage, |
|
| 27 |
+ tint: meter.color |
|
| 28 |
+ ) {
|
|
| 18 | 29 |
MeterInfoRowView(label: "Name", value: meter.name) |
| 19 | 30 |
MeterInfoRowView(label: "Device Model", value: meter.deviceModelName) |
| 20 | 31 |
MeterInfoRowView(label: "Advertised Model", value: meter.modelString) |
@@ -36,11 +47,6 @@ struct MeterOverviewSectionView: View {
|
||
| 36 | 47 |
if meter.bootCount != 0 {
|
| 37 | 48 |
MeterInfoRowView(label: "Boot Count", value: "\(meter.bootCount)") |
| 38 | 49 |
} |
| 39 |
- } else {
|
|
| 40 |
- Text("Connect to the meter to load firmware, serial, and boot details.")
|
|
| 41 |
- .font(.footnote) |
|
| 42 |
- .foregroundColor(.secondary) |
|
| 43 |
- .multilineTextAlignment(.leading) |
|
| 44 | 50 |
} |
| 45 | 51 |
} |
| 46 | 52 |
|
@@ -394,11 +394,14 @@ private struct PowerAverageSheetView: View {
|
||
| 394 | 394 |
ScrollView {
|
| 395 | 395 |
VStack(alignment: .leading, spacing: 14) {
|
| 396 | 396 |
VStack(alignment: .leading, spacing: 8) {
|
| 397 |
- Text("Power Average")
|
|
| 398 |
- .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 399 |
- Text("Inspect the recent power buffer, choose how many values to include, and compute the average power over that window.")
|
|
| 400 |
- .font(.footnote) |
|
| 401 |
- .foregroundColor(.secondary) |
|
| 397 |
+ HStack(spacing: 8) {
|
|
| 398 |
+ Text("Power Average")
|
|
| 399 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 400 |
+ ContextInfoButton( |
|
| 401 |
+ title: "Power Average", |
|
| 402 |
+ message: "Inspect the recent power buffer, choose how many values to include, and compute the average power over that window." |
|
| 403 |
+ ) |
|
| 404 |
+ } |
|
| 402 | 405 |
} |
| 403 | 406 |
.padding(18) |
| 404 | 407 |
.meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24) |
@@ -435,11 +438,11 @@ private struct PowerAverageSheetView: View {
|
||
| 435 | 438 |
} |
| 436 | 439 |
} |
| 437 | 440 |
|
| 438 |
- MeterInfoCardView(title: "Buffer Actions", tint: .secondary) {
|
|
| 439 |
- Text("Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.")
|
|
| 440 |
- .font(.footnote) |
|
| 441 |
- .foregroundColor(.secondary) |
|
| 442 |
- |
|
| 441 |
+ MeterInfoCardView( |
|
| 442 |
+ title: "Buffer Actions", |
|
| 443 |
+ infoMessage: "Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.", |
|
| 444 |
+ tint: .secondary |
|
| 445 |
+ ) {
|
|
| 443 | 446 |
Button("Reset Buffer") {
|
| 444 | 447 |
measurements.resetSeries() |
| 445 | 448 |
selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false)) |
@@ -533,11 +536,14 @@ private struct RSSIHistorySheetView: View {
|
||
| 533 | 536 |
ScrollView {
|
| 534 | 537 |
VStack(alignment: .leading, spacing: 14) {
|
| 535 | 538 |
VStack(alignment: .leading, spacing: 8) {
|
| 536 |
- Text("RSSI History")
|
|
| 537 |
- .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 538 |
- Text("Signal strength captured over time while the meter stays connected.")
|
|
| 539 |
- .font(.footnote) |
|
| 540 |
- .foregroundColor(.secondary) |
|
| 539 |
+ HStack(spacing: 8) {
|
|
| 540 |
+ Text("RSSI History")
|
|
| 541 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 542 |
+ ContextInfoButton( |
|
| 543 |
+ title: "RSSI History", |
|
| 544 |
+ message: "Signal strength captured over time while the meter stays connected." |
|
| 545 |
+ ) |
|
| 546 |
+ } |
|
| 541 | 547 |
} |
| 542 | 548 |
.padding(18) |
| 543 | 549 |
.meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24) |
@@ -717,11 +723,14 @@ private struct EnergyProjectionSheetView: View {
|
||
| 717 | 723 |
ScrollView {
|
| 718 | 724 |
VStack(alignment: .leading, spacing: 14) {
|
| 719 | 725 |
VStack(alignment: .leading, spacing: 8) {
|
| 720 |
- Text("Energy Projections")
|
|
| 721 |
- .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 722 |
- Text("Projected consumption is estimated from multiple real windows in the live buffer. A method is shown only when that full interval exists in the recent continuous data.")
|
|
| 723 |
- .font(.footnote) |
|
| 724 |
- .foregroundColor(.secondary) |
|
| 726 |
+ HStack(spacing: 8) {
|
|
| 727 |
+ Text("Energy Projections")
|
|
| 728 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 729 |
+ ContextInfoButton( |
|
| 730 |
+ title: "Energy Projections", |
|
| 731 |
+ message: "Projected consumption is estimated from multiple real windows in the live buffer. A method is shown only when that full interval exists in the recent continuous data." |
|
| 732 |
+ ) |
|
| 733 |
+ } |
|
| 725 | 734 |
} |
| 726 | 735 |
.padding(18) |
| 727 | 736 |
.meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24) |
@@ -751,9 +760,13 @@ private struct EnergyProjectionSheetView: View {
|
||
| 751 | 760 |
} |
| 752 | 761 |
} |
| 753 | 762 |
|
| 754 |
- MeterInfoCardView(title: "Projection Method", tint: .teal) {
|
|
| 763 |
+ MeterInfoCardView( |
|
| 764 |
+ title: "Projection Method", |
|
| 765 |
+ infoMessage: "Projection methods appear after the live buffer contains at least one continuous interval with enough data to estimate a rate.", |
|
| 766 |
+ tint: .teal |
|
| 767 |
+ ) {
|
|
| 755 | 768 |
if projectionVariants.isEmpty {
|
| 756 |
- Text("Projection methods appear after the live buffer contains at least one continuous interval with enough data to estimate a rate.")
|
|
| 769 |
+ Text("No projection methods available yet.")
|
|
| 757 | 770 |
.font(.footnote) |
| 758 | 771 |
.foregroundColor(.secondary) |
| 759 | 772 |
} else {
|
@@ -39,10 +39,11 @@ struct MeterSettingsTabView: View {
|
||
| 39 | 39 |
} |
| 40 | 40 |
|
| 41 | 41 |
if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
|
| 42 |
- settingsCard(title: "Meter Temperature Unit", tint: .orange) {
|
|
| 43 |
- Text("TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.")
|
|
| 44 |
- .font(.footnote) |
|
| 45 |
- .foregroundColor(.secondary) |
|
| 42 |
+ settingsCard( |
|
| 43 |
+ title: "Meter Temperature Unit", |
|
| 44 |
+ infoMessage: "TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.", |
|
| 45 |
+ tint: .orange |
|
| 46 |
+ ) {
|
|
| 46 | 47 |
Picker("", selection: $meter.tc66TemperatureUnitPreference) {
|
| 47 | 48 |
ForEach(TemperatureUnitPreference.allCases) { unit in
|
| 48 | 49 |
Text(unit.title).tag(unit) |
@@ -53,29 +54,23 @@ struct MeterSettingsTabView: View {
|
||
| 53 | 54 |
} |
| 54 | 55 |
|
| 55 | 56 |
if meter.operationalState == .dataIsAvailable && meter.model == .TC66C {
|
| 56 |
- settingsCard(title: "Screen Reporting", tint: .orange) {
|
|
| 57 |
+ settingsCard( |
|
| 58 |
+ title: "Screen Reporting", |
|
| 59 |
+ infoMessage: "TC66 is the exception: it does not report the current screen in the payload, so the app keeps this note here instead of showing it on the home screen.", |
|
| 60 |
+ tint: .orange |
|
| 61 |
+ ) {
|
|
| 57 | 62 |
MeterInfoRowView(label: "Current Screen", value: "Not Reported") |
| 58 |
- Text("TC66 is the exception: it does not report the current screen in the payload, so the app keeps this note here instead of showing it on the home screen.")
|
|
| 59 |
- .font(.footnote) |
|
| 60 |
- .foregroundColor(.secondary) |
|
| 61 | 63 |
} |
| 62 | 64 |
} |
| 63 | 65 |
|
| 64 | 66 |
if meter.operationalState == .dataIsAvailable {
|
| 65 | 67 |
settingsCard( |
| 66 | 68 |
title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls", |
| 69 |
+ infoMessage: meter.reportsCurrentScreenIndex |
|
| 70 |
+ ? "Use these controls when you want to change the screen shown on the device without crowding the main meter view." |
|
| 71 |
+ : "Use these controls when you want to switch device pages without crowding the main meter view.", |
|
| 67 | 72 |
tint: .indigo |
| 68 | 73 |
) {
|
| 69 |
- if meter.reportsCurrentScreenIndex {
|
|
| 70 |
- Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
|
|
| 71 |
- .font(.footnote) |
|
| 72 |
- .foregroundColor(.secondary) |
|
| 73 |
- } else {
|
|
| 74 |
- Text("Use these controls when you want to switch device pages without crowding the main meter view.")
|
|
| 75 |
- .font(.footnote) |
|
| 76 |
- .foregroundColor(.secondary) |
|
| 77 |
- } |
|
| 78 |
- |
|
| 79 | 74 |
MeterScreenControlsView(showsHeader: false) |
| 80 | 75 |
} |
| 81 | 76 |
} |
@@ -110,11 +105,11 @@ struct MeterSettingsTabView: View {
|
||
| 110 | 105 |
} |
| 111 | 106 |
} |
| 112 | 107 |
|
| 113 |
- settingsCard(title: "Danger Zone", tint: .red) {
|
|
| 114 |
- Text("Delete this meter from the sidebar and clear its saved metadata. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.")
|
|
| 115 |
- .font(.footnote) |
|
| 116 |
- .foregroundColor(.secondary) |
|
| 117 |
- |
|
| 108 |
+ settingsCard( |
|
| 109 |
+ title: "Danger Zone", |
|
| 110 |
+ infoMessage: "Delete this meter from the sidebar and clear its saved metadata. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.", |
|
| 111 |
+ tint: .red |
|
| 112 |
+ ) {
|
|
| 118 | 113 |
Button("Delete Meter") {
|
| 119 | 114 |
deleteConfirmationVisibility = true |
| 120 | 115 |
} |
@@ -186,12 +181,18 @@ struct MeterSettingsTabView: View {
|
||
| 186 | 181 |
|
| 187 | 182 |
private func settingsCard<Content: View>( |
| 188 | 183 |
title: String, |
| 184 |
+ infoMessage: String? = nil, |
|
| 189 | 185 |
tint: Color, |
| 190 | 186 |
@ViewBuilder content: () -> Content |
| 191 | 187 |
) -> some View {
|
| 192 | 188 |
VStack(alignment: .leading, spacing: 12) {
|
| 193 |
- Text(title) |
|
| 194 |
- .font(.headline) |
|
| 189 |
+ HStack(spacing: 8) {
|
|
| 190 |
+ Text(title) |
|
| 191 |
+ .font(.headline) |
|
| 192 |
+ if let infoMessage {
|
|
| 193 |
+ ContextInfoButton(title: title, message: infoMessage) |
|
| 194 |
+ } |
|
| 195 |
+ } |
|
| 195 | 196 |
content() |
| 196 | 197 |
} |
| 197 | 198 |
.padding(18) |
@@ -76,8 +76,14 @@ struct MeterDetailView: View {
|
||
| 76 | 76 |
|
| 77 | 77 |
private var statusCard: some View {
|
| 78 | 78 |
VStack(alignment: .leading, spacing: 10) {
|
| 79 |
- Text("Status")
|
|
| 80 |
- .font(.headline) |
|
| 79 |
+ HStack(spacing: 8) {
|
|
| 80 |
+ Text("Status")
|
|
| 81 |
+ .font(.headline) |
|
| 82 |
+ ContextInfoButton( |
|
| 83 |
+ title: "Status", |
|
| 84 |
+ message: "The meter is not currently connected. Bring it within Bluetooth range or wake it up to open live diagnostics." |
|
| 85 |
+ ) |
|
| 86 |
+ } |
|
| 81 | 87 |
HStack(spacing: 8) {
|
| 82 | 88 |
Circle() |
| 83 | 89 |
.fill(meterSummary.tint) |
@@ -86,9 +92,6 @@ struct MeterDetailView: View {
|
||
| 86 | 92 |
.font(.caption.weight(.semibold)) |
| 87 | 93 |
.foregroundColor(.secondary) |
| 88 | 94 |
} |
| 89 |
- Text("The meter is not currently connected. Bring it within Bluetooth range or wake it up to open live diagnostics.")
|
|
| 90 |
- .font(.caption) |
|
| 91 |
- .foregroundColor(.secondary) |
|
| 92 | 95 |
} |
| 93 | 96 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 94 | 97 |
.padding(18) |
@@ -113,11 +116,17 @@ struct MeterDetailView: View {
|
||
| 113 | 116 |
let chargedDevices = appData.chargedDevices(for: meterSummary.macAddress) |
| 114 | 117 |
|
| 115 | 118 |
return VStack(alignment: .leading, spacing: 10) {
|
| 116 |
- Text("Devices")
|
|
| 117 |
- .font(.headline) |
|
| 119 |
+ HStack(spacing: 8) {
|
|
| 120 |
+ Text("Devices")
|
|
| 121 |
+ .font(.headline) |
|
| 122 |
+ ContextInfoButton( |
|
| 123 |
+ title: "Devices", |
|
| 124 |
+ message: "Link devices to this meter from Charge Record to keep capacity learning and charge curves tied to the right hardware." |
|
| 125 |
+ ) |
|
| 126 |
+ } |
|
| 118 | 127 |
|
| 119 | 128 |
if chargedDevices.isEmpty {
|
| 120 |
- Text("No devices are linked to this meter yet. Connect it, open Charge Record, and select the device being charged to start learning capacity and charge curves.")
|
|
| 129 |
+ Text("No devices linked yet.")
|
|
| 121 | 130 |
.font(.caption) |
| 122 | 131 |
.foregroundColor(.secondary) |
| 123 | 132 |
} else {
|
@@ -127,7 +136,7 @@ struct MeterDetailView: View {
|
||
| 127 | 136 |
ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 52) |
| 128 | 137 |
|
| 129 | 138 |
VStack(alignment: .leading, spacing: 4) {
|
| 130 |
- Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName) |
|
| 139 |
+ Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName) |
|
| 131 | 140 |
.font(.subheadline.weight(.semibold)) |
| 132 | 141 |
Text(chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Capacity: learning")
|
| 133 | 142 |
.font(.caption) |
@@ -150,11 +159,17 @@ struct MeterDetailView: View {
|
||
| 150 | 159 |
let chargers = appData.chargers(for: meterSummary.macAddress) |
| 151 | 160 |
|
| 152 | 161 |
return VStack(alignment: .leading, spacing: 10) {
|
| 153 |
- Text("Chargers")
|
|
| 154 |
- .font(.headline) |
|
| 162 |
+ HStack(spacing: 8) {
|
|
| 163 |
+ Text("Chargers")
|
|
| 164 |
+ .font(.headline) |
|
| 165 |
+ ContextInfoButton( |
|
| 166 |
+ title: "Chargers", |
|
| 167 |
+ message: "Link chargers to this meter for wireless sessions so the app can keep charger-specific learning and efficiency data separate." |
|
| 168 |
+ ) |
|
| 169 |
+ } |
|
| 155 | 170 |
|
| 156 | 171 |
if chargers.isEmpty {
|
| 157 |
- Text("No chargers are linked to this meter yet. Pick one from Charge Record when you monitor a wireless charging session.")
|
|
| 172 |
+ Text("No chargers linked yet.")
|
|
| 158 | 173 |
.font(.caption) |
| 159 | 174 |
.foregroundColor(.secondary) |
| 160 | 175 |
} else {
|
@@ -164,7 +179,7 @@ struct MeterDetailView: View {
|
||
| 164 | 179 |
ChargedDeviceQRCodeView(qrIdentifier: charger.qrIdentifier, side: 52) |
| 165 | 180 |
|
| 166 | 181 |
VStack(alignment: .leading, spacing: 4) {
|
| 167 |
- Label(charger.name, systemImage: charger.deviceClass.symbolName) |
|
| 182 |
+ Label(charger.name, systemImage: charger.identitySymbolName) |
|
| 168 | 183 |
.font(.subheadline.weight(.semibold)) |
| 169 | 184 |
Text(charger.chargerMaximumPowerWatts.map { "Max power: \($0.format(decimalDigits: 2)) W" } ?? "Wireless charger")
|
| 170 | 185 |
.font(.caption) |
@@ -233,7 +248,12 @@ struct MeterEditorSheetView: View {
|
||
| 233 | 248 |
var body: some View {
|
| 234 | 249 |
NavigationView {
|
| 235 | 250 |
Form {
|
| 236 |
- Section(header: Text("Identity")) {
|
|
| 251 |
+ Section( |
|
| 252 |
+ header: ContextInfoHeader( |
|
| 253 |
+ title: "Identity", |
|
| 254 |
+ message: "Use the real BLE MAC address format `AA:BB:CC:DD:EE:FF`. Saved meters remain visible in the sidebar even when they are currently offline." |
|
| 255 |
+ ) |
|
| 256 |
+ ) {
|
|
| 237 | 257 |
TextField("Display name", text: $customName)
|
| 238 | 258 |
TextField("MAC Address", text: $macAddress)
|
| 239 | 259 |
.textInputAutocapitalization(.characters) |
@@ -249,12 +269,6 @@ struct MeterEditorSheetView: View {
|
||
| 249 | 269 |
|
| 250 | 270 |
TextField("Advertised name", text: $advertisedName)
|
| 251 | 271 |
} |
| 252 |
- |
|
| 253 |
- Section {
|
|
| 254 |
- Text("Use the real BLE MAC address format `AA:BB:CC:DD:EE:FF`. Saved meters remain visible in the sidebar even when they are currently offline.")
|
|
| 255 |
- .font(.footnote) |
|
| 256 |
- .foregroundColor(.secondary) |
|
| 257 |
- } |
|
| 258 | 272 |
} |
| 259 | 273 |
.navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter") |
| 260 | 274 |
.navigationBarTitleDisplayMode(.inline) |
@@ -17,16 +17,16 @@ struct MeterMappingDebugView: View {
|
||
| 17 | 17 |
VStack(alignment: .leading, spacing: 8) {
|
| 18 | 18 |
Text(store.currentCloudAvailability.helpTitle) |
| 19 | 19 |
.font(.headline) |
| 20 |
- Text("This screen is limited to meter sync metadata visible on this device through the local store and iCloud KVS.")
|
|
| 21 |
- .font(.caption) |
|
| 22 |
- .foregroundColor(.secondary) |
|
| 23 | 20 |
Text(store.currentCloudAvailability.helpMessage) |
| 24 | 21 |
.font(.caption) |
| 25 | 22 |
.foregroundColor(.secondary) |
| 26 | 23 |
} |
| 27 | 24 |
.padding(.vertical, 6) |
| 28 | 25 |
} header: {
|
| 29 |
- Text("Sync Status")
|
|
| 26 |
+ ContextInfoHeader( |
|
| 27 |
+ title: "Sync Status", |
|
| 28 |
+ message: "This screen is limited to meter sync metadata visible on this device through the local store and iCloud KVS." |
|
| 29 |
+ ) |
|
| 30 | 30 |
} |
| 31 | 31 |
|
| 32 | 32 |
Section {
|
@@ -11,20 +11,22 @@ struct SidebarBluetoothStatusCardView: View {
|
||
| 11 | 11 |
|
| 12 | 12 |
var body: some View {
|
| 13 | 13 |
VStack(alignment: .leading, spacing: 6) {
|
| 14 |
- HStack {
|
|
| 14 |
+ HStack(spacing: 8) {
|
|
| 15 | 15 |
Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
|
| 16 | 16 |
.font(.footnote.weight(.semibold)) |
| 17 | 17 |
.foregroundColor(tint) |
| 18 |
+ ContextInfoButton( |
|
| 19 |
+ title: "Bluetooth", |
|
| 20 |
+ message: "Refer to this adapter state while walking through the Bluetooth and Device troubleshooting steps.", |
|
| 21 |
+ popoverWidth: 300 |
|
| 22 |
+ ) |
|
| 18 | 23 |
Spacer() |
| 19 | 24 |
Text(statusText) |
| 20 | 25 |
.font(.caption.weight(.semibold)) |
| 21 | 26 |
.foregroundColor(.secondary) |
| 22 | 27 |
} |
| 23 |
- Text("Refer to this adapter state while walking through the Bluetooth and Device troubleshooting steps.")
|
|
| 24 |
- .font(.caption2) |
|
| 25 |
- .foregroundColor(.secondary) |
|
| 26 | 28 |
} |
| 27 | 29 |
.padding(14) |
| 28 | 30 |
.meterCard(tint: tint, fillOpacity: 0.22, strokeOpacity: 0.26, cornerRadius: 18) |
| 29 | 31 |
} |
| 30 |
-} |
|
| 32 |
+} |
|
@@ -60,13 +60,13 @@ struct SidebarView: View {
|
||
| 60 | 60 |
case .device: |
| 61 | 61 |
ChargedDeviceEditorSheetView( |
| 62 | 62 |
meterMACAddress: nil, |
| 63 |
- suggestedDeviceClass: .iphone |
|
| 63 |
+ kind: .device |
|
| 64 | 64 |
) |
| 65 | 65 |
.environmentObject(appData) |
| 66 | 66 |
case .charger: |
| 67 | 67 |
ChargedDeviceEditorSheetView( |
| 68 | 68 |
meterMACAddress: nil, |
| 69 |
- suggestedDeviceClass: .charger |
|
| 69 |
+ kind: .charger |
|
| 70 | 70 |
) |
| 71 | 71 |
.environmentObject(appData) |
| 72 | 72 |
} |