USB-Meter / USB Meter / Views / ChargedDevices / Sheets / ChargeSession / BatteryCheckpointEditorSheetView.swift
Newer Older
403 lines | 14.124kb
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

            
49
    private var allowsSubjectToggle: Bool {
50
        sourcePowerbank?.batteryLevelReporting.allowsCheckpoints == true
51
    }
52

            
53
    private var activeReporting: BatteryLevelReporting {
54
        if subject == .powerbank, let sourcePowerbank {
55
            return sourcePowerbank.batteryLevelReporting
56
        }
57
        return .percent
58
    }
59

            
60
    private var activeBarsCount: Int {
61
        max(1, sourcePowerbank?.batteryBarsCount ?? 1)
62
    }
63

            
Bogdan Timofte authored a month ago
64
    private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
Bogdan Timofte authored a month ago
65
        guard let percent = normalizedBatteryPercent else {
Bogdan Timofte authored a month ago
66
            return nil
67
        }
Bogdan Timofte authored a month ago
68
        return appData.batteryCheckpointPlausibilityWarning(
69
            percent: percent,
70
            for: sessionID,
71
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
72
        )
Bogdan Timofte authored a month ago
73
    }
Bogdan Timofte authored a month ago
74

            
Bogdan Timofte authored a month ago
75
    private var normalizedBatteryPercent: Double? {
76
        let normalized = batteryPercent
77
            .trimmingCharacters(in: .whitespacesAndNewlines)
78
            .replacingOccurrences(of: ",", with: ".")
79
        return Double(normalized)
80
    }
Bogdan Timofte authored a month ago
81

            
Bogdan Timofte authored a month ago
82
    private var canSave: Bool {
Bogdan Timofte authored a month ago
83
        switch activeReporting {
84
        case .percent:
85
            guard let percent = normalizedBatteryPercent else { return false }
86
            return percent >= 0 && percent <= 100
87
        case .bars:
88
            return barsValue >= 0 && barsValue <= activeBarsCount
89
        case .fullOnly:
90
            // Always savable — the only emitted value is the 100% anchor.
91
            return true
92
        case .none:
Bogdan Timofte authored a month ago
93
            return false
94
        }
95
    }
96

            
97
    var body: some View {
98
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
99
            if showsHeader {
100
                HStack(spacing: 8) {
101
                    Text("Checkpoint")
102
                    Spacer(minLength: 0)
103
                    ContextInfoButton(
104
                        title: "Checkpoint",
105
                        message: message
106
                    )
Bogdan Timofte authored a month ago
107
                }
108
            }
Bogdan Timofte authored a month ago
109

            
Bogdan Timofte authored a month ago
110
            if allowsSubjectToggle {
111
                Picker("Subject", selection: $subject) {
112
                    Text("Device").tag(CheckpointSubject.chargedDevice)
113
                    Text("Powerbank").tag(CheckpointSubject.powerbank)
114
                }
115
                .pickerStyle(.segmented)
116
            }
117

            
Bogdan Timofte authored a month ago
118
            compactEditorRow
119
        }
120
    }
Bogdan Timofte authored a month ago
121

            
Bogdan Timofte authored a month ago
122
    @ViewBuilder
123
    private var subjectInput: some View {
124
        switch activeReporting {
125
        case .percent:
Bogdan Timofte authored a month ago
126
            TextField("Battery %", text: $batteryPercent)
127
                .keyboardType(.decimalPad)
128
                .textFieldStyle(.roundedBorder)
129
                .frame(width: 104)
130
                .onSubmit(saveCheckpoint)
Bogdan Timofte authored a month ago
131
        case .bars:
132
            HStack(spacing: 6) {
133
                Stepper(value: $barsValue, in: 0...activeBarsCount) {
134
                    Text("\(barsValue) / \(activeBarsCount)")
135
                        .font(.subheadline)
136
                }
137
                .frame(width: 160)
138
            }
139
        case .fullOnly:
140
            // Single-LED powerbanks only signal completion. The only meaningful checkpoint
141
            // is "full" — anything else would be a guess. Tapping the action saves at 100%.
142
            Label("Full LED is on", systemImage: "lightbulb.fill")
143
                .font(.caption)
144
                .foregroundColor(.secondary)
145
                .frame(width: 220, alignment: .leading)
146
        case .none:
147
            Text("Battery level reporting disabled")
148
                .font(.caption)
149
                .foregroundColor(.secondary)
150
                .frame(width: 220, alignment: .leading)
151
        }
152
    }
153

            
154
    private var compactEditorRow: some View {
155
        HStack(spacing: 8) {
156
            subjectInput
Bogdan Timofte authored a month ago
157

            
Bogdan Timofte authored a month ago
158
            if let plausibilityWarning {
159
                Button {
160
                    showsWarningPopover.toggle()
161
                } label: {
162
                    Image(systemName: "exclamationmark.triangle.fill")
163
                        .font(.body.weight(.semibold))
164
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
165
                }
Bogdan Timofte authored a month ago
166
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
167
                .accessibilityLabel(plausibilityWarning.title)
168
                .popover(isPresented: $showsWarningPopover, arrowEdge: .top) {
169
                    VStack(alignment: .leading, spacing: 10) {
170
                        Text(plausibilityWarning.title)
171
                            .font(.headline)
172
                        Text(plausibilityWarning.message)
173
                            .font(.body)
174
                            .fixedSize(horizontal: false, vertical: true)
175
                    }
176
                    .padding(16)
177
                    .frame(width: 320, alignment: .leading)
178
                }
Bogdan Timofte authored a month ago
179
            }
Bogdan Timofte authored a month ago
180

            
181
            if let onCancel {
182
                inlineActionButton(
183
                    systemName: "xmark",
184
                    tint: .secondary,
185
                    fillOpacity: 0.12,
186
                    strokeOpacity: 0.18,
187
                    isEnabled: true,
188
                    action: onCancel
189
                )
190
            }
191

            
192
            inlineActionButton(
193
                systemName: "checkmark",
194
                tint: .green,
195
                fillOpacity: 0.16,
196
                strokeOpacity: 0.22,
197
                isEnabled: canSave,
198
                action: saveCheckpoint
199
            )
Bogdan Timofte authored a month ago
200
        }
Bogdan Timofte authored a month ago
201
    }
202

            
Bogdan Timofte authored a month ago
203
    private func inlineActionButton(
204
        systemName: String,
205
        tint: Color,
206
        fillOpacity: Double,
207
        strokeOpacity: Double,
208
        isEnabled: Bool,
209
        action: @escaping () -> Void
210
    ) -> some View {
211
        Button(action: action) {
212
            Image(systemName: systemName)
213
                .font(.caption.weight(.semibold))
214
                .frame(width: 30, height: 30)
215
                .contentShape(Rectangle())
216
        }
217
        .meterCard(
218
            tint: tint,
219
            fillOpacity: fillOpacity,
220
            strokeOpacity: strokeOpacity,
221
            cornerRadius: 10
222
        )
223
        .buttonStyle(.plain)
224
        .disabled(!isEnabled)
225
        .opacity(isEnabled ? 1 : 0.6)
226
    }
227

            
Bogdan Timofte authored a month ago
228
    private func saveCheckpoint() {
Bogdan Timofte authored a month ago
229
        let resolvedPercent: Double
230
        let resolvedBars: Int
231
        switch activeReporting {
232
        case .percent:
233
            guard let percent = normalizedBatteryPercent else { return }
234
            resolvedPercent = percent
235
            resolvedBars = 0
236
        case .bars:
237
            resolvedBars = barsValue
238
            resolvedPercent = activeBarsCount > 0
239
                ? min(100, max(0, Double(barsValue) / Double(activeBarsCount) * 100))
240
                : 0
241
        case .fullOnly:
242
            // Single-LED powerbanks: the only meaningful anchor is "full".
243
            resolvedPercent = 100
244
            resolvedBars = 0
245
        case .none:
Bogdan Timofte authored a month ago
246
            return
247
        }
248

            
Bogdan Timofte authored a month ago
249
        if appData.addBatteryCheckpoint(
Bogdan Timofte authored a month ago
250
            percent: resolvedPercent,
Bogdan Timofte authored a month ago
251
            for: sessionID,
Bogdan Timofte authored a month ago
252
            measuredEnergyWh: effectiveEnergyWhOverride,
253
            subject: subject,
254
            barsValue: resolvedBars
Bogdan Timofte authored a month ago
255
        ) {
256
            onSaved?()
257
        }
258
    }
259
}
260

            
Bogdan Timofte authored a month ago
261
struct BatteryCheckpointSectionView: View {
262
    let sessionID: UUID
263
    let checkpoints: [ChargeCheckpointSummary]
264
    let message: String
265
    let canAddCheckpoint: Bool
Bogdan Timofte authored a month ago
266
    let canDeleteCheckpoint: Bool
Bogdan Timofte authored a month ago
267
    let requirementMessage: String?
268
    let effectiveEnergyWhOverride: Double?
269
    let onDelete: (ChargeCheckpointSummary) -> Void
270

            
271
    @State private var showsInlineCheckpointEditor = false
272

            
273
    private var displayedCheckpoints: [ChargeCheckpointSummary] {
274
        Array(checkpoints.suffix(6).reversed())
275
    }
276

            
277
    var body: some View {
278
        VStack(alignment: .leading, spacing: 8) {
279
            HStack(alignment: .center, spacing: 8) {
280
                Text("Battery Checkpoints")
281
                    .font(.subheadline.weight(.semibold))
282

            
283
                ContextInfoButton(
284
                    title: "Battery Checkpoints",
285
                    message: message
286
                )
287

            
288
                Spacer(minLength: 12)
289

            
290
                if canAddCheckpoint {
291
                    if showsInlineCheckpointEditor {
292
                        BatteryCheckpointEditorContentView(
293
                            sessionID: sessionID,
294
                            message: message,
295
                            effectiveEnergyWhOverride: effectiveEnergyWhOverride,
296
                            onCancel: { showsInlineCheckpointEditor = false },
297
                            onSaved: { showsInlineCheckpointEditor = false },
298
                            showsHeader: false
299
                        )
300
                    } else {
301
                        Button {
302
                            showsInlineCheckpointEditor = true
303
                        } label: {
304
                            Image(systemName: "plus")
305
                                .font(.caption.weight(.semibold))
306
                                .frame(width: 30, height: 30)
307
                                .contentShape(Rectangle())
308
                        }
309
                        .meterCard(
310
                            tint: .green,
311
                            fillOpacity: 0.12,
312
                            strokeOpacity: 0.18,
313
                            cornerRadius: 10
314
                        )
315
                        .buttonStyle(.plain)
316
                        .help("Add checkpoint")
317
                    }
318
                }
319
            }
320

            
321
            ForEach(displayedCheckpoints, id: \.id) { checkpoint in
322
                HStack {
323
                    Text(checkpoint.timestamp.format())
324
                        .font(.caption2)
325
                        .foregroundColor(.secondary)
326
                    Text(checkpoint.flag.title)
327
                        .font(.caption2.weight(.semibold))
328
                        .foregroundColor(.secondary)
329
                    Spacer()
330
                    Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
331
                        .font(.caption.weight(.semibold))
332
                    Text("•")
333
                        .foregroundColor(.secondary)
334
                    Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
335
                        .font(.caption2)
336
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
337
                    if canDeleteCheckpoint {
338
                        Button {
339
                            onDelete(checkpoint)
340
                        } label: {
341
                            Image(systemName: "trash")
342
                                .font(.caption.weight(.semibold))
343
                                .foregroundColor(.red)
344
                        }
345
                        .buttonStyle(.plain)
346
                        .help("Delete checkpoint")
Bogdan Timofte authored a month ago
347
                    }
348
                }
349
            }
350

            
351
            if !canAddCheckpoint, let requirementMessage {
352
                Text(requirementMessage)
353
                    .font(.caption2)
354
                    .foregroundColor(.secondary)
355
            }
356
        }
357
    }
358
}
359

            
Bogdan Timofte authored a month ago
360
struct BatteryCheckpointEditorSheetView: View {
361
    @EnvironmentObject private var appData: AppData
362
    @EnvironmentObject private var meter: Meter
363
    @Environment(\.dismiss) private var dismiss
364

            
365
    private var activeSession: ChargeSessionSummary? {
366
        appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
367
    }
368

            
369
    var body: some View {
370
        NavigationView {
371
            Group {
372
                if let activeSession {
373
                    Form {
374
                        BatteryCheckpointEditorContentView(
375
                            sessionID: activeSession.id,
376
                            message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.",
377
                            effectiveEnergyWhOverride: nil,
378
                            onCancel: { dismiss() },
Bogdan Timofte authored a month ago
379
                            onSaved: { dismiss() },
380
                            showsHeader: true
Bogdan Timofte authored a month ago
381
                        )
382
                    }
383
                } else {
384
                    VStack(spacing: 12) {
385
                        Image(systemName: "bolt.slash")
386
                            .font(.title2)
387
                            .foregroundColor(.secondary)
388
                        Text("No Active Session")
389
                            .font(.headline)
390
                        Text("Start a charging session before adding a battery checkpoint.")
391
                            .font(.footnote)
392
                            .foregroundColor(.secondary)
393
                            .multilineTextAlignment(.center)
394
                    }
395
                    .padding(24)
396
                }
397
            }
398
            .navigationTitle("Battery Checkpoint")
399
            .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
400
        }
Bogdan Timofte authored a month ago
401
        .navigationViewStyle(StackNavigationViewStyle())
Bogdan Timofte authored a month ago
402
    }
403
}