USB-Meter / USB Meter / Views / Meter / Tabs / ChargeRecord / MeterChargeRecordTabView.swift
Newer Older
1550 lines | 63.054kb
Bogdan Timofte authored a month ago
1
//
2
//  MeterChargeRecordTabView.swift
3
//  USB Meter
4
//
5

            
6
import SwiftUI
7

            
Bogdan Timofte authored a month ago
8
struct MeterChargeRecordTabView: View, Equatable {
9
    static func == (lhs: MeterChargeRecordTabView, rhs: MeterChargeRecordTabView) -> Bool {
10
        true
11
    }
12

            
13
    @EnvironmentObject private var appData: AppData
14

            
15
    @State private var chargedDeviceLibraryVisibility = false
16
    @State private var chargerLibraryVisibility = false
17
    @State private var deviceLibraryMACAddress = ""
18
    @State private var chargerLibraryMACAddress = ""
19
    @State private var chargedDeviceLibraryTint: Color = .orange
20
    @State private var chargerLibraryTint: Color = .pink
21

            
Bogdan Timofte authored a month ago
22
    var body: some View {
Bogdan Timofte authored a month ago
23
        MeterChargeRecordContentView(
24
            onSelectDevice: { mac, tint in
25
                deviceLibraryMACAddress = mac
26
                chargedDeviceLibraryTint = tint
27
                chargedDeviceLibraryVisibility = true
28
            },
29
            onSelectCharger: { mac, tint in
30
                chargerLibraryMACAddress = mac
31
                chargerLibraryTint = tint
32
                chargerLibraryVisibility = true
33
            }
34
        )
35
        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
36
            ChargedDeviceLibrarySheetView(
37
                meterMACAddress: deviceLibraryMACAddress,
38
                meterTint: chargedDeviceLibraryTint,
39
                mode: .device
40
            )
41
            .environmentObject(appData)
42
        }
43
        .sheet(isPresented: $chargerLibraryVisibility) {
44
            ChargedDeviceLibrarySheetView(
45
                meterMACAddress: chargerLibraryMACAddress,
46
                meterTint: chargerLibraryTint,
47
                mode: .charger
48
            )
49
            .environmentObject(appData)
50
        }
Bogdan Timofte authored a month ago
51
    }
52
}
53

            
54
struct MeterChargeRecordContentView: View {
Bogdan Timofte authored a month ago
55
    private struct SessionMetricRow {
56
        let label: String
57
        let value: String
58
    }
59

            
Bogdan Timofte authored a month ago
60
    private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
61
        case known
62
        case unknown
63
        case flat
64

            
65
        var id: String { rawValue }
66

            
67
        var title: String {
68
            switch self {
Bogdan Timofte authored a month ago
69
            case .known:   return "Known"
70
            case .unknown: return "Unknown"
71
            case .flat:    return "Flat"
Bogdan Timofte authored a month ago
72
            }
73
        }
74
    }
75

            
Bogdan Timofte authored a month ago
76
    private enum ActiveMode: Hashable {
77
        case chargeSession
78
        case standbyPower
79
    }
Bogdan Timofte authored a month ago
80

            
81
    private enum SessionStartRequirement: Identifiable {
82
        case existingSession
83
        case device
84
        case chargingType
85
        case chargingMode
86
        case charger
87
        case initialCheckpointEmpty
88
        case initialCheckpointInvalid
89

            
90
        var id: String {
91
            switch self {
Bogdan Timofte authored a month ago
92
            case .existingSession:         return "existing-session"
93
            case .device:                  return "device"
94
            case .chargingType:            return "charging-type"
95
            case .chargingMode:            return "charging-mode"
96
            case .charger:                 return "charger"
97
            case .initialCheckpointEmpty:  return "initial-checkpoint-empty"
98
            case .initialCheckpointInvalid:return "initial-checkpoint-invalid"
Bogdan Timofte authored a month ago
99
            }
100
        }
101

            
102
        var message: String {
103
            switch self {
Bogdan Timofte authored a month ago
104
            case .existingSession:          return "Stop or pause the current session before starting another one."
105
            case .device:                   return "Select the device that is charging."
106
            case .chargingType:             return "Choose the charging type for this session."
107
            case .chargingMode:             return "Choose whether the device is on or off for this session."
108
            case .charger:                  return "Select the wireless charger used in this session."
109
            case .initialCheckpointEmpty:   return "Enter the initial battery percentage."
110
            case .initialCheckpointInvalid: return "Initial battery percentage must be between 0 and 100."
111
            }
112
        }
113
    }
114

            
115
    private enum FinalCheckpoint: Hashable {
116
        case full
117
        case skip
118
        case custom
119

            
120
        var label: String {
121
            switch self {
122
            case .full:   return "Full"
123
            case .skip:   return "Skip"
124
            case .custom: return "Other %"
125
            }
126
        }
127

            
128
        var icon: String {
129
            switch self {
130
            case .full:   return "battery.100percent"
131
            case .skip:   return "minus.circle"
132
            case .custom: return "pencil"
Bogdan Timofte authored a month ago
133
            }
134
        }
135
    }
Bogdan Timofte authored a month ago
136

            
Bogdan Timofte authored a month ago
137
    let onSelectDevice: (String, Color) -> Void
138
    let onSelectCharger: (String, Color) -> Void
139

            
Bogdan Timofte authored a month ago
140
    @EnvironmentObject private var appData: AppData
141
    @EnvironmentObject private var usbMeter: Meter
142

            
143
    @State private var showingInlineTargetEditor = false
144
    @State private var draftTargetText = ""
145
    @State private var showingStopConfirm = false
146
    @State private var finalCheckpointMode: FinalCheckpoint = .full
147
    @State private var finalCheckpointText = ""
148
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
149
    @State private var draftChargingTransportMode: ChargingTransportMode?
150
    @State private var draftChargingStateMode: ChargingStateMode?
151
    @State private var initialCheckpointMode: InitialCheckpointMode = .known
152
    @State private var initialCheckpoint = ""
153
    @State private var showsMeterTotalsInfo = false
154
    @State private var activeMode: ActiveMode = .chargeSession
155

            
Bogdan Timofte authored a month ago
156
    var body: some View {
157
        ScrollView {
Bogdan Timofte authored a month ago
158
            VStack(spacing: 14) {
159
                statusHeader
Bogdan Timofte authored a month ago
160

            
Bogdan Timofte authored a month ago
161
                if let openChargeSession {
162
                    chargingMonitorCard(openChargeSession)
Bogdan Timofte authored a month ago
163

            
Bogdan Timofte authored a month ago
164
                    if let range = sessionChartTimeRange {
165
                        sessionChartCard(timeRange: range, session: openChargeSession)
166
                    }
167
                } else {
Bogdan Timofte authored a month ago
168
                    liveMeterStripView
Bogdan Timofte authored a month ago
169
                    modePicker
170

            
171
                    switch activeMode {
172
                    case .chargeSession:
173
                        chargeSessionSetupCard
174
                    case .standbyPower:
175
                        standbyPowerCard
176
                    }
Bogdan Timofte authored a month ago
177
                }
178
            }
179
            .padding()
180
        }
181
        .background(
182
            LinearGradient(
183
                colors: [.pink.opacity(0.14), Color.clear],
184
                startPoint: .topLeading,
185
                endPoint: .bottomTrailing
186
            )
187
            .ignoresSafeArea()
188
        )
Bogdan Timofte authored a month ago
189
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
190
            Alert(
191
                title: Text("Delete Battery Checkpoint"),
192
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
193
                primaryButton: .destructive(Text("Delete")) {
194
                    if let openChargeSession {
195
                        _ = appData.deleteBatteryCheckpoint(
196
                            checkpointID: checkpoint.id,
197
                            for: openChargeSession.id
198
                        )
199
                    }
200
                },
201
                secondaryButton: .cancel()
202
            )
203
        }
Bogdan Timofte authored a month ago
204
        .onAppear {
205
            syncDraftSelections()
206
        }
207
        .onChange(of: selectedChargedDevice?.id) { _ in
208
            syncDraftSelections()
209
        }
210
        .onChange(of: openChargeSession?.id) { _ in
211
            syncDraftSelections()
Bogdan Timofte authored a month ago
212
            showingInlineTargetEditor = false
213
            draftTargetText = ""
Bogdan Timofte authored a month ago
214
        }
Bogdan Timofte authored a month ago
215
    }
216

            
Bogdan Timofte authored a month ago
217
    // MARK: - Computed Properties
218

            
Bogdan Timofte authored a month ago
219
    private var meterMACAddress: String {
220
        usbMeter.btSerial.macAddress.description
Bogdan Timofte authored a month ago
221
    }
222

            
Bogdan Timofte authored a month ago
223
    private var selectedChargedDevice: ChargedDeviceSummary? {
224
        appData.currentChargedDeviceSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
225
    }
226

            
227
    private var selectedCharger: ChargedDeviceSummary? {
Bogdan Timofte authored a month ago
228
        appData.currentChargerSummary(for: meterMACAddress)
Bogdan Timofte authored a month ago
229
    }
230

            
Bogdan Timofte authored a month ago
231
    private var openChargeSession: ChargeSessionSummary? {
232
        appData.activeChargeSessionSummary(for: meterMACAddress)
233
    }
234

            
Bogdan Timofte authored a month ago
235
    private var showsMeterTotalsCard: Bool {
236
        usbMeter.supportsRecordingView
237
            || usbMeter.supportsDataGroupCommands
238
            || usbMeter.recordedAH > 0
239
            || usbMeter.recordedWH > 0
240
            || usbMeter.recordingDuration > 0
241
    }
242

            
Bogdan Timofte authored a month ago
243
    private var selectedDraftTransportMode: ChargingTransportMode? {
244
        openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
245
    }
246

            
247
    private var selectedDraftChargingStateMode: ChargingStateMode? {
248
        openChargeSession?.chargingStateMode ?? draftChargingStateMode
249
    }
250

            
Bogdan Timofte authored a month ago
251
    private var initialCheckpointValue: Double? {
Bogdan Timofte authored a month ago
252
        guard initialCheckpointMode == .known else { return nil }
Bogdan Timofte authored a month ago
253
        let normalized = initialCheckpoint
254
            .trimmingCharacters(in: .whitespacesAndNewlines)
255
            .replacingOccurrences(of: ",", with: ".")
Bogdan Timofte authored a month ago
256
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
Bogdan Timofte authored a month ago
257
        return value
258
    }
259

            
Bogdan Timofte authored a month ago
260
    private var hasInitialCheckpointInput: Bool {
261
        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
262
    }
263

            
264
    private var shouldRequireInitialCheckpoint: Bool {
265
        initialCheckpointMode == .known
266
    }
267

            
Bogdan Timofte authored a month ago
268
    private var requiresExplicitTransportSelection: Bool {
269
        (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
270
    }
271

            
272
    private var requiresExplicitChargingStateSelection: Bool {
273
        (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
274
    }
275

            
Bogdan Timofte authored a month ago
276
    private var startRequirements: [SessionStartRequirement] {
277
        var requirements: [SessionStartRequirement] = []
278

            
279
        if openChargeSession != nil {
280
            requirements.append(.existingSession)
Bogdan Timofte authored a month ago
281
        }
282

            
Bogdan Timofte authored a month ago
283
        guard let selectedChargedDevice else {
284
            requirements.append(.device)
285
            return requirements
Bogdan Timofte authored a month ago
286
        }
287

            
Bogdan Timofte authored a month ago
288
        guard let chargingTransportMode = selectedDraftTransportMode else {
289
            requirements.append(.chargingType)
290
            return requirements
Bogdan Timofte authored a month ago
291
        }
292

            
Bogdan Timofte authored a month ago
293
        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
294
            requirements.append(.chargingType)
Bogdan Timofte authored a month ago
295
        }
296

            
Bogdan Timofte authored a month ago
297
        guard let chargingStateMode = selectedDraftChargingStateMode else {
298
            requirements.append(.chargingMode)
299
            return requirements
300
        }
301

            
302
        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
303
            requirements.append(.chargingMode)
304
        }
305

            
306
        if chargingTransportMode == .wireless, selectedCharger == nil {
307
            requirements.append(.charger)
308
        }
309

            
310
        if shouldRequireInitialCheckpoint {
311
            if hasInitialCheckpointInput == false {
312
                requirements.append(.initialCheckpointEmpty)
313
            } else if initialCheckpointValue == nil {
314
                requirements.append(.initialCheckpointInvalid)
315
            }
316
        }
317

            
318
        return requirements
319
    }
320

            
321
    private var canStartSession: Bool {
322
        startRequirements.isEmpty
Bogdan Timofte authored a month ago
323
    }
324

            
325
    private var headerStatusTitle: String {
Bogdan Timofte authored a month ago
326
        guard let openChargeSession else { return "Idle" }
Bogdan Timofte authored a month ago
327
        return openChargeSession.status.title
328
    }
329

            
330
    private var headerStatusColor: Color {
Bogdan Timofte authored a month ago
331
        guard let openChargeSession else { return .secondary }
Bogdan Timofte authored a month ago
332
        switch openChargeSession.status {
Bogdan Timofte authored a month ago
333
        case .active:    return .red
334
        case .paused:    return .orange
335
        case .completed: return .green
336
        case .abandoned: return .secondary
Bogdan Timofte authored a month ago
337
        }
338
    }
339

            
340
    private var sessionChartTimeRange: ClosedRange<Date>? {
Bogdan Timofte authored a month ago
341
        guard let openChargeSession else { return nil }
Bogdan Timofte authored a month ago
342
        let end = openChargeSession.pausedAt ?? openChargeSession.lastObservedAt
343
        return openChargeSession.startedAt...max(end, openChargeSession.startedAt)
344
    }
345

            
Bogdan Timofte authored a month ago
346
    private var showsWirelessChargerSection: Bool {
347
        let transportMode = selectedDraftTransportMode ?? selectedChargedDevice?.supportedChargingModes.first
348
        return transportMode == .wireless
349
    }
Bogdan Timofte authored a month ago
350

            
Bogdan Timofte authored a month ago
351
    // MARK: - Status Header
352

            
353
    private var statusHeader: some View {
354
        HStack {
355
            Image(systemName: "bolt.fill")
356
                .foregroundColor(.pink)
357
            Text("Charging Session")
358
                .font(.system(.title3, design: .rounded).weight(.bold))
359
            Spacer()
360
            Text(headerStatusTitle)
361
                .font(.caption.weight(.bold))
362
                .foregroundColor(headerStatusColor)
363
                .padding(.horizontal, 10)
364
                .padding(.vertical, 6)
365
                .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
366
        }
367
        .padding(.horizontal, 18)
368
        .padding(.vertical, 12)
Bogdan Timofte authored a month ago
369
        .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
370
    }
371

            
Bogdan Timofte authored a month ago
372
    // MARK: - Mode Picker
Bogdan Timofte authored a month ago
373

            
Bogdan Timofte authored a month ago
374
    private var modePicker: some View {
375
        Picker("", selection: $activeMode) {
376
            Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
377
            Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
Bogdan Timofte authored a month ago
378
        }
Bogdan Timofte authored a month ago
379
        .pickerStyle(.segmented)
380
        .labelsHidden()
Bogdan Timofte authored a month ago
381
    }
382

            
Bogdan Timofte authored a month ago
383
    // MARK: - Charge Session Setup
Bogdan Timofte authored a month ago
384

            
Bogdan Timofte authored a month ago
385
    private var chargeSessionSetupCard: some View {
386
        VStack(alignment: .leading, spacing: 0) {
387
            // Device
388
            setupRow(icon: "iphone", iconColor: .blue) {
389
                if let device = selectedChargedDevice {
390
                    ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15)
391
                        .font(.subheadline.weight(.semibold))
392
                } else {
393
                    Text("No device selected")
Bogdan Timofte authored a month ago
394
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
395
                        .font(.subheadline)
Bogdan Timofte authored a month ago
396
                }
Bogdan Timofte authored a month ago
397
                Spacer(minLength: 8)
398
                Button(selectedChargedDevice == nil ? "Select" : "Change") {
Bogdan Timofte authored a month ago
399
                    onSelectDevice(meterMACAddress, usbMeter.color)
Bogdan Timofte authored a month ago
400
                }
401
                .font(.caption.weight(.semibold))
402
                .buttonStyle(.bordered)
403
                .controlSize(.small)
Bogdan Timofte authored a month ago
404
            }
405

            
Bogdan Timofte authored a month ago
406
            // Charging type — only when device supports multiple
407
            if requiresExplicitTransportSelection, let device = selectedChargedDevice {
408
                Divider().padding(.leading, 46)
409
                setupRow(icon: draftChargingTransportMode?.symbolName ?? "bolt.slash", iconColor: .orange) {
410
                    Text("Type")
411
                        .foregroundColor(.secondary)
412
                        .font(.subheadline)
413
                    Spacer()
Bogdan Timofte authored a month ago
414
                    compactSelectionMenu(
415
                        title: draftChargingTransportMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
416
                        options: device.supportedChargingModes.map { mode in
Bogdan Timofte authored a month ago
417
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
418
                                id: mode.id, title: mode.title,
419
                                isSelected: draftChargingTransportMode == mode,
420
                                action: { draftChargingTransportMode = mode }
Bogdan Timofte authored a month ago
421
                            )
Bogdan Timofte authored a month ago
422
                        }
Bogdan Timofte authored a month ago
423
                    )
Bogdan Timofte authored a month ago
424
                }
Bogdan Timofte authored a month ago
425
            }
Bogdan Timofte authored a month ago
426

            
Bogdan Timofte authored a month ago
427
            // Charging state — only when device supports multiple
428
            if requiresExplicitChargingStateSelection, let device = selectedChargedDevice {
429
                Divider().padding(.leading, 46)
430
                setupRow(icon: draftChargingStateMode == .off ? "power.circle" : "power", iconColor: .purple) {
431
                    Text("Mode")
432
                        .foregroundColor(.secondary)
433
                        .font(.subheadline)
434
                    Spacer()
Bogdan Timofte authored a month ago
435
                    compactSelectionMenu(
436
                        title: draftChargingStateMode?.title ?? "Choose",
Bogdan Timofte authored a month ago
437
                        options: device.supportedChargingStateModes.map { mode in
Bogdan Timofte authored a month ago
438
                            CompactSelectionOption(
Bogdan Timofte authored a month ago
439
                                id: mode.id, title: mode.title,
440
                                isSelected: draftChargingStateMode == mode,
441
                                action: { draftChargingStateMode = mode }
Bogdan Timofte authored a month ago
442
                            )
Bogdan Timofte authored a month ago
443
                        }
Bogdan Timofte authored a month ago
444
                    )
Bogdan Timofte authored a month ago
445
                }
446
            }
Bogdan Timofte authored a month ago
447

            
Bogdan Timofte authored a month ago
448
            // Wireless charger — only when wireless transport
449
            if showsWirelessChargerSection {
450
                Divider().padding(.leading, 46)
451
                setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
452
                    if let charger = selectedCharger {
453
                        ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15)
454
                            .font(.subheadline.weight(.semibold))
455
                        if charger.chargerIdleCurrentAmps == nil {
456
                            Image(systemName: "exclamationmark.triangle.fill")
457
                                .foregroundColor(.orange)
458
                                .font(.caption)
459
                        }
460
                    } else {
461
                        Text("No charger selected")
462
                            .foregroundColor(.secondary)
463
                            .font(.subheadline)
464
                    }
465
                    Spacer(minLength: 8)
466
                    Button(selectedCharger == nil ? "Select" : "Change") {
Bogdan Timofte authored a month ago
467
                        onSelectCharger(meterMACAddress, usbMeter.color)
Bogdan Timofte authored a month ago
468
                    }
Bogdan Timofte authored a month ago
469
                    .font(.caption.weight(.semibold))
470
                    .buttonStyle(.bordered)
471
                    .controlSize(.small)
Bogdan Timofte authored a month ago
472
                }
Bogdan Timofte authored a month ago
473
            }
474

            
Bogdan Timofte authored a month ago
475
            // Battery checkpoint
476
            Divider().padding(.leading, 46)
477
            setupRow(icon: "battery.75percent", iconColor: .green) {
478
                if initialCheckpointMode == .known {
479
                    Button { adjustInitialCheckpoint(by: -1) } label: {
480
                        Image(systemName: "minus.circle").font(.title3)
481
                    }
482
                    .buttonStyle(.plain)
483

            
484
                    TextField("—", text: $initialCheckpoint)
485
                        .keyboardType(.decimalPad)
486
                        .textFieldStyle(.roundedBorder)
487
                        .frame(width: 52)
488
                        .multilineTextAlignment(.center)
Bogdan Timofte authored a month ago
489

            
Bogdan Timofte authored a month ago
490
                    Text("%")
491
                        .font(.subheadline)
492
                        .foregroundColor(.secondary)
493

            
494
                    Button { adjustInitialCheckpoint(by: 1) } label: {
495
                        Image(systemName: "plus.circle").font(.title3)
496
                    }
497
                    .buttonStyle(.plain)
498
                } else {
499
                    Text(initialCheckpointMode == .flat
500
                         ? "Flat (device off / discharged)"
501
                         : "Unknown")
502
                        .font(.subheadline)
503
                        .foregroundColor(.secondary)
504
                }
505
                Spacer()
Bogdan Timofte authored a month ago
506
                compactSelectionMenu(
507
                    title: initialCheckpointMode.title,
508
                    options: InitialCheckpointMode.allCases.map { mode in
509
                        CompactSelectionOption(
Bogdan Timofte authored a month ago
510
                            id: mode.id, title: mode.title,
Bogdan Timofte authored a month ago
511
                            isSelected: initialCheckpointMode == mode,
512
                            action: { initialCheckpointMode = mode }
513
                        )
514
                    }
515
                )
Bogdan Timofte authored a month ago
516
            }
517

            
Bogdan Timofte authored a month ago
518
            // Requirement errors
519
            if startRequirements.isEmpty == false {
520
                Divider()
521
                VStack(alignment: .leading, spacing: 6) {
Bogdan Timofte authored a month ago
522
                    ForEach(startRequirements) { requirement in
523
                        Label(requirement.message, systemImage: "exclamationmark.circle")
524
                            .font(.caption)
525
                            .foregroundColor(.orange)
526
                    }
527
                }
Bogdan Timofte authored a month ago
528
                .padding(.horizontal, 14)
529
                .padding(.vertical, 10)
Bogdan Timofte authored a month ago
530
            }
Bogdan Timofte authored a month ago
531

            
Bogdan Timofte authored a month ago
532
            // Start button
533
            Divider()
Bogdan Timofte authored a month ago
534
            Button("Start Session") {
535
                startSession()
536
            }
537
            .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
538
            .padding(.vertical, 11)
539
            .font(.subheadline.weight(.semibold))
540
            .foregroundColor(canStartSession ? .green : .secondary)
Bogdan Timofte authored a month ago
541
            .buttonStyle(.plain)
542
            .disabled(!canStartSession)
543
        }
Bogdan Timofte authored a month ago
544
        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored a month ago
545
    }
546

            
Bogdan Timofte authored a month ago
547
    // MARK: - Standby Power Card
Bogdan Timofte authored a month ago
548

            
Bogdan Timofte authored a month ago
549
    private var standbyPowerCard: some View {
550
        VStack(alignment: .leading, spacing: 12) {
551
            HStack(spacing: 10) {
552
                Image(systemName: "powersleep")
553
                    .foregroundColor(.orange)
554
                    .font(.title3)
555
                VStack(alignment: .leading, spacing: 2) {
556
                    Text("Charger Standby Power")
Bogdan Timofte authored a month ago
557
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
558
                    Text("Measure idle draw with no device connected.")
Bogdan Timofte authored a month ago
559
                        .font(.caption)
560
                        .foregroundColor(.secondary)
561
                }
Bogdan Timofte authored a month ago
562
            }
Bogdan Timofte authored a month ago
563

            
Bogdan Timofte authored a month ago
564
            NavigationLink(
565
                destination: ChargerStandbyPowerWizardView(
566
                    preferredMeterMACAddress: meterMACAddress
567
                )
568
            ) {
569
                HStack {
570
                    Image(systemName: "plus.circle.fill")
Bogdan Timofte authored a month ago
571
                        .foregroundColor(.orange)
Bogdan Timofte authored a month ago
572
                    Text("New Measurement")
Bogdan Timofte authored a month ago
573
                        .font(.subheadline.weight(.semibold))
Bogdan Timofte authored a month ago
574
                    Spacer()
575
                    Image(systemName: "chevron.right")
576
                        .font(.caption.weight(.semibold))
577
                        .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
578
                }
Bogdan Timofte authored a month ago
579
                .padding(.vertical, 10)
580
                .padding(.horizontal, 14)
581
                .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
Bogdan Timofte authored a month ago
582
            }
Bogdan Timofte authored a month ago
583
            .buttonStyle(.plain)
Bogdan Timofte authored a month ago
584
        }
Bogdan Timofte authored a month ago
585
        .padding(18)
586
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16)
Bogdan Timofte authored a month ago
587
    }
588

            
Bogdan Timofte authored a month ago
589
    // MARK: - Live Meter Strip (idle state)
590

            
591
    private var liveMeterStripView: some View {
592
        let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
593
        return LazyVGrid(columns: columns, spacing: 8) {
594
            metricCell(label: "Power", value: "\(usbMeter.power.format(decimalDigits: 2)) W", tint: .yellow)
595
            metricCell(label: "Current", value: "\(usbMeter.current.format(decimalDigits: 3)) A", tint: .blue)
596
            metricCell(label: "Voltage", value: "\(usbMeter.voltage.format(decimalDigits: 2)) V", tint: .teal)
597
        }
598
    }
599

            
Bogdan Timofte authored a month ago
600
    // MARK: - Charging Monitor Card
601

            
Bogdan Timofte authored a month ago
602
    private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
603
        let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
Bogdan Timofte authored a month ago
604
        let displayedChargeAh = displayedSessionChargeAh(for: openChargeSession)
Bogdan Timofte authored a month ago
605
        let canAddCheckpoint = appData.canAddBatteryCheckpoint(to: openChargeSession.id)
Bogdan Timofte authored a month ago
606
        let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
607
            for: openChargeSession,
608
            effectiveEnergyWhOverride: displayedEnergyWh
609
        )
Bogdan Timofte authored a month ago
610

            
Bogdan Timofte authored a month ago
611
        return VStack(alignment: .leading, spacing: 14) {
Bogdan Timofte authored a month ago
612
            // Header
613
            HStack {
614
                if let device = selectedChargedDevice {
615
                    ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 16)
616
                        .font(.headline)
617
                } else {
618
                    Text("Charging Monitor").font(.headline)
619
                }
620
                Spacer()
621
                Text(openChargeSession.status.title)
622
                    .font(.caption.weight(.bold))
623
                    .foregroundColor(headerStatusColor)
624
                    .padding(.horizontal, 8)
625
                    .padding(.vertical, 4)
626
                    .meterCard(tint: headerStatusColor, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
Bogdan Timofte authored a month ago
627
            }
Bogdan Timofte authored a month ago
628

            
Bogdan Timofte authored a month ago
629
            // Orphaned session warning — device was deleted from library
630
            if selectedChargedDevice == nil {
631
                VStack(alignment: .leading, spacing: 8) {
632
                    Label("Device removed from library", systemImage: "exclamationmark.triangle.fill")
633
                        .font(.subheadline.weight(.semibold))
634
                        .foregroundColor(.orange)
635
                    Text("The device associated with this session no longer exists. Stop the session to close it.")
636
                        .font(.caption)
637
                        .foregroundColor(.secondary)
638
                    Button("Stop Session") {
639
                        finalCheckpointMode = .skip
640
                        finalCheckpointText = ""
641
                        _ = appData.stopChargeSession(
642
                            sessionID: openChargeSession.id,
643
                            finalBatteryPercent: nil
644
                        )
645
                    }
646
                    .frame(maxWidth: .infinity)
647
                    .padding(.vertical, 9)
648
                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 12)
649
                    .buttonStyle(.plain)
650
                }
651
                .padding(14)
652
                .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
653
            }
654

            
655
            // Battery prediction gauge
656
            if let batteryPrediction {
657
                batteryGaugeSection(
658
                    prediction: batteryPrediction,
659
                    session: openChargeSession,
660
                    displayedEnergyWh: displayedEnergyWh
661
                )
662
            }
663

            
664
            // Metrics grid
665
            sessionMetricsGrid(
666
                for: openChargeSession,
667
                displayedEnergyWh: displayedEnergyWh,
668
                hasPrediction: batteryPrediction != nil
Bogdan Timofte authored a month ago
669
            )
670

            
Bogdan Timofte authored a month ago
671
            if openChargeSession.stopThresholdAmps > 0 {
Bogdan Timofte authored a month ago
672
                Text("Stop threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
Bogdan Timofte authored a month ago
673
                    .font(.caption)
674
                    .foregroundColor(.secondary)
675
            }
676

            
Bogdan Timofte authored a month ago
677
            if let sessionWarning = sessionWarning(for: openChargeSession) {
Bogdan Timofte authored a month ago
678
                Label(sessionWarning, systemImage: "exclamationmark.triangle")
Bogdan Timofte authored a month ago
679
                    .font(.caption)
680
                    .foregroundColor(.orange)
681
            }
682

            
683
            if openChargeSession.isPaused {
Bogdan Timofte authored a month ago
684
                Label(
685
                    "Paused \(openChargeSession.pausedAt?.format() ?? openChargeSession.lastObservedAt.format()). Auto-stops after 10 min.",
686
                    systemImage: "pause.circle"
687
                )
688
                .font(.caption)
689
                .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
690
            }
691

            
Bogdan Timofte authored a month ago
692
            if openChargeSession.requiresCompletionConfirmation && !showingStopConfirm {
Bogdan Timofte authored a month ago
693
                completionConfirmationCard(openChargeSession)
694
            }
695

            
Bogdan Timofte authored a month ago
696
            BatteryCheckpointSectionView(
697
                sessionID: openChargeSession.id,
698
                checkpoints: openChargeSession.checkpoints,
Bogdan Timofte authored a month ago
699
                message: "Checkpoints are used for capacity estimation and the typical charge curve.",
Bogdan Timofte authored a month ago
700
                canAddCheckpoint: canAddCheckpoint,
701
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: openChargeSession.id),
702
                effectiveEnergyWhOverride: displayedEnergyWh,
703
                measuredChargeAhOverride: displayedChargeAh,
704
                onDelete: { checkpoint in
705
                    pendingCheckpointDeletion = checkpoint
Bogdan Timofte authored a month ago
706
                }
Bogdan Timofte authored a month ago
707
            )
Bogdan Timofte authored a month ago
708

            
Bogdan Timofte authored a month ago
709
            targetSectionView(
710
                for: openChargeSession,
Bogdan Timofte authored a month ago
711
                predictedPercent: batteryPrediction?.predictedPercent
Bogdan Timofte authored a month ago
712
            )
713

            
714
            if showingStopConfirm {
715
                stopConfirmPanel(for: openChargeSession)
716
            } else {
717
                HStack(spacing: 10) {
718
                    if openChargeSession.status == .active {
719
                        Button("Pause") {
720
                            _ = appData.pauseChargeSession(sessionID: openChargeSession.id, from: usbMeter)
721
                        }
722
                        .frame(maxWidth: .infinity)
723
                        .padding(.vertical, 10)
724
                        .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
725
                        .buttonStyle(.plain)
726
                    } else if openChargeSession.status == .paused {
727
                        Button("Resume") {
728
                            _ = appData.resumeChargeSession(sessionID: openChargeSession.id, from: usbMeter)
729
                        }
730
                        .frame(maxWidth: .infinity)
731
                        .padding(.vertical, 10)
732
                        .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
733
                        .buttonStyle(.plain)
734
                    }
Bogdan Timofte authored a month ago
735

            
Bogdan Timofte authored a month ago
736
                    Button("Stop") {
737
                        finalCheckpointMode = .full
738
                        finalCheckpointText = ""
739
                        showingStopConfirm = true
740
                    }
741
                    .frame(maxWidth: .infinity)
742
                    .padding(.vertical, 10)
743
                    .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
744
                    .buttonStyle(.plain)
Bogdan Timofte authored a month ago
745
                }
Bogdan Timofte authored a month ago
746
            }
747
        }
748
        .padding(18)
749
        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
750
    }
751

            
Bogdan Timofte authored a month ago
752
    // MARK: - Battery Gauge Section
753

            
754
    private func batteryGaugeSection(
755
        prediction: BatteryLevelPrediction,
756
        session: ChargeSessionSummary,
757
        displayedEnergyWh: Double
758
    ) -> some View {
759
        let percent = prediction.predictedPercent
760
        let color = batteryColor(for: percent)
761
        let duration = max(session.effectiveDuration, 0)
762
        let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01
763
            ? displayedEnergyWh / duration
764
            : nil
765

            
766
        let etaToFull: String? = {
767
            guard let rate = rateWhPerSec, rate > 0.0001, percent < 98 else { return nil }
768
            let remaining = max(prediction.estimatedCapacityWh - displayedEnergyWh, 0)
769
            let seconds = remaining / rate
770
            return seconds > 120 ? formatETA(seconds) : nil
771
        }()
772

            
773
        let etaToTarget: String? = {
774
            guard let target = session.targetBatteryPercent, target > percent + 1,
775
                  let rate = rateWhPerSec, rate > 0.0001 else { return nil }
776
            let targetEnergyWh = (target / 100) * prediction.estimatedCapacityWh
777
            let remaining = max(targetEnergyWh - displayedEnergyWh, 0)
778
            let seconds = remaining / rate
779
            return seconds > 120 ? formatETA(seconds) : nil
780
        }()
781

            
782
        return VStack(spacing: 10) {
783
            HStack(alignment: .lastTextBaseline, spacing: 8) {
784
                HStack(alignment: .lastTextBaseline, spacing: 3) {
785
                    Text("\(Int(percent.rounded()))")
786
                        .font(.system(size: 52, weight: .bold, design: .rounded))
787
                        .foregroundColor(color)
788
                        .monospacedDigit()
789
                    Text("%")
790
                        .font(.title2.weight(.semibold))
791
                        .foregroundColor(color.opacity(0.8))
792
                }
793
                Spacer()
794
                VStack(alignment: .trailing, spacing: 2) {
795
                    Text("\(prediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh")
796
                        .font(.callout.weight(.bold))
797
                        .foregroundColor(.orange)
798
                        .monospacedDigit()
799
                    Text("est. capacity")
800
                        .font(.caption2)
801
                        .foregroundColor(.secondary)
802
                }
803
            }
804

            
805
            batteryProgressBar(
806
                percent: percent,
807
                startPercent: session.startBatteryPercent,
808
                targetPercent: session.targetBatteryPercent
809
            )
810

            
811
            HStack(spacing: 14) {
812
                if let etaToFull {
813
                    VStack(alignment: .leading, spacing: 1) {
814
                        HStack(spacing: 4) {
815
                            Image(systemName: "clock.fill")
816
                                .font(.caption)
817
                                .foregroundColor(.green)
818
                            Text(etaToFull)
819
                                .font(.caption.weight(.bold))
820
                        }
821
                        Text("to full")
822
                            .font(.caption2)
823
                            .foregroundColor(.secondary)
824
                    }
825
                }
826
                if let etaToTarget, let target = session.targetBatteryPercent {
827
                    VStack(alignment: .leading, spacing: 1) {
828
                        HStack(spacing: 4) {
829
                            Image(systemName: "bell.badge.fill")
830
                                .font(.caption)
831
                                .foregroundColor(.indigo)
832
                            Text(etaToTarget)
833
                                .font(.caption.weight(.bold))
834
                        }
835
                        Text("to \(Int(target.rounded()))%")
836
                            .font(.caption2)
837
                            .foregroundColor(.secondary)
838
                    }
839
                }
840
                Spacer()
841
                Text("anchored to \(prediction.anchorDescription) at \(prediction.anchorPercent.format(decimalDigits: 0))%")
842
                    .font(.caption2)
843
                    .foregroundColor(.secondary)
844
                    .multilineTextAlignment(.trailing)
845
            }
846
        }
847
        .padding(14)
848
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
849
    }
850

            
851
    private func batteryProgressBar(
852
        percent: Double,
853
        startPercent: Double?,
854
        targetPercent: Double?
855
    ) -> some View {
856
        let color = batteryColor(for: percent)
857
        return GeometryReader { geo in
858
            let width = geo.size.width
859
            ZStack(alignment: .leading) {
860
                Capsule()
861
                    .fill(Color.primary.opacity(0.10))
862
                Rectangle()
863
                    .fill(
864
                        LinearGradient(
865
                            colors: [color.opacity(0.6), color],
866
                            startPoint: .leading,
867
                            endPoint: .trailing
868
                        )
869
                    )
870
                    .frame(width: max(width * CGFloat(percent / 100), 4))
871
                    .animation(.easeInOut(duration: 0.4), value: percent)
872
                if let start = startPercent, start > 2, start < 98 {
873
                    Rectangle()
874
                        .fill(Color.white.opacity(0.55))
875
                        .frame(width: 2, height: 20)
876
                        .offset(x: width * CGFloat(start / 100) - 1)
877
                }
878
                if let target = targetPercent {
879
                    Rectangle()
880
                        .fill(Color.indigo.opacity(0.9))
881
                        .frame(width: 2.5, height: 20)
882
                        .offset(x: width * CGFloat(target / 100) - 1.25)
883
                }
884
            }
885
            .clipShape(Capsule())
886
        }
887
        .frame(height: 20)
888
    }
889

            
890
    private func batteryColor(for percent: Double) -> Color {
891
        if percent >= 75 { return .green }
892
        if percent >= 35 { return .orange }
893
        return .red
894
    }
895

            
896
    private func formatETA(_ seconds: TimeInterval) -> String {
897
        let totalMinutes = Int(seconds / 60)
898
        if totalMinutes < 60 { return "\(totalMinutes)m" }
899
        let hours = totalMinutes / 60
900
        let minutes = totalMinutes % 60
901
        return minutes == 0 ? "\(hours)h" : "\(hours)h \(minutes)m"
902
    }
903

            
904
    // MARK: - Session Metrics Grid
905

            
906
    private func sessionMetricsGrid(
907
        for session: ChargeSessionSummary,
908
        displayedEnergyWh: Double,
909
        hasPrediction: Bool
910
    ) -> some View {
911
        let capacityFallback: Double? = hasPrediction ? nil : (
912
            session.capacityEstimateWh
913
                ?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
914
                ?? selectedChargedDevice?.estimatedBatteryCapacityWh
915
        )
916
        let columns = [GridItem(.flexible()), GridItem(.flexible())]
917

            
918
        return LazyVGrid(columns: columns, spacing: 8) {
919
            metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
920
            metricCell(label: "Duration", value: formatDuration(max(session.effectiveDuration, 0)), tint: .teal)
921

            
922
            if shouldShowChargingTransport(for: session) {
923
                metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange)
924
            }
925
            if shouldShowChargingState(for: session) {
926
                metricCell(label: "Mode", value: session.chargingStateMode.title, tint: .purple)
927
            }
928

            
929
            metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary)
930

            
931
            if let capacity = capacityFallback {
932
                metricCell(label: "Est. Capacity", value: "\(capacity.format(decimalDigits: 2)) Wh", tint: .orange)
933
            }
934
        }
935
    }
936

            
937
    private func metricCell(label: String, value: String, tint: Color) -> some View {
938
        VStack(alignment: .leading, spacing: 3) {
939
            Text(label)
940
                .font(.caption2)
941
                .foregroundColor(.secondary)
942
            Text(value)
943
                .font(.subheadline.weight(.semibold))
944
                .lineLimit(1)
945
                .minimumScaleFactor(0.7)
946
                .monospacedDigit()
947
        }
948
        .frame(maxWidth: .infinity, alignment: .leading)
949
        .padding(.horizontal, 12)
950
        .padding(.vertical, 10)
951
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
952
    }
953

            
Bogdan Timofte authored a month ago
954
    private func completionConfirmationCard(_ openChargeSession: ChargeSessionSummary) -> some View {
955
        VStack(alignment: .leading, spacing: 10) {
956
            Label("Charging may have stopped", systemImage: "questionmark.circle.fill")
957
                .font(.subheadline.weight(.semibold))
958

            
959
            if let contradictionPercent = openChargeSession.completionContradictionPercent {
960
                Text("Current dropped but estimated level is only \(contradictionPercent.format(decimalDigits: 0))%.")
961
                    .font(.caption)
962
                    .foregroundColor(.secondary)
963
            } else {
964
                Text("Current dropped to the stop threshold but the prediction doesn't confirm a full charge yet.")
965
                    .font(.caption)
966
                    .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
967
            }
968

            
Bogdan Timofte authored a month ago
969
            HStack(spacing: 10) {
970
                Button("Finish") {
971
                    finalCheckpointMode = .full
972
                    finalCheckpointText = ""
973
                    showingStopConfirm = true
Bogdan Timofte authored a month ago
974
                }
975
                .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
976
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
977
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
978
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
979

            
980
                Button("Keep Monitoring") {
981
                    _ = appData.continueChargeSessionMonitoring(sessionID: openChargeSession.id)
Bogdan Timofte authored a month ago
982
                }
983
                .frame(maxWidth: .infinity)
Bogdan Timofte authored a month ago
984
                .padding(.vertical, 9)
Bogdan Timofte authored a month ago
985
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
986
                .buttonStyle(.plain)
Bogdan Timofte authored a month ago
987
            }
988
        }
Bogdan Timofte authored a month ago
989
        .padding(14)
990
        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
Bogdan Timofte authored a month ago
991
    }
992

            
Bogdan Timofte authored a month ago
993
    // MARK: - Target Section
994

            
995
    private func targetSectionView(for session: ChargeSessionSummary, predictedPercent: Double?) -> some View {
996
        let draftBelowPrediction: Bool = {
997
            guard let draft = parsedDraftTarget, let predicted = predictedPercent else { return false }
998
            return draft <= predicted
999
        }()
1000
        let savedBelowPrediction: Bool = {
1001
            guard let saved = session.targetBatteryPercent, let predicted = predictedPercent else { return false }
1002
            return saved <= predicted
1003
        }()
1004

            
1005
        return HStack(alignment: .center, spacing: 8) {
1006
            Image(systemName: "bell.badge")
1007
                .foregroundColor(.indigo)
1008
                .font(.subheadline)
1009

            
1010
            Text("Notify at")
Bogdan Timofte authored a month ago
1011
                .font(.subheadline.weight(.semibold))
1012

            
Bogdan Timofte authored a month ago
1013
            Spacer(minLength: 8)
1014

            
1015
            if showingInlineTargetEditor {
1016
                Button {
1017
                    let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
1018
                    let next = max(current - 1, 1)
1019
                    draftTargetText = next.format(decimalDigits: 0)
1020
                } label: {
1021
                    Image(systemName: "minus.circle")
1022
                        .font(.title3)
1023
                }
1024
                .buttonStyle(.plain)
1025

            
1026
                TextField("—", text: $draftTargetText)
1027
                    .keyboardType(.decimalPad)
1028
                    .textFieldStyle(.roundedBorder)
1029
                    .frame(width: 48)
1030
                    .multilineTextAlignment(.center)
1031
                    .foregroundColor(draftBelowPrediction ? .orange : .primary)
1032

            
1033
                Text("%")
1034
                    .font(.subheadline)
Bogdan Timofte authored a month ago
1035
                    .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
1036

            
1037
                if draftBelowPrediction {
1038
                    Button {} label: {
1039
                        Image(systemName: "exclamationmark.triangle.fill")
1040
                            .font(.body.weight(.semibold))
1041
                            .foregroundColor(.orange)
1042
                    }
1043
                    .buttonStyle(.plain)
1044
                    .help("Battery is already predicted at \(predictedPercent!.format(decimalDigits: 0))% — this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
1045
                }
1046

            
1047
                Button {
1048
                    let current = parsedDraftTarget ?? session.targetBatteryPercent ?? 80
1049
                    let next = min(current + 1, 100)
1050
                    draftTargetText = next.format(decimalDigits: 0)
1051
                } label: {
1052
                    Image(systemName: "plus.circle")
1053
                        .font(.title3)
1054
                }
1055
                .buttonStyle(.plain)
1056

            
1057
                Button {
1058
                    if let value = parsedDraftTarget {
1059
                        _ = appData.setTargetBatteryPercent(value, for: session.id)
1060
                    }
1061
                    showingInlineTargetEditor = false
1062
                } label: {
1063
                    Image(systemName: "checkmark.circle.fill")
1064
                        .foregroundColor(parsedDraftTarget != nil ? .indigo : .secondary)
1065
                        .font(.title3)
1066
                }
1067
                .buttonStyle(.plain)
1068
                .disabled(parsedDraftTarget == nil)
1069

            
1070
                Button {
1071
                    showingInlineTargetEditor = false
1072
                    draftTargetText = ""
1073
                } label: {
1074
                    Image(systemName: "xmark.circle")
1075
                        .foregroundColor(.secondary)
1076
                        .font(.title3)
1077
                }
1078
                .buttonStyle(.plain)
1079

            
Bogdan Timofte authored a month ago
1080
            } else {
Bogdan Timofte authored a month ago
1081
                if let targetPercent = session.targetBatteryPercent {
1082
                    Text("\(targetPercent.format(decimalDigits: 0))%")
1083
                        .font(.subheadline.weight(.semibold))
1084
                        .foregroundColor(savedBelowPrediction ? .orange : .indigo)
1085

            
1086
                    if savedBelowPrediction {
1087
                        Button {} label: {
1088
                            Image(systemName: "exclamationmark.triangle.fill")
1089
                                .font(.callout.weight(.semibold))
1090
                                .foregroundColor(.orange)
1091
                        }
1092
                        .buttonStyle(.plain)
1093
                        .help("Battery is already predicted at \(predictedPercent!.format(decimalDigits: 0))% — this alert won't fire. Raise the value or add a checkpoint to correct the prediction.")
1094
                    }
1095

            
1096
                    Button {
1097
                        _ = appData.setTargetBatteryPercent(nil, for: session.id)
1098
                    } label: {
1099
                        Image(systemName: "xmark.circle.fill")
1100
                            .foregroundColor(.secondary)
1101
                            .font(.callout)
1102
                    }
1103
                    .buttonStyle(.plain)
1104
                    .help("Remove alert")
1105
                }
1106

            
1107
                Button {
1108
                    draftTargetText = session.targetBatteryPercent.map {
1109
                        $0.format(decimalDigits: 0)
1110
                    } ?? "80"
1111
                    showingInlineTargetEditor = true
1112
                } label: {
1113
                    Image(systemName: session.targetBatteryPercent == nil ? "plus" : "pencil")
1114
                        .font(.caption.weight(.semibold))
1115
                        .frame(width: 30, height: 30)
1116
                        .contentShape(Rectangle())
1117
                }
1118
                .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 10)
1119
                .buttonStyle(.plain)
1120
                .help(session.targetBatteryPercent == nil ? "Set battery alert" : "Edit battery alert")
Bogdan Timofte authored a month ago
1121
            }
Bogdan Timofte authored a month ago
1122
        }
1123
    }
Bogdan Timofte authored a month ago
1124

            
Bogdan Timofte authored a month ago
1125
    private var parsedDraftTarget: Double? {
1126
        let normalized = draftTargetText
1127
            .trimmingCharacters(in: .whitespacesAndNewlines)
1128
            .replacingOccurrences(of: ",", with: ".")
1129
        guard let value = Double(normalized), value >= 1, value <= 100 else { return nil }
1130
        return value
1131
    }
1132

            
1133
    private func stopConfirmPanel(for session: ChargeSessionSummary) -> some View {
1134
        VStack(alignment: .leading, spacing: 12) {
1135
            Text("Final Checkpoint (optional)")
1136
                .font(.subheadline.weight(.semibold))
1137

            
1138
            // Three compact option tiles
1139
            HStack(spacing: 8) {
1140
                ForEach([FinalCheckpoint.full, .skip, .custom], id: \.self) { mode in
1141
                    Button {
1142
                        finalCheckpointMode = mode
1143
                        if mode != .custom {
1144
                            finalCheckpointText = ""
1145
                        }
1146
                    } label: {
1147
                        VStack(spacing: 5) {
1148
                            Image(systemName: mode.icon)
1149
                                .font(.title3)
1150
                                .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1151
                            Text(mode.label)
1152
                                .font(.caption.weight(.semibold))
1153
                                .foregroundColor(finalCheckpointMode == mode ? .primary : .secondary)
1154
                        }
1155
                        .frame(maxWidth: .infinity)
1156
                        .padding(.vertical, 10)
1157
                        .background(
1158
                            finalCheckpointMode == mode
1159
                                ? Color.primary.opacity(0.10)
1160
                                : Color.clear
1161
                        )
1162
                        .meterCard(
1163
                            tint: finalCheckpointMode == mode ? .primary : .secondary,
1164
                            fillOpacity: finalCheckpointMode == mode ? 0.08 : 0.04,
1165
                            strokeOpacity: finalCheckpointMode == mode ? 0.20 : 0.10,
1166
                            cornerRadius: 12
1167
                        )
1168
                    }
1169
                    .buttonStyle(.plain)
1170
                }
Bogdan Timofte authored a month ago
1171
            }
1172

            
Bogdan Timofte authored a month ago
1173
            // Custom % input
1174
            if finalCheckpointMode == .custom {
1175
                HStack(spacing: 8) {
1176
                    Button { adjustFinalCheckpoint(by: -1) } label: {
1177
                        Image(systemName: "minus.circle").font(.title3)
1178
                    }
1179
                    .buttonStyle(.plain)
1180

            
1181
                    TextField("—", text: $finalCheckpointText)
1182
                        .keyboardType(.decimalPad)
1183
                        .textFieldStyle(.roundedBorder)
1184
                        .frame(width: 56)
1185
                        .multilineTextAlignment(.center)
1186

            
1187
                    Text("%")
1188
                        .foregroundColor(.secondary)
1189

            
1190
                    Button { adjustFinalCheckpoint(by: 1) } label: {
1191
                        Image(systemName: "plus.circle").font(.title3)
1192
                    }
1193
                    .buttonStyle(.plain)
1194

            
1195
                    Spacer()
1196
                }
1197
            }
1198

            
1199
            // Action row
1200
            HStack(spacing: 10) {
1201
                Button("Cancel") {
1202
                    showingStopConfirm = false
1203
                    finalCheckpointText = ""
1204
                }
1205
                .frame(maxWidth: .infinity)
1206
                .padding(.vertical, 9)
1207
                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
1208
                .buttonStyle(.plain)
1209

            
1210
                let stopDisabled = finalCheckpointMode == .custom
1211
                    && finalCheckpointText.isEmpty == false
1212
                    && parsedFinalCheckpoint == nil
1213

            
1214
                Button("Stop Session") {
1215
                    _ = appData.stopChargeSession(
1216
                        sessionID: session.id,
1217
                        finalBatteryPercent: resolvedFinalCheckpoint
1218
                    )
1219
                    showingStopConfirm = false
1220
                    finalCheckpointText = ""
1221
                    finalCheckpointMode = .full
1222
                }
1223
                .frame(maxWidth: .infinity)
1224
                .padding(.vertical, 9)
1225
                .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
1226
                .buttonStyle(.plain)
1227
                .disabled(stopDisabled)
Bogdan Timofte authored a month ago
1228
            }
1229
        }
1230
        .padding(14)
Bogdan Timofte authored a month ago
1231
        .meterCard(tint: .red, fillOpacity: 0.06, strokeOpacity: 0.14, cornerRadius: 16)
Bogdan Timofte authored a month ago
1232
    }
1233

            
Bogdan Timofte authored a month ago
1234
    private var parsedFinalCheckpoint: Double? {
1235
        let normalized = finalCheckpointText
1236
            .trimmingCharacters(in: .whitespacesAndNewlines)
1237
            .replacingOccurrences(of: ",", with: ".")
1238
        guard let value = Double(normalized), value >= 0, value <= 100 else { return nil }
1239
        return value
1240
    }
1241

            
1242
    private var resolvedFinalCheckpoint: Double? {
1243
        switch finalCheckpointMode {
1244
        case .full:   return 100.0
1245
        case .skip:   return nil
1246
        case .custom: return parsedFinalCheckpoint
1247
        }
1248
    }
1249

            
1250
    private func adjustFinalCheckpoint(by delta: Double) {
1251
        let current = parsedFinalCheckpoint ?? 0
1252
        let next = min(max(current + delta, 0), 100)
1253
        finalCheckpointText = next.format(decimalDigits: 0)
1254
    }
1255

            
1256
    private func sessionChartCard(timeRange: ClosedRange<Date>, session: ChargeSessionSummary) -> some View {
Bogdan Timofte authored a month ago
1257
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1258
            HStack(spacing: 8) {
Bogdan Timofte authored a month ago
1259
                Image(systemName: "chart.xyaxis.line")
1260
                    .foregroundColor(.blue)
Bogdan Timofte authored a month ago
1261
                Text("Session Chart")
1262
                    .font(.headline)
1263
                ContextInfoButton(
1264
                    title: "Session Chart",
1265
                    message: "The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging."
1266
                )
1267
            }
Bogdan Timofte authored a month ago
1268

            
Bogdan Timofte authored a month ago
1269
            GeometryReader { geometry in
1270
                let chartWidth = max(geometry.size.width, 1)
1271
                let compactChartLayout = chartWidth < 760
1272
                let chartHeight = compactChartLayout ? 290.0 : 350.0
1273

            
1274
                MeasurementChartView(
1275
                    compactLayout: compactChartLayout,
1276
                    availableSize: CGSize(width: chartWidth, height: chartHeight),
1277
                    timeRange: timeRange,
1278
                    showsRangeSelector: false,
1279
                    rebasesEnergyToVisibleRangeStart: true
1280
                )
Bogdan Timofte authored a month ago
1281
                .environmentObject(usbMeter.measurements)
Bogdan Timofte authored a month ago
1282
                .frame(maxWidth: .infinity, alignment: .topLeading)
1283
            }
1284
            .frame(height: 350)
Bogdan Timofte authored a month ago
1285
        }
1286
        .padding(18)
1287
        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
1288
    }
1289

            
1290
    private var meterTotalsCard: some View {
Bogdan Timofte authored a month ago
1291
        return VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a month ago
1292
            HStack(spacing: 8) {
1293
                Text("Meter Recorder")
1294
                    .font(.headline)
1295

            
1296
                Spacer(minLength: 0)
1297

            
1298
                Button {
1299
                    showsMeterTotalsInfo.toggle()
1300
                } label: {
1301
                    Image(systemName: "info.circle")
1302
                        .font(.body.weight(.semibold))
1303
                        .foregroundColor(.secondary)
1304
                }
1305
                .buttonStyle(.plain)
1306
                .accessibilityLabel("Meter recorder info")
1307
                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
1308
                    VStack(alignment: .leading, spacing: 10) {
1309
                        Text("Meter Recorder")
1310
                            .font(.headline)
1311
                        Text("These values come directly from the meter's built-in recorder. Keep them visible while comparing the app session against what the meter captured on its own.")
1312
                            .font(.body)
1313
                            .fixedSize(horizontal: false, vertical: true)
1314
                    }
1315
                    .padding(16)
1316
                    .frame(width: 280, alignment: .leading)
1317
                }
1318
            }
Bogdan Timofte authored a month ago
1319

            
1320
            ChargeRecordMetricsTableView(
Bogdan Timofte authored a month ago
1321
                labels: ["Energy", "Duration", "Meter Threshold"],
Bogdan Timofte authored a month ago
1322
                values: [
1323
                    "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
1324
                    usbMeter.recordingDurationDescription,
1325
                    usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
1326
                ]
1327
            )
Bogdan Timofte authored a month ago
1328

            
1329
            if let recordingBootedAt = usbMeter.recordingBootedAt {
1330
                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
1331
                    .font(.caption)
1332
                    .foregroundColor(.secondary)
1333
            }
Bogdan Timofte authored a month ago
1334
        }
1335
        .padding(18)
1336
        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
1337
    }
1338

            
Bogdan Timofte authored a month ago
1339
    // MARK: - Helpers
1340

            
1341
    private func setupRow<Content: View>(
1342
        icon: String,
1343
        iconColor: Color = .secondary,
1344
        @ViewBuilder content: () -> Content
1345
    ) -> some View {
1346
        HStack(spacing: 10) {
1347
            Image(systemName: icon)
1348
                .foregroundColor(iconColor)
1349
                .font(.body.weight(.medium))
1350
                .frame(width: 22, alignment: .center)
1351
            content()
1352
        }
1353
        .padding(.horizontal, 14)
1354
        .padding(.vertical, 11)
Bogdan Timofte authored a month ago
1355
    }
1356

            
1357
    private func autoStopLabel(for session: ChargeSessionSummary) -> String {
1358
        if session.autoStopEnabled == false {
1359
            return "Manual"
1360
        }
1361
        if let sessionWarning = sessionWarning(for: session), session.chargingTransportMode == .wireless {
1362
            return sessionWarning.contains("idle-current") ? "Blocked" : "Manual"
1363
        }
1364
        if session.stopThresholdAmps > 0 {
1365
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
1366
        }
1367
        return "Learning"
1368
    }
1369

            
Bogdan Timofte authored a month ago
1370
    private func sessionMetricRows(
1371
        for session: ChargeSessionSummary,
1372
        displayedEnergyWh: Double
1373
    ) -> [SessionMetricRow] {
1374
        var rows: [SessionMetricRow] = []
1375

            
1376
        if shouldShowChargingTransport(for: session) {
1377
            rows.append(SessionMetricRow(label: "Type", value: session.chargingTransportMode.title))
1378
        }
1379

            
1380
        if shouldShowChargingState(for: session) {
1381
            rows.append(SessionMetricRow(label: "Mode", value: session.chargingStateMode.title))
1382
        }
1383

            
1384
        rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh"))
1385
        rows.append(SessionMetricRow(label: "Duration", value: formatDuration(max(session.effectiveDuration, 0))))
1386
        rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session)))
1387
        return rows
1388
    }
1389

            
1390
    private func shouldShowChargingTransport(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1391
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1392
        return selectedChargedDevice.supportedChargingModes.count > 1
1393
            || selectedChargedDevice.supportedChargingModes.contains(session.chargingTransportMode) == false
1394
    }
1395

            
1396
    private func shouldShowChargingState(for session: ChargeSessionSummary) -> Bool {
Bogdan Timofte authored a month ago
1397
        guard let selectedChargedDevice else { return true }
Bogdan Timofte authored a month ago
1398
        return selectedChargedDevice.supportedChargingStateModes.count > 1
1399
            || selectedChargedDevice.supportedChargingStateModes.contains(session.chargingStateMode) == false
1400
    }
1401

            
Bogdan Timofte authored a month ago
1402
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
1403
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
Bogdan Timofte authored a month ago
1404
        guard session.status.isOpen else { return storedEnergyWh }
1405
        guard session.meterMACAddress == meterMACAddress else { return storedEnergyWh }
Bogdan Timofte authored a month ago
1406
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1407
            return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
1408
        }
1409
        return storedEnergyWh
1410
    }
1411

            
Bogdan Timofte authored a month ago
1412
    private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
1413
        let storedChargeAh = session.measuredChargeAh
Bogdan Timofte authored a month ago
1414
        guard session.status.isOpen else { return storedChargeAh }
1415
        guard session.meterMACAddress == meterMACAddress else { return storedChargeAh }
Bogdan Timofte authored a month ago
1416
        if let baselineChargeAh = session.meterChargeBaselineAh {
1417
            return max(storedChargeAh, max(usbMeter.recordedAH - baselineChargeAh, 0))
1418
        }
1419
        return storedChargeAh
1420
    }
1421

            
Bogdan Timofte authored a month ago
1422
    private func formatDuration(_ duration: TimeInterval) -> String {
1423
        let totalSeconds = Int(duration.rounded(.down))
1424
        let hours = totalSeconds / 3600
1425
        let minutes = (totalSeconds % 3600) / 60
1426
        let seconds = totalSeconds % 60
1427
        if hours > 0 {
1428
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
1429
        }
1430
        return String(format: "%02d:%02d", minutes, seconds)
1431
    }
1432

            
Bogdan Timofte authored a month ago
1433
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
1434
        guard session.chargingTransportMode == .wireless,
1435
              let chargerID = session.chargerID,
1436
              let charger = appData.chargedDeviceSummary(id: chargerID) else {
1437
            return nil
1438
        }
Bogdan Timofte authored a month ago
1439
        guard charger.chargerIdleCurrentAmps == nil else { return nil }
Bogdan Timofte authored a month ago
1440
        return "The selected charger has no idle-current measurement. Wireless stop-threshold learning and precise auto-stop are unavailable for this session."
1441
    }
1442

            
1443
    private func startSession() {
1444
        guard let selectedChargedDevice,
1445
              let chargingTransportMode = selectedDraftTransportMode,
Bogdan Timofte authored a month ago
1446
              let chargingStateMode = selectedDraftChargingStateMode else {
Bogdan Timofte authored a month ago
1447
            return
1448
        }
1449

            
1450
        let chargerID = chargingTransportMode == .wireless ? selectedCharger?.id : nil
1451
        let didStart = appData.startChargeSession(
1452
            for: usbMeter,
1453
            chargedDeviceID: selectedChargedDevice.id,
1454
            chargerID: chargerID,
1455
            chargingTransportMode: chargingTransportMode,
1456
            chargingStateMode: chargingStateMode,
Bogdan Timofte authored a month ago
1457
            autoStopEnabled: false,
1458
            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1459
            startsFromFlatBattery: initialCheckpointMode == .flat
Bogdan Timofte authored a month ago
1460
        )
Bogdan Timofte authored a month ago
1461

            
1462
        if didStart {
1463
            initialCheckpoint = ""
Bogdan Timofte authored a month ago
1464
            initialCheckpointMode = .known
1465
        }
1466
    }
1467

            
1468
    private func adjustInitialCheckpoint(by delta: Double) {
Bogdan Timofte authored a month ago
1469
        guard initialCheckpointMode == .known else { return }
Bogdan Timofte authored a month ago
1470
        let currentValue = initialCheckpointValue ?? 0
1471
        let nextValue = min(max(currentValue + delta, 0), 100)
1472
        initialCheckpoint = nextValue.format(decimalDigits: 0)
Bogdan Timofte authored a month ago
1473
    }
1474

            
Bogdan Timofte authored a month ago
1475
    private func syncDraftSelections() {
1476
        guard let selectedChargedDevice else {
1477
            draftChargingTransportMode = nil
1478
            draftChargingStateMode = nil
1479
            return
1480
        }
1481

            
1482
        if let openChargeSession {
1483
            draftChargingTransportMode = openChargeSession.chargingTransportMode
1484
            draftChargingStateMode = openChargeSession.chargingStateMode
1485
            return
1486
        }
1487

            
1488
        if let draftChargingTransportMode,
1489
           selectedChargedDevice.supportedChargingModes.contains(draftChargingTransportMode) == false {
1490
            self.draftChargingTransportMode = nil
1491
        }
1492

            
1493
        if let draftChargingStateMode,
1494
           selectedChargedDevice.supportedChargingStateModes.contains(draftChargingStateMode) == false {
1495
            self.draftChargingStateMode = nil
1496
        }
1497

            
1498
        if selectedChargedDevice.supportedChargingModes.count == 1 {
1499
            draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
1500
        }
1501

            
Bogdan Timofte authored a month ago
1502
        if let draftChargingTransportMode {
1503
            draftChargingStateMode = draftChargingStateMode
1504
                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1505
        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
Bogdan Timofte authored a month ago
1506
            draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
1507
        }
Bogdan Timofte authored a month ago
1508
    }
Bogdan Timofte authored a month ago
1509

            
1510
    private struct CompactSelectionOption: Identifiable {
1511
        let id: String
1512
        let title: String
1513
        let isSelected: Bool
1514
        let action: () -> Void
1515
    }
1516

            
1517
    private func compactSelectionMenu(
1518
        title: String,
1519
        options: [CompactSelectionOption]
1520
    ) -> some View {
1521
        Menu {
1522
            ForEach(options) { option in
1523
                Button {
1524
                    option.action()
1525
                } label: {
1526
                    if option.isSelected {
1527
                        Label(option.title, systemImage: "checkmark")
1528
                    } else {
1529
                        Text(option.title)
1530
                    }
1531
                }
1532
            }
1533
        } label: {
1534
            HStack(spacing: 8) {
1535
                Text(title)
1536
                    .foregroundColor(.primary)
1537
                Spacer()
1538
                Image(systemName: "chevron.up.chevron.down")
1539
                    .font(.caption.weight(.semibold))
1540
                    .foregroundColor(.secondary)
1541
            }
1542
            .padding(.horizontal, 12)
1543
            .padding(.vertical, 9)
Bogdan Timofte authored a month ago
1544
            .frame(width: 160, alignment: .leading)
Bogdan Timofte authored a month ago
1545
            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
1546
        }
1547
        .buttonStyle(.plain)
1548
    }
Bogdan Timofte authored a month ago
1549
}
1550