USB-Meter / USB Meter / Views / ChargedDevices / Sheets / ChargeSession / BatteryCheckpointEditorSheetView.swift
Newer Older
323 lines | 11.298kb
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
Bogdan Timofte authored a month ago
183
    let canDeleteCheckpoint: Bool
Bogdan Timofte authored a month ago
184
    let requirementMessage: String?
185
    let effectiveEnergyWhOverride: Double?
186
    let measuredChargeAhOverride: Double?
187
    let onDelete: (ChargeCheckpointSummary) -> Void
188

            
189
    @State private var showsInlineCheckpointEditor = false
190

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

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

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

            
206
                Spacer(minLength: 12)
207

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

            
240
            ForEach(displayedCheckpoints, id: \.id) { checkpoint in
241
                HStack {
242
                    Text(checkpoint.timestamp.format())
243
                        .font(.caption2)
244
                        .foregroundColor(.secondary)
245
                    Text(checkpoint.flag.title)
246
                        .font(.caption2.weight(.semibold))
247
                        .foregroundColor(.secondary)
248
                    Spacer()
249
                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
250
                        .font(.caption.weight(.semibold))
251
                    Text("•")
252
                        .foregroundColor(.secondary)
253
                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
254
                        .font(.caption2)
255
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
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")
Bogdan Timofte authored a month ago
266
                    }
267
                }
268
            }
269

            
270
            if !canAddCheckpoint, let requirementMessage {
271
                Text(requirementMessage)
272
                    .font(.caption2)
273
                    .foregroundColor(.secondary)
274
            }
275
        }
276
    }
277
}
278

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

            
284
    private var activeSession: ChargeSessionSummary? {
285
        appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
286
    }
287

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