USB-Meter / USB Meter / Views / ChargedDevices / Sheets / ChargeSession / BatteryCheckpointEditorSheetView.swift
Newer Older
316 lines | 10.911kb
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 onCancel: (() -> Void)?
17
    let onSaved: (() -> Void)?
Bogdan Timofte authored a month ago
18
    let showsHeader: Bool
Bogdan Timofte authored a month ago
19

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
77
            compactEditorRow
78
        }
79
    }
Bogdan Timofte authored a month ago
80

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

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

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

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

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

            
Bogdan Timofte authored a month ago
159
    private func saveCheckpoint() {
160
        guard let percent = normalizedBatteryPercent else {
Bogdan Timofte authored a month ago
161
            return
162
        }
163

            
Bogdan Timofte authored a month ago
164
        if appData.addBatteryCheckpoint(
Bogdan Timofte authored a month ago
165
            percent: percent,
Bogdan Timofte authored a month ago
166
            for: sessionID,
Bogdan Timofte authored a month ago
167
            measuredEnergyWh: effectiveEnergyWhOverride
Bogdan Timofte authored a month ago
168
        ) {
169
            onSaved?()
170
        }
171
    }
172
}
173

            
Bogdan Timofte authored a month ago
174
struct BatteryCheckpointSectionView: View {
175
    let sessionID: UUID
176
    let checkpoints: [ChargeCheckpointSummary]
177
    let message: String
178
    let canAddCheckpoint: Bool
Bogdan Timofte authored a month ago
179
    let canDeleteCheckpoint: Bool
Bogdan Timofte authored a month ago
180
    let requirementMessage: String?
181
    let effectiveEnergyWhOverride: Double?
182
    let onDelete: (ChargeCheckpointSummary) -> Void
183

            
184
    @State private var showsInlineCheckpointEditor = false
185

            
186
    private var displayedCheckpoints: [ChargeCheckpointSummary] {
187
        Array(checkpoints.suffix(6).reversed())
188
    }
189

            
190
    var body: some View {
191
        VStack(alignment: .leading, spacing: 8) {
192
            HStack(alignment: .center, spacing: 8) {
193
                Text("Battery Checkpoints")
194
                    .font(.subheadline.weight(.semibold))
195

            
196
                ContextInfoButton(
197
                    title: "Battery Checkpoints",
198
                    message: message
199
                )
200

            
201
                Spacer(minLength: 12)
202

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

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

            
264
            if !canAddCheckpoint, let requirementMessage {
265
                Text(requirementMessage)
266
                    .font(.caption2)
267
                    .foregroundColor(.secondary)
268
            }
269
        }
270
    }
271
}
272

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

            
278
    private var activeSession: ChargeSessionSummary? {
279
        appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
280
    }
281

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