USB-Meter / USB Meter / Views / ChargedDevices / Sheets / ChargeSession / ChargeSessionCompletionSheetView.swift
Newer Older
257 lines | 9.464kb
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
Bogdan Timofte authored a month ago
183
    }
184

            
Bogdan Timofte authored a month ago
185
    private var saveDisabledReason: String? {
186
        if finalCheckpoint == .custom {
187
            let trimmed = batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines)
188
            if trimmed.isEmpty {
189
                return "Enter the final battery percentage or choose Skip."
190
            }
191
            if parsedBatteryPercent == nil {
192
                return "Final battery percentage must be between 0 and 100."
193
            }
194
        }
Bogdan Timofte authored a month ago
195
        if finalCheckpoint == .full && resolvedFinalBatteryPercent == nil {
196
            return "Full can be saved only when there is a current battery estimate. Choose Skip or enter the displayed percentage."
197
        }
Bogdan Timofte authored a month ago
198

            
199
        guard hasChargeDataToSave else {
200
            return "This session has no charging data to save. Discard it instead."
Bogdan Timofte authored a month ago
201
        }
Bogdan Timofte authored a month ago
202

            
203
        return nil
Bogdan Timofte authored a month ago
204
    }
205

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

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

            
Bogdan Timofte authored a month ago
222
    private var suggestedFinalBatteryPercent: Double? {
223
        guard let session else { return nil }
224
        if let endBatteryPercent = session.endBatteryPercent {
225
            return endBatteryPercent
226
        }
227
        if let latestCheckpoint = session.checkpoints.max(by: { $0.timestamp < $1.timestamp }) {
228
            return latestCheckpoint.batteryPercent
229
        }
230
        return session.targetBatteryPercent ?? session.completionContradictionPercent
231
    }
232

            
233
    private func prefillFinalCheckpointIfNeeded() {
234
        guard batteryPercent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
235
              let suggestedFinalBatteryPercent else {
236
            return
237
        }
238
        batteryPercent = suggestedFinalBatteryPercent.format(decimalDigits: 0)
239
    }
240

            
Bogdan Timofte authored a month ago
241
    private var sessionWarning: String? {
Bogdan Timofte authored a month ago
242
        nil
Bogdan Timofte authored a month ago
243
    }
Bogdan Timofte authored a month ago
244

            
245
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
246
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
247
        guard session.isTrimmed == false else { return storedEnergyWh }
248
        guard session.status.isOpen else { return storedEnergyWh }
249
        guard let monitoringMeter else { return storedEnergyWh }
250
        guard session.meterMACAddress == monitoringMeter.btSerial.macAddress.description else { return storedEnergyWh }
251
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
252
            return max(storedEnergyWh, max(monitoringMeter.recordedWH - baselineEnergyWh, 0))
253
        }
254
        return storedEnergyWh
255
    }
256

            
Bogdan Timofte authored a month ago
257
}