USB-Meter / USB Meter / Views / ChargedDevices / Sheets / ChargeSession / ChargeSessionCompletionSheetView.swift
Newer Older
273 lines | 10.397kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargeSessionCompletionSheetView.swift
3
//  USB Meter
4
//
5

            
6
import SwiftUI
7

            
8
struct ChargeSessionCompletionSheetView: View {
Bogdan Timofte authored a month ago
9
    private enum FinalCheckpoint: String, CaseIterable, Identifiable {
10
        case full
11
        case skip
12
        case custom
13

            
14
        var id: String { rawValue }
15

            
16
        var label: String {
17
            switch self {
18
            case .full:   return "Full"
19
            case .skip:   return "Skip"
20
            case .custom: return "Other %"
21
            }
22
        }
23
    }
24

            
Bogdan Timofte authored a month ago
25
    @EnvironmentObject private var appData: AppData
26
    @Environment(\.dismiss) private var dismiss
27

            
28
    let sessionID: UUID
29
    let title: String
30
    let confirmTitle: String
31
    let explanation: String
Bogdan Timofte authored a month ago
32
    let monitoringMeter: Meter?
33
    let appliesTrim: Bool
34
    let trimStart: Date?
35
    let trimEnd: Date?
Bogdan Timofte authored a month ago
36

            
37
    @State private var batteryPercent = ""
Bogdan Timofte authored a month ago
38
    @State private var finalCheckpoint: FinalCheckpoint = .skip
Bogdan Timofte authored a month ago
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
    }
Bogdan Timofte authored a month ago
60

            
61
    var body: some View {
62
        NavigationView {
63
            Form {
64
                Section(
65
                    header: ContextInfoHeader(
66
                        title: "Final Checkpoint",
67
                        message: explanation
68
                    )
69
                ) {
Bogdan Timofte authored a month ago
70
                    Picker("Final Battery", selection: $finalCheckpoint) {
71
                        ForEach(FinalCheckpoint.allCases) { mode in
72
                            Text(mode.label).tag(mode)
73
                        }
74
                    }
75
                    .pickerStyle(.segmented)
76

            
77
                    if finalCheckpoint == .custom {
78
                        TextField("Battery %", text: $batteryPercent)
79
                            .keyboardType(.decimalPad)
80
                    }
Bogdan Timofte authored a month ago
81
                }
82

            
83
                Section {
Bogdan Timofte authored a month ago
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")
Bogdan Timofte authored a month ago
92
                            .font(.footnote)
93
                            .foregroundColor(.red)
Bogdan Timofte authored a month ago
94
                    } else if let saveDisabledReason {
95
                        Label(saveDisabledReason, systemImage: "exclamationmark.triangle.fill")
96
                            .font(.footnote)
97
                            .foregroundColor(.red)
98
                    }
Bogdan Timofte authored a month ago
99

            
Bogdan Timofte authored a month ago
100
                    if hasChargeDataToSave == false {
Bogdan Timofte authored a month ago
101
                        Button(role: .destructive) {
102
                            _ = appData.deleteChargeSession(sessionID: sessionID)
103
                            dismiss()
104
                        } label: {
105
                            Label("Discard Session", systemImage: "trash")
106
                        }
Bogdan Timofte authored a month ago
107
                    } else if saveDisabledReason == nil, let sessionWarning {
Bogdan Timofte authored a month ago
108
                        Text(sessionWarning)
109
                            .font(.footnote)
110
                            .foregroundColor(.orange)
Bogdan Timofte authored a month ago
111
                    } else if saveDisabledReason == nil, resolvedFinalBatteryPercent == 100 {
Bogdan Timofte authored a month ago
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.")
113
                            .font(.footnote)
114
                            .foregroundColor(.secondary)
115
                    }
116
                }
117
            }
118
            .navigationTitle(title)
119
            .navigationBarTitleDisplayMode(.inline)
120
            .toolbar {
121
                ToolbarItem(placement: .cancellationAction) {
122
                    Button("Cancel") {
123
                        dismiss()
124
                    }
125
                }
126
                ToolbarItem(placement: .confirmationAction) {
127
                    Button(confirmTitle) {
Bogdan Timofte authored a month ago
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
                        ) {
Bogdan Timofte authored a month ago
145
                            dismiss()
Bogdan Timofte authored a month ago
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."
Bogdan Timofte authored a month ago
148
                        }
149
                    }
Bogdan Timofte authored a month ago
150
                    .disabled(!canSave)
151
                    .opacity(canSave ? 1 : 0.45)
Bogdan Timofte authored a month ago
152
                }
153
            }
154
        }
155
        .navigationViewStyle(StackNavigationViewStyle())
Bogdan Timofte authored a month ago
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
        }
Bogdan Timofte authored a month ago
167
    }
168

            
Bogdan Timofte authored a month ago
169
    private var session: ChargeSessionSummary? {
170
        appData.chargedDevices
171
            .flatMap(\.sessions)
172
            .first(where: { $0.id == sessionID })
173
    }
174

            
175
    private var canSave: Bool {
Bogdan Timofte authored a month ago
176
        saveDisabledReason == nil
Bogdan Timofte authored a month ago
177
    }
178

            
Bogdan Timofte authored a month ago
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
Bogdan Timofte authored a month ago
184
    }
185

            
Bogdan Timofte authored a month ago
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."
Bogdan Timofte authored a month ago
199
        }
Bogdan Timofte authored a month ago
200

            
201
        return nil
Bogdan Timofte authored a month ago
202
    }
203

            
Bogdan Timofte authored a month ago
204
    private var parsedBatteryPercent: Double? {
205
        let normalized = batteryPercent
206
            .trimmingCharacters(in: .whitespacesAndNewlines)
207
            .replacingOccurrences(of: ",", with: ".")
208
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
209
        return value
210
    }
211

            
Bogdan Timofte authored a month ago
212
    private var resolvedFinalBatteryPercent: Double? {
213
        switch finalCheckpoint {
214
        case .full:   return 100
215
        case .skip:   return nil
216
        case .custom: return parsedBatteryPercent
217
        }
218
    }
219

            
Bogdan Timofte authored a month ago
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

            
Bogdan Timofte authored a month ago
239
    private var sessionWarning: String? {
Bogdan Timofte authored a month ago
240
        guard let session,
Bogdan Timofte authored a month ago
241
              session.chargingTransportMode == .wireless,
242
              let chargerID = session.chargerID,
243
              let charger = appData.chargedDeviceSummary(id: chargerID),
244
              charger.chargerIdleCurrentAmps == nil else {
245
            return nil
246
        }
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."
248
    }
Bogdan Timofte authored a month ago
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
    }
Bogdan Timofte authored a month ago
273
}