Redesign ChargedDeviceSessionsView to emphasize: - Duration and Energy as primary metrics (large, side-by-side cells) - Capacity estimate with delta vs previous session estimate - Battery delta percentage shown as compact chip - Charge bar visualization (0-100% with charged portion highlighted) - Secondary info line for transport/state/source Removes max power and max current from cards for cleaner focus. Capacity delta computed by comparing chronologically adjacent sessions. Charge bar derived from start/end battery % or first/last checkpoint pair.
@@ -63,8 +63,7 @@ |
||
| 63 | 63 |
C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */; };
|
| 64 | 64 |
C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */; };
|
| 65 | 65 |
C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */; };
|
| 66 |
- C100000B3C8E4A7A00A1000B /* ChargedDeviceSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */; };
|
|
| 67 |
- C1A500013C9D000100A10001 /* ChargedDeviceActiveSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A500023C9D000100A10002 /* ChargedDeviceActiveSessionView.swift */; };
|
|
| 66 |
+ C100000B3C8E4A7A00A1000B /* ChargeSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C100001B3C8E4A7A00A1002B /* ChargeSessionDetailView.swift */; };
|
|
| 68 | 67 |
C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */; };
|
| 69 | 68 |
C1F000013C90000100A10001 /* ChargedDeviceTemplates.json in Resources */ = {isa = PBXBuildFile; fileRef = C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */; };
|
| 70 | 69 |
CD0002013FA0000000000001 /* ChargedDeviceIdentityViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0001013FA0000000000001 /* ChargedDeviceIdentityViews.swift */; };
|
@@ -95,7 +94,6 @@ |
||
| 95 | 94 |
D28F11433C8E4A7A00A10053 /* MeterDataGroupsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */; };
|
| 96 | 95 |
E430FB6B7CB3E0D4189F6D7D /* MeterMappingDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */; };
|
| 97 | 96 |
F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */; };
|
| 98 |
- F20000046F5D4C95B6487F19 /* SessionTrimEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */; };
|
|
| 99 | 97 |
/* End PBXBuildFile section */ |
| 100 | 98 |
|
| 101 | 99 |
/* Begin PBXFileReference section */ |
@@ -191,12 +189,11 @@ |
||
| 191 | 189 |
C100001A3C8E4A7A00A1001A /* USB_Meter 5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 5.xcdatamodel"; sourceTree = "<group>"; };
|
| 192 | 190 |
C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSessionsView.swift; sourceTree = "<group>"; };
|
| 193 | 191 |
C100001B3C8E4A7A00A1001B /* USB_Meter 6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 6.xcdatamodel"; sourceTree = "<group>"; };
|
| 194 |
- C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceSessionDetailView.swift; sourceTree = "<group>"; };
|
|
| 192 |
+ C100001B3C8E4A7A00A1002B /* ChargeSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeSessionDetailView.swift; sourceTree = "<group>"; };
|
|
| 195 | 193 |
C100001C3C8E4A7A00A1001C /* USB_Meter 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 7.xcdatamodel"; sourceTree = "<group>"; };
|
| 196 | 194 |
C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 8.xcdatamodel"; sourceTree = "<group>"; };
|
| 197 | 195 |
C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 9.xcdatamodel"; sourceTree = "<group>"; };
|
| 198 | 196 |
C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 10.xcdatamodel"; sourceTree = "<group>"; };
|
| 199 |
- C1A500023C9D000100A10002 /* ChargedDeviceActiveSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceActiveSessionView.swift; sourceTree = "<group>"; };
|
|
| 200 | 197 |
C1CCSS023C8E4A7A00CCSS02 /* ChargeSessionCompletionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeSessionCompletionSheetView.swift; sourceTree = "<group>"; };
|
| 201 | 198 |
C1F000023C90000100A10002 /* ChargedDeviceTemplates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ChargedDeviceTemplates.json; sourceTree = "<group>"; };
|
| 202 | 199 |
C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 12.xcdatamodel"; sourceTree = "<group>"; };
|
@@ -229,7 +226,6 @@ |
||
| 229 | 226 |
E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 14.xcdatamodel"; sourceTree = "<group>"; };
|
| 230 | 227 |
F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 15.xcdatamodel"; sourceTree = "<group>"; };
|
| 231 | 228 |
F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargingWindowDetector.swift; sourceTree = "<group>"; };
|
| 232 |
- F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTrimEditorView.swift; sourceTree = "<group>"; };
|
|
| 233 | 229 |
/* End PBXFileReference section */ |
| 234 | 230 |
|
| 235 | 231 |
/* Begin PBXFileSystemSynchronizedRootGroup section */ |
@@ -486,11 +482,9 @@ |
||
| 486 | 482 |
AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */, |
| 487 | 483 |
C10000203C8E4A7A00A10020 /* ChargedDevices */, |
| 488 | 484 |
56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */, |
| 489 |
- 4327461A24619CED0009BE4B /* MeterRowView.swift */, |
|
| 490 | 485 |
437D47CF2415F8CF00B7768E /* Meter */, |
| 491 | 486 |
D28F10023C8E4A7A00A10002 /* Components */, |
| 492 | 487 |
4311E639241384960080EA59 /* DeviceHelpView.swift */, |
| 493 |
- AAD5F9A32B1CAC0700F8E4F9 /* MeterDetailView.swift */, |
|
| 494 | 488 |
); |
| 495 | 489 |
path = Views; |
| 496 | 490 |
sourceTree = "<group>"; |
@@ -556,8 +550,7 @@ |
||
| 556 | 550 |
CD0000123FA0000000000012 /* Sessions */ = {
|
| 557 | 551 |
isa = PBXGroup; |
| 558 | 552 |
children = ( |
| 559 |
- C1A500023C9D000100A10002 /* ChargedDeviceActiveSessionView.swift */, |
|
| 560 |
- C100001B3C8E4A7A00A1002B /* ChargedDeviceSessionDetailView.swift */, |
|
| 553 |
+ C100001B3C8E4A7A00A1002B /* ChargeSessionDetailView.swift */, |
|
| 561 | 554 |
C100001A3C8E4A7A00A1002A /* ChargedDeviceSessionsView.swift */, |
| 562 | 555 |
); |
| 563 | 556 |
path = Sessions; |
@@ -758,7 +751,6 @@ |
||
| 758 | 751 |
isa = PBXGroup; |
| 759 | 752 |
children = ( |
| 760 | 753 |
D28F11423C8E4A7A00A10052 /* MeterChargeRecordTabView.swift */, |
| 761 |
- F20000026F5D4C95B6487F17 /* SessionTrimEditorView.swift */, |
|
| 762 | 754 |
); |
| 763 | 755 |
path = ChargeRecord; |
| 764 | 756 |
sourceTree = "<group>"; |
@@ -889,14 +881,12 @@ |
||
| 889 | 881 |
C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */, |
| 890 | 882 |
C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */, |
| 891 | 883 |
F20000036F5D4C95B6487F18 /* ChargingWindowDetector.swift in Sources */, |
| 892 |
- F20000046F5D4C95B6487F19 /* SessionTrimEditorView.swift in Sources */, |
|
| 893 | 884 |
C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */, |
| 894 | 885 |
C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */, |
| 895 | 886 |
C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */, |
| 896 | 887 |
C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */, |
| 897 |
- C1A500013C9D000100A10001 /* ChargedDeviceActiveSessionView.swift in Sources */, |
|
| 898 | 888 |
C100000A3C8E4A7A00A1000A /* ChargedDeviceSessionsView.swift in Sources */, |
| 899 |
- C100000B3C8E4A7A00A1000B /* ChargedDeviceSessionDetailView.swift in Sources */, |
|
| 889 |
+ C100000B3C8E4A7A00A1000B /* ChargeSessionDetailView.swift in Sources */, |
|
| 900 | 890 |
C1CCSS013C8E4A7A00CCSS01 /* ChargeSessionCompletionSheetView.swift in Sources */, |
| 901 | 891 |
C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */, |
| 902 | 892 |
C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */, |
@@ -609,6 +609,8 @@ final class ChargeInsightsStore {
|
||
| 609 | 609 |
return |
| 610 | 610 |
} |
| 611 | 611 |
|
| 612 |
+ restoreMeasuredTotalsFromLatestSampleIfNeeded(session) |
|
| 613 |
+ |
|
| 612 | 614 |
guard hasSavableChargeData(session) else {
|
| 613 | 615 |
return |
| 614 | 616 |
} |
@@ -3054,11 +3056,43 @@ final class ChargeInsightsStore {
|
||
| 3054 | 3056 |
} |
| 3055 | 3057 |
|
| 3056 | 3058 |
private func hasSavableChargeData(_ session: NSManagedObject) -> Bool {
|
| 3057 |
- boolValue(session, key: "hasObservedChargeFlow") |
|
| 3059 |
+ if boolValue(session, key: "hasObservedChargeFlow") |
|
| 3058 | 3060 |
|| doubleValue(session, key: "measuredEnergyWh") > 0 |
| 3059 | 3061 |
|| doubleValue(session, key: "measuredChargeAh") > 0 |
| 3060 | 3062 |
|| (optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? 0) > 0 |
| 3061 |
- || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 |
|
| 3063 |
+ || (optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? 0) > 0 {
|
|
| 3064 |
+ return true |
|
| 3065 |
+ } |
|
| 3066 |
+ |
|
| 3067 |
+ guard let sessionID = stringValue(session, key: "id") else {
|
|
| 3068 |
+ return false |
|
| 3069 |
+ } |
|
| 3070 |
+ |
|
| 3071 |
+ return fetchSessionSampleObjects(forSessionID: sessionID).contains { sample in
|
|
| 3072 |
+ doubleValue(sample, key: "measuredEnergyWh") > 0 |
|
| 3073 |
+ || doubleValue(sample, key: "measuredChargeAh") > 0 |
|
| 3074 |
+ || (optionalDoubleValue(sample, key: "averageCurrentAmps") ?? 0) > 0 |
|
| 3075 |
+ || (optionalDoubleValue(sample, key: "averagePowerWatts") ?? 0) > 0 |
|
| 3076 |
+ } |
|
| 3077 |
+ } |
|
| 3078 |
+ |
|
| 3079 |
+ private func restoreMeasuredTotalsFromLatestSampleIfNeeded(_ session: NSManagedObject) {
|
|
| 3080 |
+ guard let sessionID = stringValue(session, key: "id"), |
|
| 3081 |
+ let latestSample = fetchSessionSampleObjects(forSessionID: sessionID).max(by: {
|
|
| 3082 |
+ (dateValue($0, key: "timestamp") ?? .distantPast) < (dateValue($1, key: "timestamp") ?? .distantPast) |
|
| 3083 |
+ }) else {
|
|
| 3084 |
+ return |
|
| 3085 |
+ } |
|
| 3086 |
+ |
|
| 3087 |
+ let sampleEnergyWh = doubleValue(latestSample, key: "measuredEnergyWh") |
|
| 3088 |
+ if doubleValue(session, key: "measuredEnergyWh") <= 0, sampleEnergyWh > 0 {
|
|
| 3089 |
+ session.setValue(sampleEnergyWh, forKey: "measuredEnergyWh") |
|
| 3090 |
+ } |
|
| 3091 |
+ |
|
| 3092 |
+ let sampleChargeAh = doubleValue(latestSample, key: "measuredChargeAh") |
|
| 3093 |
+ if doubleValue(session, key: "measuredChargeAh") <= 0, sampleChargeAh > 0 {
|
|
| 3094 |
+ session.setValue(sampleChargeAh, forKey: "measuredChargeAh") |
|
| 3095 |
+ } |
|
| 3062 | 3096 |
} |
| 3063 | 3097 |
|
| 3064 | 3098 |
private func derivedMinimumCurrent( |
@@ -477,7 +477,10 @@ struct ChargedDeviceDetailView: View {
|
||
| 477 | 477 |
chargedDevice: ChargedDeviceSummary |
| 478 | 478 |
) -> some View {
|
| 479 | 479 |
NavigationLink( |
| 480 |
- destination: ChargedDeviceActiveSessionView(chargedDeviceID: chargedDevice.id) |
|
| 480 |
+ destination: ChargeSessionDetailView( |
|
| 481 |
+ chargedDeviceID: chargedDevice.id, |
|
| 482 |
+ sessionID: activeSession.id |
|
| 483 |
+ ) |
|
| 481 | 484 |
) {
|
| 482 | 485 |
VStack(alignment: .leading, spacing: 14) {
|
| 483 | 486 |
HStack(alignment: .firstTextBaseline) {
|
@@ -0,0 +1,1763 @@ |
||
| 1 |
+// |
|
| 2 |
+// ChargeSessionDetailView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+// Created by Codex on 22/04/2026. |
|
| 6 |
+// |
|
| 7 |
+ |
|
| 8 |
+import SwiftUI |
|
| 9 |
+ |
|
| 10 |
+enum ChargeSessionDetailPresentation {
|
|
| 11 |
+ case navigation |
|
| 12 |
+ case embedded |
|
| 13 |
+} |
|
| 14 |
+ |
|
| 15 |
+struct ChargeSessionDetailView: View {
|
|
| 16 |
+ private enum FinalCheckpoint: Hashable {
|
|
| 17 |
+ case full |
|
| 18 |
+ case skip |
|
| 19 |
+ case custom |
|
| 20 |
+ |
|
| 21 |
+ var label: String {
|
|
| 22 |
+ switch self {
|
|
| 23 |
+ case .full: return "Full" |
|
| 24 |
+ case .skip: return "Skip" |
|
| 25 |
+ case .custom: return "Other %" |
|
| 26 |
+ } |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ var icon: String {
|
|
| 30 |
+ switch self {
|
|
| 31 |
+ case .full: return "battery.100percent" |
|
| 32 |
+ case .skip: return "minus.circle" |
|
| 33 |
+ case .custom: return "pencil" |
|
| 34 |
+ } |
|
| 35 |
+ } |
|
| 36 |
+ } |
|
| 37 |
+ |
|
| 38 |
+ @EnvironmentObject private var appData: AppData |
|
| 39 |
+ |
|
| 40 |
+ let chargedDeviceID: UUID |
|
| 41 |
+ let sessionID: UUID |
|
| 42 |
+ let monitoringMeter: Meter? |
|
| 43 |
+ let presentation: ChargeSessionDetailPresentation |
|
| 44 |
+ |
|
| 45 |
+ @State private var pendingCheckpointDeletion: ChargeCheckpointSummary? |
|
| 46 |
+ @State private var pendingSessionDeletion: ChargeSessionSummary? |
|
| 47 |
+ @State private var pendingSessionStopRequest: ChargeSessionStopRequest? |
|
| 48 |
+ @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow? |
|
| 49 |
+ @State private var trimBannerDismissedForSessionID: UUID? |
|
| 50 |
+ @State private var showingInlineTargetEditor = false |
|
| 51 |
+ @State private var draftTargetText = "" |
|
| 52 |
+ @State private var showingStopConfirm = false |
|
| 53 |
+ @State private var finalCheckpointMode: FinalCheckpoint = .skip |
|
| 54 |
+ @State private var finalCheckpointText = "" |
|
| 55 |
+ @State private var stopFailureMessage: String? |
|
| 56 |
+ |
|
| 57 |
+ init( |
|
| 58 |
+ chargedDeviceID: UUID, |
|
| 59 |
+ sessionID: UUID, |
|
| 60 |
+ monitoringMeter: Meter? = nil, |
|
| 61 |
+ presentation: ChargeSessionDetailPresentation = .navigation |
|
| 62 |
+ ) {
|
|
| 63 |
+ self.chargedDeviceID = chargedDeviceID |
|
| 64 |
+ self.sessionID = sessionID |
|
| 65 |
+ self.monitoringMeter = monitoringMeter |
|
| 66 |
+ self.presentation = presentation |
|
| 67 |
+ } |
|
| 68 |
+ |
|
| 69 |
+ private var chargedDevice: ChargedDeviceSummary? {
|
|
| 70 |
+ appData.chargedDeviceSummary(id: chargedDeviceID) |
|
| 71 |
+ } |
|
| 72 |
+ |
|
| 73 |
+ private var session: ChargeSessionSummary? {
|
|
| 74 |
+ chargedDevice?.sessions.first(where: { $0.id == sessionID })
|
|
| 75 |
+ } |
|
| 76 |
+ |
|
| 77 |
+ private var liveMonitoringMeter: Meter? {
|
|
| 78 |
+ guard let session, |
|
| 79 |
+ session.status.isOpen, |
|
| 80 |
+ let meterMACAddress = session.meterMACAddress else {
|
|
| 81 |
+ return nil |
|
| 82 |
+ } |
|
| 83 |
+ |
|
| 84 |
+ if let monitoringMeter, |
|
| 85 |
+ monitoringMeter.btSerial.macAddress.description == meterMACAddress {
|
|
| 86 |
+ return monitoringMeter |
|
| 87 |
+ } |
|
| 88 |
+ |
|
| 89 |
+ return appData.meters.values.first {
|
|
| 90 |
+ $0.btSerial.macAddress.description == meterMACAddress |
|
| 91 |
+ } |
|
| 92 |
+ } |
|
| 93 |
+ |
|
| 94 |
+ private var hasMonitoringControls: Bool {
|
|
| 95 |
+ session?.status.isOpen == true && liveMonitoringMeter != nil |
|
| 96 |
+ } |
|
| 97 |
+ |
|
| 98 |
+ private var shouldShowTrimBanner: Bool {
|
|
| 99 |
+ guard hasMonitoringControls, |
|
| 100 |
+ let session, |
|
| 101 |
+ session.isTrimmed == false, |
|
| 102 |
+ trimBannerDismissedForSessionID != session.id, |
|
| 103 |
+ let detectedTrimWindow else {
|
|
| 104 |
+ return false |
|
| 105 |
+ } |
|
| 106 |
+ return detectedTrimWindow.trimRatio > ChargingWindowDetector.significantTrimThreshold |
|
| 107 |
+ } |
|
| 108 |
+ |
|
| 109 |
+ var body: some View {
|
|
| 110 |
+ Group {
|
|
| 111 |
+ if let chargedDevice, let session {
|
|
| 112 |
+ content(chargedDevice: chargedDevice, session: session) |
|
| 113 |
+ } else {
|
|
| 114 |
+ unavailableState |
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 117 |
+ .sheet(item: $pendingSessionStopRequest) { request in
|
|
| 118 |
+ ChargeSessionCompletionSheetView( |
|
| 119 |
+ sessionID: request.sessionID, |
|
| 120 |
+ title: request.title, |
|
| 121 |
+ confirmTitle: request.confirmTitle, |
|
| 122 |
+ explanation: request.explanation, |
|
| 123 |
+ monitoringMeter: liveMonitoringMeter, |
|
| 124 |
+ appliesTrim: request.appliesTrim, |
|
| 125 |
+ trimStart: request.trimStart, |
|
| 126 |
+ trimEnd: request.trimEnd |
|
| 127 |
+ ) |
|
| 128 |
+ .environmentObject(appData) |
|
| 129 |
+ } |
|
| 130 |
+ .alert(item: $pendingCheckpointDeletion) { checkpoint in
|
|
| 131 |
+ Alert( |
|
| 132 |
+ title: Text("Delete Battery Checkpoint"),
|
|
| 133 |
+ message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
|
|
| 134 |
+ primaryButton: .destructive(Text("Delete")) {
|
|
| 135 |
+ _ = appData.deleteBatteryCheckpoint( |
|
| 136 |
+ checkpointID: checkpoint.id, |
|
| 137 |
+ for: checkpoint.sessionID |
|
| 138 |
+ ) |
|
| 139 |
+ }, |
|
| 140 |
+ secondaryButton: .cancel() |
|
| 141 |
+ ) |
|
| 142 |
+ } |
|
| 143 |
+ .alert(item: $pendingSessionDeletion) { session in
|
|
| 144 |
+ Alert( |
|
| 145 |
+ title: Text("Delete Session?"),
|
|
| 146 |
+ message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
|
|
| 147 |
+ primaryButton: .destructive(Text("Delete")) {
|
|
| 148 |
+ _ = appData.deleteChargeSession(sessionID: session.id) |
|
| 149 |
+ }, |
|
| 150 |
+ secondaryButton: .cancel() |
|
| 151 |
+ ) |
|
| 152 |
+ } |
|
| 153 |
+ .onAppear {
|
|
| 154 |
+ syncMonitoringRestore() |
|
| 155 |
+ runTrimDetection() |
|
| 156 |
+ } |
|
| 157 |
+ .onChange(of: session?.id) { _ in
|
|
| 158 |
+ pendingSessionStopRequest = nil |
|
| 159 |
+ detectedTrimWindow = nil |
|
| 160 |
+ trimBannerDismissedForSessionID = nil |
|
| 161 |
+ showingInlineTargetEditor = false |
|
| 162 |
+ draftTargetText = "" |
|
| 163 |
+ showingStopConfirm = false |
|
| 164 |
+ finalCheckpointMode = .skip |
|
| 165 |
+ finalCheckpointText = "" |
|
| 166 |
+ stopFailureMessage = nil |
|
| 167 |
+ syncMonitoringRestore() |
|
| 168 |
+ runTrimDetection() |
|
| 169 |
+ } |
|
| 170 |
+ .onChange(of: session?.aggregatedSamples.count) { _ in
|
|
| 171 |
+ syncMonitoringRestore() |
|
| 172 |
+ runTrimDetection() |
|
| 173 |
+ } |
|
| 174 |
+ .onChange(of: finalCheckpointMode) { _ in
|
|
| 175 |
+ stopFailureMessage = nil |
|
| 176 |
+ } |
|
| 177 |
+ .onChange(of: finalCheckpointText) { _ in
|
|
| 178 |
+ stopFailureMessage = nil |
|
| 179 |
+ } |
|
| 180 |
+ } |
|
| 181 |
+ |
|
| 182 |
+ private func content( |
|
| 183 |
+ chargedDevice: ChargedDeviceSummary, |
|
| 184 |
+ session: ChargeSessionSummary |
|
| 185 |
+ ) -> some View {
|
|
| 186 |
+ ScrollView {
|
|
| 187 |
+ VStack(spacing: 16) {
|
|
| 188 |
+ if hasMonitoringControls {
|
|
| 189 |
+ monitoringSessionCard(session, chargedDevice: chargedDevice) |
|
| 190 |
+ |
|
| 191 |
+ if shouldShowTrimBanner {
|
|
| 192 |
+ trimDetectionBanner(session) |
|
| 193 |
+ } |
|
| 194 |
+ |
|
| 195 |
+ if shouldShowSessionChart(session) {
|
|
| 196 |
+ chartCard(session) |
|
| 197 |
+ } |
|
| 198 |
+ } else {
|
|
| 199 |
+ overviewCard(session, chargedDevice: chargedDevice) |
|
| 200 |
+ energyCard(session, chargedDevice: chargedDevice) |
|
| 201 |
+ observedMetricsCard(session, chargedDevice: chargedDevice) |
|
| 202 |
+ batteryCard(session, chargedDevice: chargedDevice) |
|
| 203 |
+ |
|
| 204 |
+ if shouldShowSessionChart(session) {
|
|
| 205 |
+ chartCard(session) |
|
| 206 |
+ } |
|
| 207 |
+ |
|
| 208 |
+ if session.status.isOpen {
|
|
| 209 |
+ followerNoticeCard(session) |
|
| 210 |
+ } else {
|
|
| 211 |
+ managementCard(session) |
|
| 212 |
+ } |
|
| 213 |
+ } |
|
| 214 |
+ } |
|
| 215 |
+ .padding(presentation == .embedded ? 16 : 20) |
|
| 216 |
+ } |
|
| 217 |
+ .background( |
|
| 218 |
+ LinearGradient( |
|
| 219 |
+ colors: [statusTint(for: session).opacity(0.14), Color.clear], |
|
| 220 |
+ startPoint: .topLeading, |
|
| 221 |
+ endPoint: .bottomTrailing |
|
| 222 |
+ ) |
|
| 223 |
+ .ignoresSafeArea() |
|
| 224 |
+ ) |
|
| 225 |
+ .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details") |
|
| 226 |
+ } |
|
| 227 |
+ |
|
| 228 |
+ private var unavailableState: some View {
|
|
| 229 |
+ VStack(spacing: 12) {
|
|
| 230 |
+ Image(systemName: "bolt.slash") |
|
| 231 |
+ .font(.title2) |
|
| 232 |
+ .foregroundColor(.secondary) |
|
| 233 |
+ Text("This session is no longer available.")
|
|
| 234 |
+ .font(.headline) |
|
| 235 |
+ Text("It may have been deleted or synced from another device.")
|
|
| 236 |
+ .font(.footnote) |
|
| 237 |
+ .foregroundColor(.secondary) |
|
| 238 |
+ .multilineTextAlignment(.center) |
|
| 239 |
+ } |
|
| 240 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity) |
|
| 241 |
+ .padding(24) |
|
| 242 |
+ .navigationTitle("Session")
|
|
| 243 |
+ } |
|
| 244 |
+ |
|
| 245 |
+ private func monitoringSessionCard( |
|
| 246 |
+ _ session: ChargeSessionSummary, |
|
| 247 |
+ chargedDevice: ChargedDeviceSummary |
|
| 248 |
+ ) -> some View {
|
|
| 249 |
+ let displayedEnergyWh = displayedSessionEnergyWh(for: session) |
|
| 250 |
+ let displayedChargeAh = displayedSessionChargeAh(for: session) |
|
| 251 |
+ let batteryPrediction = chargedDevice.batteryLevelPrediction( |
|
| 252 |
+ for: session, |
|
| 253 |
+ effectiveEnergyWhOverride: displayedEnergyWh |
|
| 254 |
+ ) |
|
| 255 |
+ |
|
| 256 |
+ return VStack(alignment: .leading, spacing: 14) {
|
|
| 257 |
+ HStack {
|
|
| 258 |
+ ChargedDeviceIdentityLabelView(chargedDevice: chargedDevice, iconPointSize: 16) |
|
| 259 |
+ .font(.headline) |
|
| 260 |
+ |
|
| 261 |
+ Spacer() |
|
| 262 |
+ |
|
| 263 |
+ Text(session.status.title) |
|
| 264 |
+ .font(.caption.weight(.bold)) |
|
| 265 |
+ .foregroundColor(monitoringStatusColor(for: session)) |
|
| 266 |
+ .padding(.horizontal, 8) |
|
| 267 |
+ .padding(.vertical, 4) |
|
| 268 |
+ .meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
|
| 269 |
+ } |
|
| 270 |
+ |
|
| 271 |
+ if let batteryPrediction {
|
|
| 272 |
+ batteryGaugeSection( |
|
| 273 |
+ prediction: batteryPrediction, |
|
| 274 |
+ session: session, |
|
| 275 |
+ displayedEnergyWh: displayedEnergyWh |
|
| 276 |
+ ) |
|
| 277 |
+ } |
|
| 278 |
+ |
|
| 279 |
+ sessionMetricsGrid( |
|
| 280 |
+ session: session, |
|
| 281 |
+ chargedDevice: chargedDevice, |
|
| 282 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 283 |
+ hasPrediction: batteryPrediction != nil |
|
| 284 |
+ ) |
|
| 285 |
+ |
|
| 286 |
+ if session.stopThresholdAmps > 0 {
|
|
| 287 |
+ Text("Stop threshold: \(session.stopThresholdAmps.format(decimalDigits: 2)) A")
|
|
| 288 |
+ .font(.caption) |
|
| 289 |
+ .foregroundColor(.secondary) |
|
| 290 |
+ } |
|
| 291 |
+ |
|
| 292 |
+ if let sessionWarning = sessionWarning(for: session) {
|
|
| 293 |
+ Label(sessionWarning, systemImage: "exclamationmark.triangle") |
|
| 294 |
+ .font(.caption) |
|
| 295 |
+ .foregroundColor(.orange) |
|
| 296 |
+ } |
|
| 297 |
+ |
|
| 298 |
+ if session.isPaused {
|
|
| 299 |
+ Label( |
|
| 300 |
+ "Paused \(session.pausedAt?.format() ?? session.lastObservedAt.format()). Auto-stops after 10 min.", |
|
| 301 |
+ systemImage: "pause.circle" |
|
| 302 |
+ ) |
|
| 303 |
+ .font(.caption) |
|
| 304 |
+ .foregroundColor(.secondary) |
|
| 305 |
+ } |
|
| 306 |
+ |
|
| 307 |
+ if session.requiresCompletionConfirmation && !showingStopConfirm {
|
|
| 308 |
+ completionConfirmationCard(session) |
|
| 309 |
+ } |
|
| 310 |
+ |
|
| 311 |
+ BatteryCheckpointSectionView( |
|
| 312 |
+ sessionID: session.id, |
|
| 313 |
+ checkpoints: session.checkpoints, |
|
| 314 |
+ message: "Checkpoints are used for capacity estimation and the typical charge curve.", |
|
| 315 |
+ canAddCheckpoint: appData.canAddBatteryCheckpoint(to: session.id), |
|
| 316 |
+ canDeleteCheckpoint: true, |
|
| 317 |
+ requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: session.id), |
|
| 318 |
+ effectiveEnergyWhOverride: displayedEnergyWh, |
|
| 319 |
+ measuredChargeAhOverride: displayedChargeAh, |
|
| 320 |
+ onDelete: { checkpoint in
|
|
| 321 |
+ pendingCheckpointDeletion = checkpoint |
|
| 322 |
+ } |
|
| 323 |
+ ) |
|
| 324 |
+ |
|
| 325 |
+ targetSectionView( |
|
| 326 |
+ session: session, |
|
| 327 |
+ predictedPercent: batteryPrediction?.predictedPercent |
|
| 328 |
+ ) |
|
| 329 |
+ |
|
| 330 |
+ if showingStopConfirm {
|
|
| 331 |
+ stopConfirmPanel( |
|
| 332 |
+ session: session, |
|
| 333 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 334 |
+ displayedChargeAh: displayedChargeAh |
|
| 335 |
+ ) |
|
| 336 |
+ } else {
|
|
| 337 |
+ monitoringActionRow(session) |
|
| 338 |
+ } |
|
| 339 |
+ } |
|
| 340 |
+ .padding(18) |
|
| 341 |
+ .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 342 |
+ } |
|
| 343 |
+ |
|
| 344 |
+ private func overviewCard( |
|
| 345 |
+ _ session: ChargeSessionSummary, |
|
| 346 |
+ chargedDevice: ChargedDeviceSummary |
|
| 347 |
+ ) -> some View {
|
|
| 348 |
+ MeterInfoCardView(title: session.status.isOpen ? "Open Session" : "Overview", tint: statusTint(for: session)) {
|
|
| 349 |
+ MeterInfoRowView(label: "Device", value: chargedDevice.name) |
|
| 350 |
+ MeterInfoRowView(label: "Status", value: session.status.title) |
|
| 351 |
+ MeterInfoRowView(label: "Started", value: session.startedAt.format()) |
|
| 352 |
+ if let endedAt = session.endedAt {
|
|
| 353 |
+ MeterInfoRowView(label: "Ended", value: endedAt.format()) |
|
| 354 |
+ } |
|
| 355 |
+ MeterInfoRowView(label: "Duration", value: sessionDurationText(session)) |
|
| 356 |
+ MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title) |
|
| 357 |
+ MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title) |
|
| 358 |
+ MeterInfoRowView(label: "Source", value: session.sourceMode.title) |
|
| 359 |
+ MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session)) |
|
| 360 |
+ if session.isTrimmed {
|
|
| 361 |
+ MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format()) |
|
| 362 |
+ MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format()) |
|
| 363 |
+ } |
|
| 364 |
+ if let meterName = session.meterName {
|
|
| 365 |
+ MeterInfoRowView(label: "Meter", value: meterName) |
|
| 366 |
+ } else if let meterMACAddress = session.meterMACAddress {
|
|
| 367 |
+ MeterInfoRowView(label: "Meter", value: meterMACAddress) |
|
| 368 |
+ } |
|
| 369 |
+ if let meterModel = session.meterModel {
|
|
| 370 |
+ MeterInfoRowView(label: "Meter Model", value: meterModel) |
|
| 371 |
+ } |
|
| 372 |
+ } |
|
| 373 |
+ } |
|
| 374 |
+ |
|
| 375 |
+ private func energyCard( |
|
| 376 |
+ _ session: ChargeSessionSummary, |
|
| 377 |
+ chargedDevice: ChargedDeviceSummary |
|
| 378 |
+ ) -> some View {
|
|
| 379 |
+ let displayedEnergyWh = displayedSessionEnergyWh(for: session) |
|
| 380 |
+ let displayedChargeAh = displayedSessionChargeAh(for: session) |
|
| 381 |
+ |
|
| 382 |
+ return MeterInfoCardView(title: "Energy", tint: .teal) {
|
|
| 383 |
+ MeterInfoRowView(label: "Battery Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 384 |
+ if abs(displayedEnergyWh - session.measuredEnergyWh) > 0.01 {
|
|
| 385 |
+ MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 386 |
+ } |
|
| 387 |
+ MeterInfoRowView(label: "Measured Charge", value: "\(displayedChargeAh.format(decimalDigits: 3)) Ah") |
|
| 388 |
+ if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh, |
|
| 389 |
+ abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
|
|
| 390 |
+ MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 391 |
+ } |
|
| 392 |
+ if let capacityEstimateWh = session.capacityEstimateWh {
|
|
| 393 |
+ MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh") |
|
| 394 |
+ } |
|
| 395 |
+ if let chargerID = session.chargerID, |
|
| 396 |
+ let charger = appData.chargedDeviceSummary(id: chargerID) {
|
|
| 397 |
+ MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name) |
|
| 398 |
+ } |
|
| 399 |
+ if let wirelessSessionHint = wirelessSessionHint(for: session) {
|
|
| 400 |
+ Text(wirelessSessionHint) |
|
| 401 |
+ .font(.caption2) |
|
| 402 |
+ .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary) |
|
| 403 |
+ } |
|
| 404 |
+ if let sessionWarning = sessionWarning(for: session) {
|
|
| 405 |
+ Label(sessionWarning, systemImage: "exclamationmark.triangle") |
|
| 406 |
+ .font(.caption2) |
|
| 407 |
+ .foregroundColor(.orange) |
|
| 408 |
+ } |
|
| 409 |
+ } |
|
| 410 |
+ } |
|
| 411 |
+ |
|
| 412 |
+ private func observedMetricsCard( |
|
| 413 |
+ _ session: ChargeSessionSummary, |
|
| 414 |
+ chargedDevice: ChargedDeviceSummary |
|
| 415 |
+ ) -> some View {
|
|
| 416 |
+ MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
|
|
| 417 |
+ if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
|
|
| 418 |
+ MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A") |
|
| 419 |
+ } |
|
| 420 |
+ if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
|
|
| 421 |
+ MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A") |
|
| 422 |
+ } |
|
| 423 |
+ if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
|
|
| 424 |
+ MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W") |
|
| 425 |
+ } |
|
| 426 |
+ if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
|
|
| 427 |
+ MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V") |
|
| 428 |
+ } |
|
| 429 |
+ if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
|
|
| 430 |
+ MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V") |
|
| 431 |
+ } |
|
| 432 |
+ if let completionCurrentAmps = session.completionCurrentAmps {
|
|
| 433 |
+ MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A") |
|
| 434 |
+ } |
|
| 435 |
+ if session.selectedDataGroup != nil {
|
|
| 436 |
+ MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)") |
|
| 437 |
+ } |
|
| 438 |
+ } |
|
| 439 |
+ } |
|
| 440 |
+ |
|
| 441 |
+ private func batteryCard( |
|
| 442 |
+ _ session: ChargeSessionSummary, |
|
| 443 |
+ chargedDevice: ChargedDeviceSummary |
|
| 444 |
+ ) -> some View {
|
|
| 445 |
+ let displayedEnergyWh = displayedSessionEnergyWh(for: session) |
|
| 446 |
+ let displayedChargeAh = displayedSessionChargeAh(for: session) |
|
| 447 |
+ let batteryPrediction = chargedDevice.batteryLevelPrediction( |
|
| 448 |
+ for: session, |
|
| 449 |
+ effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil |
|
| 450 |
+ ) |
|
| 451 |
+ |
|
| 452 |
+ return MeterInfoCardView(title: "Battery", tint: .orange) {
|
|
| 453 |
+ if let startBatteryPercent = session.startBatteryPercent {
|
|
| 454 |
+ MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%") |
|
| 455 |
+ } |
|
| 456 |
+ if let endBatteryPercent = session.endBatteryPercent {
|
|
| 457 |
+ MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%") |
|
| 458 |
+ } |
|
| 459 |
+ if let batteryDeltaPercent = session.batteryDeltaPercent {
|
|
| 460 |
+ MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%") |
|
| 461 |
+ } |
|
| 462 |
+ if let targetBatteryPercent = session.targetBatteryPercent {
|
|
| 463 |
+ MeterInfoRowView(label: "Target Notification", value: "\(targetBatteryPercent.format(decimalDigits: 0))%") |
|
| 464 |
+ } |
|
| 465 |
+ if let batteryPrediction {
|
|
| 466 |
+ MeterInfoRowView( |
|
| 467 |
+ label: "Predicted Battery", |
|
| 468 |
+ value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%" |
|
| 469 |
+ ) |
|
| 470 |
+ Text( |
|
| 471 |
+ "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity." |
|
| 472 |
+ ) |
|
| 473 |
+ .font(.caption2) |
|
| 474 |
+ .foregroundColor(.secondary) |
|
| 475 |
+ } |
|
| 476 |
+ |
|
| 477 |
+ BatteryCheckpointSectionView( |
|
| 478 |
+ sessionID: session.id, |
|
| 479 |
+ checkpoints: session.checkpoints, |
|
| 480 |
+ message: session.status.isOpen |
|
| 481 |
+ ? "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction." |
|
| 482 |
+ : "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.", |
|
| 483 |
+ canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id), |
|
| 484 |
+ canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false, |
|
| 485 |
+ requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil, |
|
| 486 |
+ effectiveEnergyWhOverride: hasMonitoringControls ? displayedEnergyWh : nil, |
|
| 487 |
+ measuredChargeAhOverride: hasMonitoringControls ? displayedChargeAh : nil, |
|
| 488 |
+ onDelete: { checkpoint in
|
|
| 489 |
+ pendingCheckpointDeletion = checkpoint |
|
| 490 |
+ } |
|
| 491 |
+ ) |
|
| 492 |
+ } |
|
| 493 |
+ } |
|
| 494 |
+ |
|
| 495 |
+ private func batteryGaugeSection( |
|
| 496 |
+ prediction: BatteryLevelPrediction, |
|
| 497 |
+ session: ChargeSessionSummary, |
|
| 498 |
+ displayedEnergyWh: Double |
|
| 499 |
+ ) -> some View {
|
|
| 500 |
+ let percent = prediction.predictedPercent |
|
| 501 |
+ let color = batteryColor(for: percent) |
|
| 502 |
+ let duration = displayedSessionDuration(for: session) |
|
| 503 |
+ let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01 |
|
| 504 |
+ ? displayedEnergyWh / duration |
|
| 505 |
+ : nil |
|
| 506 |
+ let etaToFull = etaText( |
|
| 507 |
+ rateWhPerSec: rateWhPerSec, |
|
| 508 |
+ remainingWh: max(prediction.estimatedCapacityWh - displayedEnergyWh, 0), |
|
| 509 |
+ isRelevant: percent < 98 |
|
| 510 |
+ ) |
|
| 511 |
+ let etaToTarget = etaToTargetText( |
|
| 512 |
+ session: session, |
|
| 513 |
+ prediction: prediction, |
|
| 514 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 515 |
+ rateWhPerSec: rateWhPerSec |
|
| 516 |
+ ) |
|
| 517 |
+ |
|
| 518 |
+ return VStack(spacing: 10) {
|
|
| 519 |
+ HStack(alignment: .lastTextBaseline, spacing: 8) {
|
|
| 520 |
+ HStack(alignment: .lastTextBaseline, spacing: 3) {
|
|
| 521 |
+ Text("\(Int(percent.rounded()))")
|
|
| 522 |
+ .font(.system(size: 52, weight: .bold, design: .rounded)) |
|
| 523 |
+ .foregroundColor(color) |
|
| 524 |
+ .monospacedDigit() |
|
| 525 |
+ Text("%")
|
|
| 526 |
+ .font(.title2.weight(.semibold)) |
|
| 527 |
+ .foregroundColor(color.opacity(0.8)) |
|
| 528 |
+ } |
|
| 529 |
+ |
|
| 530 |
+ Spacer() |
|
| 531 |
+ |
|
| 532 |
+ VStack(alignment: .trailing, spacing: 2) {
|
|
| 533 |
+ Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
|
|
| 534 |
+ .font(.callout.weight(.bold)) |
|
| 535 |
+ .foregroundColor(.orange) |
|
| 536 |
+ .monospacedDigit() |
|
| 537 |
+ Text("est. capacity")
|
|
| 538 |
+ .font(.caption2) |
|
| 539 |
+ .foregroundColor(.secondary) |
|
| 540 |
+ } |
|
| 541 |
+ } |
|
| 542 |
+ |
|
| 543 |
+ batteryProgressBar( |
|
| 544 |
+ percent: percent, |
|
| 545 |
+ startPercent: session.startBatteryPercent, |
|
| 546 |
+ targetPercent: session.targetBatteryPercent |
|
| 547 |
+ ) |
|
| 548 |
+ |
|
| 549 |
+ HStack(spacing: 14) {
|
|
| 550 |
+ if let etaToFull {
|
|
| 551 |
+ etaPill(icon: "clock.fill", tint: .green, value: etaToFull, label: "to full") |
|
| 552 |
+ } |
|
| 553 |
+ |
|
| 554 |
+ if let etaToTarget, let target = session.targetBatteryPercent {
|
|
| 555 |
+ etaPill( |
|
| 556 |
+ icon: "bell.badge.fill", |
|
| 557 |
+ tint: .indigo, |
|
| 558 |
+ value: etaToTarget, |
|
| 559 |
+ label: "to \(Int(target.rounded()))%" |
|
| 560 |
+ ) |
|
| 561 |
+ } |
|
| 562 |
+ |
|
| 563 |
+ Spacer() |
|
| 564 |
+ |
|
| 565 |
+ Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
|
|
| 566 |
+ .font(.caption2) |
|
| 567 |
+ .foregroundColor(.secondary) |
|
| 568 |
+ .multilineTextAlignment(.trailing) |
|
| 569 |
+ } |
|
| 570 |
+ } |
|
| 571 |
+ .padding(14) |
|
| 572 |
+ .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 573 |
+ } |
|
| 574 |
+ |
|
| 575 |
+ private func etaPill(icon: String, tint: Color, value: String, label: String) -> some View {
|
|
| 576 |
+ VStack(alignment: .leading, spacing: 1) {
|
|
| 577 |
+ HStack(spacing: 4) {
|
|
| 578 |
+ Image(systemName: icon) |
|
| 579 |
+ .font(.caption) |
|
| 580 |
+ .foregroundColor(tint) |
|
| 581 |
+ Text(value) |
|
| 582 |
+ .font(.caption.weight(.bold)) |
|
| 583 |
+ } |
|
| 584 |
+ Text(label) |
|
| 585 |
+ .font(.caption2) |
|
| 586 |
+ .foregroundColor(.secondary) |
|
| 587 |
+ } |
|
| 588 |
+ } |
|
| 589 |
+ |
|
| 590 |
+ private func batteryProgressBar( |
|
| 591 |
+ percent: Double, |
|
| 592 |
+ startPercent: Double?, |
|
| 593 |
+ targetPercent: Double? |
|
| 594 |
+ ) -> some View {
|
|
| 595 |
+ let color = batteryColor(for: percent) |
|
| 596 |
+ return GeometryReader { geo in
|
|
| 597 |
+ let width = geo.size.width |
|
| 598 |
+ ZStack(alignment: .leading) {
|
|
| 599 |
+ Capsule() |
|
| 600 |
+ .fill(Color.primary.opacity(0.10)) |
|
| 601 |
+ Rectangle() |
|
| 602 |
+ .fill( |
|
| 603 |
+ LinearGradient( |
|
| 604 |
+ colors: [color.opacity(0.6), color], |
|
| 605 |
+ startPoint: .leading, |
|
| 606 |
+ endPoint: .trailing |
|
| 607 |
+ ) |
|
| 608 |
+ ) |
|
| 609 |
+ .frame(width: max(width * CGFloat(percent / 100), 4)) |
|
| 610 |
+ .animation(.easeInOut(duration: 0.4), value: percent) |
|
| 611 |
+ if let start = startPercent, start > 2, start < 98 {
|
|
| 612 |
+ Rectangle() |
|
| 613 |
+ .fill(Color.white.opacity(0.55)) |
|
| 614 |
+ .frame(width: 2, height: 20) |
|
| 615 |
+ .offset(x: width * CGFloat(start / 100) - 1) |
|
| 616 |
+ } |
|
| 617 |
+ if let target = targetPercent {
|
|
| 618 |
+ Rectangle() |
|
| 619 |
+ .fill(Color.indigo.opacity(0.9)) |
|
| 620 |
+ .frame(width: 2.5, height: 20) |
|
| 621 |
+ .offset(x: width * CGFloat(target / 100) - 1.25) |
|
| 622 |
+ } |
|
| 623 |
+ } |
|
| 624 |
+ .clipShape(Capsule()) |
|
| 625 |
+ } |
|
| 626 |
+ .frame(height: 20) |
|
| 627 |
+ } |
|
| 628 |
+ |
|
| 629 |
+ private func sessionMetricsGrid( |
|
| 630 |
+ session: ChargeSessionSummary, |
|
| 631 |
+ chargedDevice: ChargedDeviceSummary, |
|
| 632 |
+ displayedEnergyWh: Double, |
|
| 633 |
+ hasPrediction: Bool |
|
| 634 |
+ ) -> some View {
|
|
| 635 |
+ let capacityFallback: Double? = hasPrediction ? nil : ( |
|
| 636 |
+ session.capacityEstimateWh |
|
| 637 |
+ ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode) |
|
| 638 |
+ ?? chargedDevice.estimatedBatteryCapacityWh |
|
| 639 |
+ ) |
|
| 640 |
+ let columns = [GridItem(.flexible()), GridItem(.flexible())] |
|
| 641 |
+ |
|
| 642 |
+ return LazyVGrid(columns: columns, spacing: 8) {
|
|
| 643 |
+ metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue) |
|
| 644 |
+ metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal) |
|
| 645 |
+ |
|
| 646 |
+ if shouldShowChargingTransport(for: session, chargedDevice: chargedDevice) {
|
|
| 647 |
+ metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange) |
|
| 648 |
+ } |
|
| 649 |
+ if shouldShowChargingState(for: session, chargedDevice: chargedDevice) {
|
|
| 650 |
+ metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple) |
|
| 651 |
+ } |
|
| 652 |
+ |
|
| 653 |
+ metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary) |
|
| 654 |
+ |
|
| 655 |
+ if let capacityFallback {
|
|
| 656 |
+ metricCell(label: "Est. Capacity", value: "\(capacityFallback.format(decimalDigits: 2)) Wh", tint: .orange) |
|
| 657 |
+ } |
|
| 658 |
+ } |
|
| 659 |
+ } |
|
| 660 |
+ |
|
| 661 |
+ private func metricCell(label: String, value: String, tint: Color) -> some View {
|
|
| 662 |
+ VStack(alignment: .leading, spacing: 3) {
|
|
| 663 |
+ Text(label) |
|
| 664 |
+ .font(.caption2) |
|
| 665 |
+ .foregroundColor(.secondary) |
|
| 666 |
+ Text(value) |
|
| 667 |
+ .font(.subheadline.weight(.semibold)) |
|
| 668 |
+ .lineLimit(1) |
|
| 669 |
+ .minimumScaleFactor(0.7) |
|
| 670 |
+ .monospacedDigit() |
|
| 671 |
+ } |
|
| 672 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 673 |
+ .padding(.horizontal, 12) |
|
| 674 |
+ .padding(.vertical, 10) |
|
| 675 |
+ .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) |
|
| 676 |
+ } |
|
| 677 |
+ |
|
| 678 |
+ private func completionConfirmationCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 679 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 680 |
+ Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
|
|
| 681 |
+ .font(.subheadline.weight(.semibold)) |
|
| 682 |
+ |
|
| 683 |
+ if let contradictionPercent = session.completionContradictionPercent {
|
|
| 684 |
+ Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
|
|
| 685 |
+ .font(.caption) |
|
| 686 |
+ .foregroundColor(.secondary) |
|
| 687 |
+ } else {
|
|
| 688 |
+ Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
|
|
| 689 |
+ .font(.caption) |
|
| 690 |
+ .foregroundColor(.secondary) |
|
| 691 |
+ } |
|
| 692 |
+ |
|
| 693 |
+ HStack(spacing: 10) {
|
|
| 694 |
+ Button("Finish") {
|
|
| 695 |
+ beginStopConfirmation(for: session) |
|
| 696 |
+ } |
|
| 697 |
+ .frame(maxWidth: .infinity) |
|
| 698 |
+ .padding(.vertical, 9) |
|
| 699 |
+ .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 700 |
+ .buttonStyle(.plain) |
|
| 701 |
+ |
|
| 702 |
+ Button("Keep Monitoring") {
|
|
| 703 |
+ _ = appData.continueChargeSessionMonitoring(sessionID: session.id) |
|
| 704 |
+ } |
|
| 705 |
+ .frame(maxWidth: .infinity) |
|
| 706 |
+ .padding(.vertical, 9) |
|
| 707 |
+ .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 708 |
+ .buttonStyle(.plain) |
|
| 709 |
+ } |
|
| 710 |
+ } |
|
| 711 |
+ .padding(14) |
|
| 712 |
+ .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 713 |
+ } |
|
| 714 |
+ |
|
| 715 |
+ private func targetSectionView(session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
|
|
| 716 |
+ let draftBelowPrediction: Bool = {
|
|
| 717 |
+ guard let draft = parsedDraftTarget, let predictedPercent else { return false }
|
|
| 718 |
+ return draft <= predictedPercent |
|
| 719 |
+ }() |
|
| 720 |
+ let savedBelowPrediction: Bool = {
|
|
| 721 |
+ guard let saved = session.targetBatteryPercent, let predictedPercent else { return false }
|
|
| 722 |
+ return saved <= predictedPercent |
|
| 723 |
+ }() |
|
| 724 |
+ |
|
| 725 |
+ return HStack(alignment: .center, spacing: 8) {
|
|
| 726 |
+ Image(systemName: "bell.badge") |
|
| 727 |
+ .foregroundColor(.indigo) |
|
| 728 |
+ .font(.subheadline) |
|
| 729 |
+ |
|
| 730 |
+ Text("Notify at")
|
|
| 731 |
+ .font(.subheadline.weight(.semibold)) |
|
| 732 |
+ |
|
| 733 |
+ Spacer(minLength: 8) |
|
| 734 |
+ |
|
| 735 |
+ if showingInlineTargetEditor {
|
|
| 736 |
+ targetEditorControls( |
|
| 737 |
+ session: session, |
|
| 738 |
+ draftBelowPrediction: draftBelowPrediction, |
|
| 739 |
+ predictedPercent: predictedPercent |
|
| 740 |
+ ) |
|
| 741 |
+ } else {
|
|
| 742 |
+ savedTargetControls( |
|
| 743 |
+ session: session, |
|
| 744 |
+ savedBelowPrediction: savedBelowPrediction, |
|
| 745 |
+ predictedPercent: predictedPercent |
|
| 746 |
+ ) |
|
| 747 |
+ } |
|
| 748 |
+ } |
|
| 749 |
+ } |
|
| 750 |
+ |
|
| 751 |
+ private func targetEditorControls( |
|
| 752 |
+ session: ChargeSessionSummary, |
|
| 753 |
+ draftBelowPrediction: Bool, |
|
| 754 |
+ predictedPercent: Double? |
|
| 755 |
+ ) -> some View {
|
|
| 756 |
+ Group {
|
|
| 757 |
+ Button {
|
|
| 758 |
+ let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80 |
|
| 759 |
+ draftTargetText = max(current - 1, 1).format(decimalDigits: 0) |
|
| 760 |
+ } label: {
|
|
| 761 |
+ Image(systemName: "minus.circle") |
|
| 762 |
+ .font(.title3) |
|
| 763 |
+ } |
|
| 764 |
+ .buttonStyle(.plain) |
|
| 765 |
+ |
|
| 766 |
+ TextField("-", text: $draftTargetText)
|
|
| 767 |
+ .keyboardType(.decimalPad) |
|
| 768 |
+ .textFieldStyle(.roundedBorder) |
|
| 769 |
+ .frame(width: 48) |
|
| 770 |
+ .multilineTextAlignment(.center) |
|
| 771 |
+ .foregroundColor(draftBelowPrediction ? .orange : .primary) |
|
| 772 |
+ |
|
| 773 |
+ Text("%")
|
|
| 774 |
+ .font(.subheadline) |
|
| 775 |
+ .foregroundColor(.secondary) |
|
| 776 |
+ |
|
| 777 |
+ if draftBelowPrediction, let predictedPercent {
|
|
| 778 |
+ predictionWarningButton(predictedPercent: predictedPercent) |
|
| 779 |
+ } |
|
| 780 |
+ |
|
| 781 |
+ Button {
|
|
| 782 |
+ let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80 |
|
| 783 |
+ draftTargetText = min(current + 1, 100).format(decimalDigits: 0) |
|
| 784 |
+ } label: {
|
|
| 785 |
+ Image(systemName: "plus.circle") |
|
| 786 |
+ .font(.title3) |
|
| 787 |
+ } |
|
| 788 |
+ .buttonStyle(.plain) |
|
| 789 |
+ |
|
| 790 |
+ Button {
|
|
| 791 |
+ if let value = parsedDraftTarget {
|
|
| 792 |
+ _ = appData.setTargetBatteryPercent(value, for: session.id) |
|
| 793 |
+ } |
|
| 794 |
+ showingInlineTargetEditor = false |
|
| 795 |
+ } label: {
|
|
| 796 |
+ Image(systemName: "checkmark.circle.fill") |
|
| 797 |
+ .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary) |
|
| 798 |
+ .font(.title3) |
|
| 799 |
+ } |
|
| 800 |
+ .buttonStyle(.plain) |
|
| 801 |
+ .disabled(parsedDraftTarget == nil) |
|
| 802 |
+ |
|
| 803 |
+ Button {
|
|
| 804 |
+ showingInlineTargetEditor = false |
|
| 805 |
+ draftTargetText = "" |
|
| 806 |
+ } label: {
|
|
| 807 |
+ Image(systemName: "xmark.circle") |
|
| 808 |
+ .foregroundColor(.secondary) |
|
| 809 |
+ .font(.title3) |
|
| 810 |
+ } |
|
| 811 |
+ .buttonStyle(.plain) |
|
| 812 |
+ } |
|
| 813 |
+ } |
|
| 814 |
+ |
|
| 815 |
+ private func savedTargetControls( |
|
| 816 |
+ session: ChargeSessionSummary, |
|
| 817 |
+ savedBelowPrediction: Bool, |
|
| 818 |
+ predictedPercent: Double? |
|
| 819 |
+ ) -> some View {
|
|
| 820 |
+ Group {
|
|
| 821 |
+ if let targetPercent = session.targetBatteryPercent {
|
|
| 822 |
+ Text("\(targetPercent.format(decimalDigits: 0))%")
|
|
| 823 |
+ .font(.subheadline.weight(.semibold)) |
|
| 824 |
+ .foregroundColor(savedBelowPrediction ? .orange : .indigo) |
|
| 825 |
+ |
|
| 826 |
+ if savedBelowPrediction, let predictedPercent {
|
|
| 827 |
+ predictionWarningButton(predictedPercent: predictedPercent) |
|
| 828 |
+ } |
|
| 829 |
+ |
|
| 830 |
+ Button {
|
|
| 831 |
+ _ = appData.setTargetBatteryPercent(nil, for: session.id) |
|
| 832 |
+ } label: {
|
|
| 833 |
+ Image(systemName: "xmark.circle.fill") |
|
| 834 |
+ .foregroundColor(.secondary) |
|
| 835 |
+ .font(.callout) |
|
| 836 |
+ } |
|
| 837 |
+ .buttonStyle(.plain) |
|
| 838 |
+ .help("Remove alert")
|
|
| 839 |
+ } |
|
| 840 |
+ |
|
| 841 |
+ Button {
|
|
| 842 |
+ draftTargetText = session.targetBatteryPercent.map {
|
|
| 843 |
+ $0.format(decimalDigits: 0) |
|
| 844 |
+ } ?? "80" |
|
| 845 |
+ showingInlineTargetEditor = true |
|
| 846 |
+ } label: {
|
|
| 847 |
+ Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil") |
|
| 848 |
+ .font(.caption.weight(.semibold)) |
|
| 849 |
+ .frame(width: 30, height: 30) |
|
| 850 |
+ .contentShape(Rectangle()) |
|
| 851 |
+ } |
|
| 852 |
+ .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10) |
|
| 853 |
+ .buttonStyle(.plain) |
|
| 854 |
+ .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert") |
|
| 855 |
+ } |
|
| 856 |
+ } |
|
| 857 |
+ |
|
| 858 |
+ private func predictionWarningButton(predictedPercent: Double) -> some View {
|
|
| 859 |
+ Button {} label: {
|
|
| 860 |
+ Image(systemName: "exclamationmark.triangle.fill") |
|
| 861 |
+ .font(.callout.weight(.semibold)) |
|
| 862 |
+ .foregroundColor(.orange) |
|
| 863 |
+ } |
|
| 864 |
+ .buttonStyle(.plain) |
|
| 865 |
+ .help("Battery is already predicted at \(predictedPercent.format(decimalDigits: 0))% - this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
|
|
| 866 |
+ } |
|
| 867 |
+ |
|
| 868 |
+ private func monitoringActionRow(_ session: ChargeSessionSummary) -> some View {
|
|
| 869 |
+ HStack(spacing: 10) {
|
|
| 870 |
+ if session.status == .active {
|
|
| 871 |
+ Button("Pause") {
|
|
| 872 |
+ _ = appData.pauseChargeSession(sessionID: session.id, from: liveMonitoringMeter) |
|
| 873 |
+ } |
|
| 874 |
+ .monitoringActionStyle(tint: .orange) |
|
| 875 |
+ } else if session.status == .paused {
|
|
| 876 |
+ Button("Resume") {
|
|
| 877 |
+ _ = appData.resumeChargeSession(sessionID: session.id, from: liveMonitoringMeter) |
|
| 878 |
+ } |
|
| 879 |
+ .monitoringActionStyle(tint: .blue) |
|
| 880 |
+ } |
|
| 881 |
+ |
|
| 882 |
+ Button("Terminate Session") {
|
|
| 883 |
+ beginStopConfirmation(for: session) |
|
| 884 |
+ } |
|
| 885 |
+ .monitoringActionStyle(tint: .red) |
|
| 886 |
+ } |
|
| 887 |
+ } |
|
| 888 |
+ |
|
| 889 |
+ private func stopConfirmPanel( |
|
| 890 |
+ session: ChargeSessionSummary, |
|
| 891 |
+ displayedEnergyWh: Double, |
|
| 892 |
+ displayedChargeAh: Double |
|
| 893 |
+ ) -> some View {
|
|
| 894 |
+ let canSave = hasSavableChargeData( |
|
| 895 |
+ session: session, |
|
| 896 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 897 |
+ displayedChargeAh: displayedChargeAh |
|
| 898 |
+ ) |
|
| 899 |
+ let saveDisabledReason = saveDisabledReason( |
|
| 900 |
+ session: session, |
|
| 901 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 902 |
+ displayedChargeAh: displayedChargeAh |
|
| 903 |
+ ) |
|
| 904 |
+ let isSaveEnabled = saveDisabledReason == nil |
|
| 905 |
+ |
|
| 906 |
+ return VStack(alignment: .leading, spacing: 12) {
|
|
| 907 |
+ HStack {
|
|
| 908 |
+ Text("Final Checkpoint")
|
|
| 909 |
+ .font(.subheadline.weight(.semibold)) |
|
| 910 |
+ Text("optional")
|
|
| 911 |
+ .font(.caption2.weight(.semibold)) |
|
| 912 |
+ .foregroundColor(.secondary) |
|
| 913 |
+ } |
|
| 914 |
+ |
|
| 915 |
+ finalCheckpointPicker(session) |
|
| 916 |
+ |
|
| 917 |
+ if finalCheckpointMode == .custom {
|
|
| 918 |
+ customFinalCheckpointRow |
|
| 919 |
+ } |
|
| 920 |
+ |
|
| 921 |
+ if let saveDisabledReason {
|
|
| 922 |
+ Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill") |
|
| 923 |
+ .font(.caption) |
|
| 924 |
+ .foregroundColor(.red) |
|
| 925 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 926 |
+ } else if let stopFailureMessage {
|
|
| 927 |
+ Label(stopFailureMessage, systemImage: "exclamationmark.triangle.fill") |
|
| 928 |
+ .font(.caption) |
|
| 929 |
+ .foregroundColor(.red) |
|
| 930 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 931 |
+ } else if finalCheckpointMode == .custom, let parsedFinalCheckpoint {
|
|
| 932 |
+ Label("Final checkpoint will be saved at \(parsedFinalCheckpoint.format(decimalDigits: 0))%.", systemImage: "checkmark.circle.fill")
|
|
| 933 |
+ .font(.caption) |
|
| 934 |
+ .foregroundColor(.green) |
|
| 935 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 936 |
+ } |
|
| 937 |
+ |
|
| 938 |
+ HStack(spacing: 8) {
|
|
| 939 |
+ Button("Discard") {
|
|
| 940 |
+ discardSession(session) |
|
| 941 |
+ } |
|
| 942 |
+ .monitoringPanelActionStyle(tint: .secondary) |
|
| 943 |
+ |
|
| 944 |
+ Button {
|
|
| 945 |
+ stopSession( |
|
| 946 |
+ session, |
|
| 947 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 948 |
+ displayedChargeAh: displayedChargeAh |
|
| 949 |
+ ) |
|
| 950 |
+ } label: {
|
|
| 951 |
+ Label("Save Session", systemImage: "checkmark.circle.fill")
|
|
| 952 |
+ .frame(maxWidth: .infinity) |
|
| 953 |
+ } |
|
| 954 |
+ .monitoringPanelActionStyle(tint: isSaveEnabled ? .green : .secondary, isProminent: isSaveEnabled) |
|
| 955 |
+ .disabled(!isSaveEnabled) |
|
| 956 |
+ .help(saveDisabledReason ?? "Close and save this session") |
|
| 957 |
+ |
|
| 958 |
+ Button("Cancel") {
|
|
| 959 |
+ resetStopConfirmation() |
|
| 960 |
+ } |
|
| 961 |
+ .monitoringPanelActionStyle(tint: .secondary) |
|
| 962 |
+ } |
|
| 963 |
+ } |
|
| 964 |
+ .padding(14) |
|
| 965 |
+ .meterCard(tint: canSave ? .green : .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16) |
|
| 966 |
+ } |
|
| 967 |
+ |
|
| 968 |
+ private func finalCheckpointPicker(_ session: ChargeSessionSummary) -> some View {
|
|
| 969 |
+ return HStack(spacing: 8) {
|
|
| 970 |
+ ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
|
|
| 971 |
+ Button {
|
|
| 972 |
+ finalCheckpointMode = mode |
|
| 973 |
+ if mode == .custom {
|
|
| 974 |
+ prefillFinalCheckpointIfNeeded(for: session) |
|
| 975 |
+ } else {
|
|
| 976 |
+ finalCheckpointText = "" |
|
| 977 |
+ } |
|
| 978 |
+ } label: {
|
|
| 979 |
+ VStack(spacing: 5) {
|
|
| 980 |
+ Image(systemName: mode.icon) |
|
| 981 |
+ .font(.title3) |
|
| 982 |
+ .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary) |
|
| 983 |
+ Text(mode.label) |
|
| 984 |
+ .font(.caption.weight(.semibold)) |
|
| 985 |
+ .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary) |
|
| 986 |
+ } |
|
| 987 |
+ .frame(maxWidth: .infinity) |
|
| 988 |
+ .padding(.vertical, 10) |
|
| 989 |
+ .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear) |
|
| 990 |
+ .meterCard( |
|
| 991 |
+ tint: finalCheckpointMode == mode ? .primary : .secondary, |
|
| 992 |
+ fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04, |
|
| 993 |
+ strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10, |
|
| 994 |
+ cornerRadius: 12 |
|
| 995 |
+ ) |
|
| 996 |
+ } |
|
| 997 |
+ .buttonStyle(.plain) |
|
| 998 |
+ } |
|
| 999 |
+ } |
|
| 1000 |
+ } |
|
| 1001 |
+ |
|
| 1002 |
+ private var customFinalCheckpointRow: some View {
|
|
| 1003 |
+ let isInvalid = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
|
| 1004 |
+ || parsedFinalCheckpoint == nil |
|
| 1005 |
+ |
|
| 1006 |
+ return HStack(spacing: 8) {
|
|
| 1007 |
+ Button {
|
|
| 1008 |
+ adjustFinalCheckpoint(by: -1) |
|
| 1009 |
+ } label: {
|
|
| 1010 |
+ Image(systemName: "minus.circle").font(.title3) |
|
| 1011 |
+ } |
|
| 1012 |
+ .buttonStyle(.plain) |
|
| 1013 |
+ |
|
| 1014 |
+ TextField("-", text: $finalCheckpointText)
|
|
| 1015 |
+ .keyboardType(.decimalPad) |
|
| 1016 |
+ .textFieldStyle(.roundedBorder) |
|
| 1017 |
+ .frame(width: 56) |
|
| 1018 |
+ .multilineTextAlignment(.center) |
|
| 1019 |
+ .overlay( |
|
| 1020 |
+ RoundedRectangle(cornerRadius: 6) |
|
| 1021 |
+ .stroke(isInvalid ? Color.red.opacity(0.75) : Color.clear, lineWidth: 1) |
|
| 1022 |
+ ) |
|
| 1023 |
+ |
|
| 1024 |
+ Text("%").foregroundColor(.secondary)
|
|
| 1025 |
+ |
|
| 1026 |
+ Text("required")
|
|
| 1027 |
+ .font(.caption2.weight(.semibold)) |
|
| 1028 |
+ .foregroundColor(isInvalid ? .red : .secondary) |
|
| 1029 |
+ |
|
| 1030 |
+ Button {
|
|
| 1031 |
+ adjustFinalCheckpoint(by: 1) |
|
| 1032 |
+ } label: {
|
|
| 1033 |
+ Image(systemName: "plus.circle").font(.title3) |
|
| 1034 |
+ } |
|
| 1035 |
+ .buttonStyle(.plain) |
|
| 1036 |
+ |
|
| 1037 |
+ Spacer() |
|
| 1038 |
+ } |
|
| 1039 |
+ } |
|
| 1040 |
+ |
|
| 1041 |
+ private func followerNoticeCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 1042 |
+ MeterInfoCardView(title: "Monitoring Device", tint: .secondary) {
|
|
| 1043 |
+ if let meterName = session.meterName {
|
|
| 1044 |
+ MeterInfoRowView(label: "Controlled On", value: meterName) |
|
| 1045 |
+ } |
|
| 1046 |
+ Text("Pause, stop, checkpoint, and live trim controls are available on the device connected to the monitoring meter.")
|
|
| 1047 |
+ .font(.caption2) |
|
| 1048 |
+ .foregroundColor(.secondary) |
|
| 1049 |
+ } |
|
| 1050 |
+ } |
|
| 1051 |
+ |
|
| 1052 |
+ private func managementCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 1053 |
+ MeterInfoCardView(title: "Administration", tint: .red) {
|
|
| 1054 |
+ Button(role: .destructive) {
|
|
| 1055 |
+ pendingSessionDeletion = session |
|
| 1056 |
+ } label: {
|
|
| 1057 |
+ Label("Delete Session", systemImage: "trash")
|
|
| 1058 |
+ .font(.subheadline.weight(.semibold)) |
|
| 1059 |
+ .frame(maxWidth: .infinity) |
|
| 1060 |
+ .padding(.vertical, 10) |
|
| 1061 |
+ .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14) |
|
| 1062 |
+ } |
|
| 1063 |
+ .buttonStyle(.plain) |
|
| 1064 |
+ } |
|
| 1065 |
+ } |
|
| 1066 |
+ |
|
| 1067 |
+ @ViewBuilder |
|
| 1068 |
+ private func trimDetectionBanner(_ session: ChargeSessionSummary) -> some View {
|
|
| 1069 |
+ if let window = detectedTrimWindow {
|
|
| 1070 |
+ HStack(spacing: 12) {
|
|
| 1071 |
+ Image(systemName: "scissors.circle.fill") |
|
| 1072 |
+ .font(.title3) |
|
| 1073 |
+ .foregroundColor(.blue) |
|
| 1074 |
+ |
|
| 1075 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 1076 |
+ Text("Charging ended early")
|
|
| 1077 |
+ .font(.subheadline.weight(.semibold)) |
|
| 1078 |
+ Text("Active charging was detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")).")
|
|
| 1079 |
+ .font(.caption) |
|
| 1080 |
+ .foregroundColor(.secondary) |
|
| 1081 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 1082 |
+ } |
|
| 1083 |
+ |
|
| 1084 |
+ Spacer(minLength: 0) |
|
| 1085 |
+ |
|
| 1086 |
+ VStack(spacing: 6) {
|
|
| 1087 |
+ Button("Trim Start") {
|
|
| 1088 |
+ setSessionTrim(sessionID: session.id, start: window.start, end: session.trimEnd) |
|
| 1089 |
+ trimBannerDismissedForSessionID = session.id |
|
| 1090 |
+ } |
|
| 1091 |
+ .font(.caption.weight(.semibold)) |
|
| 1092 |
+ .buttonStyle(.borderedProminent) |
|
| 1093 |
+ .controlSize(.small) |
|
| 1094 |
+ .tint(.blue) |
|
| 1095 |
+ |
|
| 1096 |
+ Button("End & Finish") {
|
|
| 1097 |
+ requestStop( |
|
| 1098 |
+ session, |
|
| 1099 |
+ applyingTrimStart: session.trimStart ?? window.start, |
|
| 1100 |
+ trimEnd: window.end, |
|
| 1101 |
+ title: "Trim End & Finish", |
|
| 1102 |
+ confirmTitle: "Finish", |
|
| 1103 |
+ explanation: "The detected charging window will be saved before the session is closed." |
|
| 1104 |
+ ) |
|
| 1105 |
+ trimBannerDismissedForSessionID = session.id |
|
| 1106 |
+ } |
|
| 1107 |
+ .font(.caption.weight(.semibold)) |
|
| 1108 |
+ .buttonStyle(.bordered) |
|
| 1109 |
+ .controlSize(.small) |
|
| 1110 |
+ .tint(.red) |
|
| 1111 |
+ } |
|
| 1112 |
+ } |
|
| 1113 |
+ .padding(14) |
|
| 1114 |
+ .background( |
|
| 1115 |
+ RoundedRectangle(cornerRadius: 14) |
|
| 1116 |
+ .fill(Color.blue.opacity(0.10)) |
|
| 1117 |
+ .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1)) |
|
| 1118 |
+ ) |
|
| 1119 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 1120 |
+ } |
|
| 1121 |
+ } |
|
| 1122 |
+ |
|
| 1123 |
+ private func shouldShowSessionChart(_ session: ChargeSessionSummary) -> Bool {
|
|
| 1124 |
+ !session.aggregatedSamples.isEmpty || liveMonitoringMeter != nil |
|
| 1125 |
+ } |
|
| 1126 |
+ |
|
| 1127 |
+ private func chartCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 1128 |
+ ChargeSessionChartCardView( |
|
| 1129 |
+ session: session, |
|
| 1130 |
+ monitoringMeter: liveMonitoringMeter, |
|
| 1131 |
+ controlMode: chartControlMode(for: session), |
|
| 1132 |
+ onSetTrim: { start, end in
|
|
| 1133 |
+ setSessionTrim(sessionID: session.id, start: start, end: end) |
|
| 1134 |
+ }, |
|
| 1135 |
+ onStopWithTrim: { start, end in
|
|
| 1136 |
+ requestStop( |
|
| 1137 |
+ session, |
|
| 1138 |
+ applyingTrimStart: start, |
|
| 1139 |
+ trimEnd: end, |
|
| 1140 |
+ title: "Trim End & Finish", |
|
| 1141 |
+ confirmTitle: "Finish", |
|
| 1142 |
+ explanation: "The selected chart window will be saved as this session's active charging window before the session is closed." |
|
| 1143 |
+ ) |
|
| 1144 |
+ } |
|
| 1145 |
+ ) |
|
| 1146 |
+ } |
|
| 1147 |
+ |
|
| 1148 |
+ private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
|
|
| 1149 |
+ if hasMonitoringControls {
|
|
| 1150 |
+ return .activeMonitoring |
|
| 1151 |
+ } |
|
| 1152 |
+ |
|
| 1153 |
+ if session.status.isOpen == false {
|
|
| 1154 |
+ return .closed |
|
| 1155 |
+ } |
|
| 1156 |
+ |
|
| 1157 |
+ return .none |
|
| 1158 |
+ } |
|
| 1159 |
+ |
|
| 1160 |
+ private func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) {
|
|
| 1161 |
+ _ = appData.setSessionTrim(sessionID: sessionID, start: start, end: end) |
|
| 1162 |
+ trimBannerDismissedForSessionID = sessionID |
|
| 1163 |
+ } |
|
| 1164 |
+ |
|
| 1165 |
+ private func requestStop( |
|
| 1166 |
+ _ session: ChargeSessionSummary, |
|
| 1167 |
+ applyingTrimStart trimStart: Date?, |
|
| 1168 |
+ trimEnd: Date?, |
|
| 1169 |
+ title: String, |
|
| 1170 |
+ confirmTitle: String, |
|
| 1171 |
+ explanation: String |
|
| 1172 |
+ ) {
|
|
| 1173 |
+ pendingSessionStopRequest = ChargeSessionStopRequest( |
|
| 1174 |
+ sessionID: session.id, |
|
| 1175 |
+ title: title, |
|
| 1176 |
+ confirmTitle: confirmTitle, |
|
| 1177 |
+ explanation: explanation, |
|
| 1178 |
+ appliesTrim: trimStart != nil || trimEnd != nil, |
|
| 1179 |
+ trimStart: trimStart, |
|
| 1180 |
+ trimEnd: trimEnd |
|
| 1181 |
+ ) |
|
| 1182 |
+ } |
|
| 1183 |
+ |
|
| 1184 |
+ private var parsedDraftTarget: Double? {
|
|
| 1185 |
+ let normalized = draftTargetText |
|
| 1186 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 1187 |
+ .replacingOccurrences(of: ",", with: ".") |
|
| 1188 |
+ guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
|
|
| 1189 |
+ return value |
|
| 1190 |
+ } |
|
| 1191 |
+ |
|
| 1192 |
+ private var parsedFinalCheckpoint: Double? {
|
|
| 1193 |
+ let normalized = finalCheckpointText |
|
| 1194 |
+ .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 1195 |
+ .replacingOccurrences(of: ",", with: ".") |
|
| 1196 |
+ guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
|
|
| 1197 |
+ return value |
|
| 1198 |
+ } |
|
| 1199 |
+ |
|
| 1200 |
+ private var resolvedFinalCheckpoint: Double? {
|
|
| 1201 |
+ switch finalCheckpointMode {
|
|
| 1202 |
+ case .full: return 100 |
|
| 1203 |
+ case .skip: return nil |
|
| 1204 |
+ case .custom: return parsedFinalCheckpoint |
|
| 1205 |
+ } |
|
| 1206 |
+ } |
|
| 1207 |
+ |
|
| 1208 |
+ private func adjustFinalCheckpoint(by delta: Double) {
|
|
| 1209 |
+ let current = parsedFinalCheckpoint ?? suggestedFinalCheckpointPercent(for: session) ?? 0 |
|
| 1210 |
+ let next = min(max(current + delta, 0), 100) |
|
| 1211 |
+ finalCheckpointText = next.format(decimalDigits: 0) |
|
| 1212 |
+ } |
|
| 1213 |
+ |
|
| 1214 |
+ private func suggestedFinalCheckpointPercent(for session: ChargeSessionSummary?) -> Double? {
|
|
| 1215 |
+ guard let session else { return nil }
|
|
| 1216 |
+ if let endBatteryPercent = session.endBatteryPercent {
|
|
| 1217 |
+ return endBatteryPercent |
|
| 1218 |
+ } |
|
| 1219 |
+ if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
|
|
| 1220 |
+ return latestCheckpoint.batteryPercent |
|
| 1221 |
+ } |
|
| 1222 |
+ return session.targetBatteryPercent ?? session.completionContradictionPercent |
|
| 1223 |
+ } |
|
| 1224 |
+ |
|
| 1225 |
+ private func prefillFinalCheckpointIfNeeded(for session: ChargeSessionSummary) {
|
|
| 1226 |
+ guard finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, |
|
| 1227 |
+ let suggestedPercent = suggestedFinalCheckpointPercent(for: session) else {
|
|
| 1228 |
+ return |
|
| 1229 |
+ } |
|
| 1230 |
+ finalCheckpointText = suggestedPercent.format(decimalDigits: 0) |
|
| 1231 |
+ } |
|
| 1232 |
+ |
|
| 1233 |
+ private func hasSavableChargeData( |
|
| 1234 |
+ session: ChargeSessionSummary, |
|
| 1235 |
+ displayedEnergyWh: Double, |
|
| 1236 |
+ displayedChargeAh: Double |
|
| 1237 |
+ ) -> Bool {
|
|
| 1238 |
+ session.hasSavableChargeData |
|
| 1239 |
+ || displayedEnergyWh > 0 |
|
| 1240 |
+ || displayedChargeAh > 0 |
|
| 1241 |
+ } |
|
| 1242 |
+ |
|
| 1243 |
+ private func saveDisabledReason( |
|
| 1244 |
+ session: ChargeSessionSummary, |
|
| 1245 |
+ displayedEnergyWh: Double, |
|
| 1246 |
+ displayedChargeAh: Double |
|
| 1247 |
+ ) -> String? {
|
|
| 1248 |
+ if finalCheckpointMode == .custom {
|
|
| 1249 |
+ let trimmed = finalCheckpointText.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 1250 |
+ if trimmed.isEmpty {
|
|
| 1251 |
+ return "Enter the final battery percentage or choose Skip." |
|
| 1252 |
+ } |
|
| 1253 |
+ if parsedFinalCheckpoint == nil {
|
|
| 1254 |
+ return "Final battery percentage must be between 0 and 100." |
|
| 1255 |
+ } |
|
| 1256 |
+ } |
|
| 1257 |
+ |
|
| 1258 |
+ guard hasSavableChargeData( |
|
| 1259 |
+ session: session, |
|
| 1260 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 1261 |
+ displayedChargeAh: displayedChargeAh |
|
| 1262 |
+ ) else {
|
|
| 1263 |
+ return "This session has no charging data to save. Discard it instead." |
|
| 1264 |
+ } |
|
| 1265 |
+ |
|
| 1266 |
+ return nil |
|
| 1267 |
+ } |
|
| 1268 |
+ |
|
| 1269 |
+ private func stopSession( |
|
| 1270 |
+ _ session: ChargeSessionSummary, |
|
| 1271 |
+ displayedEnergyWh: Double, |
|
| 1272 |
+ displayedChargeAh: Double |
|
| 1273 |
+ ) {
|
|
| 1274 |
+ stopFailureMessage = nil |
|
| 1275 |
+ |
|
| 1276 |
+ if let saveDisabledReason = saveDisabledReason( |
|
| 1277 |
+ session: session, |
|
| 1278 |
+ displayedEnergyWh: displayedEnergyWh, |
|
| 1279 |
+ displayedChargeAh: displayedChargeAh |
|
| 1280 |
+ ) {
|
|
| 1281 |
+ stopFailureMessage = saveDisabledReason |
|
| 1282 |
+ return |
|
| 1283 |
+ } |
|
| 1284 |
+ |
|
| 1285 |
+ let didSave = appData.stopChargeSession( |
|
| 1286 |
+ sessionID: session.id, |
|
| 1287 |
+ finalBatteryPercent: resolvedFinalCheckpoint, |
|
| 1288 |
+ from: liveMonitoringMeter |
|
| 1289 |
+ ) |
|
| 1290 |
+ if didSave {
|
|
| 1291 |
+ resetStopConfirmation() |
|
| 1292 |
+ } else {
|
|
| 1293 |
+ stopFailureMessage = "The session could not be closed. Live readings were flushed, but the stored session did not accept the save yet. Try again in a moment." |
|
| 1294 |
+ } |
|
| 1295 |
+ } |
|
| 1296 |
+ |
|
| 1297 |
+ private func beginStopConfirmation(for session: ChargeSessionSummary) {
|
|
| 1298 |
+ finalCheckpointMode = .skip |
|
| 1299 |
+ finalCheckpointText = "" |
|
| 1300 |
+ stopFailureMessage = nil |
|
| 1301 |
+ showingStopConfirm = true |
|
| 1302 |
+ } |
|
| 1303 |
+ |
|
| 1304 |
+ private func discardSession(_ session: ChargeSessionSummary) {
|
|
| 1305 |
+ _ = appData.deleteChargeSession(sessionID: session.id) |
|
| 1306 |
+ resetStopConfirmation() |
|
| 1307 |
+ } |
|
| 1308 |
+ |
|
| 1309 |
+ private func resetStopConfirmation() {
|
|
| 1310 |
+ showingStopConfirm = false |
|
| 1311 |
+ finalCheckpointText = "" |
|
| 1312 |
+ finalCheckpointMode = .skip |
|
| 1313 |
+ stopFailureMessage = nil |
|
| 1314 |
+ } |
|
| 1315 |
+ |
|
| 1316 |
+ private func syncMonitoringRestore() {
|
|
| 1317 |
+ guard let session, |
|
| 1318 |
+ session.status.isOpen, |
|
| 1319 |
+ let liveMonitoringMeter, |
|
| 1320 |
+ session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else {
|
|
| 1321 |
+ return |
|
| 1322 |
+ } |
|
| 1323 |
+ liveMonitoringMeter.restoreChargeMonitoringIfNeeded(from: session) |
|
| 1324 |
+ } |
|
| 1325 |
+ |
|
| 1326 |
+ private func runTrimDetection() {
|
|
| 1327 |
+ guard hasMonitoringControls, |
|
| 1328 |
+ let session, |
|
| 1329 |
+ session.isTrimmed == false, |
|
| 1330 |
+ !session.aggregatedSamples.isEmpty else {
|
|
| 1331 |
+ detectedTrimWindow = nil |
|
| 1332 |
+ return |
|
| 1333 |
+ } |
|
| 1334 |
+ |
|
| 1335 |
+ let sessionEnd = session.endedAt ?? session.lastObservedAt |
|
| 1336 |
+ detectedTrimWindow = ChargingWindowDetector.detect( |
|
| 1337 |
+ samples: session.aggregatedSamples, |
|
| 1338 |
+ sessionStart: session.startedAt, |
|
| 1339 |
+ sessionEnd: sessionEnd |
|
| 1340 |
+ ) |
|
| 1341 |
+ } |
|
| 1342 |
+ |
|
| 1343 |
+ private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
|
|
| 1344 |
+ let storedEnergyWh = session.effectiveOrMeasuredEnergyWh |
|
| 1345 |
+ guard session.isTrimmed == false else { return storedEnergyWh }
|
|
| 1346 |
+ guard session.status.isOpen else { return storedEnergyWh }
|
|
| 1347 |
+ guard let liveMonitoringMeter else { return storedEnergyWh }
|
|
| 1348 |
+ guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
|
|
| 1349 |
+ if let baselineEnergyWh = session.meterEnergyBaselineWh {
|
|
| 1350 |
+ return max(storedEnergyWh, max(liveMonitoringMeter.recordedWH - baselineEnergyWh, 0)) |
|
| 1351 |
+ } |
|
| 1352 |
+ return storedEnergyWh |
|
| 1353 |
+ } |
|
| 1354 |
+ |
|
| 1355 |
+ private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
|
|
| 1356 |
+ let storedChargeAh = session.measuredChargeAh |
|
| 1357 |
+ guard session.isTrimmed == false else { return storedChargeAh }
|
|
| 1358 |
+ guard session.status.isOpen else { return storedChargeAh }
|
|
| 1359 |
+ guard let liveMonitoringMeter else { return storedChargeAh }
|
|
| 1360 |
+ guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
|
|
| 1361 |
+ if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
| 1362 |
+ return max(storedChargeAh, max(liveMonitoringMeter.recordedAH - baselineChargeAh, 0)) |
|
| 1363 |
+ } |
|
| 1364 |
+ return storedChargeAh |
|
| 1365 |
+ } |
|
| 1366 |
+ |
|
| 1367 |
+ private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
|
|
| 1368 |
+ let storedDuration = max(session.effectiveDuration, 0) |
|
| 1369 |
+ guard session.isTrimmed == false else { return storedDuration }
|
|
| 1370 |
+ guard session.status.isOpen else { return storedDuration }
|
|
| 1371 |
+ guard let liveMonitoringMeter else { return storedDuration }
|
|
| 1372 |
+ guard session.meterMACAddress == liveMonitoringMeter.btSerial.macAddress.description else { return storedDuration }
|
|
| 1373 |
+ return max(storedDuration, max(liveMonitoringMeter.chargeRecordDuration, 0)) |
|
| 1374 |
+ } |
|
| 1375 |
+ |
|
| 1376 |
+ private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
|
|
| 1377 |
+ let displayedDuration = displayedSessionDuration(for: session) |
|
| 1378 |
+ let formatter = DateComponentsFormatter() |
|
| 1379 |
+ formatter.allowedUnits = displayedDuration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 1380 |
+ formatter.unitsStyle = .abbreviated |
|
| 1381 |
+ formatter.zeroFormattingBehavior = .dropAll |
|
| 1382 |
+ return formatter.string(from: displayedDuration) ?? "0m" |
|
| 1383 |
+ } |
|
| 1384 |
+ |
|
| 1385 |
+ private func formatDuration(_ duration: TimeInterval) -> String {
|
|
| 1386 |
+ let totalSeconds = Int(duration.rounded(.down)) |
|
| 1387 |
+ let hours = totalSeconds / 3600 |
|
| 1388 |
+ let minutes = (totalSeconds % 3600) / 60 |
|
| 1389 |
+ let seconds = totalSeconds % 60 |
|
| 1390 |
+ if hours > 0 {
|
|
| 1391 |
+ return String(format: "%d:%02d:%02d", hours, minutes, seconds) |
|
| 1392 |
+ } |
|
| 1393 |
+ return String(format: "%02d:%02d", minutes, seconds) |
|
| 1394 |
+ } |
|
| 1395 |
+ |
|
| 1396 |
+ private func autoStopDescription(for session: ChargeSessionSummary) -> String {
|
|
| 1397 |
+ if session.autoStopEnabled == false {
|
|
| 1398 |
+ return "Manual" |
|
| 1399 |
+ } |
|
| 1400 |
+ |
|
| 1401 |
+ if let sessionWarning = sessionWarning(for: session), |
|
| 1402 |
+ sessionWarning.contains("idle-current") {
|
|
| 1403 |
+ return "Blocked by charger setup" |
|
| 1404 |
+ } |
|
| 1405 |
+ |
|
| 1406 |
+ if session.stopThresholdAmps > 0 {
|
|
| 1407 |
+ return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A" |
|
| 1408 |
+ } |
|
| 1409 |
+ |
|
| 1410 |
+ return "Learning" |
|
| 1411 |
+ } |
|
| 1412 |
+ |
|
| 1413 |
+ private func autoStopLabel(for session: ChargeSessionSummary) -> String {
|
|
| 1414 |
+ if session.autoStopEnabled == false {
|
|
| 1415 |
+ return "Manual" |
|
| 1416 |
+ } |
|
| 1417 |
+ if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
|
|
| 1418 |
+ return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
|
|
| 1419 |
+ } |
|
| 1420 |
+ if session.stopThresholdAmps > 0 {
|
|
| 1421 |
+ return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A" |
|
| 1422 |
+ } |
|
| 1423 |
+ return "Learning" |
|
| 1424 |
+ } |
|
| 1425 |
+ |
|
| 1426 |
+ private func shouldShowChargingTransport( |
|
| 1427 |
+ for session: ChargeSessionSummary, |
|
| 1428 |
+ chargedDevice: ChargedDeviceSummary |
|
| 1429 |
+ ) -> Bool {
|
|
| 1430 |
+ chargedDevice.supportedChargingModes.count > 1 |
|
| 1431 |
+ || chargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false |
|
| 1432 |
+ } |
|
| 1433 |
+ |
|
| 1434 |
+ private func shouldShowChargingState( |
|
| 1435 |
+ for session: ChargeSessionSummary, |
|
| 1436 |
+ chargedDevice: ChargedDeviceSummary |
|
| 1437 |
+ ) -> Bool {
|
|
| 1438 |
+ chargedDevice.supportedChargingStateModes.count > 1 |
|
| 1439 |
+ || chargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false |
|
| 1440 |
+ } |
|
| 1441 |
+ |
|
| 1442 |
+ private func batteryColor(for percent: Double) -> Color {
|
|
| 1443 |
+ if percent >= 75 { return .green }
|
|
| 1444 |
+ if percent >= 35 { return .orange }
|
|
| 1445 |
+ return .red |
|
| 1446 |
+ } |
|
| 1447 |
+ |
|
| 1448 |
+ private func etaText( |
|
| 1449 |
+ rateWhPerSec: Double?, |
|
| 1450 |
+ remainingWh: Double, |
|
| 1451 |
+ isRelevant: Bool |
|
| 1452 |
+ ) -> String? {
|
|
| 1453 |
+ guard isRelevant, let rateWhPerSec, rateWhPerSec > 0.0001 else { return nil }
|
|
| 1454 |
+ let seconds = remainingWh / rateWhPerSec |
|
| 1455 |
+ return seconds > 120 ? formatETA(seconds) : nil |
|
| 1456 |
+ } |
|
| 1457 |
+ |
|
| 1458 |
+ private func etaToTargetText( |
|
| 1459 |
+ session: ChargeSessionSummary, |
|
| 1460 |
+ prediction: BatteryLevelPrediction, |
|
| 1461 |
+ displayedEnergyWh: Double, |
|
| 1462 |
+ rateWhPerSec: Double? |
|
| 1463 |
+ ) -> String? {
|
|
| 1464 |
+ guard let target = session.targetBatteryPercent, target > prediction.predictedPercent + 1 else {
|
|
| 1465 |
+ return nil |
|
| 1466 |
+ } |
|
| 1467 |
+ let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh |
|
| 1468 |
+ return etaText( |
|
| 1469 |
+ rateWhPerSec: rateWhPerSec, |
|
| 1470 |
+ remainingWh: max(targetEnergyWh - displayedEnergyWh, 0), |
|
| 1471 |
+ isRelevant: true |
|
| 1472 |
+ ) |
|
| 1473 |
+ } |
|
| 1474 |
+ |
|
| 1475 |
+ private func formatETA(_ seconds: TimeInterval) -> String {
|
|
| 1476 |
+ let totalMinutes = Int(seconds / 60) |
|
| 1477 |
+ if totalMinutes < 60 { return "\(totalMinutes)m" }
|
|
| 1478 |
+ let hours = totalMinutes / 60 |
|
| 1479 |
+ let minutes = totalMinutes % 60 |
|
| 1480 |
+ return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m" |
|
| 1481 |
+ } |
|
| 1482 |
+ |
|
| 1483 |
+ private func monitoringStatusColor(for session: ChargeSessionSummary) -> Color {
|
|
| 1484 |
+ switch session.status {
|
|
| 1485 |
+ case .active: |
|
| 1486 |
+ return .red |
|
| 1487 |
+ case .paused: |
|
| 1488 |
+ return .orange |
|
| 1489 |
+ case .completed: |
|
| 1490 |
+ return .green |
|
| 1491 |
+ case .abandoned: |
|
| 1492 |
+ return .secondary |
|
| 1493 |
+ } |
|
| 1494 |
+ } |
|
| 1495 |
+ |
|
| 1496 |
+ private func sessionWarning(for session: ChargeSessionSummary) -> String? {
|
|
| 1497 |
+ guard session.chargingTransportMode == .wireless, |
|
| 1498 |
+ let chargerID = session.chargerID, |
|
| 1499 |
+ let charger = appData.chargedDeviceSummary(id: chargerID), |
|
| 1500 |
+ charger.chargerIdleCurrentAmps == nil else {
|
|
| 1501 |
+ return nil |
|
| 1502 |
+ } |
|
| 1503 |
+ |
|
| 1504 |
+ return "This charger has no idle-current measurement, so the wireless stop threshold cannot be learned or auto-applied for this session." |
|
| 1505 |
+ } |
|
| 1506 |
+ |
|
| 1507 |
+ private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
|
|
| 1508 |
+ guard session.chargingTransportMode == .wireless else {
|
|
| 1509 |
+ return nil |
|
| 1510 |
+ } |
|
| 1511 |
+ |
|
| 1512 |
+ var components: [String] = [] |
|
| 1513 |
+ if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
|
|
| 1514 |
+ components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
|
|
| 1515 |
+ } |
|
| 1516 |
+ if session.usesEstimatedWirelessEfficiency {
|
|
| 1517 |
+ components.append("Estimated from wired baseline and checkpoints")
|
|
| 1518 |
+ } |
|
| 1519 |
+ if session.shouldWarnAboutLowWirelessEfficiency {
|
|
| 1520 |
+ components.append("Low wireless efficiency, so capacity confidence is reduced")
|
|
| 1521 |
+ } |
|
| 1522 |
+ |
|
| 1523 |
+ return components.isEmpty ? nil : components.joined(separator: " - ") |
|
| 1524 |
+ } |
|
| 1525 |
+ |
|
| 1526 |
+ private func statusTint(for session: ChargeSessionSummary) -> Color {
|
|
| 1527 |
+ switch session.status {
|
|
| 1528 |
+ case .active: |
|
| 1529 |
+ return .green |
|
| 1530 |
+ case .paused: |
|
| 1531 |
+ return .orange |
|
| 1532 |
+ case .completed: |
|
| 1533 |
+ return .teal |
|
| 1534 |
+ case .abandoned: |
|
| 1535 |
+ return .secondary |
|
| 1536 |
+ } |
|
| 1537 |
+ } |
|
| 1538 |
+} |
|
| 1539 |
+ |
|
| 1540 |
+enum ChargeSessionChartControlMode {
|
|
| 1541 |
+ case none |
|
| 1542 |
+ case activeMonitoring |
|
| 1543 |
+ case closed |
|
| 1544 |
+} |
|
| 1545 |
+ |
|
| 1546 |
+struct ChargeSessionChartCardView: View {
|
|
| 1547 |
+ let session: ChargeSessionSummary |
|
| 1548 |
+ let monitoringMeter: Meter? |
|
| 1549 |
+ let controlMode: ChargeSessionChartControlMode |
|
| 1550 |
+ let onSetTrim: (Date?, Date?) -> Void |
|
| 1551 |
+ let onStopWithTrim: (Date?, Date?) -> Void |
|
| 1552 |
+ |
|
| 1553 |
+ @StateObject private var storedMeasurements = Measurements() |
|
| 1554 |
+ |
|
| 1555 |
+ private var chartMeasurements: Measurements {
|
|
| 1556 |
+ if let monitoringMeter, |
|
| 1557 |
+ session.status.isOpen, |
|
| 1558 |
+ session.meterMACAddress == monitoringMeter.btSerial.macAddress.description {
|
|
| 1559 |
+ return monitoringMeter.chargeRecordMeasurements |
|
| 1560 |
+ } |
|
| 1561 |
+ return storedMeasurements |
|
| 1562 |
+ } |
|
| 1563 |
+ |
|
| 1564 |
+ private var fullTimeRange: ClosedRange<Date> {
|
|
| 1565 |
+ let start = session.startedAt |
|
| 1566 |
+ let end = max(session.endedAt ?? session.lastObservedAt, start) |
|
| 1567 |
+ return start...end |
|
| 1568 |
+ } |
|
| 1569 |
+ |
|
| 1570 |
+ private var fixedTimeRange: ClosedRange<Date>? {
|
|
| 1571 |
+ if monitoringMeter != nil && session.status.isOpen {
|
|
| 1572 |
+ return nil |
|
| 1573 |
+ } |
|
| 1574 |
+ return session.effectiveTimeRange |
|
| 1575 |
+ } |
|
| 1576 |
+ |
|
| 1577 |
+ private var liveTrimBounds: (lower: Date?, upper: Date?) {
|
|
| 1578 |
+ guard monitoringMeter != nil && session.status.isOpen else {
|
|
| 1579 |
+ return (nil, nil) |
|
| 1580 |
+ } |
|
| 1581 |
+ return (session.trimStart, session.trimEnd) |
|
| 1582 |
+ } |
|
| 1583 |
+ |
|
| 1584 |
+ private var showsRangeSelector: Bool {
|
|
| 1585 |
+ controlMode != .none && !session.aggregatedSamples.isEmpty |
|
| 1586 |
+ } |
|
| 1587 |
+ |
|
| 1588 |
+ var body: some View {
|
|
| 1589 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 1590 |
+ HStack(spacing: 8) {
|
|
| 1591 |
+ Image(systemName: "chart.xyaxis.line") |
|
| 1592 |
+ .foregroundColor(.blue) |
|
| 1593 |
+ Text("Session Chart")
|
|
| 1594 |
+ .font(.headline) |
|
| 1595 |
+ ContextInfoButton( |
|
| 1596 |
+ title: "Session Chart", |
|
| 1597 |
+ message: chartInfoMessage |
|
| 1598 |
+ ) |
|
| 1599 |
+ Spacer(minLength: 0) |
|
| 1600 |
+ } |
|
| 1601 |
+ |
|
| 1602 |
+ MeasurementChartView( |
|
| 1603 |
+ timeRange: fixedTimeRange, |
|
| 1604 |
+ timeRangeLowerBound: liveTrimBounds.lower, |
|
| 1605 |
+ timeRangeUpperBound: liveTrimBounds.upper, |
|
| 1606 |
+ showsRangeSelector: showsRangeSelector, |
|
| 1607 |
+ rebasesEnergyToVisibleRangeStart: true, |
|
| 1608 |
+ extendsTimelineToPresent: false, |
|
| 1609 |
+ showsTemperatureSeries: false, |
|
| 1610 |
+ rangeSelectorConfiguration: rangeSelectorConfiguration |
|
| 1611 |
+ ) |
|
| 1612 |
+ .environmentObject(chartMeasurements) |
|
| 1613 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 1614 |
+ } |
|
| 1615 |
+ .padding(18) |
|
| 1616 |
+ .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 1617 |
+ .onAppear(perform: restoreStoredMeasurementsIfNeeded) |
|
| 1618 |
+ .onChange(of: session.id) { _ in
|
|
| 1619 |
+ restoreStoredMeasurementsIfNeeded() |
|
| 1620 |
+ } |
|
| 1621 |
+ .onChange(of: session.aggregatedSamples.count) { _ in
|
|
| 1622 |
+ restoreStoredMeasurementsIfNeeded() |
|
| 1623 |
+ } |
|
| 1624 |
+ } |
|
| 1625 |
+ |
|
| 1626 |
+ private var chartInfoMessage: String {
|
|
| 1627 |
+ if monitoringMeter != nil && session.status.isOpen {
|
|
| 1628 |
+ return "This chart combines the persisted session curve with current live data from this meter." |
|
| 1629 |
+ } |
|
| 1630 |
+ |
|
| 1631 |
+ return "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging." |
|
| 1632 |
+ } |
|
| 1633 |
+ |
|
| 1634 |
+ private var rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? {
|
|
| 1635 |
+ switch controlMode {
|
|
| 1636 |
+ case .none: |
|
| 1637 |
+ return nil |
|
| 1638 |
+ case .activeMonitoring: |
|
| 1639 |
+ return MeasurementChartRangeSelectorConfiguration( |
|
| 1640 |
+ keepAction: MeasurementChartSelectionAction( |
|
| 1641 |
+ title: "Trim Start", |
|
| 1642 |
+ shortTitle: "Start", |
|
| 1643 |
+ systemName: "arrow.right.to.line", |
|
| 1644 |
+ tone: .destructive, |
|
| 1645 |
+ handler: applyActiveStartTrim |
|
| 1646 |
+ ), |
|
| 1647 |
+ removeAction: MeasurementChartSelectionAction( |
|
| 1648 |
+ title: "Trim End & Finish", |
|
| 1649 |
+ shortTitle: "End", |
|
| 1650 |
+ systemName: "arrow.left.to.line", |
|
| 1651 |
+ tone: .destructiveProminent, |
|
| 1652 |
+ handler: requestActiveEndTrim |
|
| 1653 |
+ ), |
|
| 1654 |
+ resetAction: MeasurementChartResetAction( |
|
| 1655 |
+ title: "Reset Trim", |
|
| 1656 |
+ shortTitle: "Reset", |
|
| 1657 |
+ systemName: "arrow.counterclockwise", |
|
| 1658 |
+ tone: .reversible, |
|
| 1659 |
+ confirmationTitle: "Reset session trim?", |
|
| 1660 |
+ confirmationButtonTitle: "Reset trim", |
|
| 1661 |
+ handler: {
|
|
| 1662 |
+ onSetTrim(nil, nil) |
|
| 1663 |
+ } |
|
| 1664 |
+ ) |
|
| 1665 |
+ ) |
|
| 1666 |
+ case .closed: |
|
| 1667 |
+ return MeasurementChartRangeSelectorConfiguration( |
|
| 1668 |
+ keepAction: MeasurementChartSelectionAction( |
|
| 1669 |
+ title: "Trim Window", |
|
| 1670 |
+ shortTitle: "Trim", |
|
| 1671 |
+ systemName: "scissors", |
|
| 1672 |
+ tone: .destructive, |
|
| 1673 |
+ handler: applyClosedTrim |
|
| 1674 |
+ ), |
|
| 1675 |
+ removeAction: nil, |
|
| 1676 |
+ resetAction: MeasurementChartResetAction( |
|
| 1677 |
+ title: "Reset Trim", |
|
| 1678 |
+ shortTitle: "Reset", |
|
| 1679 |
+ systemName: "arrow.counterclockwise", |
|
| 1680 |
+ tone: .reversible, |
|
| 1681 |
+ confirmationTitle: "Reset session trim?", |
|
| 1682 |
+ confirmationButtonTitle: "Reset trim", |
|
| 1683 |
+ handler: {
|
|
| 1684 |
+ onSetTrim(nil, nil) |
|
| 1685 |
+ } |
|
| 1686 |
+ ) |
|
| 1687 |
+ ) |
|
| 1688 |
+ } |
|
| 1689 |
+ } |
|
| 1690 |
+ |
|
| 1691 |
+ private func restoreStoredMeasurementsIfNeeded() {
|
|
| 1692 |
+ guard monitoringMeter == nil || session.status.isOpen == false else {
|
|
| 1693 |
+ return |
|
| 1694 |
+ } |
|
| 1695 |
+ storedMeasurements.resetSeries() |
|
| 1696 |
+ _ = storedMeasurements.restorePersistedChargeSessionSamplesIfNeeded( |
|
| 1697 |
+ from: session, |
|
| 1698 |
+ replacingLiveBufferIfNeeded: true |
|
| 1699 |
+ ) |
|
| 1700 |
+ } |
|
| 1701 |
+ |
|
| 1702 |
+ private func applyActiveStartTrim(_ range: ClosedRange<Date>) {
|
|
| 1703 |
+ onSetTrim(normalizedStart(range.lowerBound), session.trimEnd) |
|
| 1704 |
+ } |
|
| 1705 |
+ |
|
| 1706 |
+ private func requestActiveEndTrim(_ range: ClosedRange<Date>) {
|
|
| 1707 |
+ let start = session.trimStart ?? normalizedStart(range.lowerBound) |
|
| 1708 |
+ let end = normalizedEnd(range.upperBound) |
|
| 1709 |
+ onStopWithTrim(start, end) |
|
| 1710 |
+ } |
|
| 1711 |
+ |
|
| 1712 |
+ private func applyClosedTrim(_ range: ClosedRange<Date>) {
|
|
| 1713 |
+ onSetTrim(normalizedStart(range.lowerBound), normalizedEnd(range.upperBound)) |
|
| 1714 |
+ } |
|
| 1715 |
+ |
|
| 1716 |
+ private func normalizedStart(_ date: Date) -> Date? {
|
|
| 1717 |
+ date.timeIntervalSince(fullTimeRange.lowerBound) <= 1 ? nil : date |
|
| 1718 |
+ } |
|
| 1719 |
+ |
|
| 1720 |
+ private func normalizedEnd(_ date: Date) -> Date? {
|
|
| 1721 |
+ fullTimeRange.upperBound.timeIntervalSince(date) <= 1 ? nil : date |
|
| 1722 |
+ } |
|
| 1723 |
+} |
|
| 1724 |
+ |
|
| 1725 |
+private struct ChargeSessionStopRequest: Identifiable {
|
|
| 1726 |
+ let sessionID: UUID |
|
| 1727 |
+ let title: String |
|
| 1728 |
+ let confirmTitle: String |
|
| 1729 |
+ let explanation: String |
|
| 1730 |
+ let appliesTrim: Bool |
|
| 1731 |
+ let trimStart: Date? |
|
| 1732 |
+ let trimEnd: Date? |
|
| 1733 |
+ |
|
| 1734 |
+ var id: String {
|
|
| 1735 |
+ [ |
|
| 1736 |
+ sessionID.uuidString, |
|
| 1737 |
+ title, |
|
| 1738 |
+ trimStart?.timeIntervalSince1970.description ?? "nil", |
|
| 1739 |
+ trimEnd?.timeIntervalSince1970.description ?? "nil" |
|
| 1740 |
+ ].joined(separator: "-") |
|
| 1741 |
+ } |
|
| 1742 |
+} |
|
| 1743 |
+ |
|
| 1744 |
+private extension View {
|
|
| 1745 |
+ func monitoringActionStyle(tint: Color) -> some View {
|
|
| 1746 |
+ frame(maxWidth: .infinity) |
|
| 1747 |
+ .padding(.vertical, 10) |
|
| 1748 |
+ .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 1749 |
+ .buttonStyle(.plain) |
|
| 1750 |
+ } |
|
| 1751 |
+ |
|
| 1752 |
+ func monitoringPanelActionStyle(tint: Color, isProminent: Bool = false) -> some View {
|
|
| 1753 |
+ frame(maxWidth: .infinity) |
|
| 1754 |
+ .padding(.vertical, 9) |
|
| 1755 |
+ .meterCard( |
|
| 1756 |
+ tint: tint, |
|
| 1757 |
+ fillOpacity: isProminent ? 0.22 : 0.10, |
|
| 1758 |
+ strokeOpacity: isProminent ? 0.32 : 0.14, |
|
| 1759 |
+ cornerRadius: 14 |
|
| 1760 |
+ ) |
|
| 1761 |
+ .buttonStyle(.plain) |
|
| 1762 |
+ } |
|
| 1763 |
+} |
|
@@ -1,537 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// ChargedDeviceActiveSessionView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Codex on 22/04/2026. |
|
| 6 |
-// |
|
| 7 |
- |
|
| 8 |
-import SwiftUI |
|
| 9 |
- |
|
| 10 |
-struct ChargedDeviceActiveSessionView: View {
|
|
| 11 |
- @EnvironmentObject private var appData: AppData |
|
| 12 |
- @State private var targetNotificationEditorVisibility = false |
|
| 13 |
- @State private var pendingCheckpointDeletion: ChargeCheckpointSummary? |
|
| 14 |
- @State private var pendingSessionStopRequest: ActiveDeviceSessionStopRequest? |
|
| 15 |
- |
|
| 16 |
- let chargedDeviceID: UUID |
|
| 17 |
- |
|
| 18 |
- private var chargedDevice: ChargedDeviceSummary? {
|
|
| 19 |
- appData.chargedDeviceSummary(id: chargedDeviceID) |
|
| 20 |
- } |
|
| 21 |
- |
|
| 22 |
- private var activeSession: ChargeSessionSummary? {
|
|
| 23 |
- chargedDevice?.activeSession |
|
| 24 |
- } |
|
| 25 |
- |
|
| 26 |
- var body: some View {
|
|
| 27 |
- Group {
|
|
| 28 |
- if let chargedDevice, let activeSession {
|
|
| 29 |
- ScrollView {
|
|
| 30 |
- VStack(spacing: 16) {
|
|
| 31 |
- activeSessionCard(activeSession, chargedDevice: chargedDevice) |
|
| 32 |
- |
|
| 33 |
- if !activeSession.displayedAggregatedSamples.isEmpty {
|
|
| 34 |
- storedCurveCard(activeSession) |
|
| 35 |
- } |
|
| 36 |
- } |
|
| 37 |
- .padding() |
|
| 38 |
- } |
|
| 39 |
- .background( |
|
| 40 |
- LinearGradient( |
|
| 41 |
- colors: [statusTint(for: activeSession).opacity(0.14), Color.clear], |
|
| 42 |
- startPoint: .topLeading, |
|
| 43 |
- endPoint: .bottomTrailing |
|
| 44 |
- ) |
|
| 45 |
- .ignoresSafeArea() |
|
| 46 |
- ) |
|
| 47 |
- .navigationTitle("Current Session")
|
|
| 48 |
- } else {
|
|
| 49 |
- Text("There is no open session for this device.")
|
|
| 50 |
- .foregroundColor(.secondary) |
|
| 51 |
- .navigationTitle("Current Session")
|
|
| 52 |
- } |
|
| 53 |
- } |
|
| 54 |
- .sheet(isPresented: $targetNotificationEditorVisibility) {
|
|
| 55 |
- if let activeSession {
|
|
| 56 |
- ActiveSessionTargetNotificationEditorSheetView( |
|
| 57 |
- sessionID: activeSession.id, |
|
| 58 |
- initialTargetPercent: activeSession.targetBatteryPercent |
|
| 59 |
- ) |
|
| 60 |
- .environmentObject(appData) |
|
| 61 |
- } |
|
| 62 |
- } |
|
| 63 |
- .sheet(item: $pendingSessionStopRequest) { request in
|
|
| 64 |
- ChargeSessionCompletionSheetView( |
|
| 65 |
- sessionID: request.sessionID, |
|
| 66 |
- title: request.title, |
|
| 67 |
- confirmTitle: request.confirmTitle, |
|
| 68 |
- explanation: request.explanation |
|
| 69 |
- ) |
|
| 70 |
- .environmentObject(appData) |
|
| 71 |
- } |
|
| 72 |
- .alert(item: $pendingCheckpointDeletion) { checkpoint in
|
|
| 73 |
- Alert( |
|
| 74 |
- title: Text("Delete Battery Checkpoint"),
|
|
| 75 |
- message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
|
|
| 76 |
- primaryButton: .destructive(Text("Delete")) {
|
|
| 77 |
- _ = appData.deleteBatteryCheckpoint( |
|
| 78 |
- checkpointID: checkpoint.id, |
|
| 79 |
- for: checkpoint.sessionID |
|
| 80 |
- ) |
|
| 81 |
- }, |
|
| 82 |
- secondaryButton: .cancel() |
|
| 83 |
- ) |
|
| 84 |
- } |
|
| 85 |
- } |
|
| 86 |
- |
|
| 87 |
- private func activeSessionCard( |
|
| 88 |
- _ activeSession: ChargeSessionSummary, |
|
| 89 |
- chargedDevice: ChargedDeviceSummary |
|
| 90 |
- ) -> some View {
|
|
| 91 |
- MeterInfoCardView(title: "Open Session", tint: statusTint(for: activeSession)) {
|
|
| 92 |
- MeterInfoRowView(label: "Started", value: activeSession.startedAt.format()) |
|
| 93 |
- MeterInfoRowView(label: "Status", value: activeSession.status.title) |
|
| 94 |
- MeterInfoRowView(label: "Duration", value: sessionDurationText(activeSession)) |
|
| 95 |
- MeterInfoRowView(label: "Charging Type", value: activeSession.chargingTransportMode.title) |
|
| 96 |
- MeterInfoRowView(label: "Charging Mode", value: activeSession.chargingStateMode.title) |
|
| 97 |
- MeterInfoRowView(label: "Battery Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 98 |
- if activeSession.chargingTransportMode == .wireless, |
|
| 99 |
- let effectiveBatteryEnergyWh = activeSession.effectiveBatteryEnergyWh, |
|
| 100 |
- abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 {
|
|
| 101 |
- MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 102 |
- } |
|
| 103 |
- MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: activeSession)) |
|
| 104 |
- MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title) |
|
| 105 |
- if chargedDevice.isCharger == false, |
|
| 106 |
- let chargerID = activeSession.chargerID, |
|
| 107 |
- let charger = appData.chargedDeviceSummary(id: chargerID) {
|
|
| 108 |
- MeterInfoRowView(label: "Wireless Charger", value: charger.name) |
|
| 109 |
- } |
|
| 110 |
- if let maximumObservedCurrentAmps = activeSession.maximumObservedCurrentAmps {
|
|
| 111 |
- MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A") |
|
| 112 |
- } |
|
| 113 |
- if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
|
|
| 114 |
- MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W") |
|
| 115 |
- } |
|
| 116 |
- if activeSession.chargingTransportMode == .wired, |
|
| 117 |
- let maximumObservedVoltageVolts = activeSession.maximumObservedVoltageVolts {
|
|
| 118 |
- MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V") |
|
| 119 |
- } |
|
| 120 |
- if chargedDevice.isCharger, let selectedSourceVoltageVolts = activeSession.selectedSourceVoltageVolts {
|
|
| 121 |
- MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V") |
|
| 122 |
- } |
|
| 123 |
- if let targetBatteryPercent = activeSession.targetBatteryPercent {
|
|
| 124 |
- MeterInfoRowView( |
|
| 125 |
- label: "Target Notification", |
|
| 126 |
- value: "\(targetBatteryPercent.format(decimalDigits: 0))%" |
|
| 127 |
- ) |
|
| 128 |
- } |
|
| 129 |
- if let sessionWarning = sessionWarning(for: activeSession) {
|
|
| 130 |
- Text(sessionWarning) |
|
| 131 |
- .font(.caption2) |
|
| 132 |
- .foregroundColor(.orange) |
|
| 133 |
- } |
|
| 134 |
- if let wirelessSessionHint = wirelessSessionHint(for: activeSession) {
|
|
| 135 |
- Text(wirelessSessionHint) |
|
| 136 |
- .font(.caption2) |
|
| 137 |
- .foregroundColor(activeSession.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary) |
|
| 138 |
- } |
|
| 139 |
- if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
|
|
| 140 |
- MeterInfoRowView( |
|
| 141 |
- label: "Predicted Battery", |
|
| 142 |
- value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%" |
|
| 143 |
- ) |
|
| 144 |
- Text( |
|
| 145 |
- "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity." |
|
| 146 |
- ) |
|
| 147 |
- .font(.caption2) |
|
| 148 |
- .foregroundColor(.secondary) |
|
| 149 |
- } |
|
| 150 |
- |
|
| 151 |
- BatteryCheckpointSectionView( |
|
| 152 |
- sessionID: activeSession.id, |
|
| 153 |
- checkpoints: activeSession.checkpoints, |
|
| 154 |
- message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.", |
|
| 155 |
- canAddCheckpoint: appData.canAddBatteryCheckpoint(to: activeSession.id), |
|
| 156 |
- requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: activeSession.id), |
|
| 157 |
- effectiveEnergyWhOverride: nil, |
|
| 158 |
- measuredChargeAhOverride: nil, |
|
| 159 |
- onDelete: { checkpoint in
|
|
| 160 |
- pendingCheckpointDeletion = checkpoint |
|
| 161 |
- } |
|
| 162 |
- ) |
|
| 163 |
- |
|
| 164 |
- Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
|
|
| 165 |
- targetNotificationEditorVisibility = true |
|
| 166 |
- } |
|
| 167 |
- .frame(maxWidth: .infinity) |
|
| 168 |
- .padding(.vertical, 10) |
|
| 169 |
- .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 170 |
- .buttonStyle(.plain) |
|
| 171 |
- |
|
| 172 |
- if activeSession.targetBatteryPercent != nil {
|
|
| 173 |
- Button("Clear Target Notification") {
|
|
| 174 |
- _ = appData.setTargetBatteryPercent(nil, for: activeSession.id) |
|
| 175 |
- } |
|
| 176 |
- .frame(maxWidth: .infinity) |
|
| 177 |
- .padding(.vertical, 10) |
|
| 178 |
- .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14) |
|
| 179 |
- .buttonStyle(.plain) |
|
| 180 |
- } |
|
| 181 |
- |
|
| 182 |
- if activeSession.status == .active {
|
|
| 183 |
- Button("Pause Session") {
|
|
| 184 |
- _ = appData.pauseChargeSession(sessionID: activeSession.id) |
|
| 185 |
- } |
|
| 186 |
- .frame(maxWidth: .infinity) |
|
| 187 |
- .padding(.vertical, 10) |
|
| 188 |
- .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 189 |
- .buttonStyle(.plain) |
|
| 190 |
- } else if activeSession.status == .paused {
|
|
| 191 |
- Button("Resume Session") {
|
|
| 192 |
- _ = appData.resumeChargeSession(sessionID: activeSession.id) |
|
| 193 |
- } |
|
| 194 |
- .frame(maxWidth: .infinity) |
|
| 195 |
- .padding(.vertical, 10) |
|
| 196 |
- .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 197 |
- .buttonStyle(.plain) |
|
| 198 |
- |
|
| 199 |
- Text("Paused sessions close automatically after 10 minutes.")
|
|
| 200 |
- .font(.caption2) |
|
| 201 |
- .foregroundColor(.secondary) |
|
| 202 |
- } |
|
| 203 |
- |
|
| 204 |
- Button(activeSession.requiresCompletionConfirmation ? "Finish Session With Checkpoint" : "Stop Session") {
|
|
| 205 |
- pendingSessionStopRequest = ActiveDeviceSessionStopRequest( |
|
| 206 |
- sessionID: activeSession.id, |
|
| 207 |
- title: activeSession.requiresCompletionConfirmation ? "Finish Session" : "Stop Session", |
|
| 208 |
- confirmTitle: activeSession.requiresCompletionConfirmation ? "Finish" : "Stop", |
|
| 209 |
- explanation: "Add the final battery checkpoint before closing this session." |
|
| 210 |
- ) |
|
| 211 |
- } |
|
| 212 |
- .frame(maxWidth: .infinity) |
|
| 213 |
- .padding(.vertical, 10) |
|
| 214 |
- .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 215 |
- .buttonStyle(.plain) |
|
| 216 |
- |
|
| 217 |
- if activeSession.requiresCompletionConfirmation {
|
|
| 218 |
- Divider() |
|
| 219 |
- if let contradictionPercent = activeSession.completionContradictionPercent {
|
|
| 220 |
- Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
|
|
| 221 |
- .font(.caption2) |
|
| 222 |
- .foregroundColor(.secondary) |
|
| 223 |
- } |
|
| 224 |
- |
|
| 225 |
- Button("Keep Monitoring") {
|
|
| 226 |
- _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id) |
|
| 227 |
- } |
|
| 228 |
- .frame(maxWidth: .infinity) |
|
| 229 |
- .padding(.vertical, 10) |
|
| 230 |
- .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 231 |
- .buttonStyle(.plain) |
|
| 232 |
- } |
|
| 233 |
- } |
|
| 234 |
- } |
|
| 235 |
- |
|
| 236 |
- private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 237 |
- let displayedSamples = session.displayedAggregatedSamples |
|
| 238 |
- let currentSeries = storedSeriesSnapshot( |
|
| 239 |
- from: displayedSamples, |
|
| 240 |
- minimumYSpan: 0.15 |
|
| 241 |
- ) { $0.averageCurrentAmps }
|
|
| 242 |
- let energySeries = storedSeriesSnapshot( |
|
| 243 |
- from: displayedSamples, |
|
| 244 |
- minimumYSpan: 0.2 |
|
| 245 |
- ) { $0.measuredEnergyWh }
|
|
| 246 |
- |
|
| 247 |
- return VStack(alignment: .leading, spacing: 14) {
|
|
| 248 |
- HStack(alignment: .firstTextBaseline) {
|
|
| 249 |
- VStack(alignment: .leading, spacing: 4) {
|
|
| 250 |
- HStack(spacing: 8) {
|
|
| 251 |
- Text("Stored Session Curve")
|
|
| 252 |
- .font(.headline) |
|
| 253 |
- ContextInfoButton( |
|
| 254 |
- title: "Stored Session Curve", |
|
| 255 |
- 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." |
|
| 256 |
- ) |
|
| 257 |
- } |
|
| 258 |
- Text("Open session, persisted as aggregated samples.")
|
|
| 259 |
- .font(.caption) |
|
| 260 |
- .foregroundColor(.secondary) |
|
| 261 |
- } |
|
| 262 |
- |
|
| 263 |
- Spacer() |
|
| 264 |
- |
|
| 265 |
- Text("\(displayedSamples.count) points")
|
|
| 266 |
- .font(.caption.weight(.semibold)) |
|
| 267 |
- .foregroundColor(.secondary) |
|
| 268 |
- } |
|
| 269 |
- |
|
| 270 |
- if let currentSeries {
|
|
| 271 |
- storedSeriesChart( |
|
| 272 |
- title: "Current", |
|
| 273 |
- unit: "A", |
|
| 274 |
- strokeColor: .blue, |
|
| 275 |
- snapshot: currentSeries |
|
| 276 |
- ) |
|
| 277 |
- } |
|
| 278 |
- |
|
| 279 |
- if let energySeries {
|
|
| 280 |
- storedSeriesChart( |
|
| 281 |
- title: "Energy", |
|
| 282 |
- unit: "Wh", |
|
| 283 |
- strokeColor: .teal, |
|
| 284 |
- areaChart: true, |
|
| 285 |
- snapshot: energySeries |
|
| 286 |
- ) |
|
| 287 |
- } |
|
| 288 |
- } |
|
| 289 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 290 |
- .padding(18) |
|
| 291 |
- .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18) |
|
| 292 |
- } |
|
| 293 |
- |
|
| 294 |
- private func storedSeriesSnapshot( |
|
| 295 |
- from samples: [ChargeSessionSampleSummary], |
|
| 296 |
- minimumYSpan: Double, |
|
| 297 |
- value: (ChargeSessionSampleSummary) -> Double |
|
| 298 |
- ) -> ActiveSessionSeriesSnapshot? {
|
|
| 299 |
- let sortedSamples = samples.sorted { lhs, rhs in
|
|
| 300 |
- if lhs.bucketIndex != rhs.bucketIndex {
|
|
| 301 |
- return lhs.bucketIndex < rhs.bucketIndex |
|
| 302 |
- } |
|
| 303 |
- return lhs.timestamp < rhs.timestamp |
|
| 304 |
- } |
|
| 305 |
- |
|
| 306 |
- guard |
|
| 307 |
- let firstSample = sortedSamples.first, |
|
| 308 |
- let lastSample = sortedSamples.last |
|
| 309 |
- else {
|
|
| 310 |
- return nil |
|
| 311 |
- } |
|
| 312 |
- |
|
| 313 |
- let points = sortedSamples.enumerated().map { index, sample in
|
|
| 314 |
- Measurements.Measurement.Point( |
|
| 315 |
- id: index, |
|
| 316 |
- timestamp: sample.timestamp, |
|
| 317 |
- value: value(sample), |
|
| 318 |
- kind: .sample |
|
| 319 |
- ) |
|
| 320 |
- } |
|
| 321 |
- |
|
| 322 |
- let minimumValue = points.map(\.value).min() ?? 0 |
|
| 323 |
- let maximumValue = points.map(\.value).max() ?? minimumValue |
|
| 324 |
- let context = ChartContext() |
|
| 325 |
- context.setBounds( |
|
| 326 |
- xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970), |
|
| 327 |
- xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)), |
|
| 328 |
- yMin: CGFloat(minimumValue), |
|
| 329 |
- yMax: CGFloat(maximumValue) |
|
| 330 |
- ) |
|
| 331 |
- context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan))) |
|
| 332 |
- |
|
| 333 |
- return ActiveSessionSeriesSnapshot( |
|
| 334 |
- points: points, |
|
| 335 |
- context: context, |
|
| 336 |
- minimumValue: minimumValue, |
|
| 337 |
- maximumValue: maximumValue |
|
| 338 |
- ) |
|
| 339 |
- } |
|
| 340 |
- |
|
| 341 |
- private func storedSeriesChart( |
|
| 342 |
- title: String, |
|
| 343 |
- unit: String, |
|
| 344 |
- strokeColor: Color, |
|
| 345 |
- areaChart: Bool = false, |
|
| 346 |
- snapshot: ActiveSessionSeriesSnapshot |
|
| 347 |
- ) -> some View {
|
|
| 348 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 349 |
- HStack(alignment: .firstTextBaseline) {
|
|
| 350 |
- Text(title) |
|
| 351 |
- .font(.subheadline.weight(.semibold)) |
|
| 352 |
- Spacer() |
|
| 353 |
- Text( |
|
| 354 |
- "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) - \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)" |
|
| 355 |
- ) |
|
| 356 |
- .font(.caption2) |
|
| 357 |
- .foregroundColor(.secondary) |
|
| 358 |
- } |
|
| 359 |
- |
|
| 360 |
- TimeSeriesChart( |
|
| 361 |
- points: snapshot.points, |
|
| 362 |
- context: snapshot.context, |
|
| 363 |
- areaChart: areaChart, |
|
| 364 |
- strokeColor: strokeColor |
|
| 365 |
- ) |
|
| 366 |
- .frame(height: 118) |
|
| 367 |
- .padding(.horizontal, 6) |
|
| 368 |
- .padding(.vertical, 8) |
|
| 369 |
- .background( |
|
| 370 |
- RoundedRectangle(cornerRadius: 16) |
|
| 371 |
- .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06)) |
|
| 372 |
- ) |
|
| 373 |
- |
|
| 374 |
- HStack {
|
|
| 375 |
- Text(snapshot.startLabel) |
|
| 376 |
- Spacer() |
|
| 377 |
- Text(snapshot.endLabel) |
|
| 378 |
- } |
|
| 379 |
- .font(.caption2) |
|
| 380 |
- .foregroundColor(.secondary) |
|
| 381 |
- } |
|
| 382 |
- } |
|
| 383 |
- |
|
| 384 |
- private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
|
|
| 385 |
- let formatter = DateComponentsFormatter() |
|
| 386 |
- let effectiveDuration = max(session.effectiveDuration, 0) |
|
| 387 |
- formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 388 |
- formatter.unitsStyle = .abbreviated |
|
| 389 |
- formatter.zeroFormattingBehavior = .dropAll |
|
| 390 |
- return formatter.string(from: effectiveDuration) ?? "0m" |
|
| 391 |
- } |
|
| 392 |
- |
|
| 393 |
- private func autoStopDescription(for session: ChargeSessionSummary) -> String {
|
|
| 394 |
- if session.autoStopEnabled == false {
|
|
| 395 |
- return "Manual" |
|
| 396 |
- } |
|
| 397 |
- |
|
| 398 |
- if let sessionWarning = sessionWarning(for: session), |
|
| 399 |
- sessionWarning.contains("idle-current") {
|
|
| 400 |
- return "Blocked by charger setup" |
|
| 401 |
- } |
|
| 402 |
- |
|
| 403 |
- if session.stopThresholdAmps > 0 {
|
|
| 404 |
- return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A" |
|
| 405 |
- } |
|
| 406 |
- |
|
| 407 |
- return "Learning" |
|
| 408 |
- } |
|
| 409 |
- |
|
| 410 |
- private func sessionWarning(for session: ChargeSessionSummary) -> String? {
|
|
| 411 |
- guard session.chargingTransportMode == .wireless, |
|
| 412 |
- let chargerID = session.chargerID, |
|
| 413 |
- let charger = appData.chargedDeviceSummary(id: chargerID), |
|
| 414 |
- charger.chargerIdleCurrentAmps == nil else {
|
|
| 415 |
- return nil |
|
| 416 |
- } |
|
| 417 |
- |
|
| 418 |
- return "This charger has no idle-current measurement, so the wireless stop threshold cannot be learned or auto-applied for this session." |
|
| 419 |
- } |
|
| 420 |
- |
|
| 421 |
- private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
|
|
| 422 |
- guard session.chargingTransportMode == .wireless else {
|
|
| 423 |
- return nil |
|
| 424 |
- } |
|
| 425 |
- |
|
| 426 |
- var components: [String] = [] |
|
| 427 |
- if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
|
|
| 428 |
- components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
|
|
| 429 |
- } |
|
| 430 |
- if session.usesEstimatedWirelessEfficiency {
|
|
| 431 |
- components.append("Estimated from wired baseline and checkpoints")
|
|
| 432 |
- } |
|
| 433 |
- if session.shouldWarnAboutLowWirelessEfficiency {
|
|
| 434 |
- components.append("Low wireless efficiency, so capacity confidence is reduced")
|
|
| 435 |
- } |
|
| 436 |
- |
|
| 437 |
- return components.isEmpty ? nil : components.joined(separator: " - ") |
|
| 438 |
- } |
|
| 439 |
- |
|
| 440 |
- private func statusTint(for session: ChargeSessionSummary) -> Color {
|
|
| 441 |
- switch session.status {
|
|
| 442 |
- case .active: |
|
| 443 |
- return .green |
|
| 444 |
- case .paused: |
|
| 445 |
- return .orange |
|
| 446 |
- case .completed: |
|
| 447 |
- return .teal |
|
| 448 |
- case .abandoned: |
|
| 449 |
- return .secondary |
|
| 450 |
- } |
|
| 451 |
- } |
|
| 452 |
-} |
|
| 453 |
- |
|
| 454 |
-private struct ActiveSessionSeriesSnapshot {
|
|
| 455 |
- let points: [Measurements.Measurement.Point] |
|
| 456 |
- let context: ChartContext |
|
| 457 |
- let minimumValue: Double |
|
| 458 |
- let maximumValue: Double |
|
| 459 |
- |
|
| 460 |
- var lastValue: Double {
|
|
| 461 |
- points.last?.value ?? 0 |
|
| 462 |
- } |
|
| 463 |
- |
|
| 464 |
- var startLabel: String {
|
|
| 465 |
- guard let firstTimestamp = points.first?.timestamp else { return "" }
|
|
| 466 |
- return firstTimestamp.formatted(date: .omitted, time: .shortened) |
|
| 467 |
- } |
|
| 468 |
- |
|
| 469 |
- var endLabel: String {
|
|
| 470 |
- guard let lastTimestamp = points.last?.timestamp else { return "" }
|
|
| 471 |
- return lastTimestamp.formatted(date: .omitted, time: .shortened) |
|
| 472 |
- } |
|
| 473 |
-} |
|
| 474 |
- |
|
| 475 |
-private struct ActiveSessionTargetNotificationEditorSheetView: View {
|
|
| 476 |
- @Environment(\.dismiss) private var dismiss |
|
| 477 |
- @EnvironmentObject private var appData: AppData |
|
| 478 |
- |
|
| 479 |
- let sessionID: UUID |
|
| 480 |
- let initialTargetPercent: Double? |
|
| 481 |
- |
|
| 482 |
- @State private var targetPercent: Double |
|
| 483 |
- |
|
| 484 |
- init(sessionID: UUID, initialTargetPercent: Double?) {
|
|
| 485 |
- self.sessionID = sessionID |
|
| 486 |
- self.initialTargetPercent = initialTargetPercent |
|
| 487 |
- _targetPercent = State(initialValue: initialTargetPercent ?? 80) |
|
| 488 |
- } |
|
| 489 |
- |
|
| 490 |
- var body: some View {
|
|
| 491 |
- NavigationView {
|
|
| 492 |
- Form {
|
|
| 493 |
- Section( |
|
| 494 |
- header: ContextInfoHeader( |
|
| 495 |
- title: "Target Level", |
|
| 496 |
- message: "A local notification will be generated on synced devices when the estimated battery level reaches this target." |
|
| 497 |
- ) |
|
| 498 |
- ) {
|
|
| 499 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 500 |
- Text("\(targetPercent.format(decimalDigits: 0))%")
|
|
| 501 |
- .font(.title3.weight(.bold)) |
|
| 502 |
- Slider(value: $targetPercent, in: 20...100, step: 1) |
|
| 503 |
- } |
|
| 504 |
- } |
|
| 505 |
- } |
|
| 506 |
- .navigationTitle("Battery Target")
|
|
| 507 |
- .navigationBarTitleDisplayMode(.inline) |
|
| 508 |
- .toolbar {
|
|
| 509 |
- ToolbarItem(placement: .cancellationAction) {
|
|
| 510 |
- Button("Cancel") {
|
|
| 511 |
- dismiss() |
|
| 512 |
- } |
|
| 513 |
- } |
|
| 514 |
- |
|
| 515 |
- ToolbarItem(placement: .confirmationAction) {
|
|
| 516 |
- Button("Save") {
|
|
| 517 |
- if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
|
|
| 518 |
- dismiss() |
|
| 519 |
- } |
|
| 520 |
- } |
|
| 521 |
- } |
|
| 522 |
- } |
|
| 523 |
- } |
|
| 524 |
- .navigationViewStyle(StackNavigationViewStyle()) |
|
| 525 |
- } |
|
| 526 |
-} |
|
| 527 |
- |
|
| 528 |
-private struct ActiveDeviceSessionStopRequest: Identifiable {
|
|
| 529 |
- let sessionID: UUID |
|
| 530 |
- let title: String |
|
| 531 |
- let confirmTitle: String |
|
| 532 |
- let explanation: String |
|
| 533 |
- |
|
| 534 |
- var id: UUID {
|
|
| 535 |
- sessionID |
|
| 536 |
- } |
|
| 537 |
-} |
|
@@ -1,427 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// ChargedDeviceSessionDetailView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
-// Created by Codex on 22/04/2026. |
|
| 6 |
-// |
|
| 7 |
- |
|
| 8 |
-import SwiftUI |
|
| 9 |
- |
|
| 10 |
-struct ChargedDeviceSessionDetailView: View {
|
|
| 11 |
- @EnvironmentObject private var appData: AppData |
|
| 12 |
- @State private var pendingSessionDeletion: ChargeSessionSummary? |
|
| 13 |
- @State private var pendingCheckpointDeletion: ChargeCheckpointSummary? |
|
| 14 |
- |
|
| 15 |
- let chargedDeviceID: UUID |
|
| 16 |
- let sessionID: UUID |
|
| 17 |
- |
|
| 18 |
- private var chargedDevice: ChargedDeviceSummary? {
|
|
| 19 |
- appData.chargedDeviceSummary(id: chargedDeviceID) |
|
| 20 |
- } |
|
| 21 |
- |
|
| 22 |
- private var session: ChargeSessionSummary? {
|
|
| 23 |
- chargedDevice?.sessions.first(where: { $0.id == sessionID })
|
|
| 24 |
- } |
|
| 25 |
- |
|
| 26 |
- var body: some View {
|
|
| 27 |
- Group {
|
|
| 28 |
- if let chargedDevice, let session {
|
|
| 29 |
- ScrollView {
|
|
| 30 |
- VStack(spacing: 16) {
|
|
| 31 |
- overviewCard(session, chargedDevice: chargedDevice) |
|
| 32 |
- energyCard(session, chargedDevice: chargedDevice) |
|
| 33 |
- observedMetricsCard(session, chargedDevice: chargedDevice) |
|
| 34 |
- batteryCard(session) |
|
| 35 |
- |
|
| 36 |
- if !session.displayedAggregatedSamples.isEmpty {
|
|
| 37 |
- storedCurveCard(session) |
|
| 38 |
- } |
|
| 39 |
- |
|
| 40 |
- managementCard(session) |
|
| 41 |
- } |
|
| 42 |
- .padding() |
|
| 43 |
- } |
|
| 44 |
- .background( |
|
| 45 |
- LinearGradient( |
|
| 46 |
- colors: [statusTint(for: session).opacity(0.14), Color.clear], |
|
| 47 |
- startPoint: .topLeading, |
|
| 48 |
- endPoint: .bottomTrailing |
|
| 49 |
- ) |
|
| 50 |
- .ignoresSafeArea() |
|
| 51 |
- ) |
|
| 52 |
- .navigationTitle("Session Details")
|
|
| 53 |
- } else {
|
|
| 54 |
- Text("This session is no longer available.")
|
|
| 55 |
- .foregroundColor(.secondary) |
|
| 56 |
- .navigationTitle("Session")
|
|
| 57 |
- } |
|
| 58 |
- } |
|
| 59 |
- .alert(item: $pendingSessionDeletion) { session in
|
|
| 60 |
- Alert( |
|
| 61 |
- title: Text("Delete Session?"),
|
|
| 62 |
- message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
|
|
| 63 |
- primaryButton: .destructive(Text("Delete")) {
|
|
| 64 |
- _ = appData.deleteChargeSession(sessionID: session.id) |
|
| 65 |
- }, |
|
| 66 |
- secondaryButton: .cancel() |
|
| 67 |
- ) |
|
| 68 |
- } |
|
| 69 |
- .alert(item: $pendingCheckpointDeletion) { checkpoint in
|
|
| 70 |
- Alert( |
|
| 71 |
- title: Text("Delete Battery Checkpoint"),
|
|
| 72 |
- message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
|
|
| 73 |
- primaryButton: .destructive(Text("Delete")) {
|
|
| 74 |
- _ = appData.deleteBatteryCheckpoint( |
|
| 75 |
- checkpointID: checkpoint.id, |
|
| 76 |
- for: checkpoint.sessionID |
|
| 77 |
- ) |
|
| 78 |
- }, |
|
| 79 |
- secondaryButton: .cancel() |
|
| 80 |
- ) |
|
| 81 |
- } |
|
| 82 |
- } |
|
| 83 |
- |
|
| 84 |
- private func overviewCard( |
|
| 85 |
- _ session: ChargeSessionSummary, |
|
| 86 |
- chargedDevice: ChargedDeviceSummary |
|
| 87 |
- ) -> some View {
|
|
| 88 |
- MeterInfoCardView(title: "Overview", tint: statusTint(for: session)) {
|
|
| 89 |
- MeterInfoRowView(label: "Device", value: chargedDevice.name) |
|
| 90 |
- MeterInfoRowView(label: "Status", value: session.status.title) |
|
| 91 |
- MeterInfoRowView(label: "Started", value: session.startedAt.format()) |
|
| 92 |
- if let endedAt = session.endedAt {
|
|
| 93 |
- MeterInfoRowView(label: "Ended", value: endedAt.format()) |
|
| 94 |
- } |
|
| 95 |
- MeterInfoRowView(label: "Duration", value: sessionDurationText(session)) |
|
| 96 |
- MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title) |
|
| 97 |
- MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title) |
|
| 98 |
- MeterInfoRowView(label: "Source", value: session.sourceMode.title) |
|
| 99 |
- MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session)) |
|
| 100 |
- if session.isTrimmed {
|
|
| 101 |
- MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format()) |
|
| 102 |
- MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format()) |
|
| 103 |
- } |
|
| 104 |
- if let meterName = session.meterName {
|
|
| 105 |
- MeterInfoRowView(label: "Meter", value: meterName) |
|
| 106 |
- } else if let meterMACAddress = session.meterMACAddress {
|
|
| 107 |
- MeterInfoRowView(label: "Meter", value: meterMACAddress) |
|
| 108 |
- } |
|
| 109 |
- if let meterModel = session.meterModel {
|
|
| 110 |
- MeterInfoRowView(label: "Meter Model", value: meterModel) |
|
| 111 |
- } |
|
| 112 |
- } |
|
| 113 |
- } |
|
| 114 |
- |
|
| 115 |
- private func energyCard( |
|
| 116 |
- _ session: ChargeSessionSummary, |
|
| 117 |
- chargedDevice: ChargedDeviceSummary |
|
| 118 |
- ) -> some View {
|
|
| 119 |
- MeterInfoCardView(title: "Energy", tint: .teal) {
|
|
| 120 |
- MeterInfoRowView(label: "Battery Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 121 |
- MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 122 |
- MeterInfoRowView(label: "Measured Charge", value: "\(session.measuredChargeAh.format(decimalDigits: 3)) Ah") |
|
| 123 |
- if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh, |
|
| 124 |
- abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
|
|
| 125 |
- MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh") |
|
| 126 |
- } |
|
| 127 |
- if let capacityEstimateWh = session.capacityEstimateWh {
|
|
| 128 |
- MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh") |
|
| 129 |
- } |
|
| 130 |
- if let chargerID = session.chargerID, |
|
| 131 |
- let charger = appData.chargedDeviceSummary(id: chargerID) {
|
|
| 132 |
- MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name) |
|
| 133 |
- } |
|
| 134 |
- if let wirelessSessionHint = wirelessSessionHint(for: session) {
|
|
| 135 |
- Text(wirelessSessionHint) |
|
| 136 |
- .font(.caption2) |
|
| 137 |
- .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary) |
|
| 138 |
- } |
|
| 139 |
- } |
|
| 140 |
- } |
|
| 141 |
- |
|
| 142 |
- private func observedMetricsCard( |
|
| 143 |
- _ session: ChargeSessionSummary, |
|
| 144 |
- chargedDevice: ChargedDeviceSummary |
|
| 145 |
- ) -> some View {
|
|
| 146 |
- MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
|
|
| 147 |
- if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
|
|
| 148 |
- MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A") |
|
| 149 |
- } |
|
| 150 |
- if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
|
|
| 151 |
- MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A") |
|
| 152 |
- } |
|
| 153 |
- if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
|
|
| 154 |
- MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W") |
|
| 155 |
- } |
|
| 156 |
- if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
|
|
| 157 |
- MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V") |
|
| 158 |
- } |
|
| 159 |
- if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
|
|
| 160 |
- MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V") |
|
| 161 |
- } |
|
| 162 |
- if let completionCurrentAmps = session.completionCurrentAmps {
|
|
| 163 |
- MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A") |
|
| 164 |
- } |
|
| 165 |
- if session.selectedDataGroup != nil {
|
|
| 166 |
- MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)") |
|
| 167 |
- } |
|
| 168 |
- } |
|
| 169 |
- } |
|
| 170 |
- |
|
| 171 |
- private func batteryCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 172 |
- MeterInfoCardView(title: "Battery", tint: .orange) {
|
|
| 173 |
- if let startBatteryPercent = session.startBatteryPercent {
|
|
| 174 |
- MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%") |
|
| 175 |
- } |
|
| 176 |
- if let endBatteryPercent = session.endBatteryPercent {
|
|
| 177 |
- MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%") |
|
| 178 |
- } |
|
| 179 |
- if let batteryDeltaPercent = session.batteryDeltaPercent {
|
|
| 180 |
- MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%") |
|
| 181 |
- } |
|
| 182 |
- |
|
| 183 |
- BatteryCheckpointSectionView( |
|
| 184 |
- sessionID: session.id, |
|
| 185 |
- checkpoints: session.checkpoints, |
|
| 186 |
- message: "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.", |
|
| 187 |
- canAddCheckpoint: false, |
|
| 188 |
- requirementMessage: nil, |
|
| 189 |
- effectiveEnergyWhOverride: nil, |
|
| 190 |
- measuredChargeAhOverride: nil, |
|
| 191 |
- onDelete: { checkpoint in
|
|
| 192 |
- pendingCheckpointDeletion = checkpoint |
|
| 193 |
- } |
|
| 194 |
- ) |
|
| 195 |
- } |
|
| 196 |
- } |
|
| 197 |
- |
|
| 198 |
- private func managementCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 199 |
- MeterInfoCardView(title: "Administration", tint: .red) {
|
|
| 200 |
- Button(role: .destructive) {
|
|
| 201 |
- pendingSessionDeletion = session |
|
| 202 |
- } label: {
|
|
| 203 |
- Label("Delete Session", systemImage: "trash")
|
|
| 204 |
- .font(.subheadline.weight(.semibold)) |
|
| 205 |
- .frame(maxWidth: .infinity) |
|
| 206 |
- .padding(.vertical, 10) |
|
| 207 |
- .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14) |
|
| 208 |
- } |
|
| 209 |
- .buttonStyle(.plain) |
|
| 210 |
- } |
|
| 211 |
- } |
|
| 212 |
- |
|
| 213 |
- private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 214 |
- let displayedSamples = session.displayedAggregatedSamples |
|
| 215 |
- let currentSeries = storedSeriesSnapshot( |
|
| 216 |
- from: displayedSamples, |
|
| 217 |
- minimumYSpan: 0.15 |
|
| 218 |
- ) { $0.averageCurrentAmps }
|
|
| 219 |
- let energySeries = storedSeriesSnapshot( |
|
| 220 |
- from: displayedSamples, |
|
| 221 |
- minimumYSpan: 0.2 |
|
| 222 |
- ) { $0.measuredEnergyWh }
|
|
| 223 |
- |
|
| 224 |
- return VStack(alignment: .leading, spacing: 14) {
|
|
| 225 |
- HStack(alignment: .firstTextBaseline) {
|
|
| 226 |
- VStack(alignment: .leading, spacing: 4) {
|
|
| 227 |
- Text("Session Curve")
|
|
| 228 |
- .font(.headline) |
|
| 229 |
- Text(session.isTrimmed ? "Showing the saved trim window." : "Persisted aggregate samples for this session.") |
|
| 230 |
- .font(.caption) |
|
| 231 |
- .foregroundColor(.secondary) |
|
| 232 |
- } |
|
| 233 |
- |
|
| 234 |
- Spacer() |
|
| 235 |
- |
|
| 236 |
- Text("\(displayedSamples.count) points")
|
|
| 237 |
- .font(.caption.weight(.semibold)) |
|
| 238 |
- .foregroundColor(.secondary) |
|
| 239 |
- } |
|
| 240 |
- |
|
| 241 |
- if let currentSeries {
|
|
| 242 |
- storedSeriesChart( |
|
| 243 |
- title: "Current", |
|
| 244 |
- unit: "A", |
|
| 245 |
- strokeColor: .blue, |
|
| 246 |
- snapshot: currentSeries |
|
| 247 |
- ) |
|
| 248 |
- } |
|
| 249 |
- |
|
| 250 |
- if let energySeries {
|
|
| 251 |
- storedSeriesChart( |
|
| 252 |
- title: "Energy", |
|
| 253 |
- unit: "Wh", |
|
| 254 |
- strokeColor: .teal, |
|
| 255 |
- areaChart: true, |
|
| 256 |
- snapshot: energySeries |
|
| 257 |
- ) |
|
| 258 |
- } |
|
| 259 |
- } |
|
| 260 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 261 |
- .padding(18) |
|
| 262 |
- .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18) |
|
| 263 |
- } |
|
| 264 |
- |
|
| 265 |
- private func storedSeriesSnapshot( |
|
| 266 |
- from samples: [ChargeSessionSampleSummary], |
|
| 267 |
- minimumYSpan: Double, |
|
| 268 |
- value: (ChargeSessionSampleSummary) -> Double |
|
| 269 |
- ) -> StoredSessionSeriesSnapshot? {
|
|
| 270 |
- let sortedSamples = samples.sorted { lhs, rhs in
|
|
| 271 |
- if lhs.bucketIndex != rhs.bucketIndex {
|
|
| 272 |
- return lhs.bucketIndex < rhs.bucketIndex |
|
| 273 |
- } |
|
| 274 |
- return lhs.timestamp < rhs.timestamp |
|
| 275 |
- } |
|
| 276 |
- |
|
| 277 |
- guard |
|
| 278 |
- let firstSample = sortedSamples.first, |
|
| 279 |
- let lastSample = sortedSamples.last |
|
| 280 |
- else {
|
|
| 281 |
- return nil |
|
| 282 |
- } |
|
| 283 |
- |
|
| 284 |
- let points = sortedSamples.enumerated().map { index, sample in
|
|
| 285 |
- Measurements.Measurement.Point( |
|
| 286 |
- id: index, |
|
| 287 |
- timestamp: sample.timestamp, |
|
| 288 |
- value: value(sample), |
|
| 289 |
- kind: .sample |
|
| 290 |
- ) |
|
| 291 |
- } |
|
| 292 |
- |
|
| 293 |
- let minimumValue = points.map(\.value).min() ?? 0 |
|
| 294 |
- let maximumValue = points.map(\.value).max() ?? minimumValue |
|
| 295 |
- let context = ChartContext() |
|
| 296 |
- context.setBounds( |
|
| 297 |
- xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970), |
|
| 298 |
- xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)), |
|
| 299 |
- yMin: CGFloat(minimumValue), |
|
| 300 |
- yMax: CGFloat(maximumValue) |
|
| 301 |
- ) |
|
| 302 |
- context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan))) |
|
| 303 |
- |
|
| 304 |
- return StoredSessionSeriesSnapshot( |
|
| 305 |
- points: points, |
|
| 306 |
- context: context, |
|
| 307 |
- minimumValue: minimumValue, |
|
| 308 |
- maximumValue: maximumValue |
|
| 309 |
- ) |
|
| 310 |
- } |
|
| 311 |
- |
|
| 312 |
- private func storedSeriesChart( |
|
| 313 |
- title: String, |
|
| 314 |
- unit: String, |
|
| 315 |
- strokeColor: Color, |
|
| 316 |
- areaChart: Bool = false, |
|
| 317 |
- snapshot: StoredSessionSeriesSnapshot |
|
| 318 |
- ) -> some View {
|
|
| 319 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 320 |
- HStack(alignment: .firstTextBaseline) {
|
|
| 321 |
- Text(title) |
|
| 322 |
- .font(.subheadline.weight(.semibold)) |
|
| 323 |
- Spacer() |
|
| 324 |
- Text( |
|
| 325 |
- "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) - \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)" |
|
| 326 |
- ) |
|
| 327 |
- .font(.caption2) |
|
| 328 |
- .foregroundColor(.secondary) |
|
| 329 |
- } |
|
| 330 |
- |
|
| 331 |
- TimeSeriesChart( |
|
| 332 |
- points: snapshot.points, |
|
| 333 |
- context: snapshot.context, |
|
| 334 |
- areaChart: areaChart, |
|
| 335 |
- strokeColor: strokeColor |
|
| 336 |
- ) |
|
| 337 |
- .frame(height: 118) |
|
| 338 |
- .padding(.horizontal, 6) |
|
| 339 |
- .padding(.vertical, 8) |
|
| 340 |
- .background( |
|
| 341 |
- RoundedRectangle(cornerRadius: 16) |
|
| 342 |
- .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06)) |
|
| 343 |
- ) |
|
| 344 |
- |
|
| 345 |
- HStack {
|
|
| 346 |
- Text(snapshot.startLabel) |
|
| 347 |
- Spacer() |
|
| 348 |
- Text(snapshot.endLabel) |
|
| 349 |
- } |
|
| 350 |
- .font(.caption2) |
|
| 351 |
- .foregroundColor(.secondary) |
|
| 352 |
- } |
|
| 353 |
- } |
|
| 354 |
- |
|
| 355 |
- private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
|
|
| 356 |
- let formatter = DateComponentsFormatter() |
|
| 357 |
- let effectiveDuration = max(session.effectiveDuration, 0) |
|
| 358 |
- formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 359 |
- formatter.unitsStyle = .abbreviated |
|
| 360 |
- formatter.zeroFormattingBehavior = .dropAll |
|
| 361 |
- return formatter.string(from: effectiveDuration) ?? "0m" |
|
| 362 |
- } |
|
| 363 |
- |
|
| 364 |
- private func autoStopDescription(for session: ChargeSessionSummary) -> String {
|
|
| 365 |
- if session.autoStopEnabled == false {
|
|
| 366 |
- return "Manual" |
|
| 367 |
- } |
|
| 368 |
- if session.stopThresholdAmps > 0 {
|
|
| 369 |
- return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A" |
|
| 370 |
- } |
|
| 371 |
- return "Learning" |
|
| 372 |
- } |
|
| 373 |
- |
|
| 374 |
- private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
|
|
| 375 |
- guard session.chargingTransportMode == .wireless else {
|
|
| 376 |
- return nil |
|
| 377 |
- } |
|
| 378 |
- |
|
| 379 |
- var components: [String] = [] |
|
| 380 |
- if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
|
|
| 381 |
- components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
|
|
| 382 |
- } |
|
| 383 |
- if session.usesEstimatedWirelessEfficiency {
|
|
| 384 |
- components.append("Estimated from wired baseline and checkpoints")
|
|
| 385 |
- } |
|
| 386 |
- if session.shouldWarnAboutLowWirelessEfficiency {
|
|
| 387 |
- components.append("Low wireless efficiency, so capacity confidence is reduced")
|
|
| 388 |
- } |
|
| 389 |
- |
|
| 390 |
- return components.isEmpty ? nil : components.joined(separator: " - ") |
|
| 391 |
- } |
|
| 392 |
- |
|
| 393 |
- private func statusTint(for session: ChargeSessionSummary) -> Color {
|
|
| 394 |
- switch session.status {
|
|
| 395 |
- case .active: |
|
| 396 |
- return .green |
|
| 397 |
- case .paused: |
|
| 398 |
- return .orange |
|
| 399 |
- case .completed: |
|
| 400 |
- return .teal |
|
| 401 |
- case .abandoned: |
|
| 402 |
- return .secondary |
|
| 403 |
- } |
|
| 404 |
- } |
|
| 405 |
-} |
|
| 406 |
- |
|
| 407 |
-private struct StoredSessionSeriesSnapshot {
|
|
| 408 |
- let points: [Measurements.Measurement.Point] |
|
| 409 |
- let context: ChartContext |
|
| 410 |
- let minimumValue: Double |
|
| 411 |
- let maximumValue: Double |
|
| 412 |
- |
|
| 413 |
- var lastValue: Double {
|
|
| 414 |
- points.last?.value ?? 0 |
|
| 415 |
- } |
|
| 416 |
- |
|
| 417 |
- var startLabel: String {
|
|
| 418 |
- guard let firstTimestamp = points.first?.timestamp else { return "" }
|
|
| 419 |
- return firstTimestamp.formatted(date: .omitted, time: .shortened) |
|
| 420 |
- } |
|
| 421 |
- |
|
| 422 |
- var endLabel: String {
|
|
| 423 |
- guard let lastTimestamp = points.last?.timestamp else { return "" }
|
|
| 424 |
- return lastTimestamp.formatted(date: .omitted, time: .shortened) |
|
| 425 |
- } |
|
| 426 |
-} |
|
| 427 |
- |
|
@@ -21,6 +21,22 @@ struct ChargedDeviceSessionsView: View {
|
||
| 21 | 21 |
chargedDevice?.sessions.filter { !$0.status.isOpen } ?? []
|
| 22 | 22 |
} |
| 23 | 23 |
|
| 24 |
+ /// Maps session ID → capacity delta vs the closest preceding session that has an estimate. |
|
| 25 |
+ private var capacityDeltas: [UUID: Double] {
|
|
| 26 |
+ let sorted = sessions.sorted { $0.startedAt < $1.startedAt }
|
|
| 27 |
+ var result: [UUID: Double] = [:] |
|
| 28 |
+ var previousCapacity: Double? = nil |
|
| 29 |
+ for session in sorted {
|
|
| 30 |
+ if let current = session.capacityEstimateWh {
|
|
| 31 |
+ if let prev = previousCapacity {
|
|
| 32 |
+ result[session.id] = current - prev |
|
| 33 |
+ } |
|
| 34 |
+ previousCapacity = current |
|
| 35 |
+ } |
|
| 36 |
+ } |
|
| 37 |
+ return result |
|
| 38 |
+ } |
|
| 39 |
+ |
|
| 24 | 40 |
var body: some View {
|
| 25 | 41 |
Group {
|
| 26 | 42 |
if let chargedDevice {
|
@@ -31,8 +47,9 @@ struct ChargedDeviceSessionsView: View {
|
||
| 31 | 47 |
} else {
|
| 32 | 48 |
summaryHeader(chargedDevice) |
| 33 | 49 |
|
| 50 |
+ let deltas = capacityDeltas |
|
| 34 | 51 |
ForEach(sessions, id: \.id) { session in
|
| 35 |
- sessionCard(session, chargedDevice: chargedDevice) |
|
| 52 |
+ sessionCard(session, chargedDevice: chargedDevice, capacityDelta: deltas[session.id]) |
|
| 36 | 53 |
} |
| 37 | 54 |
} |
| 38 | 55 |
} |
@@ -93,15 +110,24 @@ struct ChargedDeviceSessionsView: View {
|
||
| 93 | 110 |
} |
| 94 | 111 |
} |
| 95 | 112 |
|
| 96 |
- private func sessionCard(_ session: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary) -> some View {
|
|
| 97 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 113 |
+ // MARK: - Session Card |
|
| 114 |
+ |
|
| 115 |
+ private func sessionCard( |
|
| 116 |
+ _ session: ChargeSessionSummary, |
|
| 117 |
+ chargedDevice: ChargedDeviceSummary, |
|
| 118 |
+ capacityDelta: Double? |
|
| 119 |
+ ) -> some View {
|
|
| 120 |
+ let sessionTint = statusTint(for: session) |
|
| 121 |
+ |
|
| 122 |
+ return VStack(alignment: .leading, spacing: 10) {
|
|
| 98 | 123 |
NavigationLink( |
| 99 |
- destination: ChargedDeviceSessionDetailView( |
|
| 124 |
+ destination: ChargeSessionDetailView( |
|
| 100 | 125 |
chargedDeviceID: chargedDevice.id, |
| 101 | 126 |
sessionID: session.id |
| 102 | 127 |
) |
| 103 | 128 |
) {
|
| 104 | 129 |
VStack(alignment: .leading, spacing: 10) {
|
| 130 |
+ // Header: date + status badge |
|
| 105 | 131 |
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
| 106 | 132 |
Text(session.startedAt.format()) |
| 107 | 133 |
.font(.subheadline.weight(.semibold)) |
@@ -109,13 +135,10 @@ struct ChargedDeviceSessionsView: View {
|
||
| 109 | 135 |
|
| 110 | 136 |
Text(session.status.title) |
| 111 | 137 |
.font(.caption2.weight(.semibold)) |
| 112 |
- .foregroundColor(statusTint(for: session)) |
|
| 138 |
+ .foregroundColor(sessionTint) |
|
| 113 | 139 |
.padding(.horizontal, 8) |
| 114 | 140 |
.padding(.vertical, 4) |
| 115 |
- .background( |
|
| 116 |
- Capsule() |
|
| 117 |
- .fill(statusTint(for: session).opacity(0.16)) |
|
| 118 |
- ) |
|
| 141 |
+ .background(Capsule().fill(sessionTint.opacity(0.16))) |
|
| 119 | 142 |
|
| 120 | 143 |
Spacer() |
| 121 | 144 |
|
@@ -124,19 +147,37 @@ struct ChargedDeviceSessionsView: View {
|
||
| 124 | 147 |
.foregroundColor(.secondary) |
| 125 | 148 |
} |
| 126 | 149 |
|
| 127 |
- Text(sessionSummaryLine(session, chargedDevice: chargedDevice)) |
|
| 128 |
- .font(.caption) |
|
| 129 |
- .foregroundColor(.secondary) |
|
| 150 |
+ // Primary metrics: Energy + Duration |
|
| 151 |
+ HStack(spacing: 8) {
|
|
| 152 |
+ primaryMetricCell( |
|
| 153 |
+ label: "Energy", |
|
| 154 |
+ value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", |
|
| 155 |
+ tint: .teal |
|
| 156 |
+ ) |
|
| 157 |
+ primaryMetricCell( |
|
| 158 |
+ label: "Duration", |
|
| 159 |
+ value: sessionDurationText(session), |
|
| 160 |
+ tint: .orange |
|
| 161 |
+ ) |
|
| 162 |
+ } |
|
| 130 | 163 |
|
| 131 |
- LazyVGrid(columns: metricColumns, spacing: 8) {
|
|
| 132 |
- metricCell(label: "Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", tint: .teal) |
|
| 133 |
- metricCell(label: "Duration", value: sessionDurationText(session), tint: .orange) |
|
| 134 |
- if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
|
|
| 135 |
- metricCell(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W", tint: .blue) |
|
| 136 |
- } |
|
| 137 |
- if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
|
|
| 138 |
- metricCell(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A", tint: .indigo) |
|
| 139 |
- } |
|
| 164 |
+ // Charge bar (if start/end battery % known) |
|
| 165 |
+ if let chargeRange = chargeBarRange(for: session) {
|
|
| 166 |
+ chargeBarView(range: chargeRange, tint: sessionTint) |
|
| 167 |
+ } |
|
| 168 |
+ |
|
| 169 |
+ // Capacity estimate + battery delta chips |
|
| 170 |
+ let chips = chipContent(session: session, capacityDelta: capacityDelta) |
|
| 171 |
+ if !chips.isEmpty {
|
|
| 172 |
+ chipsRow(chips) |
|
| 173 |
+ } |
|
| 174 |
+ |
|
| 175 |
+ // Secondary info line |
|
| 176 |
+ let secondary = secondaryInfoLine(session, chargedDevice: chargedDevice) |
|
| 177 |
+ if !secondary.isEmpty {
|
|
| 178 |
+ Text(secondary) |
|
| 179 |
+ .font(.caption) |
|
| 180 |
+ .foregroundColor(.secondary) |
|
| 140 | 181 |
} |
| 141 | 182 |
} |
| 142 | 183 |
} |
@@ -160,33 +201,25 @@ struct ChargedDeviceSessionsView: View {
|
||
| 160 | 201 |
.font(.caption.weight(.semibold)) |
| 161 | 202 |
.foregroundColor(.red) |
| 162 | 203 |
.frame(width: 30, height: 30) |
| 163 |
- .background( |
|
| 164 |
- Circle() |
|
| 165 |
- .fill(Color.red.opacity(0.10)) |
|
| 166 |
- ) |
|
| 204 |
+ .background(Circle().fill(Color.red.opacity(0.10))) |
|
| 167 | 205 |
} |
| 168 | 206 |
.buttonStyle(.plain) |
| 169 | 207 |
.help("Delete session")
|
| 170 | 208 |
} |
| 171 | 209 |
} |
| 172 | 210 |
.padding(14) |
| 173 |
- .meterCard(tint: statusTint(for: session), fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 211 |
+ .meterCard(tint: sessionTint, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 174 | 212 |
} |
| 175 | 213 |
|
| 176 |
- private var metricColumns: [GridItem] {
|
|
| 177 |
- [ |
|
| 178 |
- GridItem(.flexible(minimum: 92), spacing: 8), |
|
| 179 |
- GridItem(.flexible(minimum: 92), spacing: 8) |
|
| 180 |
- ] |
|
| 181 |
- } |
|
| 214 |
+ // MARK: - Primary metric cell |
|
| 182 | 215 |
|
| 183 |
- private func metricCell(label: String, value: String, tint: Color) -> some View {
|
|
| 184 |
- VStack(alignment: .leading, spacing: 4) {
|
|
| 216 |
+ private func primaryMetricCell(label: String, value: String, tint: Color) -> some View {
|
|
| 217 |
+ VStack(alignment: .leading, spacing: 3) {
|
|
| 185 | 218 |
Text(label) |
| 186 | 219 |
.font(.caption2) |
| 187 | 220 |
.foregroundColor(.secondary) |
| 188 | 221 |
Text(value) |
| 189 |
- .font(.footnote.weight(.semibold)) |
|
| 222 |
+ .font(.subheadline.weight(.bold)) |
|
| 190 | 223 |
.foregroundColor(.primary) |
| 191 | 224 |
.monospacedDigit() |
| 192 | 225 |
.lineLimit(1) |
@@ -197,18 +230,130 @@ struct ChargedDeviceSessionsView: View {
|
||
| 197 | 230 |
.meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) |
| 198 | 231 |
} |
| 199 | 232 |
|
| 200 |
- private func sessionSummaryLine( |
|
| 233 |
+ // MARK: - Charge bar |
|
| 234 |
+ |
|
| 235 |
+ /// Returns (startPercent, endPercent) if we have enough data to render a charge bar. |
|
| 236 |
+ private func chargeBarRange(for session: ChargeSessionSummary) -> (start: Double, end: Double)? {
|
|
| 237 |
+ let start = session.startBatteryPercent |
|
| 238 |
+ let end = session.endBatteryPercent |
|
| 239 |
+ |
|
| 240 |
+ if let s = start, let e = end, e > s {
|
|
| 241 |
+ return (s, e) |
|
| 242 |
+ } |
|
| 243 |
+ |
|
| 244 |
+ // Fall back to first / last checkpoint |
|
| 245 |
+ let sorted = session.checkpoints.sorted { $0.timestamp < $1.timestamp }
|
|
| 246 |
+ if sorted.count >= 2, |
|
| 247 |
+ let first = sorted.first, |
|
| 248 |
+ let last = sorted.last, |
|
| 249 |
+ last.batteryPercent > first.batteryPercent {
|
|
| 250 |
+ return (first.batteryPercent, last.batteryPercent) |
|
| 251 |
+ } |
|
| 252 |
+ |
|
| 253 |
+ return nil |
|
| 254 |
+ } |
|
| 255 |
+ |
|
| 256 |
+ private func chargeBarView(range: (start: Double, end: Double), tint: Color) -> some View {
|
|
| 257 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 258 |
+ GeometryReader { geo in
|
|
| 259 |
+ let w = geo.size.width |
|
| 260 |
+ let startX = w * CGFloat(range.start / 100) |
|
| 261 |
+ let fillWidth = max(w * CGFloat((range.end - range.start) / 100), 4) |
|
| 262 |
+ ZStack(alignment: .leading) {
|
|
| 263 |
+ Capsule() |
|
| 264 |
+ .fill(Color.primary.opacity(0.08)) |
|
| 265 |
+ // Filled charged portion |
|
| 266 |
+ Rectangle() |
|
| 267 |
+ .fill(LinearGradient( |
|
| 268 |
+ colors: [tint.opacity(0.6), tint], |
|
| 269 |
+ startPoint: .leading, |
|
| 270 |
+ endPoint: .trailing |
|
| 271 |
+ )) |
|
| 272 |
+ .frame(width: fillWidth) |
|
| 273 |
+ .offset(x: startX) |
|
| 274 |
+ // Start marker line |
|
| 275 |
+ if range.start > 3 {
|
|
| 276 |
+ Rectangle() |
|
| 277 |
+ .fill(Color.white.opacity(0.5)) |
|
| 278 |
+ .frame(width: 1.5, height: 14) |
|
| 279 |
+ .offset(x: startX - 0.75) |
|
| 280 |
+ } |
|
| 281 |
+ } |
|
| 282 |
+ .clipShape(Capsule()) |
|
| 283 |
+ } |
|
| 284 |
+ .frame(height: 14) |
|
| 285 |
+ |
|
| 286 |
+ // Labels |
|
| 287 |
+ HStack {
|
|
| 288 |
+ Text("\(Int(range.start.rounded()))%")
|
|
| 289 |
+ .font(.caption2) |
|
| 290 |
+ .foregroundColor(.secondary) |
|
| 291 |
+ Spacer() |
|
| 292 |
+ Text("+\(Int((range.end - range.start).rounded()))%")
|
|
| 293 |
+ .font(.caption2.weight(.semibold)) |
|
| 294 |
+ .foregroundColor(tint) |
|
| 295 |
+ Spacer() |
|
| 296 |
+ Text("\(Int(range.end.rounded()))%")
|
|
| 297 |
+ .font(.caption2) |
|
| 298 |
+ .foregroundColor(.secondary) |
|
| 299 |
+ } |
|
| 300 |
+ } |
|
| 301 |
+ } |
|
| 302 |
+ |
|
| 303 |
+ // MARK: - Chips |
|
| 304 |
+ |
|
| 305 |
+ private struct ChipContent {
|
|
| 306 |
+ let label: String |
|
| 307 |
+ let tint: Color |
|
| 308 |
+ } |
|
| 309 |
+ |
|
| 310 |
+ private func chipContent(session: ChargeSessionSummary, capacityDelta: Double?) -> [ChipContent] {
|
|
| 311 |
+ var chips: [ChipContent] = [] |
|
| 312 |
+ |
|
| 313 |
+ if let capacityWh = session.capacityEstimateWh {
|
|
| 314 |
+ var label = "\(capacityWh.format(decimalDigits: 1)) Wh" |
|
| 315 |
+ if let delta = capacityDelta {
|
|
| 316 |
+ let sign = delta >= 0 ? "+" : "" |
|
| 317 |
+ label += " (\(sign)\(delta.format(decimalDigits: 1)))" |
|
| 318 |
+ } |
|
| 319 |
+ chips.append(ChipContent(label: label, tint: .orange)) |
|
| 320 |
+ } |
|
| 321 |
+ |
|
| 322 |
+ if let batteryDelta = session.batteryDeltaPercent {
|
|
| 323 |
+ let sign = batteryDelta >= 0 ? "+" : "" |
|
| 324 |
+ chips.append(ChipContent(label: "\(sign)\(Int(batteryDelta.rounded()))% charged", tint: .teal)) |
|
| 325 |
+ } |
|
| 326 |
+ |
|
| 327 |
+ return chips |
|
| 328 |
+ } |
|
| 329 |
+ |
|
| 330 |
+ private func chipsRow(_ chips: [ChipContent]) -> some View {
|
|
| 331 |
+ HStack(spacing: 6) {
|
|
| 332 |
+ ForEach(chips.indices, id: \.self) { i in
|
|
| 333 |
+ let chip = chips[i] |
|
| 334 |
+ Text(chip.label) |
|
| 335 |
+ .font(.caption2.weight(.semibold)) |
|
| 336 |
+ .foregroundColor(chip.tint) |
|
| 337 |
+ .padding(.horizontal, 8) |
|
| 338 |
+ .padding(.vertical, 4) |
|
| 339 |
+ .background( |
|
| 340 |
+ RoundedRectangle(cornerRadius: 8) |
|
| 341 |
+ .fill(chip.tint.opacity(0.14)) |
|
| 342 |
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(chip.tint.opacity(0.22), lineWidth: 1)) |
|
| 343 |
+ ) |
|
| 344 |
+ } |
|
| 345 |
+ Spacer() |
|
| 346 |
+ } |
|
| 347 |
+ } |
|
| 348 |
+ |
|
| 349 |
+ // MARK: - Secondary info line |
|
| 350 |
+ |
|
| 351 |
+ private func secondaryInfoLine( |
|
| 201 | 352 |
_ session: ChargeSessionSummary, |
| 202 | 353 |
chargedDevice: ChargedDeviceSummary |
| 203 | 354 |
) -> String {
|
| 204 | 355 |
var components: [String] = [] |
| 205 | 356 |
|
| 206 |
- if let batteryDeltaPercent = session.batteryDeltaPercent {
|
|
| 207 |
- components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
|
|
| 208 |
- } |
|
| 209 |
- if let capacityEstimateWh = session.capacityEstimateWh {
|
|
| 210 |
- components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
|
|
| 211 |
- } |
|
| 212 | 357 |
if chargedDevice.shouldShowChargingTransport(session.chargingTransportMode) {
|
| 213 | 358 |
components.append(session.chargingTransportMode.title) |
| 214 | 359 |
} |
@@ -220,9 +365,11 @@ struct ChargedDeviceSessionsView: View {
|
||
| 220 | 365 |
} |
| 221 | 366 |
components.append(session.sourceMode.title) |
| 222 | 367 |
|
| 223 |
- return components.joined(separator: " - ") |
|
| 368 |
+ return components.joined(separator: " · ") |
|
| 224 | 369 |
} |
| 225 | 370 |
|
| 371 |
+ // MARK: - Helpers |
|
| 372 |
+ |
|
| 226 | 373 |
private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
|
| 227 | 374 |
let formatter = DateComponentsFormatter() |
| 228 | 375 |
let effectiveDuration = max(session.effectiveDuration, 0) |
@@ -234,30 +381,20 @@ struct ChargedDeviceSessionsView: View {
|
||
| 234 | 381 |
|
| 235 | 382 |
private func statusTint(for session: ChargeSessionSummary) -> Color {
|
| 236 | 383 |
switch session.status {
|
| 237 |
- case .active: |
|
| 238 |
- return .green |
|
| 239 |
- case .paused: |
|
| 240 |
- return .orange |
|
| 241 |
- case .completed: |
|
| 242 |
- return .teal |
|
| 243 |
- case .abandoned: |
|
| 244 |
- return .secondary |
|
| 384 |
+ case .active: return .green |
|
| 385 |
+ case .paused: return .orange |
|
| 386 |
+ case .completed: return .teal |
|
| 387 |
+ case .abandoned: return .secondary |
|
| 245 | 388 |
} |
| 246 | 389 |
} |
| 247 | 390 |
|
| 248 | 391 |
private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
|
| 249 | 392 |
switch chargedDevice.deviceClass {
|
| 250 |
- case .iphone: |
|
| 251 |
- return .blue |
|
| 252 |
- case .watch: |
|
| 253 |
- return .green |
|
| 254 |
- case .powerbank: |
|
| 255 |
- return .orange |
|
| 256 |
- case .charger: |
|
| 257 |
- return .pink |
|
| 258 |
- case .other: |
|
| 259 |
- return .secondary |
|
| 393 |
+ case .iphone: return .blue |
|
| 394 |
+ case .watch: return .green |
|
| 395 |
+ case .powerbank: return .orange |
|
| 396 |
+ case .charger: return .pink |
|
| 397 |
+ case .other: return .secondary |
|
| 260 | 398 |
} |
| 261 | 399 |
} |
| 262 | 400 |
} |
| 263 |
- |
|
@@ -180,6 +180,7 @@ struct BatteryCheckpointSectionView: View {
|
||
| 180 | 180 |
let checkpoints: [ChargeCheckpointSummary] |
| 181 | 181 |
let message: String |
| 182 | 182 |
let canAddCheckpoint: Bool |
| 183 |
+ let canDeleteCheckpoint: Bool |
|
| 183 | 184 |
let requirementMessage: String? |
| 184 | 185 |
let effectiveEnergyWhOverride: Double? |
| 185 | 186 |
let measuredChargeAhOverride: Double? |
@@ -252,15 +253,17 @@ struct BatteryCheckpointSectionView: View {
|
||
| 252 | 253 |
Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
|
| 253 | 254 |
.font(.caption2) |
| 254 | 255 |
.foregroundColor(.secondary) |
| 255 |
- Button {
|
|
| 256 |
- onDelete(checkpoint) |
|
| 257 |
- } label: {
|
|
| 258 |
- Image(systemName: "trash") |
|
| 259 |
- .font(.caption.weight(.semibold)) |
|
| 260 |
- .foregroundColor(.red) |
|
| 256 |
+ if canDeleteCheckpoint {
|
|
| 257 |
+ Button {
|
|
| 258 |
+ onDelete(checkpoint) |
|
| 259 |
+ } label: {
|
|
| 260 |
+ Image(systemName: "trash") |
|
| 261 |
+ .font(.caption.weight(.semibold)) |
|
| 262 |
+ .foregroundColor(.red) |
|
| 263 |
+ } |
|
| 264 |
+ .buttonStyle(.plain) |
|
| 265 |
+ .help("Delete checkpoint")
|
|
| 261 | 266 |
} |
| 262 |
- .buttonStyle(.plain) |
|
| 263 |
- .help("Delete checkpoint")
|
|
| 264 | 267 |
} |
| 265 | 268 |
} |
| 266 | 269 |
|
@@ -29,9 +29,34 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 29 | 29 |
let title: String |
| 30 | 30 |
let confirmTitle: String |
| 31 | 31 |
let explanation: String |
| 32 |
+ let monitoringMeter: Meter? |
|
| 33 |
+ let appliesTrim: Bool |
|
| 34 |
+ let trimStart: Date? |
|
| 35 |
+ let trimEnd: Date? |
|
| 32 | 36 |
|
| 33 | 37 |
@State private var batteryPercent = "" |
| 34 | 38 |
@State private var finalCheckpoint: FinalCheckpoint = .skip |
| 39 |
+ @State private var saveFailureMessage: String? |
|
| 40 |
+ |
|
| 41 |
+ init( |
|
| 42 |
+ sessionID: UUID, |
|
| 43 |
+ title: String, |
|
| 44 |
+ confirmTitle: String, |
|
| 45 |
+ explanation: String, |
|
| 46 |
+ monitoringMeter: Meter? = nil, |
|
| 47 |
+ appliesTrim: Bool = false, |
|
| 48 |
+ trimStart: Date? = nil, |
|
| 49 |
+ trimEnd: Date? = nil |
|
| 50 |
+ ) {
|
|
| 51 |
+ self.sessionID = sessionID |
|
| 52 |
+ self.title = title |
|
| 53 |
+ self.confirmTitle = confirmTitle |
|
| 54 |
+ self.explanation = explanation |
|
| 55 |
+ self.monitoringMeter = monitoringMeter |
|
| 56 |
+ self.appliesTrim = appliesTrim |
|
| 57 |
+ self.trimStart = trimStart |
|
| 58 |
+ self.trimEnd = trimEnd |
|
| 59 |
+ } |
|
| 35 | 60 |
|
| 36 | 61 |
var body: some View {
|
| 37 | 62 |
NavigationView {
|
@@ -56,26 +81,34 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 56 | 81 |
} |
| 57 | 82 |
|
| 58 | 83 |
Section {
|
| 59 |
- if let refusalReason {
|
|
| 60 |
- Label(refusalReason, systemImage: "exclamationmark.triangle.fill") |
|
| 84 |
+ if appliesTrim {
|
|
| 85 |
+ Label("The selected trim window will be applied before the session is closed.", systemImage: "scissors")
|
|
| 86 |
+ .font(.footnote) |
|
| 87 |
+ .foregroundColor(.blue) |
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ if let saveFailureMessage {
|
|
| 91 |
+ Label(saveFailureMessage, systemImage: "exclamationmark.triangle.fill") |
|
| 61 | 92 |
.font(.footnote) |
| 62 | 93 |
.foregroundColor(.red) |
| 94 |
+ } else if let saveDisabledReason {
|
|
| 95 |
+ Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill") |
|
| 96 |
+ .font(.footnote) |
|
| 97 |
+ .foregroundColor(.red) |
|
| 98 |
+ } |
|
| 63 | 99 |
|
| 100 |
+ if hasChargeDataToSave == false {
|
|
| 64 | 101 |
Button(role: .destructive) {
|
| 65 | 102 |
_ = appData.deleteChargeSession(sessionID: sessionID) |
| 66 | 103 |
dismiss() |
| 67 | 104 |
} label: {
|
| 68 | 105 |
Label("Discard Session", systemImage: "trash")
|
| 69 | 106 |
} |
| 70 |
- } else if let customCheckpointWarning {
|
|
| 71 |
- Label(customCheckpointWarning, systemImage: "exclamationmark.triangle.fill") |
|
| 72 |
- .font(.footnote) |
|
| 73 |
- .foregroundColor(.orange) |
|
| 74 |
- } else if let sessionWarning {
|
|
| 107 |
+ } else if saveDisabledReason == nil, let sessionWarning {
|
|
| 75 | 108 |
Text(sessionWarning) |
| 76 | 109 |
.font(.footnote) |
| 77 | 110 |
.foregroundColor(.orange) |
| 78 |
- } else if resolvedFinalBatteryPercent == 100 {
|
|
| 111 |
+ } else if saveDisabledReason == nil, resolvedFinalBatteryPercent == 100 {
|
|
| 79 | 112 |
Text("A final checkpoint at 100% lets the app learn the stop current for this exact charging type when the session data is reliable.")
|
| 80 | 113 |
.font(.footnote) |
| 81 | 114 |
.foregroundColor(.secondary) |
@@ -92,9 +125,26 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 92 | 125 |
} |
| 93 | 126 |
ToolbarItem(placement: .confirmationAction) {
|
| 94 | 127 |
Button(confirmTitle) {
|
| 95 |
- guard canSave else { return }
|
|
| 96 |
- if appData.stopChargeSession(sessionID: sessionID, finalBatteryPercent: resolvedFinalBatteryPercent) {
|
|
| 128 |
+ guard canSave else {
|
|
| 129 |
+ saveFailureMessage = saveDisabledReason |
|
| 130 |
+ return |
|
| 131 |
+ } |
|
| 132 |
+ if appliesTrim {
|
|
| 133 |
+ _ = appData.setSessionTrim( |
|
| 134 |
+ sessionID: sessionID, |
|
| 135 |
+ start: trimStart, |
|
| 136 |
+ end: trimEnd |
|
| 137 |
+ ) |
|
| 138 |
+ } |
|
| 139 |
+ |
|
| 140 |
+ if appData.stopChargeSession( |
|
| 141 |
+ sessionID: sessionID, |
|
| 142 |
+ finalBatteryPercent: resolvedFinalBatteryPercent, |
|
| 143 |
+ from: monitoringMeter |
|
| 144 |
+ ) {
|
|
| 97 | 145 |
dismiss() |
| 146 |
+ } else {
|
|
| 147 |
+ saveFailureMessage = "The session could not be closed. Live readings were flushed, but the stored session did not accept the save yet. Try again in a moment." |
|
| 98 | 148 |
} |
| 99 | 149 |
} |
| 100 | 150 |
.disabled(!canSave) |
@@ -103,6 +153,17 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 103 | 153 |
} |
| 104 | 154 |
} |
| 105 | 155 |
.navigationViewStyle(StackNavigationViewStyle()) |
| 156 |
+ .onChange(of: finalCheckpoint) { mode in
|
|
| 157 |
+ saveFailureMessage = nil |
|
| 158 |
+ if mode == .custom {
|
|
| 159 |
+ prefillFinalCheckpointIfNeeded() |
|
| 160 |
+ } else {
|
|
| 161 |
+ batteryPercent = "" |
|
| 162 |
+ } |
|
| 163 |
+ } |
|
| 164 |
+ .onChange(of: batteryPercent) { _ in
|
|
| 165 |
+ saveFailureMessage = nil |
|
| 166 |
+ } |
|
| 106 | 167 |
} |
| 107 | 168 |
|
| 108 | 169 |
private var session: ChargeSessionSummary? {
|
@@ -112,20 +173,32 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 112 | 173 |
} |
| 113 | 174 |
|
| 114 | 175 |
private var canSave: Bool {
|
| 115 |
- session?.hasSavableChargeData == true |
|
| 176 |
+ saveDisabledReason == nil |
|
| 116 | 177 |
} |
| 117 | 178 |
|
| 118 |
- private var refusalReason: String? {
|
|
| 119 |
- canSave ? nil : "This session has no charging data to save. Discard it instead." |
|
| 179 |
+ private var hasChargeDataToSave: Bool {
|
|
| 180 |
+ guard let session else { return false }
|
|
| 181 |
+ return session.hasSavableChargeData |
|
| 182 |
+ || displayedSessionEnergyWh(for: session) > 0 |
|
| 183 |
+ || displayedSessionChargeAh(for: session) > 0 |
|
| 120 | 184 |
} |
| 121 | 185 |
|
| 122 |
- private var customCheckpointWarning: String? {
|
|
| 123 |
- guard finalCheckpoint == .custom, |
|
| 124 |
- batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, |
|
| 125 |
- parsedBatteryPercent == nil else {
|
|
| 126 |
- return nil |
|
| 186 |
+ private var saveDisabledReason: String? {
|
|
| 187 |
+ if finalCheckpoint == .custom {
|
|
| 188 |
+ let trimmed = batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 189 |
+ if trimmed.isEmpty {
|
|
| 190 |
+ return "Enter the final battery percentage or choose Skip." |
|
| 191 |
+ } |
|
| 192 |
+ if parsedBatteryPercent == nil {
|
|
| 193 |
+ return "Final battery percentage must be between 0 and 100." |
|
| 194 |
+ } |
|
| 195 |
+ } |
|
| 196 |
+ |
|
| 197 |
+ guard hasChargeDataToSave else {
|
|
| 198 |
+ return "This session has no charging data to save. Discard it instead." |
|
| 127 | 199 |
} |
| 128 |
- return "Final battery percentage must be between 0 and 100. Save will close the session without a final checkpoint." |
|
| 200 |
+ |
|
| 201 |
+ return nil |
|
| 129 | 202 |
} |
| 130 | 203 |
|
| 131 | 204 |
private var parsedBatteryPercent: Double? {
|
@@ -144,6 +217,25 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 144 | 217 |
} |
| 145 | 218 |
} |
| 146 | 219 |
|
| 220 |
+ private var suggestedFinalBatteryPercent: Double? {
|
|
| 221 |
+ guard let session else { return nil }
|
|
| 222 |
+ if let endBatteryPercent = session.endBatteryPercent {
|
|
| 223 |
+ return endBatteryPercent |
|
| 224 |
+ } |
|
| 225 |
+ if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
|
|
| 226 |
+ return latestCheckpoint.batteryPercent |
|
| 227 |
+ } |
|
| 228 |
+ return session.targetBatteryPercent ?? session.completionContradictionPercent |
|
| 229 |
+ } |
|
| 230 |
+ |
|
| 231 |
+ private func prefillFinalCheckpointIfNeeded() {
|
|
| 232 |
+ guard batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, |
|
| 233 |
+ let suggestedFinalBatteryPercent else {
|
|
| 234 |
+ return |
|
| 235 |
+ } |
|
| 236 |
+ batteryPercent = suggestedFinalBatteryPercent.format(decimalDigits: 0) |
|
| 237 |
+ } |
|
| 238 |
+ |
|
| 147 | 239 |
private var sessionWarning: String? {
|
| 148 | 240 |
guard let session, |
| 149 | 241 |
session.chargingTransportMode == .wireless, |
@@ -154,4 +246,28 @@ struct ChargeSessionCompletionSheetView: View {
|
||
| 154 | 246 |
} |
| 155 | 247 |
return "This charger has no idle-current measurement, so the final checkpoint will stop the session but will not learn a wireless stop threshold yet." |
| 156 | 248 |
} |
| 249 |
+ |
|
| 250 |
+ private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
|
|
| 251 |
+ let storedEnergyWh = session.effectiveOrMeasuredEnergyWh |
|
| 252 |
+ guard session.isTrimmed == false else { return storedEnergyWh }
|
|
| 253 |
+ guard session.status.isOpen else { return storedEnergyWh }
|
|
| 254 |
+ guard let monitoringMeter else { return storedEnergyWh }
|
|
| 255 |
+ guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
|
|
| 256 |
+ if let baselineEnergyWh = session.meterEnergyBaselineWh {
|
|
| 257 |
+ return max(storedEnergyWh, max(monitoringMeter.recordedWH - baselineEnergyWh, 0)) |
|
| 258 |
+ } |
|
| 259 |
+ return storedEnergyWh |
|
| 260 |
+ } |
|
| 261 |
+ |
|
| 262 |
+ private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
|
|
| 263 |
+ let storedChargeAh = session.measuredChargeAh |
|
| 264 |
+ guard session.isTrimmed == false else { return storedChargeAh }
|
|
| 265 |
+ guard session.status.isOpen else { return storedChargeAh }
|
|
| 266 |
+ guard let monitoringMeter else { return storedChargeAh }
|
|
| 267 |
+ guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedChargeAh }
|
|
| 268 |
+ if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
| 269 |
+ return max(storedChargeAh, max(monitoringMeter.recordedAH - baselineChargeAh, 0)) |
|
| 270 |
+ } |
|
| 271 |
+ return storedChargeAh |
|
| 272 |
+ } |
|
| 157 | 273 |
} |
@@ -115,7 +115,28 @@ struct MeterView: View {
|
||
| 115 | 115 |
@State private var navBarRSSI: Int = 0 |
| 116 | 116 |
@State private var landscapeTabBarHeight: CGFloat = 0 |
| 117 | 117 |
|
| 118 |
+ // Offline mode state |
|
| 119 |
+ private enum OfflineTab: String { case info, settings }
|
|
| 120 |
+ @State private var selectedOfflineTab: OfflineTab = .info |
|
| 121 |
+ @State private var offlineEditingName: Bool = false |
|
| 122 |
+ @State private var offlineName: String = "" |
|
| 123 |
+ @State private var offlineDeleteConfirmation: Bool = false |
|
| 124 |
+ @State private var offlineTemperatureUnit: TemperatureUnitPreference = .celsius |
|
| 125 |
+ |
|
| 126 |
+ private let offlineSummary: AppData.MeterSummary? |
|
| 127 |
+ |
|
| 128 |
+ init() { offlineSummary = nil }
|
|
| 129 |
+ init(offlineSummary: AppData.MeterSummary) { self.offlineSummary = offlineSummary }
|
|
| 130 |
+ |
|
| 118 | 131 |
var body: some View {
|
| 132 |
+ if let summary = offlineSummary {
|
|
| 133 |
+ offlineBody(summary: summary) |
|
| 134 |
+ } else {
|
|
| 135 |
+ liveBody |
|
| 136 |
+ } |
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 139 |
+ private var liveBody: some View {
|
|
| 119 | 140 |
GeometryReader { proxy in
|
| 120 | 141 |
let landscape = isLandscape(size: proxy.size) |
| 121 | 142 |
let usesOverlayTabBar = landscape && Self.isPhone |
@@ -363,6 +384,7 @@ struct MeterView: View {
|
||
| 363 | 384 |
HStack(spacing: 8) {
|
| 364 | 385 |
ForEach(availableMeterTabs, id: \.self) { tab in
|
| 365 | 386 |
let isSelected = displayedMeterTab == tab |
| 387 |
+ let isUnavailable = requiresLiveData(tab) && !isLiveDataAvailable |
|
| 366 | 388 |
|
| 367 | 389 |
Button {
|
| 368 | 390 |
withAnimation(.easeInOut(duration: 0.2)) {
|
@@ -381,7 +403,7 @@ struct MeterView: View {
|
||
| 381 | 403 |
.foregroundColor( |
| 382 | 404 |
isSelected |
| 383 | 405 |
? .white |
| 384 |
- : unselectedForegroundColor |
|
| 406 |
+ : (isUnavailable ? Color.secondary.opacity(0.5) : unselectedForegroundColor) |
|
| 385 | 407 |
) |
| 386 | 408 |
.padding(.horizontal, style.chipHorizontalPadding) |
| 387 | 409 |
.padding(.vertical, style.chipVerticalPadding) |
@@ -391,7 +413,7 @@ struct MeterView: View {
|
||
| 391 | 413 |
.fill( |
| 392 | 414 |
isSelected |
| 393 | 415 |
? meter.color.opacity(isFloating ? 0.94 : 1) |
| 394 |
- : unselectedChipFill |
|
| 416 |
+ : (isUnavailable ? Color.secondary.opacity(0.06) : unselectedChipFill) |
|
| 395 | 417 |
) |
| 396 | 418 |
) |
| 397 | 419 |
} |
@@ -646,6 +668,282 @@ struct MeterView: View {
|
||
| 646 | 668 |
} |
| 647 | 669 |
} |
| 648 | 670 |
|
| 671 |
+ private func requiresLiveData(_ tab: MeterTab) -> Bool {
|
|
| 672 |
+ switch tab {
|
|
| 673 |
+ case .live, .chart: return true |
|
| 674 |
+ case .home, .chargeRecord, .dataGroups, .settings: return false |
|
| 675 |
+ } |
|
| 676 |
+ } |
|
| 677 |
+ |
|
| 678 |
+ private var isLiveDataAvailable: Bool {
|
|
| 679 |
+ meter.operationalState >= .dataIsAvailable |
|
| 680 |
+ } |
|
| 681 |
+ |
|
| 682 |
+ // MARK: - Offline mode |
|
| 683 |
+ |
|
| 684 |
+ @ViewBuilder |
|
| 685 |
+ private func offlineBody(summary: AppData.MeterSummary) -> some View {
|
|
| 686 |
+ VStack(spacing: 0) {
|
|
| 687 |
+ if Self.isTrueMacApp {
|
|
| 688 |
+ offlineMacHeader(name: summary.displayName) |
|
| 689 |
+ } |
|
| 690 |
+ offlineTabBar(tint: summary.tint) |
|
| 691 |
+ offlineTabContent(summary: summary) |
|
| 692 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 693 |
+ .id(selectedOfflineTab) |
|
| 694 |
+ .transition(.opacity.combined(with: .move(edge: .trailing))) |
|
| 695 |
+ .animation(.easeInOut(duration: 0.22), value: selectedOfflineTab) |
|
| 696 |
+ } |
|
| 697 |
+ .background(offlineBackground(tint: summary.tint)) |
|
| 698 |
+ #if !targetEnvironment(macCatalyst) |
|
| 699 |
+ .navigationBarHidden(Self.isTrueMacApp) |
|
| 700 |
+ #else |
|
| 701 |
+ .navigationBarHidden(false) |
|
| 702 |
+ #endif |
|
| 703 |
+ .navigationBarTitle(summary.displayName, displayMode: .inline) |
|
| 704 |
+ .onAppear {
|
|
| 705 |
+ offlineName = summary.displayName |
|
| 706 |
+ offlineTemperatureUnit = appData.temperatureUnitPreference(for: summary.macAddress) |
|
| 707 |
+ } |
|
| 708 |
+ } |
|
| 709 |
+ |
|
| 710 |
+ private func offlineTabBar(tint: Color) -> some View {
|
|
| 711 |
+ HStack {
|
|
| 712 |
+ Spacer(minLength: 0) |
|
| 713 |
+ HStack(spacing: 8) {
|
|
| 714 |
+ ForEach([OfflineTab.info, OfflineTab.settings], id: \.rawValue) { tab in
|
|
| 715 |
+ let isSelected = selectedOfflineTab == tab |
|
| 716 |
+ Button {
|
|
| 717 |
+ withAnimation(.easeInOut(duration: 0.2)) { selectedOfflineTab = tab }
|
|
| 718 |
+ } label: {
|
|
| 719 |
+ HStack(spacing: 6) {
|
|
| 720 |
+ Image(systemName: tab == .info ? "house.fill" : "gearshape.fill") |
|
| 721 |
+ .font(.subheadline.weight(.semibold)) |
|
| 722 |
+ Text(tab == .info ? "Info" : "Settings") |
|
| 723 |
+ .font(.subheadline.weight(.semibold)) |
|
| 724 |
+ .lineLimit(1) |
|
| 725 |
+ } |
|
| 726 |
+ .foregroundColor(isSelected ? .white : .primary) |
|
| 727 |
+ .padding(.horizontal, 10) |
|
| 728 |
+ .padding(.vertical, 7) |
|
| 729 |
+ .frame(maxWidth: .infinity) |
|
| 730 |
+ .background(Capsule().fill(isSelected ? tint : Color.secondary.opacity(0.12))) |
|
| 731 |
+ } |
|
| 732 |
+ .buttonStyle(.plain) |
|
| 733 |
+ } |
|
| 734 |
+ } |
|
| 735 |
+ .padding(6) |
|
| 736 |
+ .background( |
|
| 737 |
+ RoundedRectangle(cornerRadius: 14, style: .continuous) |
|
| 738 |
+ .fill(Color.secondary.opacity(0.10)) |
|
| 739 |
+ ) |
|
| 740 |
+ .background( |
|
| 741 |
+ RoundedRectangle(cornerRadius: 14, style: .continuous) |
|
| 742 |
+ .fill(.ultraThinMaterial) |
|
| 743 |
+ .opacity(0.78) |
|
| 744 |
+ ) |
|
| 745 |
+ Spacer(minLength: 0) |
|
| 746 |
+ } |
|
| 747 |
+ .padding(.horizontal, 16) |
|
| 748 |
+ .padding(.top, 10) |
|
| 749 |
+ .padding(.bottom, 8) |
|
| 750 |
+ .background( |
|
| 751 |
+ Rectangle() |
|
| 752 |
+ .fill(.ultraThinMaterial) |
|
| 753 |
+ .opacity(0.78) |
|
| 754 |
+ .ignoresSafeArea(edges: .top) |
|
| 755 |
+ ) |
|
| 756 |
+ .overlay(alignment: .bottom) {
|
|
| 757 |
+ Rectangle() |
|
| 758 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 759 |
+ .frame(height: 1) |
|
| 760 |
+ } |
|
| 761 |
+ } |
|
| 762 |
+ |
|
| 763 |
+ @ViewBuilder |
|
| 764 |
+ private func offlineTabContent(summary: AppData.MeterSummary) -> some View {
|
|
| 765 |
+ switch selectedOfflineTab {
|
|
| 766 |
+ case .info: |
|
| 767 |
+ ScrollView {
|
|
| 768 |
+ VStack(alignment: .leading, spacing: 20) {
|
|
| 769 |
+ offlineStatusHeader(summary: summary) |
|
| 770 |
+ MeterInfoCardView(title: "Meter", tint: summary.tint) {
|
|
| 771 |
+ MeterInfoRowView(label: "Name", value: summary.displayName) |
|
| 772 |
+ MeterInfoRowView(label: "Model", value: summary.modelSummary.isEmpty ? "Unknown" : summary.modelSummary) |
|
| 773 |
+ if let advertisedName = summary.advertisedName {
|
|
| 774 |
+ MeterInfoRowView(label: "Advertised Name", value: advertisedName) |
|
| 775 |
+ } |
|
| 776 |
+ MeterInfoRowView(label: "MAC", value: summary.macAddress) |
|
| 777 |
+ MeterInfoRowView(label: "Last Seen", value: historyText(for: summary.lastSeen)) |
|
| 778 |
+ MeterInfoRowView(label: "Last Connected", value: historyText(for: summary.lastConnected)) |
|
| 779 |
+ } |
|
| 780 |
+ } |
|
| 781 |
+ .padding(16) |
|
| 782 |
+ } |
|
| 783 |
+ case .settings: |
|
| 784 |
+ offlineSettingsContent(summary: summary) |
|
| 785 |
+ } |
|
| 786 |
+ } |
|
| 787 |
+ |
|
| 788 |
+ private func offlineSettingsContent(summary: AppData.MeterSummary) -> some View {
|
|
| 789 |
+ let isTC66 = summary.modelSummary == "TC66C" |
|
| 790 |
+ return ScrollView {
|
|
| 791 |
+ VStack(spacing: 14) {
|
|
| 792 |
+ offlineSettingsCard(title: "Name", tint: summary.tint) {
|
|
| 793 |
+ HStack {
|
|
| 794 |
+ Spacer() |
|
| 795 |
+ if !offlineEditingName {
|
|
| 796 |
+ Text(offlineName).foregroundColor(.secondary) |
|
| 797 |
+ } |
|
| 798 |
+ ChevronView(rotate: $offlineEditingName) |
|
| 799 |
+ } |
|
| 800 |
+ if offlineEditingName {
|
|
| 801 |
+ TextField("Name", text: $offlineName, onCommit: {
|
|
| 802 |
+ appData.setMeterName(offlineName, for: summary.macAddress) |
|
| 803 |
+ offlineEditingName = false |
|
| 804 |
+ }) |
|
| 805 |
+ .textFieldStyle(RoundedBorderTextFieldStyle()) |
|
| 806 |
+ .lineLimit(1) |
|
| 807 |
+ .disableAutocorrection(true) |
|
| 808 |
+ .multilineTextAlignment(.center) |
|
| 809 |
+ } |
|
| 810 |
+ } |
|
| 811 |
+ |
|
| 812 |
+ if isTC66 {
|
|
| 813 |
+ offlineSettingsCard( |
|
| 814 |
+ title: "Meter Temperature Unit", |
|
| 815 |
+ infoMessage: "TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.", |
|
| 816 |
+ tint: .orange |
|
| 817 |
+ ) {
|
|
| 818 |
+ Picker("", selection: $offlineTemperatureUnit) {
|
|
| 819 |
+ ForEach(TemperatureUnitPreference.allCases) { unit in
|
|
| 820 |
+ Text(unit.title).tag(unit) |
|
| 821 |
+ } |
|
| 822 |
+ } |
|
| 823 |
+ .pickerStyle(SegmentedPickerStyle()) |
|
| 824 |
+ .onChange(of: offlineTemperatureUnit) { newValue in
|
|
| 825 |
+ appData.setTemperatureUnitPreference(newValue, for: summary.macAddress) |
|
| 826 |
+ } |
|
| 827 |
+ } |
|
| 828 |
+ } |
|
| 829 |
+ |
|
| 830 |
+ offlineSettingsCard( |
|
| 831 |
+ title: "Danger Zone", |
|
| 832 |
+ infoMessage: "Delete this meter from the sidebar and clear its saved metadata. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.", |
|
| 833 |
+ tint: .red |
|
| 834 |
+ ) {
|
|
| 835 |
+ Button("Delete Meter") {
|
|
| 836 |
+ offlineDeleteConfirmation = true |
|
| 837 |
+ } |
|
| 838 |
+ .frame(maxWidth: .infinity) |
|
| 839 |
+ .padding(.vertical, 10) |
|
| 840 |
+ .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 841 |
+ .buttonStyle(.plain) |
|
| 842 |
+ } |
|
| 843 |
+ } |
|
| 844 |
+ .padding() |
|
| 845 |
+ } |
|
| 846 |
+ .alert("Delete Meter?", isPresented: $offlineDeleteConfirmation) {
|
|
| 847 |
+ Button("Delete", role: .destructive) {
|
|
| 848 |
+ appData.deleteMeter(macAddress: summary.macAddress) |
|
| 849 |
+ dismiss() |
|
| 850 |
+ } |
|
| 851 |
+ Button("Cancel", role: .cancel) {}
|
|
| 852 |
+ } message: {
|
|
| 853 |
+ Text("This removes the saved meter entry. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.")
|
|
| 854 |
+ } |
|
| 855 |
+ } |
|
| 856 |
+ |
|
| 857 |
+ private func offlineSettingsCard<Content: View>( |
|
| 858 |
+ title: String, |
|
| 859 |
+ infoMessage: String? = nil, |
|
| 860 |
+ tint: Color, |
|
| 861 |
+ @ViewBuilder content: () -> Content |
|
| 862 |
+ ) -> some View {
|
|
| 863 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 864 |
+ HStack(spacing: 8) {
|
|
| 865 |
+ Text(title).font(.headline) |
|
| 866 |
+ if let infoMessage {
|
|
| 867 |
+ ContextInfoButton(title: title, message: infoMessage) |
|
| 868 |
+ } |
|
| 869 |
+ } |
|
| 870 |
+ content() |
|
| 871 |
+ } |
|
| 872 |
+ .padding(18) |
|
| 873 |
+ .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 874 |
+ } |
|
| 875 |
+ |
|
| 876 |
+ private func offlineMacHeader(name: String) -> some View {
|
|
| 877 |
+ HStack(spacing: 12) {
|
|
| 878 |
+ Button { dismiss() } label: {
|
|
| 879 |
+ HStack(spacing: 4) {
|
|
| 880 |
+ Image(systemName: "chevron.left") |
|
| 881 |
+ .font(.body.weight(.semibold)) |
|
| 882 |
+ Text("USB Meters")
|
|
| 883 |
+ } |
|
| 884 |
+ .foregroundColor(.accentColor) |
|
| 885 |
+ } |
|
| 886 |
+ .buttonStyle(.plain) |
|
| 887 |
+ Text(name).font(.headline).lineLimit(1) |
|
| 888 |
+ Spacer() |
|
| 889 |
+ } |
|
| 890 |
+ .padding(.horizontal, 16) |
|
| 891 |
+ .padding(.vertical, 10) |
|
| 892 |
+ .background( |
|
| 893 |
+ Rectangle() |
|
| 894 |
+ .fill(.ultraThinMaterial) |
|
| 895 |
+ .ignoresSafeArea(edges: .top) |
|
| 896 |
+ ) |
|
| 897 |
+ .overlay(alignment: .bottom) {
|
|
| 898 |
+ Rectangle() |
|
| 899 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 900 |
+ .frame(height: 1) |
|
| 901 |
+ } |
|
| 902 |
+ } |
|
| 903 |
+ |
|
| 904 |
+ private func offlineStatusHeader(summary: AppData.MeterSummary) -> some View {
|
|
| 905 |
+ HStack(spacing: 12) {
|
|
| 906 |
+ Image(systemName: "sensor.tag.radiowaves.forward.fill") |
|
| 907 |
+ .font(.system(size: 22, weight: .semibold)) |
|
| 908 |
+ .foregroundColor(.secondary) |
|
| 909 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 910 |
+ Text(summary.displayName) |
|
| 911 |
+ .font(.title3.weight(.semibold)) |
|
| 912 |
+ .lineLimit(1) |
|
| 913 |
+ Text(summary.modelSummary.isEmpty ? "Known Meter" : summary.modelSummary) |
|
| 914 |
+ .font(.caption) |
|
| 915 |
+ .foregroundColor(.secondary) |
|
| 916 |
+ } |
|
| 917 |
+ Spacer() |
|
| 918 |
+ HStack(spacing: 6) {
|
|
| 919 |
+ Circle().fill(Color.secondary).frame(width: 8, height: 8) |
|
| 920 |
+ Text("Offline")
|
|
| 921 |
+ .font(.caption.weight(.semibold)) |
|
| 922 |
+ .foregroundColor(.secondary) |
|
| 923 |
+ } |
|
| 924 |
+ .padding(.horizontal, 10) |
|
| 925 |
+ .padding(.vertical, 6) |
|
| 926 |
+ .background(Capsule(style: .continuous).fill(Color.secondary.opacity(0.12))) |
|
| 927 |
+ .overlay(Capsule(style: .continuous).stroke(Color.secondary.opacity(0.22), lineWidth: 1)) |
|
| 928 |
+ } |
|
| 929 |
+ .padding(14) |
|
| 930 |
+ .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 18) |
|
| 931 |
+ } |
|
| 932 |
+ |
|
| 933 |
+ private func offlineBackground(tint: Color) -> some View {
|
|
| 934 |
+ LinearGradient( |
|
| 935 |
+ colors: [tint.opacity(0.14), Color.secondary.opacity(0.06), Color.clear], |
|
| 936 |
+ startPoint: .topLeading, |
|
| 937 |
+ endPoint: .bottomTrailing |
|
| 938 |
+ ) |
|
| 939 |
+ .ignoresSafeArea() |
|
| 940 |
+ } |
|
| 941 |
+ |
|
| 942 |
+ private func historyText(for date: Date?) -> String {
|
|
| 943 |
+ guard let date else { return "Never" }
|
|
| 944 |
+ return date.format(as: "yyyy-MM-dd HH:mm") |
|
| 945 |
+ } |
|
| 946 |
+ |
|
| 649 | 947 |
} |
| 650 | 948 |
|
| 651 | 949 |
private struct MeterTabBarHeightPreferenceKey: PreferenceKey {
|
@@ -16,11 +16,6 @@ struct MeterChargeRecordTabView: View, Equatable {
|
||
| 16 | 16 |
} |
| 17 | 17 |
|
| 18 | 18 |
struct MeterChargeRecordContentView: View {
|
| 19 |
- private struct SessionMetricRow {
|
|
| 20 |
- let label: String |
|
| 21 |
- let value: String |
|
| 22 |
- } |
|
| 23 |
- |
|
| 24 | 19 |
private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
|
| 25 | 20 |
case known |
| 26 | 21 |
case unknown |
@@ -42,28 +37,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 42 | 37 |
case standbyPower |
| 43 | 38 |
} |
| 44 | 39 |
|
| 45 |
- private enum FinalCheckpoint: Hashable {
|
|
| 46 |
- case full |
|
| 47 |
- case skip |
|
| 48 |
- case custom |
|
| 49 |
- |
|
| 50 |
- var label: String {
|
|
| 51 |
- switch self {
|
|
| 52 |
- case .full: return "Full" |
|
| 53 |
- case .skip: return "Skip" |
|
| 54 |
- case .custom: return "Other %" |
|
| 55 |
- } |
|
| 56 |
- } |
|
| 57 |
- |
|
| 58 |
- var icon: String {
|
|
| 59 |
- switch self {
|
|
| 60 |
- case .full: return "battery.100percent" |
|
| 61 |
- case .skip: return "minus.circle" |
|
| 62 |
- case .custom: return "pencil" |
|
| 63 |
- } |
|
| 64 |
- } |
|
| 65 |
- } |
|
| 66 |
- |
|
| 67 | 40 |
private enum SessionStartRequirement: Identifiable {
|
| 68 | 41 |
case existingSession |
| 69 | 42 |
case device |
@@ -98,63 +71,42 @@ struct MeterChargeRecordContentView: View {
|
||
| 98 | 71 |
} |
| 99 | 72 |
} |
| 100 | 73 |
|
| 101 |
-@EnvironmentObject private var appData: AppData |
|
| 74 |
+ @EnvironmentObject private var appData: AppData |
|
| 102 | 75 |
@EnvironmentObject private var usbMeter: Meter |
| 103 | 76 |
|
| 104 |
- @State private var showingInlineTargetEditor = false |
|
| 105 |
- @State private var draftTargetText = "" |
|
| 106 |
- @State private var showingStopConfirm = false |
|
| 107 |
- @State private var finalCheckpointMode: FinalCheckpoint = .skip |
|
| 108 |
- @State private var finalCheckpointText = "" |
|
| 109 |
- @State private var pendingCheckpointDeletion: ChargeCheckpointSummary? |
|
| 110 | 77 |
@State private var draftChargingTransportMode: ChargingTransportMode? |
| 111 | 78 |
@State private var draftChargingStateMode: ChargingStateMode? |
| 112 | 79 |
@State private var initialCheckpointMode: InitialCheckpointMode = .known |
| 113 | 80 |
@State private var initialCheckpoint = "" |
| 114 | 81 |
@State private var showsMeterTotalsInfo = false |
| 115 | 82 |
@State private var activeMode: ActiveMode = .chargeSession |
| 116 |
- @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow? |
|
| 117 |
- @State private var trimBannerDismissedForSessionID: UUID? |
|
| 118 |
- |
|
| 119 |
- private var shouldShowTrimBanner: Bool {
|
|
| 120 |
- guard let session = openChargeSession, |
|
| 121 |
- session.isTrimmed == false, |
|
| 122 |
- trimBannerDismissedForSessionID != session.id else { return false }
|
|
| 123 |
- guard let window = detectedTrimWindow else { return false }
|
|
| 124 |
- return window.trimRatio > ChargingWindowDetector.significantTrimThreshold |
|
| 125 |
- } |
|
| 126 | 83 |
|
| 127 | 84 |
var body: some View {
|
| 128 |
- ScrollView {
|
|
| 129 |
- VStack(spacing: 14) {
|
|
| 130 |
- statusHeader |
|
| 131 |
- |
|
| 132 |
- if let openChargeSession {
|
|
| 133 |
- chargingMonitorCard(openChargeSession) |
|
| 134 |
- |
|
| 135 |
- if shouldShowTrimBanner {
|
|
| 136 |
- trimDetectionBanner(for: openChargeSession) |
|
| 137 |
- } |
|
| 138 |
- |
|
| 139 |
- if shouldShowSessionChart(for: openChargeSession) {
|
|
| 140 |
- sessionChartCard( |
|
| 141 |
- timeRange: sessionChartFixedTimeRange(for: openChargeSession), |
|
| 142 |
- session: openChargeSession |
|
| 143 |
- ) |
|
| 144 |
- } |
|
| 145 |
- } else {
|
|
| 146 |
- liveMeterStripView |
|
| 147 |
- modePicker |
|
| 148 |
- |
|
| 149 |
- switch activeMode {
|
|
| 150 |
- case .chargeSession: |
|
| 151 |
- chargeSessionSetupCard |
|
| 152 |
- case .standbyPower: |
|
| 153 |
- standbyPowerCard |
|
| 85 |
+ Group {
|
|
| 86 |
+ if let openChargeSession {
|
|
| 87 |
+ ChargeSessionDetailView( |
|
| 88 |
+ chargedDeviceID: openChargeSession.chargedDeviceID, |
|
| 89 |
+ sessionID: openChargeSession.id, |
|
| 90 |
+ monitoringMeter: usbMeter, |
|
| 91 |
+ presentation: .embedded |
|
| 92 |
+ ) |
|
| 93 |
+ } else {
|
|
| 94 |
+ ScrollView {
|
|
| 95 |
+ VStack(spacing: 14) {
|
|
| 96 |
+ statusHeader |
|
| 97 |
+ liveMeterStripView |
|
| 98 |
+ modePicker |
|
| 99 |
+ |
|
| 100 |
+ switch activeMode {
|
|
| 101 |
+ case .chargeSession: |
|
| 102 |
+ chargeSessionSetupCard |
|
| 103 |
+ case .standbyPower: |
|
| 104 |
+ standbyPowerCard |
|
| 105 |
+ } |
|
| 154 | 106 |
} |
| 107 |
+ .padding() |
|
| 155 | 108 |
} |
| 156 | 109 |
} |
| 157 |
- .padding() |
|
| 158 | 110 |
} |
| 159 | 111 |
.background( |
| 160 | 112 |
LinearGradient( |
@@ -164,64 +116,15 @@ struct MeterChargeRecordContentView: View {
|
||
| 164 | 116 |
) |
| 165 | 117 |
.ignoresSafeArea() |
| 166 | 118 |
) |
| 167 |
- .alert(item: $pendingCheckpointDeletion) { checkpoint in
|
|
| 168 |
- Alert( |
|
| 169 |
- title: Text("Delete Battery Checkpoint"),
|
|
| 170 |
- message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
|
|
| 171 |
- primaryButton: .destructive(Text("Delete")) {
|
|
| 172 |
- if let openChargeSession {
|
|
| 173 |
- _ = appData.deleteBatteryCheckpoint( |
|
| 174 |
- checkpointID: checkpoint.id, |
|
| 175 |
- for: openChargeSession.id |
|
| 176 |
- ) |
|
| 177 |
- } |
|
| 178 |
- }, |
|
| 179 |
- secondaryButton: .cancel() |
|
| 180 |
- ) |
|
| 181 |
- } |
|
| 182 | 119 |
.onAppear {
|
| 183 |
- syncActiveSessionRestore() |
|
| 184 | 120 |
syncDraftSelections() |
| 185 |
- runTrimDetection() |
|
| 186 | 121 |
} |
| 187 | 122 |
.onChange(of: selectedChargedDevice?.id) { _ in
|
| 188 | 123 |
syncDraftSelections() |
| 189 | 124 |
} |
| 190 | 125 |
.onChange(of: openChargeSession?.id) { _ in
|
| 191 |
- syncActiveSessionRestore() |
|
| 192 | 126 |
syncDraftSelections() |
| 193 |
- showingInlineTargetEditor = false |
|
| 194 |
- draftTargetText = "" |
|
| 195 |
- detectedTrimWindow = nil |
|
| 196 |
- trimBannerDismissedForSessionID = nil |
|
| 197 |
- runTrimDetection() |
|
| 198 |
- } |
|
| 199 |
- .onChange(of: openChargeSession?.aggregatedSamples.count) { _ in
|
|
| 200 |
- syncActiveSessionRestore() |
|
| 201 |
- runTrimDetection() |
|
| 202 |
- } |
|
| 203 |
- } |
|
| 204 |
- |
|
| 205 |
- private func syncActiveSessionRestore() {
|
|
| 206 |
- guard let session = openChargeSession else { return }
|
|
| 207 |
- guard session.status.isOpen else { return }
|
|
| 208 |
- guard session.meterMACAddress == meterMACAddress else { return }
|
|
| 209 |
- usbMeter.restoreChargeMonitoringIfNeeded(from: session) |
|
| 210 |
- } |
|
| 211 |
- |
|
| 212 |
- private func runTrimDetection() {
|
|
| 213 |
- guard let session = openChargeSession, |
|
| 214 |
- session.isTrimmed == false, |
|
| 215 |
- !session.aggregatedSamples.isEmpty else {
|
|
| 216 |
- detectedTrimWindow = nil |
|
| 217 |
- return |
|
| 218 | 127 |
} |
| 219 |
- let sessionEnd = session.endedAt ?? session.lastObservedAt |
|
| 220 |
- detectedTrimWindow = ChargingWindowDetector.detect( |
|
| 221 |
- samples: session.aggregatedSamples, |
|
| 222 |
- sessionStart: session.startedAt, |
|
| 223 |
- sessionEnd: sessionEnd |
|
| 224 |
- ) |
|
| 225 | 128 |
} |
| 226 | 129 |
|
| 227 | 130 |
// MARK: - Computed Properties |
@@ -375,28 +278,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 375 | 278 |
} |
| 376 | 279 |
} |
| 377 | 280 |
|
| 378 |
- private func shouldShowSessionChart(for session: ChargeSessionSummary) -> Bool {
|
|
| 379 |
- sessionChartFixedTimeRange(for: session) != nil || usesChargeRecordBuffer(for: session) |
|
| 380 |
- } |
|
| 381 |
- |
|
| 382 |
- private func sessionChartFixedTimeRange(for session: ChargeSessionSummary) -> ClosedRange<Date>? {
|
|
| 383 |
- if usesChargeRecordBuffer(for: session) {
|
|
| 384 |
- return nil |
|
| 385 |
- } |
|
| 386 |
- return session.effectiveTimeRange |
|
| 387 |
- } |
|
| 388 |
- |
|
| 389 |
- private func sessionChartLiveTrimBounds(for session: ChargeSessionSummary) -> (lower: Date?, upper: Date?) {
|
|
| 390 |
- guard usesChargeRecordBuffer(for: session) else {
|
|
| 391 |
- return (nil, nil) |
|
| 392 |
- } |
|
| 393 |
- return (session.trimStart, session.trimEnd) |
|
| 394 |
- } |
|
| 395 |
- |
|
| 396 |
- private func usesChargeRecordBuffer(for session: ChargeSessionSummary) -> Bool {
|
|
| 397 |
- session.status.isOpen && session.meterMACAddress == meterMACAddress |
|
| 398 |
- } |
|
| 399 |
- |
|
| 400 | 281 |
private var showsWirelessChargerSection: Bool {
|
| 401 | 282 |
let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first |
| 402 | 283 |
return transportMode == .wireless |
@@ -667,346 +548,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 667 | 548 |
} |
| 668 | 549 |
} |
| 669 | 550 |
|
| 670 |
- // MARK: - Charging Monitor Card |
|
| 671 |
- |
|
| 672 |
- private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
|
|
| 673 |
- let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession) |
|
| 674 |
- let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession) |
|
| 675 |
- let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id) |
|
| 676 |
- let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction( |
|
| 677 |
- for: openChargeSession, |
|
| 678 |
- effectiveEnergyWhOverride: displayedEnergyWh |
|
| 679 |
- ) |
|
| 680 |
- |
|
| 681 |
- return VStack(alignment: .leading, spacing: 14) {
|
|
| 682 |
- // Header |
|
| 683 |
- HStack {
|
|
| 684 |
- if let device = selectedChargedDevice {
|
|
| 685 |
- ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 16) |
|
| 686 |
- .font(.headline) |
|
| 687 |
- } else {
|
|
| 688 |
- Text("Charging Monitor").font(.headline)
|
|
| 689 |
- } |
|
| 690 |
- Spacer() |
|
| 691 |
- Text(openChargeSession.status.title) |
|
| 692 |
- .font(.caption.weight(.bold)) |
|
| 693 |
- .foregroundColor(headerStatusColor) |
|
| 694 |
- .padding(.horizontal, 8) |
|
| 695 |
- .padding(.vertical, 4) |
|
| 696 |
- .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
|
| 697 |
- } |
|
| 698 |
- |
|
| 699 |
- // Orphaned session warning — device was deleted from library |
|
| 700 |
- if selectedChargedDevice == nil {
|
|
| 701 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 702 |
- Label("Device removed from library", systemImage: "exclamationmark.triangle.fill")
|
|
| 703 |
- .font(.subheadline.weight(.semibold)) |
|
| 704 |
- .foregroundColor(.orange) |
|
| 705 |
- Text("The device associated with this session no longer exists. Stop the session to close it.")
|
|
| 706 |
- .font(.caption) |
|
| 707 |
- .foregroundColor(.secondary) |
|
| 708 |
- Button("Terminate Session") {
|
|
| 709 |
- _ = appData.stopChargeSession( |
|
| 710 |
- sessionID: openChargeSession.id, |
|
| 711 |
- finalBatteryPercent: nil |
|
| 712 |
- ) |
|
| 713 |
- } |
|
| 714 |
- .frame(maxWidth: .infinity) |
|
| 715 |
- .padding(.vertical, 9) |
|
| 716 |
- .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 12) |
|
| 717 |
- .buttonStyle(.plain) |
|
| 718 |
- } |
|
| 719 |
- .padding(14) |
|
| 720 |
- .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 721 |
- } |
|
| 722 |
- |
|
| 723 |
- // Battery prediction gauge |
|
| 724 |
- if let batteryPrediction {
|
|
| 725 |
- batteryGaugeSection( |
|
| 726 |
- prediction: batteryPrediction, |
|
| 727 |
- session: openChargeSession, |
|
| 728 |
- displayedEnergyWh: displayedEnergyWh |
|
| 729 |
- ) |
|
| 730 |
- } |
|
| 731 |
- |
|
| 732 |
- // Metrics grid |
|
| 733 |
- sessionMetricsGrid( |
|
| 734 |
- for: openChargeSession, |
|
| 735 |
- displayedEnergyWh: displayedEnergyWh, |
|
| 736 |
- hasPrediction: batteryPrediction != nil |
|
| 737 |
- ) |
|
| 738 |
- |
|
| 739 |
- if openChargeSession.stopThresholdAmps > 0 {
|
|
| 740 |
- Text("Stop threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
|
|
| 741 |
- .font(.caption) |
|
| 742 |
- .foregroundColor(.secondary) |
|
| 743 |
- } |
|
| 744 |
- |
|
| 745 |
- if let sessionWarning = sessionWarning(for: openChargeSession) {
|
|
| 746 |
- Label(sessionWarning, systemImage: "exclamationmark.triangle") |
|
| 747 |
- .font(.caption) |
|
| 748 |
- .foregroundColor(.orange) |
|
| 749 |
- } |
|
| 750 |
- |
|
| 751 |
- if openChargeSession.isPaused {
|
|
| 752 |
- Label( |
|
| 753 |
- "Paused \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). Auto-stops after 10 min.", |
|
| 754 |
- systemImage: "pause.circle" |
|
| 755 |
- ) |
|
| 756 |
- .font(.caption) |
|
| 757 |
- .foregroundColor(.secondary) |
|
| 758 |
- } |
|
| 759 |
- |
|
| 760 |
- if openChargeSession.requiresCompletionConfirmation && !showingStopConfirm {
|
|
| 761 |
- completionConfirmationCard(openChargeSession) |
|
| 762 |
- } |
|
| 763 |
- |
|
| 764 |
- BatteryCheckpointSectionView( |
|
| 765 |
- sessionID: openChargeSession.id, |
|
| 766 |
- checkpoints: openChargeSession.checkpoints, |
|
| 767 |
- message: "Checkpoints are used for capacity estimation and the typical charge curve.", |
|
| 768 |
- canAddCheckpoint: canAddCheckpoint, |
|
| 769 |
- requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: openChargeSession.id), |
|
| 770 |
- effectiveEnergyWhOverride: displayedEnergyWh, |
|
| 771 |
- measuredChargeAhOverride: displayedChargeAh, |
|
| 772 |
- onDelete: { checkpoint in
|
|
| 773 |
- pendingCheckpointDeletion = checkpoint |
|
| 774 |
- } |
|
| 775 |
- ) |
|
| 776 |
- |
|
| 777 |
- targetSectionView( |
|
| 778 |
- for: openChargeSession, |
|
| 779 |
- predictedPercent: batteryPrediction?.predictedPercent |
|
| 780 |
- ) |
|
| 781 |
- |
|
| 782 |
- if showingStopConfirm {
|
|
| 783 |
- stopConfirmPanel( |
|
| 784 |
- for: openChargeSession, |
|
| 785 |
- displayedEnergyWh: displayedEnergyWh, |
|
| 786 |
- displayedChargeAh: displayedChargeAh |
|
| 787 |
- ) |
|
| 788 |
- } else {
|
|
| 789 |
- HStack(spacing: 10) {
|
|
| 790 |
- if openChargeSession.status == .active {
|
|
| 791 |
- Button("Pause") {
|
|
| 792 |
- _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter) |
|
| 793 |
- } |
|
| 794 |
- .frame(maxWidth: .infinity) |
|
| 795 |
- .padding(.vertical, 10) |
|
| 796 |
- .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 797 |
- .buttonStyle(.plain) |
|
| 798 |
- } else if openChargeSession.status == .paused {
|
|
| 799 |
- Button("Resume") {
|
|
| 800 |
- _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter) |
|
| 801 |
- } |
|
| 802 |
- .frame(maxWidth: .infinity) |
|
| 803 |
- .padding(.vertical, 10) |
|
| 804 |
- .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 805 |
- .buttonStyle(.plain) |
|
| 806 |
- } |
|
| 807 |
- |
|
| 808 |
- Button("Terminate Session") {
|
|
| 809 |
- finalCheckpointMode = .skip |
|
| 810 |
- finalCheckpointText = "" |
|
| 811 |
- showingStopConfirm = true |
|
| 812 |
- } |
|
| 813 |
- .frame(maxWidth: .infinity) |
|
| 814 |
- .padding(.vertical, 10) |
|
| 815 |
- .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 816 |
- .buttonStyle(.plain) |
|
| 817 |
- } |
|
| 818 |
- } |
|
| 819 |
- } |
|
| 820 |
- .padding(18) |
|
| 821 |
- .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 822 |
- } |
|
| 823 |
- |
|
| 824 |
- // MARK: - Battery Gauge Section |
|
| 825 |
- |
|
| 826 |
- private func batteryGaugeSection( |
|
| 827 |
- prediction: BatteryLevelPrediction, |
|
| 828 |
- session: ChargeSessionSummary, |
|
| 829 |
- displayedEnergyWh: Double |
|
| 830 |
- ) -> some View {
|
|
| 831 |
- let percent = prediction.predictedPercent |
|
| 832 |
- let color = batteryColor(for: percent) |
|
| 833 |
- let duration = displayedSessionDuration(for: session) |
|
| 834 |
- let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01 |
|
| 835 |
- ? displayedEnergyWh / duration |
|
| 836 |
- : nil |
|
| 837 |
- |
|
| 838 |
- let etaToFull: String? = {
|
|
| 839 |
- guard let rate = rateWhPerSec, rate > 0.0001, percent < 98 else { return nil }
|
|
| 840 |
- let remaining = max(prediction.estimatedCapacityWh - displayedEnergyWh, 0) |
|
| 841 |
- let seconds = remaining / rate |
|
| 842 |
- return seconds > 120 ? formatETA(seconds) : nil |
|
| 843 |
- }() |
|
| 844 |
- |
|
| 845 |
- let etaToTarget: String? = {
|
|
| 846 |
- guard let target = session.targetBatteryPercent, target > percent + 1, |
|
| 847 |
- let rate = rateWhPerSec, rate > 0.0001 else { return nil }
|
|
| 848 |
- let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh |
|
| 849 |
- let remaining = max(targetEnergyWh - displayedEnergyWh, 0) |
|
| 850 |
- let seconds = remaining / rate |
|
| 851 |
- return seconds > 120 ? formatETA(seconds) : nil |
|
| 852 |
- }() |
|
| 853 |
- |
|
| 854 |
- return VStack(spacing: 10) {
|
|
| 855 |
- HStack(alignment: .lastTextBaseline, spacing: 8) {
|
|
| 856 |
- HStack(alignment: .lastTextBaseline, spacing: 3) {
|
|
| 857 |
- Text("\(Int(percent.rounded()))")
|
|
| 858 |
- .font(.system(size: 52, weight: .bold, design: .rounded)) |
|
| 859 |
- .foregroundColor(color) |
|
| 860 |
- .monospacedDigit() |
|
| 861 |
- Text("%")
|
|
| 862 |
- .font(.title2.weight(.semibold)) |
|
| 863 |
- .foregroundColor(color.opacity(0.8)) |
|
| 864 |
- } |
|
| 865 |
- Spacer() |
|
| 866 |
- VStack(alignment: .trailing, spacing: 2) {
|
|
| 867 |
- Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
|
|
| 868 |
- .font(.callout.weight(.bold)) |
|
| 869 |
- .foregroundColor(.orange) |
|
| 870 |
- .monospacedDigit() |
|
| 871 |
- Text("est. capacity")
|
|
| 872 |
- .font(.caption2) |
|
| 873 |
- .foregroundColor(.secondary) |
|
| 874 |
- } |
|
| 875 |
- } |
|
| 876 |
- |
|
| 877 |
- batteryProgressBar( |
|
| 878 |
- percent: percent, |
|
| 879 |
- startPercent: session.startBatteryPercent, |
|
| 880 |
- targetPercent: session.targetBatteryPercent |
|
| 881 |
- ) |
|
| 882 |
- |
|
| 883 |
- HStack(spacing: 14) {
|
|
| 884 |
- if let etaToFull {
|
|
| 885 |
- VStack(alignment: .leading, spacing: 1) {
|
|
| 886 |
- HStack(spacing: 4) {
|
|
| 887 |
- Image(systemName: "clock.fill") |
|
| 888 |
- .font(.caption) |
|
| 889 |
- .foregroundColor(.green) |
|
| 890 |
- Text(etaToFull) |
|
| 891 |
- .font(.caption.weight(.bold)) |
|
| 892 |
- } |
|
| 893 |
- Text("to full")
|
|
| 894 |
- .font(.caption2) |
|
| 895 |
- .foregroundColor(.secondary) |
|
| 896 |
- } |
|
| 897 |
- } |
|
| 898 |
- if let etaToTarget, let target = session.targetBatteryPercent {
|
|
| 899 |
- VStack(alignment: .leading, spacing: 1) {
|
|
| 900 |
- HStack(spacing: 4) {
|
|
| 901 |
- Image(systemName: "bell.badge.fill") |
|
| 902 |
- .font(.caption) |
|
| 903 |
- .foregroundColor(.indigo) |
|
| 904 |
- Text(etaToTarget) |
|
| 905 |
- .font(.caption.weight(.bold)) |
|
| 906 |
- } |
|
| 907 |
- Text("to \(Int(target.rounded()))%")
|
|
| 908 |
- .font(.caption2) |
|
| 909 |
- .foregroundColor(.secondary) |
|
| 910 |
- } |
|
| 911 |
- } |
|
| 912 |
- Spacer() |
|
| 913 |
- Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
|
|
| 914 |
- .font(.caption2) |
|
| 915 |
- .foregroundColor(.secondary) |
|
| 916 |
- .multilineTextAlignment(.trailing) |
|
| 917 |
- } |
|
| 918 |
- } |
|
| 919 |
- .padding(14) |
|
| 920 |
- .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 921 |
- } |
|
| 922 |
- |
|
| 923 |
- private func batteryProgressBar( |
|
| 924 |
- percent: Double, |
|
| 925 |
- startPercent: Double?, |
|
| 926 |
- targetPercent: Double? |
|
| 927 |
- ) -> some View {
|
|
| 928 |
- let color = batteryColor(for: percent) |
|
| 929 |
- return GeometryReader { geo in
|
|
| 930 |
- let width = geo.size.width |
|
| 931 |
- ZStack(alignment: .leading) {
|
|
| 932 |
- Capsule() |
|
| 933 |
- .fill(Color.primary.opacity(0.10)) |
|
| 934 |
- Rectangle() |
|
| 935 |
- .fill( |
|
| 936 |
- LinearGradient( |
|
| 937 |
- colors: [color.opacity(0.6), color], |
|
| 938 |
- startPoint: .leading, |
|
| 939 |
- endPoint: .trailing |
|
| 940 |
- ) |
|
| 941 |
- ) |
|
| 942 |
- .frame(width: max(width * CGFloat(percent / 100), 4)) |
|
| 943 |
- .animation(.easeInOut(duration: 0.4), value: percent) |
|
| 944 |
- if let start = startPercent, start > 2, start < 98 {
|
|
| 945 |
- Rectangle() |
|
| 946 |
- .fill(Color.white.opacity(0.55)) |
|
| 947 |
- .frame(width: 2, height: 20) |
|
| 948 |
- .offset(x: width * CGFloat(start / 100) - 1) |
|
| 949 |
- } |
|
| 950 |
- if let target = targetPercent {
|
|
| 951 |
- Rectangle() |
|
| 952 |
- .fill(Color.indigo.opacity(0.9)) |
|
| 953 |
- .frame(width: 2.5, height: 20) |
|
| 954 |
- .offset(x: width * CGFloat(target / 100) - 1.25) |
|
| 955 |
- } |
|
| 956 |
- } |
|
| 957 |
- .clipShape(Capsule()) |
|
| 958 |
- } |
|
| 959 |
- .frame(height: 20) |
|
| 960 |
- } |
|
| 961 |
- |
|
| 962 |
- private func batteryColor(for percent: Double) -> Color {
|
|
| 963 |
- if percent >= 75 { return .green }
|
|
| 964 |
- if percent >= 35 { return .orange }
|
|
| 965 |
- return .red |
|
| 966 |
- } |
|
| 967 |
- |
|
| 968 |
- private func formatETA(_ seconds: TimeInterval) -> String {
|
|
| 969 |
- let totalMinutes = Int(seconds / 60) |
|
| 970 |
- if totalMinutes < 60 { return "\(totalMinutes)m" }
|
|
| 971 |
- let hours = totalMinutes / 60 |
|
| 972 |
- let minutes = totalMinutes % 60 |
|
| 973 |
- return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m" |
|
| 974 |
- } |
|
| 975 |
- |
|
| 976 |
- // MARK: - Session Metrics Grid |
|
| 977 |
- |
|
| 978 |
- private func sessionMetricsGrid( |
|
| 979 |
- for session: ChargeSessionSummary, |
|
| 980 |
- displayedEnergyWh: Double, |
|
| 981 |
- hasPrediction: Bool |
|
| 982 |
- ) -> some View {
|
|
| 983 |
- let displayedDuration = displayedSessionDuration(for: session) |
|
| 984 |
- let capacityFallback: Double? = hasPrediction ? nil : ( |
|
| 985 |
- session.capacityEstimateWh |
|
| 986 |
- ?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode) |
|
| 987 |
- ?? selectedChargedDevice?.estimatedBatteryCapacityWh |
|
| 988 |
- ) |
|
| 989 |
- let columns = [GridItem(.flexible()), GridItem(.flexible())] |
|
| 990 |
- |
|
| 991 |
- return LazyVGrid(columns: columns, spacing: 8) {
|
|
| 992 |
- metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue) |
|
| 993 |
- metricCell(label: "Duration", value: formatDuration(displayedDuration), tint: .teal) |
|
| 994 |
- |
|
| 995 |
- if shouldShowChargingTransport(for: session) {
|
|
| 996 |
- metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange) |
|
| 997 |
- } |
|
| 998 |
- if shouldShowChargingState(for: session) {
|
|
| 999 |
- metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple) |
|
| 1000 |
- } |
|
| 1001 |
- |
|
| 1002 |
- metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary) |
|
| 1003 |
- |
|
| 1004 |
- if let capacity = capacityFallback {
|
|
| 1005 |
- metricCell(label: "Est. Capacity", value: "\(capacity.format(decimalDigits: 2)) Wh", tint: .orange) |
|
| 1006 |
- } |
|
| 1007 |
- } |
|
| 1008 |
- } |
|
| 1009 |
- |
|
| 1010 | 551 |
private func metricCell(label: String, value: String, tint: Color) -> some View {
|
| 1011 | 552 |
VStack(alignment: .leading, spacing: 3) {
|
| 1012 | 553 |
Text(label) |
@@ -1024,467 +565,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 1024 | 565 |
.meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12) |
| 1025 | 566 |
} |
| 1026 | 567 |
|
| 1027 |
- private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
|
|
| 1028 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 1029 |
- Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
|
|
| 1030 |
- .font(.subheadline.weight(.semibold)) |
|
| 1031 |
- |
|
| 1032 |
- if let contradictionPercent = openChargeSession.completionContradictionPercent {
|
|
| 1033 |
- Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
|
|
| 1034 |
- .font(.caption) |
|
| 1035 |
- .foregroundColor(.secondary) |
|
| 1036 |
- } else {
|
|
| 1037 |
- Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
|
|
| 1038 |
- .font(.caption) |
|
| 1039 |
- .foregroundColor(.secondary) |
|
| 1040 |
- } |
|
| 1041 |
- |
|
| 1042 |
- HStack(spacing: 10) {
|
|
| 1043 |
- Button("Finish") {
|
|
| 1044 |
- finalCheckpointMode = .skip |
|
| 1045 |
- finalCheckpointText = "" |
|
| 1046 |
- showingStopConfirm = true |
|
| 1047 |
- } |
|
| 1048 |
- .frame(maxWidth: .infinity) |
|
| 1049 |
- .padding(.vertical, 9) |
|
| 1050 |
- .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 1051 |
- .buttonStyle(.plain) |
|
| 1052 |
- |
|
| 1053 |
- Button("Keep Monitoring") {
|
|
| 1054 |
- _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id) |
|
| 1055 |
- } |
|
| 1056 |
- .frame(maxWidth: .infinity) |
|
| 1057 |
- .padding(.vertical, 9) |
|
| 1058 |
- .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) |
|
| 1059 |
- .buttonStyle(.plain) |
|
| 1060 |
- } |
|
| 1061 |
- } |
|
| 1062 |
- .padding(14) |
|
| 1063 |
- .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16) |
|
| 1064 |
- } |
|
| 1065 |
- |
|
| 1066 |
- // MARK: - Target Section |
|
| 1067 |
- |
|
| 1068 |
- private func targetSectionView(for session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
|
|
| 1069 |
- let draftBelowPrediction: Bool = {
|
|
| 1070 |
- guard let draft = parsedDraftTarget, let predicted = predictedPercent else { return false }
|
|
| 1071 |
- return draft <= predicted |
|
| 1072 |
- }() |
|
| 1073 |
- let savedBelowPrediction: Bool = {
|
|
| 1074 |
- guard let saved = session.targetBatteryPercent, let predicted = predictedPercent else { return false }
|
|
| 1075 |
- return saved <= predicted |
|
| 1076 |
- }() |
|
| 1077 |
- |
|
| 1078 |
- return HStack(alignment: .center, spacing: 8) {
|
|
| 1079 |
- Image(systemName: "bell.badge") |
|
| 1080 |
- .foregroundColor(.indigo) |
|
| 1081 |
- .font(.subheadline) |
|
| 1082 |
- |
|
| 1083 |
- Text("Notify at")
|
|
| 1084 |
- .font(.subheadline.weight(.semibold)) |
|
| 1085 |
- |
|
| 1086 |
- Spacer(minLength: 8) |
|
| 1087 |
- |
|
| 1088 |
- if showingInlineTargetEditor {
|
|
| 1089 |
- Button {
|
|
| 1090 |
- let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80 |
|
| 1091 |
- let next = max(current - 1, 1) |
|
| 1092 |
- draftTargetText = next.format(decimalDigits: 0) |
|
| 1093 |
- } label: {
|
|
| 1094 |
- Image(systemName: "minus.circle") |
|
| 1095 |
- .font(.title3) |
|
| 1096 |
- } |
|
| 1097 |
- .buttonStyle(.plain) |
|
| 1098 |
- |
|
| 1099 |
- TextField("—", text: $draftTargetText)
|
|
| 1100 |
- .keyboardType(.decimalPad) |
|
| 1101 |
- .textFieldStyle(.roundedBorder) |
|
| 1102 |
- .frame(width: 48) |
|
| 1103 |
- .multilineTextAlignment(.center) |
|
| 1104 |
- .foregroundColor(draftBelowPrediction ? .orange : .primary) |
|
| 1105 |
- |
|
| 1106 |
- Text("%")
|
|
| 1107 |
- .font(.subheadline) |
|
| 1108 |
- .foregroundColor(.secondary) |
|
| 1109 |
- |
|
| 1110 |
- if draftBelowPrediction {
|
|
| 1111 |
- Button {} label: {
|
|
| 1112 |
- Image(systemName: "exclamationmark.triangle.fill") |
|
| 1113 |
- .font(.body.weight(.semibold)) |
|
| 1114 |
- .foregroundColor(.orange) |
|
| 1115 |
- } |
|
| 1116 |
- .buttonStyle(.plain) |
|
| 1117 |
- .help("Battery is already predicted at \(predictedPercent!.format(decimalDigits: 0))% — this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
|
|
| 1118 |
- } |
|
| 1119 |
- |
|
| 1120 |
- Button {
|
|
| 1121 |
- let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80 |
|
| 1122 |
- let next = min(current + 1, 100) |
|
| 1123 |
- draftTargetText = next.format(decimalDigits: 0) |
|
| 1124 |
- } label: {
|
|
| 1125 |
- Image(systemName: "plus.circle") |
|
| 1126 |
- .font(.title3) |
|
| 1127 |
- } |
|
| 1128 |
- .buttonStyle(.plain) |
|
| 1129 |
- |
|
| 1130 |
- Button {
|
|
| 1131 |
- if let value = parsedDraftTarget {
|
|
| 1132 |
- _ = appData.setTargetBatteryPercent(value, for: session.id) |
|
| 1133 |
- } |
|
| 1134 |
- showingInlineTargetEditor = false |
|
| 1135 |
- } label: {
|
|
| 1136 |
- Image(systemName: "checkmark.circle.fill") |
|
| 1137 |
- .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary) |
|
| 1138 |
- .font(.title3) |
|
| 1139 |
- } |
|
| 1140 |
- .buttonStyle(.plain) |
|
| 1141 |
- .disabled(parsedDraftTarget == nil) |
|
| 1142 |
- |
|
| 1143 |
- Button {
|
|
| 1144 |
- showingInlineTargetEditor = false |
|
| 1145 |
- draftTargetText = "" |
|
| 1146 |
- } label: {
|
|
| 1147 |
- Image(systemName: "xmark.circle") |
|
| 1148 |
- .foregroundColor(.secondary) |
|
| 1149 |
- .font(.title3) |
|
| 1150 |
- } |
|
| 1151 |
- .buttonStyle(.plain) |
|
| 1152 |
- |
|
| 1153 |
- } else {
|
|
| 1154 |
- if let targetPercent = session.targetBatteryPercent {
|
|
| 1155 |
- Text("\(targetPercent.format(decimalDigits: 0))%")
|
|
| 1156 |
- .font(.subheadline.weight(.semibold)) |
|
| 1157 |
- .foregroundColor(savedBelowPrediction ? .orange : .indigo) |
|
| 1158 |
- |
|
| 1159 |
- if savedBelowPrediction {
|
|
| 1160 |
- Button {} label: {
|
|
| 1161 |
- Image(systemName: "exclamationmark.triangle.fill") |
|
| 1162 |
- .font(.callout.weight(.semibold)) |
|
| 1163 |
- .foregroundColor(.orange) |
|
| 1164 |
- } |
|
| 1165 |
- .buttonStyle(.plain) |
|
| 1166 |
- .help("Battery is already predicted at \(predictedPercent!.format(decimalDigits: 0))% — this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
|
|
| 1167 |
- } |
|
| 1168 |
- |
|
| 1169 |
- Button {
|
|
| 1170 |
- _ = appData.setTargetBatteryPercent(nil, for: session.id) |
|
| 1171 |
- } label: {
|
|
| 1172 |
- Image(systemName: "xmark.circle.fill") |
|
| 1173 |
- .foregroundColor(.secondary) |
|
| 1174 |
- .font(.callout) |
|
| 1175 |
- } |
|
| 1176 |
- .buttonStyle(.plain) |
|
| 1177 |
- .help("Remove alert")
|
|
| 1178 |
- } |
|
| 1179 |
- |
|
| 1180 |
- Button {
|
|
| 1181 |
- draftTargetText = session.targetBatteryPercent.map {
|
|
| 1182 |
- $0.format(decimalDigits: 0) |
|
| 1183 |
- } ?? "80" |
|
| 1184 |
- showingInlineTargetEditor = true |
|
| 1185 |
- } label: {
|
|
| 1186 |
- Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil") |
|
| 1187 |
- .font(.caption.weight(.semibold)) |
|
| 1188 |
- .frame(width: 30, height: 30) |
|
| 1189 |
- .contentShape(Rectangle()) |
|
| 1190 |
- } |
|
| 1191 |
- .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10) |
|
| 1192 |
- .buttonStyle(.plain) |
|
| 1193 |
- .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert") |
|
| 1194 |
- } |
|
| 1195 |
- } |
|
| 1196 |
- } |
|
| 1197 |
- |
|
| 1198 |
- private var parsedDraftTarget: Double? {
|
|
| 1199 |
- let normalized = draftTargetText |
|
| 1200 |
- .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 1201 |
- .replacingOccurrences(of: ",", with: ".") |
|
| 1202 |
- guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
|
|
| 1203 |
- return value |
|
| 1204 |
- } |
|
| 1205 |
- |
|
| 1206 |
- private func stopConfirmPanel( |
|
| 1207 |
- for session: ChargeSessionSummary, |
|
| 1208 |
- displayedEnergyWh: Double, |
|
| 1209 |
- displayedChargeAh: Double |
|
| 1210 |
- ) -> some View {
|
|
| 1211 |
- let canSave = hasSavableChargeData( |
|
| 1212 |
- for: session, |
|
| 1213 |
- displayedEnergyWh: displayedEnergyWh, |
|
| 1214 |
- displayedChargeAh: displayedChargeAh |
|
| 1215 |
- ) |
|
| 1216 |
- let hasInvalidCustomCheckpoint = finalCheckpointMode == .custom |
|
| 1217 |
- && finalCheckpointText.isEmpty == false |
|
| 1218 |
- && parsedFinalCheckpoint == nil |
|
| 1219 |
- |
|
| 1220 |
- return VStack(alignment: .leading, spacing: 12) {
|
|
| 1221 |
- Text("Final Checkpoint (optional)")
|
|
| 1222 |
- .font(.subheadline.weight(.semibold)) |
|
| 1223 |
- |
|
| 1224 |
- HStack(spacing: 8) {
|
|
| 1225 |
- ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
|
|
| 1226 |
- Button {
|
|
| 1227 |
- finalCheckpointMode = mode |
|
| 1228 |
- if mode != .custom { finalCheckpointText = "" }
|
|
| 1229 |
- } label: {
|
|
| 1230 |
- VStack(spacing: 5) {
|
|
| 1231 |
- Image(systemName: mode.icon) |
|
| 1232 |
- .font(.title3) |
|
| 1233 |
- .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary) |
|
| 1234 |
- Text(mode.label) |
|
| 1235 |
- .font(.caption.weight(.semibold)) |
|
| 1236 |
- .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary) |
|
| 1237 |
- } |
|
| 1238 |
- .frame(maxWidth: .infinity) |
|
| 1239 |
- .padding(.vertical, 10) |
|
| 1240 |
- .background(finalCheckpointMode == mode ? Color.primary.opacity(0.10) : Color.clear) |
|
| 1241 |
- .meterCard( |
|
| 1242 |
- tint: finalCheckpointMode == mode ? .primary : .secondary, |
|
| 1243 |
- fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04, |
|
| 1244 |
- strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10, |
|
| 1245 |
- cornerRadius: 12 |
|
| 1246 |
- ) |
|
| 1247 |
- } |
|
| 1248 |
- .buttonStyle(.plain) |
|
| 1249 |
- } |
|
| 1250 |
- } |
|
| 1251 |
- |
|
| 1252 |
- if finalCheckpointMode == .custom {
|
|
| 1253 |
- HStack(spacing: 8) {
|
|
| 1254 |
- Button { adjustFinalCheckpoint(by: -1) } label: {
|
|
| 1255 |
- Image(systemName: "minus.circle").font(.title3) |
|
| 1256 |
- } |
|
| 1257 |
- .buttonStyle(.plain) |
|
| 1258 |
- |
|
| 1259 |
- TextField("—", text: $finalCheckpointText)
|
|
| 1260 |
- .keyboardType(.decimalPad) |
|
| 1261 |
- .textFieldStyle(.roundedBorder) |
|
| 1262 |
- .frame(width: 56) |
|
| 1263 |
- .multilineTextAlignment(.center) |
|
| 1264 |
- |
|
| 1265 |
- Text("%").foregroundColor(.secondary)
|
|
| 1266 |
- |
|
| 1267 |
- Button { adjustFinalCheckpoint(by: 1) } label: {
|
|
| 1268 |
- Image(systemName: "plus.circle").font(.title3) |
|
| 1269 |
- } |
|
| 1270 |
- .buttonStyle(.plain) |
|
| 1271 |
- |
|
| 1272 |
- Spacer() |
|
| 1273 |
- } |
|
| 1274 |
- } |
|
| 1275 |
- |
|
| 1276 |
- if !canSave {
|
|
| 1277 |
- Label("This session has no charging data to save. Discard it instead.", systemImage: "exclamationmark.triangle.fill")
|
|
| 1278 |
- .font(.caption) |
|
| 1279 |
- .foregroundColor(.red) |
|
| 1280 |
- .fixedSize(horizontal: false, vertical: true) |
|
| 1281 |
- } else if hasInvalidCustomCheckpoint {
|
|
| 1282 |
- Label("Final battery percentage must be between 0 and 100. Save will close the session without a final checkpoint.", systemImage: "exclamationmark.triangle.fill")
|
|
| 1283 |
- .font(.caption) |
|
| 1284 |
- .foregroundColor(.orange) |
|
| 1285 |
- .fixedSize(horizontal: false, vertical: true) |
|
| 1286 |
- } |
|
| 1287 |
- |
|
| 1288 |
- HStack(spacing: 8) {
|
|
| 1289 |
- Button("Discard") {
|
|
| 1290 |
- _ = appData.deleteChargeSession(sessionID: session.id) |
|
| 1291 |
- showingStopConfirm = false |
|
| 1292 |
- finalCheckpointText = "" |
|
| 1293 |
- finalCheckpointMode = .full |
|
| 1294 |
- } |
|
| 1295 |
- .frame(maxWidth: .infinity) |
|
| 1296 |
- .padding(.vertical, 9) |
|
| 1297 |
- .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14) |
|
| 1298 |
- .buttonStyle(.plain) |
|
| 1299 |
- |
|
| 1300 |
- Button("Save") {
|
|
| 1301 |
- _ = appData.stopChargeSession( |
|
| 1302 |
- sessionID: session.id, |
|
| 1303 |
- finalBatteryPercent: resolvedFinalCheckpoint, |
|
| 1304 |
- from: usbMeter |
|
| 1305 |
- ) |
|
| 1306 |
- showingStopConfirm = false |
|
| 1307 |
- finalCheckpointText = "" |
|
| 1308 |
- finalCheckpointMode = .full |
|
| 1309 |
- } |
|
| 1310 |
- .frame(maxWidth: .infinity) |
|
| 1311 |
- .padding(.vertical, 9) |
|
| 1312 |
- .meterCard( |
|
| 1313 |
- tint: canSave ? .green : .red, |
|
| 1314 |
- fillOpacity: canSave ? 0.16 : 0.08, |
|
| 1315 |
- strokeOpacity: canSave ? 0.22 : 0.28, |
|
| 1316 |
- cornerRadius: 14 |
|
| 1317 |
- ) |
|
| 1318 |
- .buttonStyle(.plain) |
|
| 1319 |
- .disabled(!canSave) |
|
| 1320 |
- .opacity(canSave ? 1 : 0.52) |
|
| 1321 |
- |
|
| 1322 |
- Button("Cancel") {
|
|
| 1323 |
- showingStopConfirm = false |
|
| 1324 |
- finalCheckpointText = "" |
|
| 1325 |
- finalCheckpointMode = .full |
|
| 1326 |
- } |
|
| 1327 |
- .frame(maxWidth: .infinity) |
|
| 1328 |
- .padding(.vertical, 9) |
|
| 1329 |
- .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14) |
|
| 1330 |
- .buttonStyle(.plain) |
|
| 1331 |
- } |
|
| 1332 |
- } |
|
| 1333 |
- .padding(14) |
|
| 1334 |
- .meterCard(tint: .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16) |
|
| 1335 |
- } |
|
| 1336 |
- |
|
| 1337 |
- private var parsedFinalCheckpoint: Double? {
|
|
| 1338 |
- let normalized = finalCheckpointText |
|
| 1339 |
- .trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 1340 |
- .replacingOccurrences(of: ",", with: ".") |
|
| 1341 |
- guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
|
|
| 1342 |
- return value |
|
| 1343 |
- } |
|
| 1344 |
- |
|
| 1345 |
- private var resolvedFinalCheckpoint: Double? {
|
|
| 1346 |
- switch finalCheckpointMode {
|
|
| 1347 |
- case .full: return 100.0 |
|
| 1348 |
- case .skip: return nil |
|
| 1349 |
- case .custom: return parsedFinalCheckpoint |
|
| 1350 |
- } |
|
| 1351 |
- } |
|
| 1352 |
- |
|
| 1353 |
- private func adjustFinalCheckpoint(by delta: Double) {
|
|
| 1354 |
- let current = parsedFinalCheckpoint ?? 0 |
|
| 1355 |
- let next = min(max(current + delta, 0), 100) |
|
| 1356 |
- finalCheckpointText = next.format(decimalDigits: 0) |
|
| 1357 |
- } |
|
| 1358 |
- |
|
| 1359 |
- private func hasSavableChargeData( |
|
| 1360 |
- for session: ChargeSessionSummary, |
|
| 1361 |
- displayedEnergyWh: Double, |
|
| 1362 |
- displayedChargeAh: Double |
|
| 1363 |
- ) -> Bool {
|
|
| 1364 |
- session.hasSavableChargeData |
|
| 1365 |
- || displayedEnergyWh > 0 |
|
| 1366 |
- || displayedChargeAh > 0 |
|
| 1367 |
- } |
|
| 1368 |
- |
|
| 1369 |
- // MARK: - Trim Detection Banner |
|
| 1370 |
- |
|
| 1371 |
- @ViewBuilder |
|
| 1372 |
- private func trimDetectionBanner(for session: ChargeSessionSummary) -> some View {
|
|
| 1373 |
- if let window = detectedTrimWindow {
|
|
| 1374 |
- HStack(spacing: 12) {
|
|
| 1375 |
- Image(systemName: "scissors.circle.fill") |
|
| 1376 |
- .font(.title3) |
|
| 1377 |
- .foregroundColor(.blue) |
|
| 1378 |
- |
|
| 1379 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 1380 |
- Text("Charging ended early")
|
|
| 1381 |
- .font(.subheadline.weight(.semibold)) |
|
| 1382 |
- Text("Active charging detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")). The rest may be standby or another device.")
|
|
| 1383 |
- .font(.caption) |
|
| 1384 |
- .foregroundColor(.secondary) |
|
| 1385 |
- .fixedSize(horizontal: false, vertical: true) |
|
| 1386 |
- } |
|
| 1387 |
- |
|
| 1388 |
- Spacer(minLength: 0) |
|
| 1389 |
- |
|
| 1390 |
- VStack(spacing: 6) {
|
|
| 1391 |
- Button("Apply") {
|
|
| 1392 |
- _ = appData.setSessionTrim( |
|
| 1393 |
- sessionID: session.id, |
|
| 1394 |
- start: window.start, |
|
| 1395 |
- end: window.end |
|
| 1396 |
- ) |
|
| 1397 |
- trimBannerDismissedForSessionID = session.id |
|
| 1398 |
- } |
|
| 1399 |
- .font(.caption.weight(.semibold)) |
|
| 1400 |
- .buttonStyle(.borderedProminent) |
|
| 1401 |
- .controlSize(.small) |
|
| 1402 |
- .tint(.blue) |
|
| 1403 |
- |
|
| 1404 |
- Button {
|
|
| 1405 |
- trimBannerDismissedForSessionID = session.id |
|
| 1406 |
- } label: {
|
|
| 1407 |
- Image(systemName: "xmark") |
|
| 1408 |
- .font(.caption2.weight(.semibold)) |
|
| 1409 |
- .foregroundColor(.secondary) |
|
| 1410 |
- } |
|
| 1411 |
- .buttonStyle(.plain) |
|
| 1412 |
- } |
|
| 1413 |
- } |
|
| 1414 |
- .padding(14) |
|
| 1415 |
- .background( |
|
| 1416 |
- RoundedRectangle(cornerRadius: 14) |
|
| 1417 |
- .fill(Color.blue.opacity(0.10)) |
|
| 1418 |
- .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1)) |
|
| 1419 |
- ) |
|
| 1420 |
- .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 1421 |
- } |
|
| 1422 |
- } |
|
| 1423 |
- |
|
| 1424 |
- private func sessionChartCard(timeRange: ClosedRange<Date>?, session: ChargeSessionSummary) -> some View {
|
|
| 1425 |
- let hasRangeSelector = session.aggregatedSamples.isEmpty == false |
|
| 1426 |
- |
|
| 1427 |
- return VStack(alignment: .leading, spacing: 12) {
|
|
| 1428 |
- HStack(spacing: 8) {
|
|
| 1429 |
- Image(systemName: "chart.xyaxis.line") |
|
| 1430 |
- .foregroundColor(.blue) |
|
| 1431 |
- Text("Session Chart")
|
|
| 1432 |
- .font(.headline) |
|
| 1433 |
- ContextInfoButton( |
|
| 1434 |
- title: "Session Chart", |
|
| 1435 |
- message: usesChargeRecordBuffer(for: session) |
|
| 1436 |
- ? "This chart combines the persisted session curve with current live data from this meter." |
|
| 1437 |
- : "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging." |
|
| 1438 |
- ) |
|
| 1439 |
- Spacer(minLength: 0) |
|
| 1440 |
- } |
|
| 1441 |
- |
|
| 1442 |
- MeasurementChartView( |
|
| 1443 |
- timeRange: timeRange, |
|
| 1444 |
- timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower, |
|
| 1445 |
- timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper, |
|
| 1446 |
- showsRangeSelector: hasRangeSelector, |
|
| 1447 |
- rebasesEnergyToVisibleRangeStart: true, |
|
| 1448 |
- extendsTimelineToPresent: false, |
|
| 1449 |
- showsTemperatureSeries: false, |
|
| 1450 |
- rangeSelectorConfiguration: hasRangeSelector |
|
| 1451 |
- ? MeasurementChartRangeSelectorConfiguration( |
|
| 1452 |
- keepAction: MeasurementChartSelectionAction( |
|
| 1453 |
- title: "Keep Selection", |
|
| 1454 |
- shortTitle: "Keep", |
|
| 1455 |
- systemName: "scissors", |
|
| 1456 |
- tone: .destructive, |
|
| 1457 |
- handler: { range in
|
|
| 1458 |
- _ = appData.setSessionTrim( |
|
| 1459 |
- sessionID: session.id, |
|
| 1460 |
- start: range.lowerBound, |
|
| 1461 |
- end: range.upperBound |
|
| 1462 |
- ) |
|
| 1463 |
- trimBannerDismissedForSessionID = session.id |
|
| 1464 |
- } |
|
| 1465 |
- ), |
|
| 1466 |
- removeAction: nil, |
|
| 1467 |
- resetAction: MeasurementChartResetAction( |
|
| 1468 |
- title: "Reset Trim", |
|
| 1469 |
- shortTitle: "Reset", |
|
| 1470 |
- systemName: "arrow.counterclockwise", |
|
| 1471 |
- tone: .reversible, |
|
| 1472 |
- confirmationTitle: "Reset session trim?", |
|
| 1473 |
- confirmationButtonTitle: "Reset trim", |
|
| 1474 |
- handler: {
|
|
| 1475 |
- _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil) |
|
| 1476 |
- } |
|
| 1477 |
- ) |
|
| 1478 |
- ) |
|
| 1479 |
- : nil |
|
| 1480 |
- ) |
|
| 1481 |
- .environmentObject(usbMeter.chargeRecordMeasurements) |
|
| 1482 |
- .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 1483 |
- } |
|
| 1484 |
- .padding(18) |
|
| 1485 |
- .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 1486 |
- } |
|
| 1487 |
- |
|
| 1488 | 568 |
private var meterTotalsCard: some View {
|
| 1489 | 569 |
return VStack(alignment: .leading, spacing: 12) {
|
| 1490 | 570 |
HStack(spacing: 8) {
|
@@ -1552,102 +632,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 1552 | 632 |
.padding(.vertical, 11) |
| 1553 | 633 |
} |
| 1554 | 634 |
|
| 1555 |
- private func autoStopLabel(for session: ChargeSessionSummary) -> String {
|
|
| 1556 |
- if session.autoStopEnabled == false {
|
|
| 1557 |
- return "Manual" |
|
| 1558 |
- } |
|
| 1559 |
- if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
|
|
| 1560 |
- return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
|
|
| 1561 |
- } |
|
| 1562 |
- if session.stopThresholdAmps > 0 {
|
|
| 1563 |
- return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A" |
|
| 1564 |
- } |
|
| 1565 |
- return "Learning" |
|
| 1566 |
- } |
|
| 1567 |
- |
|
| 1568 |
- private func sessionMetricRows( |
|
| 1569 |
- for session: ChargeSessionSummary, |
|
| 1570 |
- displayedEnergyWh: Double |
|
| 1571 |
- ) -> [SessionMetricRow] {
|
|
| 1572 |
- var rows: [SessionMetricRow] = [] |
|
| 1573 |
- |
|
| 1574 |
- if shouldShowChargingTransport(for: session) {
|
|
| 1575 |
- rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title)) |
|
| 1576 |
- } |
|
| 1577 |
- |
|
| 1578 |
- if shouldShowChargingState(for: session) {
|
|
| 1579 |
- rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title)) |
|
| 1580 |
- } |
|
| 1581 |
- |
|
| 1582 |
- rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")) |
|
| 1583 |
- rows.append(SessionMetricRow(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)))) |
|
| 1584 |
- rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session))) |
|
| 1585 |
- return rows |
|
| 1586 |
- } |
|
| 1587 |
- |
|
| 1588 |
- private func shouldShowChargingTransport(for session: ChargeSessionSummary) -> Bool {
|
|
| 1589 |
- guard let selectedChargedDevice else { return true }
|
|
| 1590 |
- return selectedChargedDevice.supportedChargingModes.count > 1 |
|
| 1591 |
- || selectedChargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false |
|
| 1592 |
- } |
|
| 1593 |
- |
|
| 1594 |
- private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool {
|
|
| 1595 |
- guard let selectedChargedDevice else { return true }
|
|
| 1596 |
- return selectedChargedDevice.supportedChargingStateModes.count > 1 |
|
| 1597 |
- || selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false |
|
| 1598 |
- } |
|
| 1599 |
- |
|
| 1600 |
- private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
|
|
| 1601 |
- let storedEnergyWh = session.effectiveOrMeasuredEnergyWh |
|
| 1602 |
- guard session.isTrimmed == false else { return storedEnergyWh }
|
|
| 1603 |
- guard session.status.isOpen else { return storedEnergyWh }
|
|
| 1604 |
- guard session.meterMACAddress == meterMACAddress else { return storedEnergyWh }
|
|
| 1605 |
- if let baselineEnergyWh = session.meterEnergyBaselineWh {
|
|
| 1606 |
- return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0)) |
|
| 1607 |
- } |
|
| 1608 |
- return storedEnergyWh |
|
| 1609 |
- } |
|
| 1610 |
- |
|
| 1611 |
- private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
|
|
| 1612 |
- let storedChargeAh = session.measuredChargeAh |
|
| 1613 |
- guard session.isTrimmed == false else { return storedChargeAh }
|
|
| 1614 |
- guard session.status.isOpen else { return storedChargeAh }
|
|
| 1615 |
- guard session.meterMACAddress == meterMACAddress else { return storedChargeAh }
|
|
| 1616 |
- if let baselineChargeAh = session.meterChargeBaselineAh {
|
|
| 1617 |
- return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0)) |
|
| 1618 |
- } |
|
| 1619 |
- return storedChargeAh |
|
| 1620 |
- } |
|
| 1621 |
- |
|
| 1622 |
- private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
|
|
| 1623 |
- let storedDuration = max(session.effectiveDuration, 0) |
|
| 1624 |
- guard session.isTrimmed == false else { return storedDuration }
|
|
| 1625 |
- guard session.status.isOpen else { return storedDuration }
|
|
| 1626 |
- guard session.meterMACAddress == meterMACAddress else { return storedDuration }
|
|
| 1627 |
- return max(storedDuration, max(usbMeter.chargeRecordDuration, 0)) |
|
| 1628 |
- } |
|
| 1629 |
- |
|
| 1630 |
- private func formatDuration(_ duration: TimeInterval) -> String {
|
|
| 1631 |
- let totalSeconds = Int(duration.rounded(.down)) |
|
| 1632 |
- let hours = totalSeconds / 3600 |
|
| 1633 |
- let minutes = (totalSeconds % 3600) / 60 |
|
| 1634 |
- let seconds = totalSeconds % 60 |
|
| 1635 |
- if hours > 0 {
|
|
| 1636 |
- return String(format: "%d:%02d:%02d", hours, minutes, seconds) |
|
| 1637 |
- } |
|
| 1638 |
- return String(format: "%02d:%02d", minutes, seconds) |
|
| 1639 |
- } |
|
| 1640 |
- |
|
| 1641 |
- private func sessionWarning(for session: ChargeSessionSummary) -> String? {
|
|
| 1642 |
- guard session.chargingTransportMode == .wireless, |
|
| 1643 |
- let chargerID = session.chargerID, |
|
| 1644 |
- let charger = appData.chargedDeviceSummary(id: chargerID) else {
|
|
| 1645 |
- return nil |
|
| 1646 |
- } |
|
| 1647 |
- guard charger.chargerIdleCurrentAmps == nil else { return nil }
|
|
| 1648 |
- return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session." |
|
| 1649 |
- } |
|
| 1650 |
- |
|
| 1651 | 635 |
private func startSession() {
|
| 1652 | 636 |
guard let selectedChargedDevice, |
| 1653 | 637 |
let chargingTransportMode = selectedDraftTransportMode, |
@@ -1,378 +0,0 @@ |
||
| 1 |
-// |
|
| 2 |
-// SessionTrimEditorView.swift |
|
| 3 |
-// USB Meter |
|
| 4 |
-// |
|
| 5 |
- |
|
| 6 |
-import SwiftUI |
|
| 7 |
- |
|
| 8 |
-struct SessionTrimEditorView: View {
|
|
| 9 |
- |
|
| 10 |
- let session: ChargeSessionSummary |
|
| 11 |
- let liveTimeRange: ClosedRange<Date>? |
|
| 12 |
- let onApply: (Date?, Date?) -> Void |
|
| 13 |
- let onDismiss: () -> Void |
|
| 14 |
- |
|
| 15 |
- @State private var trimStart: Date |
|
| 16 |
- @State private var trimEnd: Date |
|
| 17 |
- |
|
| 18 |
- private var fullStart: Date { liveTimeRange?.lowerBound ?? session.startedAt }
|
|
| 19 |
- private var fullEnd: Date { liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt) }
|
|
| 20 |
- private var sessionDuration: TimeInterval { max(fullEnd.timeIntervalSince(fullStart), 1) }
|
|
| 21 |
- |
|
| 22 |
- private var startFraction: Double {
|
|
| 23 |
- trimStart.timeIntervalSince(fullStart) / sessionDuration |
|
| 24 |
- } |
|
| 25 |
- private var endFraction: Double {
|
|
| 26 |
- trimEnd.timeIntervalSince(fullStart) / sessionDuration |
|
| 27 |
- } |
|
| 28 |
- |
|
| 29 |
- // Energy preview from cumulative sample values |
|
| 30 |
- private var previewEnergyWh: Double {
|
|
| 31 |
- let sorted = session.aggregatedSamples.sorted { $0.timestamp < $1.timestamp }
|
|
| 32 |
- let baseline = sorted.last { $0.timestamp <= trimStart }
|
|
| 33 |
- guard let endSample = sorted.last(where: { $0.timestamp <= trimEnd }) else { return 0 }
|
|
| 34 |
- return max(endSample.measuredEnergyWh - (baseline?.measuredEnergyWh ?? 0), 0) |
|
| 35 |
- } |
|
| 36 |
- |
|
| 37 |
- private var trimmedDuration: TimeInterval {
|
|
| 38 |
- max(trimEnd.timeIntervalSince(trimStart), 0) |
|
| 39 |
- } |
|
| 40 |
- |
|
| 41 |
- private var checkpointsToRemove: [ChargeCheckpointSummary] {
|
|
| 42 |
- session.checkpoints.filter { $0.timestamp < trimStart || $0.timestamp > trimEnd }
|
|
| 43 |
- } |
|
| 44 |
- |
|
| 45 |
- private var isModified: Bool {
|
|
| 46 |
- trimStart != (session.trimStart ?? fullStart) || |
|
| 47 |
- trimEnd != (session.trimEnd ?? fullEnd) |
|
| 48 |
- } |
|
| 49 |
- |
|
| 50 |
- init( |
|
| 51 |
- session: ChargeSessionSummary, |
|
| 52 |
- detectedWindow: ChargingWindowDetector.DetectedWindow? = nil, |
|
| 53 |
- liveTimeRange: ClosedRange<Date>? = nil, |
|
| 54 |
- onApply: @escaping (Date?, Date?) -> Void, |
|
| 55 |
- onDismiss: @escaping () -> Void |
|
| 56 |
- ) {
|
|
| 57 |
- self.session = session |
|
| 58 |
- self.liveTimeRange = liveTimeRange |
|
| 59 |
- self.onApply = onApply |
|
| 60 |
- self.onDismiss = onDismiss |
|
| 61 |
- |
|
| 62 |
- let fullStart = liveTimeRange?.lowerBound ?? session.startedAt |
|
| 63 |
- let fullEnd = liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt) |
|
| 64 |
- let start = session.trimStart |
|
| 65 |
- ?? detectedWindow?.start |
|
| 66 |
- ?? fullStart |
|
| 67 |
- let end = session.trimEnd |
|
| 68 |
- ?? detectedWindow?.end |
|
| 69 |
- ?? fullEnd |
|
| 70 |
- |
|
| 71 |
- _trimStart = State(initialValue: start) |
|
| 72 |
- _trimEnd = State(initialValue: end) |
|
| 73 |
- } |
|
| 74 |
- |
|
| 75 |
- var body: some View {
|
|
| 76 |
- VStack(spacing: 0) {
|
|
| 77 |
- header |
|
| 78 |
- ScrollView {
|
|
| 79 |
- VStack(spacing: 16) {
|
|
| 80 |
- chartWithHandles |
|
| 81 |
- rangeControls |
|
| 82 |
- previewMetrics |
|
| 83 |
- if !checkpointsToRemove.isEmpty {
|
|
| 84 |
- checkpointWarning |
|
| 85 |
- } |
|
| 86 |
- } |
|
| 87 |
- .padding(16) |
|
| 88 |
- } |
|
| 89 |
- applyBar |
|
| 90 |
- } |
|
| 91 |
- .background(Color(.systemGroupedBackground).ignoresSafeArea()) |
|
| 92 |
- } |
|
| 93 |
- |
|
| 94 |
- // MARK: - Header |
|
| 95 |
- |
|
| 96 |
- private var header: some View {
|
|
| 97 |
- HStack {
|
|
| 98 |
- Button("Cancel", action: onDismiss)
|
|
| 99 |
- .foregroundColor(.secondary) |
|
| 100 |
- Spacer() |
|
| 101 |
- Text("Trim Session")
|
|
| 102 |
- .font(.headline) |
|
| 103 |
- Spacer() |
|
| 104 |
- Button("Reset") {
|
|
| 105 |
- withAnimation(.spring(response: 0.3)) {
|
|
| 106 |
- trimStart = fullStart |
|
| 107 |
- trimEnd = fullEnd |
|
| 108 |
- } |
|
| 109 |
- } |
|
| 110 |
- .foregroundColor(.orange) |
|
| 111 |
- .disabled(trimStart == fullStart && trimEnd == fullEnd) |
|
| 112 |
- } |
|
| 113 |
- .padding(.horizontal, 18) |
|
| 114 |
- .padding(.vertical, 14) |
|
| 115 |
- .background(.regularMaterial) |
|
| 116 |
- } |
|
| 117 |
- |
|
| 118 |
- // MARK: - Chart with trim overlay |
|
| 119 |
- |
|
| 120 |
- private var chartWithHandles: some View {
|
|
| 121 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 122 |
- HStack(spacing: 6) {
|
|
| 123 |
- Image(systemName: "scissors") |
|
| 124 |
- .foregroundColor(.blue) |
|
| 125 |
- Text("Session Window")
|
|
| 126 |
- .font(.headline) |
|
| 127 |
- } |
|
| 128 |
- |
|
| 129 |
- GeometryReader { geo in
|
|
| 130 |
- let chartW = geo.size.width |
|
| 131 |
- ZStack(alignment: .topLeading) {
|
|
| 132 |
- // Background chart — full session |
|
| 133 |
- MeasurementChartView( |
|
| 134 |
- sizing: .provided(size: geo.size, compact: true), |
|
| 135 |
- timeRange: fullStart...fullEnd, |
|
| 136 |
- showsRangeSelector: false, |
|
| 137 |
- rebasesEnergyToVisibleRangeStart: false, |
|
| 138 |
- showsTemperatureSeries: false |
|
| 139 |
- ) |
|
| 140 |
- |
|
| 141 |
- // Dimmed region before trimStart |
|
| 142 |
- Rectangle() |
|
| 143 |
- .fill(Color.black.opacity(0.35)) |
|
| 144 |
- .frame(width: max(startFraction * chartW, 0)) |
|
| 145 |
- .allowsHitTesting(false) |
|
| 146 |
- |
|
| 147 |
- // Dimmed region after trimEnd |
|
| 148 |
- let endX = endFraction * chartW |
|
| 149 |
- Rectangle() |
|
| 150 |
- .fill(Color.black.opacity(0.35)) |
|
| 151 |
- .frame(width: max(chartW - endX, 0)) |
|
| 152 |
- .offset(x: endX) |
|
| 153 |
- .allowsHitTesting(false) |
|
| 154 |
- |
|
| 155 |
- // Start handle |
|
| 156 |
- trimHandle( |
|
| 157 |
- color: .green, |
|
| 158 |
- symbol: "arrow.right.to.line", |
|
| 159 |
- xFraction: startFraction, |
|
| 160 |
- chartWidth: chartW, |
|
| 161 |
- onDrag: { dx in
|
|
| 162 |
- let newFrac = max(0, min(startFraction + dx / chartW, endFraction - 0.01)) |
|
| 163 |
- trimStart = fullStart.addingTimeInterval(newFrac * sessionDuration) |
|
| 164 |
- } |
|
| 165 |
- ) |
|
| 166 |
- |
|
| 167 |
- // End handle |
|
| 168 |
- trimHandle( |
|
| 169 |
- color: .red, |
|
| 170 |
- symbol: "arrow.left.to.line", |
|
| 171 |
- xFraction: endFraction, |
|
| 172 |
- chartWidth: chartW, |
|
| 173 |
- onDrag: { dx in
|
|
| 174 |
- let newFrac = min(1, max(endFraction + dx / chartW, startFraction + 0.01)) |
|
| 175 |
- trimEnd = fullStart.addingTimeInterval(newFrac * sessionDuration) |
|
| 176 |
- } |
|
| 177 |
- ) |
|
| 178 |
- } |
|
| 179 |
- .clipped() |
|
| 180 |
- } |
|
| 181 |
- .frame(height: 260) |
|
| 182 |
- } |
|
| 183 |
- .padding(16) |
|
| 184 |
- .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground))) |
|
| 185 |
- } |
|
| 186 |
- |
|
| 187 |
- @ViewBuilder |
|
| 188 |
- private func trimHandle( |
|
| 189 |
- color: Color, |
|
| 190 |
- symbol: String, |
|
| 191 |
- xFraction: Double, |
|
| 192 |
- chartWidth: CGFloat, |
|
| 193 |
- onDrag: @escaping (CGFloat) -> Void |
|
| 194 |
- ) -> some View {
|
|
| 195 |
- let xPos = CGFloat(xFraction) * chartWidth |
|
| 196 |
- |
|
| 197 |
- ZStack(alignment: .top) {
|
|
| 198 |
- // Vertical line |
|
| 199 |
- Rectangle() |
|
| 200 |
- .fill(color) |
|
| 201 |
- .frame(width: 2) |
|
| 202 |
- .frame(maxHeight: .infinity) |
|
| 203 |
- .offset(x: xPos - 1) |
|
| 204 |
- .allowsHitTesting(false) |
|
| 205 |
- |
|
| 206 |
- // Drag knob |
|
| 207 |
- Circle() |
|
| 208 |
- .fill(color) |
|
| 209 |
- .frame(width: 28, height: 28) |
|
| 210 |
- .overlay( |
|
| 211 |
- Image(systemName: symbol) |
|
| 212 |
- .font(.system(size: 11, weight: .bold)) |
|
| 213 |
- .foregroundColor(.white) |
|
| 214 |
- ) |
|
| 215 |
- .shadow(radius: 3) |
|
| 216 |
- .offset(x: xPos - 14) |
|
| 217 |
- .gesture( |
|
| 218 |
- DragGesture(minimumDistance: 0, coordinateSpace: .local) |
|
| 219 |
- .onChanged { value in
|
|
| 220 |
- onDrag(value.translation.width) |
|
| 221 |
- } |
|
| 222 |
- ) |
|
| 223 |
- } |
|
| 224 |
- } |
|
| 225 |
- |
|
| 226 |
- // MARK: - Range controls |
|
| 227 |
- |
|
| 228 |
- private var rangeControls: some View {
|
|
| 229 |
- VStack(spacing: 12) {
|
|
| 230 |
- rangeRow( |
|
| 231 |
- label: "Start", |
|
| 232 |
- color: .green, |
|
| 233 |
- symbol: "arrow.right.to.line", |
|
| 234 |
- date: $trimStart, |
|
| 235 |
- sliderValue: Binding( |
|
| 236 |
- get: { startFraction },
|
|
| 237 |
- set: { v in
|
|
| 238 |
- let clamped = max(0, min(v, endFraction - 0.01)) |
|
| 239 |
- trimStart = fullStart.addingTimeInterval(clamped * sessionDuration) |
|
| 240 |
- } |
|
| 241 |
- ) |
|
| 242 |
- ) |
|
| 243 |
- rangeRow( |
|
| 244 |
- label: "End", |
|
| 245 |
- color: .red, |
|
| 246 |
- symbol: "arrow.left.to.line", |
|
| 247 |
- date: $trimEnd, |
|
| 248 |
- sliderValue: Binding( |
|
| 249 |
- get: { endFraction },
|
|
| 250 |
- set: { v in
|
|
| 251 |
- let clamped = min(1, max(v, startFraction + 0.01)) |
|
| 252 |
- trimEnd = fullStart.addingTimeInterval(clamped * sessionDuration) |
|
| 253 |
- } |
|
| 254 |
- ) |
|
| 255 |
- ) |
|
| 256 |
- } |
|
| 257 |
- .padding(16) |
|
| 258 |
- .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground))) |
|
| 259 |
- } |
|
| 260 |
- |
|
| 261 |
- private func rangeRow( |
|
| 262 |
- label: String, |
|
| 263 |
- color: Color, |
|
| 264 |
- symbol: String, |
|
| 265 |
- date: Binding<Date>, |
|
| 266 |
- sliderValue: Binding<Double> |
|
| 267 |
- ) -> some View {
|
|
| 268 |
- VStack(spacing: 6) {
|
|
| 269 |
- HStack {
|
|
| 270 |
- Image(systemName: symbol) |
|
| 271 |
- .foregroundColor(color) |
|
| 272 |
- .frame(width: 20) |
|
| 273 |
- Text(label) |
|
| 274 |
- .font(.subheadline.weight(.semibold)) |
|
| 275 |
- Spacer() |
|
| 276 |
- Text(date.wrappedValue.format()) |
|
| 277 |
- .font(.caption.monospacedDigit()) |
|
| 278 |
- .foregroundColor(.secondary) |
|
| 279 |
- } |
|
| 280 |
- Slider(value: sliderValue, in: 0...1) |
|
| 281 |
- .tint(color) |
|
| 282 |
- } |
|
| 283 |
- } |
|
| 284 |
- |
|
| 285 |
- // MARK: - Preview metrics |
|
| 286 |
- |
|
| 287 |
- private var previewMetrics: some View {
|
|
| 288 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 289 |
- HStack(spacing: 6) {
|
|
| 290 |
- Image(systemName: "waveform.path.ecg") |
|
| 291 |
- .foregroundColor(.teal) |
|
| 292 |
- Text("Trimmed Metrics")
|
|
| 293 |
- .font(.headline) |
|
| 294 |
- } |
|
| 295 |
- |
|
| 296 |
- let columns = [GridItem(.flexible()), GridItem(.flexible())] |
|
| 297 |
- LazyVGrid(columns: columns, spacing: 8) {
|
|
| 298 |
- previewCell(label: "Energy", value: "\(previewEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue) |
|
| 299 |
- previewCell(label: "Duration", value: formatDuration(trimmedDuration), tint: .teal) |
|
| 300 |
- } |
|
| 301 |
- } |
|
| 302 |
- .padding(16) |
|
| 303 |
- .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground))) |
|
| 304 |
- } |
|
| 305 |
- |
|
| 306 |
- private func previewCell(label: String, value: String, tint: Color) -> some View {
|
|
| 307 |
- VStack(alignment: .leading, spacing: 4) {
|
|
| 308 |
- Text(label) |
|
| 309 |
- .font(.caption) |
|
| 310 |
- .foregroundColor(.secondary) |
|
| 311 |
- Text(value) |
|
| 312 |
- .font(.system(.subheadline, design: .rounded).weight(.semibold)) |
|
| 313 |
- .foregroundColor(tint) |
|
| 314 |
- .monospacedDigit() |
|
| 315 |
- } |
|
| 316 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 317 |
- .padding(10) |
|
| 318 |
- .background(RoundedRectangle(cornerRadius: 10).fill(tint.opacity(0.10))) |
|
| 319 |
- } |
|
| 320 |
- |
|
| 321 |
- // MARK: - Checkpoint warning |
|
| 322 |
- |
|
| 323 |
- private var checkpointWarning: some View {
|
|
| 324 |
- HStack(alignment: .top, spacing: 10) {
|
|
| 325 |
- Image(systemName: "exclamationmark.triangle.fill") |
|
| 326 |
- .foregroundColor(.orange) |
|
| 327 |
- VStack(alignment: .leading, spacing: 3) {
|
|
| 328 |
- Text("\(checkpointsToRemove.count) checkpoint\(checkpointsToRemove.count == 1 ? "" : "s") outside the selected window will be removed.")
|
|
| 329 |
- .font(.subheadline) |
|
| 330 |
- ForEach(checkpointsToRemove) { cp in
|
|
| 331 |
- Text("• \(cp.timestamp.format()) — \(cp.batteryPercent.format(decimalDigits: 0))%")
|
|
| 332 |
- .font(.caption) |
|
| 333 |
- .foregroundColor(.secondary) |
|
| 334 |
- } |
|
| 335 |
- } |
|
| 336 |
- } |
|
| 337 |
- .padding(14) |
|
| 338 |
- .background( |
|
| 339 |
- RoundedRectangle(cornerRadius: 12) |
|
| 340 |
- .fill(Color.orange.opacity(0.12)) |
|
| 341 |
- .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.orange.opacity(0.25), lineWidth: 1)) |
|
| 342 |
- ) |
|
| 343 |
- } |
|
| 344 |
- |
|
| 345 |
- // MARK: - Apply bar |
|
| 346 |
- |
|
| 347 |
- private var applyBar: some View {
|
|
| 348 |
- VStack(spacing: 0) {
|
|
| 349 |
- Divider() |
|
| 350 |
- Button {
|
|
| 351 |
- let newStart = trimStart == fullStart ? nil : trimStart |
|
| 352 |
- let newEnd = trimEnd == fullEnd ? nil : trimEnd |
|
| 353 |
- onApply(newStart, newEnd) |
|
| 354 |
- } label: {
|
|
| 355 |
- Label("Apply Trim", systemImage: "scissors")
|
|
| 356 |
- .font(.body.weight(.semibold)) |
|
| 357 |
- .frame(maxWidth: .infinity) |
|
| 358 |
- .padding(.vertical, 14) |
|
| 359 |
- } |
|
| 360 |
- .buttonStyle(.borderedProminent) |
|
| 361 |
- .tint(.blue) |
|
| 362 |
- .disabled(!isModified) |
|
| 363 |
- .padding(16) |
|
| 364 |
- } |
|
| 365 |
- .background(.regularMaterial) |
|
| 366 |
- } |
|
| 367 |
- |
|
| 368 |
- // MARK: - Helpers |
|
| 369 |
- |
|
| 370 |
- private func formatDuration(_ duration: TimeInterval) -> String {
|
|
| 371 |
- let totalSeconds = Int(duration.rounded(.down)) |
|
| 372 |
- let hours = totalSeconds / 3600 |
|
| 373 |
- let minutes = (totalSeconds % 3600) / 60 |
|
| 374 |
- let seconds = totalSeconds % 60 |
|
| 375 |
- if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) }
|
|
| 376 |
- return String(format: "%02d:%02d", minutes, seconds) |
|
| 377 |
- } |
|
| 378 |
-} |
|
@@ -0,0 +1,70 @@ |
||
| 1 |
+// |
|
| 2 |
+// SidebarOfflineMeterCardView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+ |
|
| 8 |
+struct SidebarOfflineMeterCardView: View {
|
|
| 9 |
+ let summary: AppData.MeterSummary |
|
| 10 |
+ |
|
| 11 |
+ var body: some View {
|
|
| 12 |
+ HStack(spacing: 14) {
|
|
| 13 |
+ Image(systemName: "sensor.tag.radiowaves.forward.fill") |
|
| 14 |
+ .font(.system(size: 18, weight: .semibold)) |
|
| 15 |
+ .foregroundColor(.secondary) |
|
| 16 |
+ .frame(width: 42, height: 42) |
|
| 17 |
+ .background( |
|
| 18 |
+ Circle() |
|
| 19 |
+ .fill(Color.secondary.opacity(0.14)) |
|
| 20 |
+ ) |
|
| 21 |
+ .overlay(alignment: .bottomTrailing) {
|
|
| 22 |
+ Circle() |
|
| 23 |
+ .fill(Color.secondary) |
|
| 24 |
+ .frame(width: 12, height: 12) |
|
| 25 |
+ .overlay( |
|
| 26 |
+ Circle() |
|
| 27 |
+ .stroke(Color(uiColor: .systemBackground), lineWidth: 2) |
|
| 28 |
+ ) |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 32 |
+ Text(summary.displayName) |
|
| 33 |
+ .font(.headline) |
|
| 34 |
+ Text(summary.modelSummary.isEmpty ? "Unknown Model" : summary.modelSummary) |
|
| 35 |
+ .font(.caption) |
|
| 36 |
+ .foregroundColor(.secondary) |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 39 |
+ Spacer() |
|
| 40 |
+ |
|
| 41 |
+ VStack(alignment: .trailing, spacing: 4) {
|
|
| 42 |
+ HStack(spacing: 6) {
|
|
| 43 |
+ Circle() |
|
| 44 |
+ .fill(Color.secondary) |
|
| 45 |
+ .frame(width: 8, height: 8) |
|
| 46 |
+ Text("Offline")
|
|
| 47 |
+ .font(.caption.weight(.semibold)) |
|
| 48 |
+ .foregroundColor(.secondary) |
|
| 49 |
+ } |
|
| 50 |
+ .padding(.horizontal, 10) |
|
| 51 |
+ .padding(.vertical, 6) |
|
| 52 |
+ .background( |
|
| 53 |
+ Capsule(style: .continuous) |
|
| 54 |
+ .fill(Color.secondary.opacity(0.12)) |
|
| 55 |
+ ) |
|
| 56 |
+ .overlay( |
|
| 57 |
+ Capsule(style: .continuous) |
|
| 58 |
+ .stroke(Color.secondary.opacity(0.22), lineWidth: 1) |
|
| 59 |
+ ) |
|
| 60 |
+ } |
|
| 61 |
+ } |
|
| 62 |
+ .padding(14) |
|
| 63 |
+ .meterCard( |
|
| 64 |
+ tint: .secondary, |
|
| 65 |
+ fillOpacity: 0.10, |
|
| 66 |
+ strokeOpacity: 0.16, |
|
| 67 |
+ cornerRadius: 18 |
|
| 68 |
+ ) |
|
| 69 |
+ } |
|
| 70 |
+} |
|
@@ -41,6 +41,12 @@ struct SidebarUSBMetersSectionView: View {
|
||
| 41 | 41 |
} |
| 42 | 42 |
.buttonStyle(.plain) |
| 43 | 43 |
.transition(.opacity.combined(with: .move(edge: .top))) |
| 44 |
+ } else {
|
|
| 45 |
+ NavigationLink(destination: MeterView(offlineSummary: meterSummary)) {
|
|
| 46 |
+ SidebarOfflineMeterCardView(summary: meterSummary) |
|
| 47 |
+ } |
|
| 48 |
+ .buttonStyle(.plain) |
|
| 49 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 44 | 50 |
} |
| 45 | 51 |
} |
| 46 | 52 |
} |