USB-Meter / USB Meter / Views / ChargedDevices / Sheets / ChargeSession / BatteryCheckpointEditorSheetView.swift
Newer Older
419 lines | 14.703kb
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 barsValue: Int = 0
22
    @State private var subject: CheckpointSubject = .chargedDevice
Bogdan Timofte authored a month ago
23
    @State private var showsWarningPopover = false
Bogdan Timofte authored a month ago
24

            
Bogdan Timofte authored a month ago
25
    init(
26
        sessionID: UUID,
27
        message: String,
28
        effectiveEnergyWhOverride: 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.onCancel = onCancel
37
        self.onSaved = onSaved
38
        self.showsHeader = showsHeader
39
    }
40

            
Bogdan Timofte authored a month ago
41
    private var sourcePowerbank: PowerbankSummary? {
42
        guard let session = appData.chargeSessionSummary(id: sessionID),
43
              let powerbankID = session.sourcePowerbankID else {
44
            return nil
45
        }
46
        return appData.powerbankSummaries.first { $0.id == powerbankID }
47
    }
48

            
Bogdan Timofte authored a month ago
49
    private var chargedPowerbank: PowerbankSummary? {
50
        guard let session = appData.chargeSessionSummary(id: sessionID),
51
              let powerbankID = session.chargedPowerbankID else {
52
            return nil
53
        }
54
        return appData.powerbankSummaries.first { $0.id == powerbankID }
55
    }
56

            
Bogdan Timofte authored a month ago
57
    private var allowsSubjectToggle: Bool {
Bogdan Timofte authored a month ago
58
        chargedPowerbank == nil && sourcePowerbank?.batteryLevelReporting.allowsCheckpoints == true
Bogdan Timofte authored a month ago
59
    }
60

            
61
    private var activeReporting: BatteryLevelReporting {
Bogdan Timofte authored a month ago
62
        if let chargedPowerbank {
63
            return chargedPowerbank.batteryLevelReporting
64
        }
Bogdan Timofte authored a month ago
65
        if subject == .powerbank, let sourcePowerbank {
66
            return sourcePowerbank.batteryLevelReporting
67
        }
68
        return .percent
69
    }
70

            
71
    private var activeBarsCount: Int {
Bogdan Timofte authored a month ago
72
        max(1, (chargedPowerbank ?? sourcePowerbank)?.batteryBarsCount ?? 1)
Bogdan Timofte authored a month ago
73
    }
74

            
Bogdan Timofte authored a month ago
75
    private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
Bogdan Timofte authored a month ago
76
        guard let percent = normalizedBatteryPercent else {
Bogdan Timofte authored a month ago
77
            return nil
78
        }
Bogdan Timofte authored a month ago
79
        return appData.batteryCheckpointPlausibilityWarning(
80
            percent: percent,
81
            for: sessionID,
82
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
83
        )
Bogdan Timofte authored a month ago
84
    }
Bogdan Timofte authored a month ago
85

            
Bogdan Timofte authored a month ago
86
    private var normalizedBatteryPercent: Double? {
87
        let normalized = batteryPercent
88
            .trimmingCharacters(in: .whitespacesAndNewlines)
89
            .replacingOccurrences(of: ",", with: ".")
90
        return Double(normalized)
91
    }
Bogdan Timofte authored a month ago
92

            
Bogdan Timofte authored a month ago
93
    private var canSave: Bool {
Bogdan Timofte authored a month ago
94
        switch activeReporting {
95
        case .percent:
96
            guard let percent = normalizedBatteryPercent else { return false }
97
            return percent >= 0 && percent <= 100
98
        case .bars:
99
            return barsValue >= 0 && barsValue <= activeBarsCount
100
        case .fullOnly:
101
            // Always savable — the only emitted value is the 100% anchor.
102
            return true
103
        case .none:
Bogdan Timofte authored a month ago
104
            return false
105
        }
106
    }
107

            
108
    var body: some View {
109
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
110
            if showsHeader {
111
                HStack(spacing: 8) {
112
                    Text("Checkpoint")
113
                    Spacer(minLength: 0)
114
                    ContextInfoButton(
115
                        title: "Checkpoint",
116
                        message: message
117
                    )
Bogdan Timofte authored a month ago
118
                }
119
            }
Bogdan Timofte authored a month ago
120

            
Bogdan Timofte authored a month ago
121
            if allowsSubjectToggle {
122
                Picker("Subject", selection: $subject) {
123
                    Text("Device").tag(CheckpointSubject.chargedDevice)
124
                    Text("Powerbank").tag(CheckpointSubject.powerbank)
125
                }
126
                .pickerStyle(.segmented)
127
            }
128

            
Bogdan Timofte authored a month ago
129
            compactEditorRow
130
        }
Bogdan Timofte authored a month ago
131
        .onAppear {
132
            if chargedPowerbank != nil {
133
                subject = .powerbank
134
            }
135
        }
Bogdan Timofte authored a month ago
136
    }
Bogdan Timofte authored a month ago
137

            
Bogdan Timofte authored a month ago
138
    @ViewBuilder
139
    private var subjectInput: some View {
140
        switch activeReporting {
141
        case .percent:
Bogdan Timofte authored a month ago
142
            TextField("Battery %", text: $batteryPercent)
143
                .keyboardType(.decimalPad)
144
                .textFieldStyle(.roundedBorder)
145
                .frame(width: 104)
146
                .onSubmit(saveCheckpoint)
Bogdan Timofte authored a month ago
147
        case .bars:
148
            HStack(spacing: 6) {
149
                Stepper(value: $barsValue, in: 0...activeBarsCount) {
150
                    Text("\(barsValue) / \(activeBarsCount)")
151
                        .font(.subheadline)
152
                }
153
                .frame(width: 160)
154
            }
155
        case .fullOnly:
156
            // Single-LED powerbanks only signal completion. The only meaningful checkpoint
157
            // is "full" — anything else would be a guess. Tapping the action saves at 100%.
158
            Label("Full LED is on", systemImage: "lightbulb.fill")
159
                .font(.caption)
160
                .foregroundColor(.secondary)
161
                .frame(width: 220, alignment: .leading)
162
        case .none:
163
            Text("Battery level reporting disabled")
164
                .font(.caption)
165
                .foregroundColor(.secondary)
166
                .frame(width: 220, alignment: .leading)
167
        }
168
    }
169

            
170
    private var compactEditorRow: some View {
171
        HStack(spacing: 8) {
172
            subjectInput
Bogdan Timofte authored a month ago
173

            
Bogdan Timofte authored a month ago
174
            if let plausibilityWarning {
175
                Button {
176
                    showsWarningPopover.toggle()
177
                } label: {
178
                    Image(systemName: "exclamationmark.triangle.fill")
179
                        .font(.body.weight(.semibold))
180
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
181
                }
Bogdan Timofte authored a month ago
182
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
183
                .accessibilityLabel(plausibilityWarning.title)
184
                .popover(isPresented: $showsWarningPopover, arrowEdge: .top) {
185
                    VStack(alignment: .leading, spacing: 10) {
186
                        Text(plausibilityWarning.title)
187
                            .font(.headline)
188
                        Text(plausibilityWarning.message)
189
                            .font(.body)
190
                            .fixedSize(horizontal: false, vertical: true)
191
                    }
192
                    .padding(16)
193
                    .frame(width: 320, alignment: .leading)
194
                }
Bogdan Timofte authored a month ago
195
            }
Bogdan Timofte authored a month ago
196

            
197
            if let onCancel {
198
                inlineActionButton(
199
                    systemName: "xmark",
200
                    tint: .secondary,
201
                    fillOpacity: 0.12,
202
                    strokeOpacity: 0.18,
203
                    isEnabled: true,
204
                    action: onCancel
205
                )
206
            }
207

            
208
            inlineActionButton(
209
                systemName: "checkmark",
210
                tint: .green,
211
                fillOpacity: 0.16,
212
                strokeOpacity: 0.22,
213
                isEnabled: canSave,
214
                action: saveCheckpoint
215
            )
Bogdan Timofte authored a month ago
216
        }
Bogdan Timofte authored a month ago
217
    }
218

            
Bogdan Timofte authored a month ago
219
    private func inlineActionButton(
220
        systemName: String,
221
        tint: Color,
222
        fillOpacity: Double,
223
        strokeOpacity: Double,
224
        isEnabled: Bool,
225
        action: @escaping () -> Void
226
    ) -> some View {
227
        Button(action: action) {
228
            Image(systemName: systemName)
229
                .font(.caption.weight(.semibold))
230
                .frame(width: 30, height: 30)
231
                .contentShape(Rectangle())
232
        }
233
        .meterCard(
234
            tint: tint,
235
            fillOpacity: fillOpacity,
236
            strokeOpacity: strokeOpacity,
237
            cornerRadius: 10
238
        )
239
        .buttonStyle(.plain)
240
        .disabled(!isEnabled)
241
        .opacity(isEnabled ? 1 : 0.6)
242
    }
243

            
Bogdan Timofte authored a month ago
244
    private func saveCheckpoint() {
Bogdan Timofte authored a month ago
245
        let resolvedPercent: Double
246
        let resolvedBars: Int
247
        switch activeReporting {
248
        case .percent:
249
            guard let percent = normalizedBatteryPercent else { return }
250
            resolvedPercent = percent
251
            resolvedBars = 0
252
        case .bars:
253
            resolvedBars = barsValue
254
            resolvedPercent = activeBarsCount > 0
255
                ? min(100, max(0, Double(barsValue) / Double(activeBarsCount) * 100))
256
                : 0
257
        case .fullOnly:
258
            // Single-LED powerbanks: the only meaningful anchor is "full".
259
            resolvedPercent = 100
260
            resolvedBars = 0
261
        case .none:
Bogdan Timofte authored a month ago
262
            return
263
        }
264

            
Bogdan Timofte authored a month ago
265
        if appData.addBatteryCheckpoint(
Bogdan Timofte authored a month ago
266
            percent: resolvedPercent,
Bogdan Timofte authored a month ago
267
            for: sessionID,
Bogdan Timofte authored a month ago
268
            measuredEnergyWh: effectiveEnergyWhOverride,
269
            subject: subject,
270
            barsValue: resolvedBars
Bogdan Timofte authored a month ago
271
        ) {
272
            onSaved?()
273
        }
274
    }
275
}
276

            
Bogdan Timofte authored a month ago
277
struct BatteryCheckpointSectionView: View {
278
    let sessionID: UUID
279
    let checkpoints: [ChargeCheckpointSummary]
280
    let message: String
281
    let canAddCheckpoint: Bool
Bogdan Timofte authored a month ago
282
    let canDeleteCheckpoint: Bool
Bogdan Timofte authored a month ago
283
    let requirementMessage: String?
284
    let effectiveEnergyWhOverride: Double?
285
    let onDelete: (ChargeCheckpointSummary) -> Void
286

            
287
    @State private var showsInlineCheckpointEditor = false
288

            
289
    private var displayedCheckpoints: [ChargeCheckpointSummary] {
290
        Array(checkpoints.suffix(6).reversed())
291
    }
292

            
293
    var body: some View {
294
        VStack(alignment: .leading, spacing: 8) {
295
            HStack(alignment: .center, spacing: 8) {
296
                Text("Battery Checkpoints")
297
                    .font(.subheadline.weight(.semibold))
298

            
299
                ContextInfoButton(
300
                    title: "Battery Checkpoints",
301
                    message: message
302
                )
303

            
304
                Spacer(minLength: 12)
305

            
306
                if canAddCheckpoint {
307
                    if showsInlineCheckpointEditor {
308
                        BatteryCheckpointEditorContentView(
309
                            sessionID: sessionID,
310
                            message: message,
311
                            effectiveEnergyWhOverride: effectiveEnergyWhOverride,
312
                            onCancel: { showsInlineCheckpointEditor = false },
313
                            onSaved: { showsInlineCheckpointEditor = false },
314
                            showsHeader: false
315
                        )
316
                    } else {
317
                        Button {
318
                            showsInlineCheckpointEditor = true
319
                        } label: {
320
                            Image(systemName: "plus")
321
                                .font(.caption.weight(.semibold))
322
                                .frame(width: 30, height: 30)
323
                                .contentShape(Rectangle())
324
                        }
325
                        .meterCard(
326
                            tint: .green,
327
                            fillOpacity: 0.12,
328
                            strokeOpacity: 0.18,
329
                            cornerRadius: 10
330
                        )
331
                        .buttonStyle(.plain)
332
                        .help("Add checkpoint")
333
                    }
334
                }
335
            }
336

            
337
            ForEach(displayedCheckpoints, id: \.id) { checkpoint in
338
                HStack {
339
                    Text(checkpoint.timestamp.format())
340
                        .font(.caption2)
341
                        .foregroundColor(.secondary)
342
                    Text(checkpoint.flag.title)
343
                        .font(.caption2.weight(.semibold))
344
                        .foregroundColor(.secondary)
345
                    Spacer()
346
                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
347
                        .font(.caption.weight(.semibold))
348
                    Text("•")
349
                        .foregroundColor(.secondary)
350
                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
351
                        .font(.caption2)
352
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
353
                    if canDeleteCheckpoint {
354
                        Button {
355
                            onDelete(checkpoint)
356
                        } label: {
357
                            Image(systemName: "trash")
358
                                .font(.caption.weight(.semibold))
359
                                .foregroundColor(.red)
360
                        }
361
                        .buttonStyle(.plain)
362
                        .help("Delete checkpoint")
Bogdan Timofte authored a month ago
363
                    }
364
                }
365
            }
366

            
367
            if !canAddCheckpoint, let requirementMessage {
368
                Text(requirementMessage)
369
                    .font(.caption2)
370
                    .foregroundColor(.secondary)
371
            }
372
        }
373
    }
374
}
375

            
Bogdan Timofte authored a month ago
376
struct BatteryCheckpointEditorSheetView: View {
377
    @EnvironmentObject private var appData: AppData
378
    @EnvironmentObject private var meter: Meter
379
    @Environment(\.dismiss) private var dismiss
380

            
381
    private var activeSession: ChargeSessionSummary? {
382
        appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
383
    }
384

            
385
    var body: some View {
386
        NavigationView {
387
            Group {
388
                if let activeSession {
389
                    Form {
390
                        BatteryCheckpointEditorContentView(
391
                            sessionID: activeSession.id,
392
                            message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
393
                            effectiveEnergyWhOverride: nil,
394
                            onCancel: { dismiss() },
Bogdan Timofte authored a month ago
395
                            onSaved: { dismiss() },
396
                            showsHeader: true
Bogdan Timofte authored a month ago
397
                        )
398
                    }
399
                } else {
400
                    VStack(spacing: 12) {
401
                        Image(systemName: "bolt.slash")
402
                            .font(.title2)
403
                            .foregroundColor(.secondary)
404
                        Text("No Active Session")
405
                            .font(.headline)
406
                        Text("Start a charging session before adding a battery checkpoint.")
407
                            .font(.footnote)
408
                            .foregroundColor(.secondary)
409
                            .multilineTextAlignment(.center)
410
                    }
411
                    .padding(24)
412
                }
413
            }
414
            .navigationTitle("Battery Checkpoint")
415
            .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
416
        }
Bogdan Timofte authored a month ago
417
        .navigationViewStyle(StackNavigationViewStyle())
Bogdan Timofte authored a month ago
418
    }
419
}