USB-Meter / USB Meter / Views / ChargedDevices / BatteryCheckpointEditorSheetView.swift
Newer Older
320 lines | 11.161kb
Bogdan Timofte authored a month ago
1
//
2
//  BatteryCheckpointEditorSheetView.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 10/04/2026.
6
//
7

            
8
import SwiftUI
9

            
Bogdan Timofte authored a month ago
10
struct BatteryCheckpointEditorContentView: View {
Bogdan Timofte authored a month ago
11
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored a month ago
12

            
13
    let sessionID: UUID
14
    let message: String
15
    let effectiveEnergyWhOverride: Double?
16
    let measuredChargeAhOverride: Double?
17
    let onCancel: (() -> Void)?
18
    let onSaved: (() -> Void)?
Bogdan Timofte authored a month ago
19
    let showsHeader: Bool
Bogdan Timofte authored a month ago
20

            
21
    @State private var batteryPercent = ""
Bogdan Timofte authored a month ago
22
    @State private var showsWarningPopover = false
Bogdan Timofte authored a month ago
23

            
Bogdan Timofte authored a month ago
24
    init(
25
        sessionID: UUID,
26
        message: String,
27
        effectiveEnergyWhOverride: Double?,
28
        measuredChargeAhOverride: Double?,
29
        onCancel: (() -> Void)?,
30
        onSaved: (() -> Void)?,
31
        showsHeader: Bool = true
32
    ) {
33
        self.sessionID = sessionID
34
        self.message = message
35
        self.effectiveEnergyWhOverride = effectiveEnergyWhOverride
36
        self.measuredChargeAhOverride = measuredChargeAhOverride
37
        self.onCancel = onCancel
38
        self.onSaved = onSaved
39
        self.showsHeader = showsHeader
40
    }
41

            
Bogdan Timofte authored a month ago
42
    private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
Bogdan Timofte authored a month ago
43
        guard let percent = normalizedBatteryPercent else {
Bogdan Timofte authored a month ago
44
            return nil
45
        }
Bogdan Timofte authored a month ago
46
        return appData.batteryCheckpointPlausibilityWarning(
47
            percent: percent,
48
            for: sessionID,
49
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
50
        )
Bogdan Timofte authored a month ago
51
    }
Bogdan Timofte authored a month ago
52

            
Bogdan Timofte authored a month ago
53
    private var normalizedBatteryPercent: Double? {
54
        let normalized = batteryPercent
55
            .trimmingCharacters(in: .whitespacesAndNewlines)
56
            .replacingOccurrences(of: ",", with: ".")
57
        return Double(normalized)
58
    }
Bogdan Timofte authored a month ago
59

            
Bogdan Timofte authored a month ago
60
    private var canSave: Bool {
61
        guard let percent = normalizedBatteryPercent else {
62
            return false
63
        }
64
        return percent >= 0 && percent <= 100
65
    }
66

            
67
    var body: some View {
68
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
69
            if showsHeader {
70
                HStack(spacing: 8) {
71
                    Text("Checkpoint")
72
                    Spacer(minLength: 0)
73
                    ContextInfoButton(
74
                        title: "Checkpoint",
75
                        message: message
76
                    )
Bogdan Timofte authored a month ago
77
                }
78
            }
Bogdan Timofte authored a month ago
79

            
Bogdan Timofte authored a month ago
80
            compactEditorRow
81
        }
82
    }
Bogdan Timofte authored a month ago
83

            
Bogdan Timofte authored a month ago
84
    private var compactEditorRow: some View {
85
        HStack(spacing: 8) {
86
            TextField("Battery %", text: $batteryPercent)
87
                .keyboardType(.decimalPad)
88
                .textFieldStyle(.roundedBorder)
89
                .frame(width: 104)
90
                .onSubmit(saveCheckpoint)
Bogdan Timofte authored a month ago
91

            
Bogdan Timofte authored a month ago
92
            if let plausibilityWarning {
93
                Button {
94
                    showsWarningPopover.toggle()
95
                } label: {
96
                    Image(systemName: "exclamationmark.triangle.fill")
97
                        .font(.body.weight(.semibold))
98
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
99
                }
Bogdan Timofte authored a month ago
100
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
101
                .accessibilityLabel(plausibilityWarning.title)
102
                .popover(isPresented: $showsWarningPopover, arrowEdge: .top) {
103
                    VStack(alignment: .leading, spacing: 10) {
104
                        Text(plausibilityWarning.title)
105
                            .font(.headline)
106
                        Text(plausibilityWarning.message)
107
                            .font(.body)
108
                            .fixedSize(horizontal: false, vertical: true)
109
                    }
110
                    .padding(16)
111
                    .frame(width: 320, alignment: .leading)
112
                }
Bogdan Timofte authored a month ago
113
            }
Bogdan Timofte authored a month ago
114

            
115
            if let onCancel {
116
                inlineActionButton(
117
                    systemName: "xmark",
118
                    tint: .secondary,
119
                    fillOpacity: 0.12,
120
                    strokeOpacity: 0.18,
121
                    isEnabled: true,
122
                    action: onCancel
123
                )
124
            }
125

            
126
            inlineActionButton(
127
                systemName: "checkmark",
128
                tint: .green,
129
                fillOpacity: 0.16,
130
                strokeOpacity: 0.22,
131
                isEnabled: canSave,
132
                action: saveCheckpoint
133
            )
Bogdan Timofte authored a month ago
134
        }
Bogdan Timofte authored a month ago
135
    }
136

            
Bogdan Timofte authored a month ago
137
    private func inlineActionButton(
138
        systemName: String,
139
        tint: Color,
140
        fillOpacity: Double,
141
        strokeOpacity: Double,
142
        isEnabled: Bool,
143
        action: @escaping () -> Void
144
    ) -> some View {
145
        Button(action: action) {
146
            Image(systemName: systemName)
147
                .font(.caption.weight(.semibold))
148
                .frame(width: 30, height: 30)
149
                .contentShape(Rectangle())
150
        }
151
        .meterCard(
152
            tint: tint,
153
            fillOpacity: fillOpacity,
154
            strokeOpacity: strokeOpacity,
155
            cornerRadius: 10
156
        )
157
        .buttonStyle(.plain)
158
        .disabled(!isEnabled)
159
        .opacity(isEnabled ? 1 : 0.6)
160
    }
161

            
Bogdan Timofte authored a month ago
162
    private func saveCheckpoint() {
163
        guard let percent = normalizedBatteryPercent else {
Bogdan Timofte authored a month ago
164
            return
165
        }
166

            
Bogdan Timofte authored a month ago
167
        if appData.addBatteryCheckpoint(
Bogdan Timofte authored a month ago
168
            percent: percent,
Bogdan Timofte authored a month ago
169
            for: sessionID,
170
            measuredEnergyWh: effectiveEnergyWhOverride,
171
            measuredChargeAh: measuredChargeAhOverride
172
        ) {
173
            onSaved?()
174
        }
175
    }
176
}
177

            
Bogdan Timofte authored a month ago
178
struct BatteryCheckpointSectionView: View {
179
    let sessionID: UUID
180
    let checkpoints: [ChargeCheckpointSummary]
181
    let message: String
182
    let canAddCheckpoint: Bool
183
    let requirementMessage: String?
184
    let effectiveEnergyWhOverride: Double?
185
    let measuredChargeAhOverride: Double?
186
    let onDelete: (ChargeCheckpointSummary) -> Void
187

            
188
    @State private var showsInlineCheckpointEditor = false
189

            
190
    private var displayedCheckpoints: [ChargeCheckpointSummary] {
191
        Array(checkpoints.suffix(6).reversed())
192
    }
193

            
194
    var body: some View {
195
        VStack(alignment: .leading, spacing: 8) {
196
            HStack(alignment: .center, spacing: 8) {
197
                Text("Battery Checkpoints")
198
                    .font(.subheadline.weight(.semibold))
199

            
200
                ContextInfoButton(
201
                    title: "Battery Checkpoints",
202
                    message: message
203
                )
204

            
205
                Spacer(minLength: 12)
206

            
207
                if canAddCheckpoint {
208
                    if showsInlineCheckpointEditor {
209
                        BatteryCheckpointEditorContentView(
210
                            sessionID: sessionID,
211
                            message: message,
212
                            effectiveEnergyWhOverride: effectiveEnergyWhOverride,
213
                            measuredChargeAhOverride: measuredChargeAhOverride,
214
                            onCancel: { showsInlineCheckpointEditor = false },
215
                            onSaved: { showsInlineCheckpointEditor = false },
216
                            showsHeader: false
217
                        )
218
                    } else {
219
                        Button {
220
                            showsInlineCheckpointEditor = true
221
                        } label: {
222
                            Image(systemName: "plus")
223
                                .font(.caption.weight(.semibold))
224
                                .frame(width: 30, height: 30)
225
                                .contentShape(Rectangle())
226
                        }
227
                        .meterCard(
228
                            tint: .green,
229
                            fillOpacity: 0.12,
230
                            strokeOpacity: 0.18,
231
                            cornerRadius: 10
232
                        )
233
                        .buttonStyle(.plain)
234
                        .help("Add checkpoint")
235
                    }
236
                }
237
            }
238

            
239
            ForEach(displayedCheckpoints, id: \.id) { checkpoint in
240
                HStack {
241
                    Text(checkpoint.timestamp.format())
242
                        .font(.caption2)
243
                        .foregroundColor(.secondary)
244
                    Text(checkpoint.flag.title)
245
                        .font(.caption2.weight(.semibold))
246
                        .foregroundColor(.secondary)
247
                    Spacer()
248
                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
249
                        .font(.caption.weight(.semibold))
250
                    Text("•")
251
                        .foregroundColor(.secondary)
252
                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
253
                        .font(.caption2)
254
                        .foregroundColor(.secondary)
255
                    Button {
256
                        onDelete(checkpoint)
257
                    } label: {
258
                        Image(systemName: "trash")
259
                            .font(.caption.weight(.semibold))
260
                            .foregroundColor(.red)
261
                    }
262
                    .buttonStyle(.plain)
263
                    .help("Delete checkpoint")
264
                }
265
            }
266

            
267
            if !canAddCheckpoint, let requirementMessage {
268
                Text(requirementMessage)
269
                    .font(.caption2)
270
                    .foregroundColor(.secondary)
271
            }
272
        }
273
    }
274
}
275

            
Bogdan Timofte authored a month ago
276
struct BatteryCheckpointEditorSheetView: View {
277
    @EnvironmentObject private var appData: AppData
278
    @EnvironmentObject private var meter: Meter
279
    @Environment(\.dismiss) private var dismiss
280

            
281
    private var activeSession: ChargeSessionSummary? {
282
        appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
283
    }
284

            
285
    var body: some View {
286
        NavigationView {
287
            Group {
288
                if let activeSession {
289
                    Form {
290
                        BatteryCheckpointEditorContentView(
291
                            sessionID: activeSession.id,
292
                            message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
293
                            effectiveEnergyWhOverride: nil,
294
                            measuredChargeAhOverride: nil,
295
                            onCancel: { dismiss() },
Bogdan Timofte authored a month ago
296
                            onSaved: { dismiss() },
297
                            showsHeader: true
Bogdan Timofte authored a month ago
298
                        )
299
                    }
300
                } else {
301
                    VStack(spacing: 12) {
302
                        Image(systemName: "bolt.slash")
303
                            .font(.title2)
304
                            .foregroundColor(.secondary)
305
                        Text("No Active Session")
306
                            .font(.headline)
307
                        Text("Start a charging session before adding a battery checkpoint.")
308
                            .font(.footnote)
309
                            .foregroundColor(.secondary)
310
                            .multilineTextAlignment(.center)
311
                    }
312
                    .padding(24)
313
                }
314
            }
315
            .navigationTitle("Battery Checkpoint")
316
            .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
317
        }
Bogdan Timofte authored a month ago
318
        .navigationViewStyle(StackNavigationViewStyle())
Bogdan Timofte authored a month ago
319
    }
320
}