USB-Meter / USB Meter / Views / ChargedDevices / ChargedDeviceActiveSessionView.swift
Newer Older
537 lines | 22.215kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargedDeviceActiveSessionView.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 22/04/2026.
6
//
7

            
8
import SwiftUI
9

            
10
struct ChargedDeviceActiveSessionView: View {
11
    @EnvironmentObject private var appData: AppData
12
    @State private var targetNotificationEditorVisibility = false
13
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
14
    @State private var pendingSessionStopRequest: ActiveDeviceSessionStopRequest?
15

            
16
    let chargedDeviceID: UUID
17

            
18
    private var chargedDevice: ChargedDeviceSummary? {
19
        appData.chargedDeviceSummary(id: chargedDeviceID)
20
    }
21

            
22
    private var activeSession: ChargeSessionSummary? {
23
        chargedDevice?.activeSession
24
    }
25

            
26
    var body: some View {
27
        Group {
28
            if let chargedDevice, let activeSession {
29
                ScrollView {
30
                    VStack(spacing: 16) {
31
                        activeSessionCard(activeSession, chargedDevice: chargedDevice)
32

            
33
                        if !activeSession.displayedAggregatedSamples.isEmpty {
34
                            storedCurveCard(activeSession)
35
                        }
36
                    }
37
                    .padding()
38
                }
39
                .background(
40
                    LinearGradient(
41
                        colors: [statusTint(for: activeSession).opacity(0.14), Color.clear],
42
                        startPoint: .topLeading,
43
                        endPoint: .bottomTrailing
44
                    )
45
                    .ignoresSafeArea()
46
                )
47
                .navigationTitle("Current Session")
48
            } else {
49
                Text("There is no open session for this device.")
50
                    .foregroundColor(.secondary)
51
                    .navigationTitle("Current Session")
52
            }
53
        }
54
        .sheet(isPresented: $targetNotificationEditorVisibility) {
55
            if let activeSession {
56
                ActiveSessionTargetNotificationEditorSheetView(
57
                    sessionID: activeSession.id,
58
                    initialTargetPercent: activeSession.targetBatteryPercent
59
                )
60
                .environmentObject(appData)
61
            }
62
        }
63
        .sheet(item: $pendingSessionStopRequest) { request in
64
            ChargeSessionCompletionSheetView(
65
                sessionID: request.sessionID,
66
                title: request.title,
67
                confirmTitle: request.confirmTitle,
68
                explanation: request.explanation
69
            )
70
            .environmentObject(appData)
71
        }
72
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
73
            Alert(
74
                title: Text("Delete Battery Checkpoint"),
75
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
76
                primaryButton: .destructive(Text("Delete")) {
77
                    _ = appData.deleteBatteryCheckpoint(
78
                        checkpointID: checkpoint.id,
79
                        for: checkpoint.sessionID
80
                    )
81
                },
82
                secondaryButton: .cancel()
83
            )
84
        }
85
    }
86

            
87
    private func activeSessionCard(
88
        _ activeSession: ChargeSessionSummary,
89
        chargedDevice: ChargedDeviceSummary
90
    ) -> some View {
91
        MeterInfoCardView(title: "Open Session", tint: statusTint(for: activeSession)) {
92
            MeterInfoRowView(label: "Started", value: activeSession.startedAt.format())
93
            MeterInfoRowView(label: "Status", value: activeSession.status.title)
94
            MeterInfoRowView(label: "Duration", value: sessionDurationText(activeSession))
95
            MeterInfoRowView(label: "Charging Type", value: activeSession.chargingTransportMode.title)
96
            MeterInfoRowView(label: "Charging Mode", value: activeSession.chargingStateMode.title)
97
            MeterInfoRowView(label: "Battery Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
98
            if activeSession.chargingTransportMode == .wireless,
99
               let effectiveBatteryEnergyWh = activeSession.effectiveBatteryEnergyWh,
100
               abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 {
101
                MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh")
102
            }
103
            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: activeSession))
104
            MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title)
105
            if chargedDevice.isCharger == false,
106
               let chargerID = activeSession.chargerID,
107
               let charger = appData.chargedDeviceSummary(id: chargerID) {
108
                MeterInfoRowView(label: "Wireless Charger", value: charger.name)
109
            }
110
            if let maximumObservedCurrentAmps = activeSession.maximumObservedCurrentAmps {
111
                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
112
            }
113
            if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
114
                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
115
            }
116
            if activeSession.chargingTransportMode == .wired,
117
               let maximumObservedVoltageVolts = activeSession.maximumObservedVoltageVolts {
118
                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
119
            }
120
            if chargedDevice.isCharger, let selectedSourceVoltageVolts = activeSession.selectedSourceVoltageVolts {
121
                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
122
            }
123
            if let targetBatteryPercent = activeSession.targetBatteryPercent {
124
                MeterInfoRowView(
125
                    label: "Target Notification",
126
                    value: "\(targetBatteryPercent.format(decimalDigits: 0))%"
127
                )
128
            }
129
            if let sessionWarning = sessionWarning(for: activeSession) {
130
                Text(sessionWarning)
131
                    .font(.caption2)
132
                    .foregroundColor(.orange)
133
            }
134
            if let wirelessSessionHint = wirelessSessionHint(for: activeSession) {
135
                Text(wirelessSessionHint)
136
                    .font(.caption2)
137
                    .foregroundColor(activeSession.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
138
            }
139
            if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
140
                MeterInfoRowView(
141
                    label: "Predicted Battery",
142
                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
143
                )
144
                Text(
145
                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
146
                )
147
                .font(.caption2)
148
                .foregroundColor(.secondary)
149
            }
150

            
151
            BatteryCheckpointSectionView(
152
                sessionID: activeSession.id,
153
                checkpoints: activeSession.checkpoints,
154
                message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.",
155
                canAddCheckpoint: appData.canAddBatteryCheckpoint(to: activeSession.id),
156
                requirementMessage: appData.batteryCheckpointCaptureRequirementMessage(for: activeSession.id),
157
                effectiveEnergyWhOverride: nil,
158
                measuredChargeAhOverride: nil,
159
                onDelete: { checkpoint in
160
                    pendingCheckpointDeletion = checkpoint
161
                }
162
            )
163

            
164
            Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
165
                targetNotificationEditorVisibility = true
166
            }
167
            .frame(maxWidth: .infinity)
168
            .padding(.vertical, 10)
169
            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
170
            .buttonStyle(.plain)
171

            
172
            if activeSession.targetBatteryPercent != nil {
173
                Button("Clear Target Notification") {
174
                    _ = appData.setTargetBatteryPercent(nil, for: activeSession.id)
175
                }
176
                .frame(maxWidth: .infinity)
177
                .padding(.vertical, 10)
178
                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
179
                .buttonStyle(.plain)
180
            }
181

            
182
            if activeSession.status == .active {
183
                Button("Pause Session") {
184
                    _ = appData.pauseChargeSession(sessionID: activeSession.id)
185
                }
186
                .frame(maxWidth: .infinity)
187
                .padding(.vertical, 10)
188
                .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
189
                .buttonStyle(.plain)
190
            } else if activeSession.status == .paused {
191
                Button("Resume Session") {
192
                    _ = appData.resumeChargeSession(sessionID: activeSession.id)
193
                }
194
                .frame(maxWidth: .infinity)
195
                .padding(.vertical, 10)
196
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
197
                .buttonStyle(.plain)
198

            
199
                Text("Paused sessions close automatically after 10 minutes.")
200
                    .font(.caption2)
201
                    .foregroundColor(.secondary)
202
            }
203

            
204
            Button(activeSession.requiresCompletionConfirmation ? "Finish Session With Checkpoint" : "Stop Session") {
205
                pendingSessionStopRequest = ActiveDeviceSessionStopRequest(
206
                    sessionID: activeSession.id,
207
                    title: activeSession.requiresCompletionConfirmation ? "Finish Session" : "Stop Session",
208
                    confirmTitle: activeSession.requiresCompletionConfirmation ? "Finish" : "Stop",
209
                    explanation: "Add the final battery checkpoint before closing this session."
210
                )
211
            }
212
            .frame(maxWidth: .infinity)
213
            .padding(.vertical, 10)
214
            .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
215
            .buttonStyle(.plain)
216

            
217
            if activeSession.requiresCompletionConfirmation {
218
                Divider()
219
                if let contradictionPercent = activeSession.completionContradictionPercent {
220
                    Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
221
                        .font(.caption2)
222
                        .foregroundColor(.secondary)
223
                }
224

            
225
                Button("Keep Monitoring") {
226
                    _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id)
227
                }
228
                .frame(maxWidth: .infinity)
229
                .padding(.vertical, 10)
230
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
231
                .buttonStyle(.plain)
232
            }
233
        }
234
    }
235

            
236
    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
237
        let displayedSamples = session.displayedAggregatedSamples
238
        let currentSeries = storedSeriesSnapshot(
239
            from: displayedSamples,
240
            minimumYSpan: 0.15
241
        ) { $0.averageCurrentAmps }
242
        let energySeries = storedSeriesSnapshot(
243
            from: displayedSamples,
244
            minimumYSpan: 0.2
245
        ) { $0.measuredEnergyWh }
246

            
247
        return VStack(alignment: .leading, spacing: 14) {
248
            HStack(alignment: .firstTextBaseline) {
249
                VStack(alignment: .leading, spacing: 4) {
250
                    HStack(spacing: 8) {
251
                        Text("Stored Session Curve")
252
                            .font(.headline)
253
                        ContextInfoButton(
254
                            title: "Stored Session Curve",
255
                            message: "Database storage and iCloud sync use 300 aggregated points per hour. Active sessions persist their partial aggregated curve continuously, so a reconnect or restart can restore the stored progress."
256
                        )
257
                    }
258
                    Text("Open session, persisted as aggregated samples.")
259
                        .font(.caption)
260
                        .foregroundColor(.secondary)
261
                }
262

            
263
                Spacer()
264

            
265
                Text("\(displayedSamples.count) points")
266
                    .font(.caption.weight(.semibold))
267
                    .foregroundColor(.secondary)
268
            }
269

            
270
            if let currentSeries {
271
                storedSeriesChart(
272
                    title: "Current",
273
                    unit: "A",
274
                    strokeColor: .blue,
275
                    snapshot: currentSeries
276
                )
277
            }
278

            
279
            if let energySeries {
280
                storedSeriesChart(
281
                    title: "Energy",
282
                    unit: "Wh",
283
                    strokeColor: .teal,
284
                    areaChart: true,
285
                    snapshot: energySeries
286
                )
287
            }
288
        }
289
        .frame(maxWidth: .infinity, alignment: .leading)
290
        .padding(18)
291
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
292
    }
293

            
294
    private func storedSeriesSnapshot(
295
        from samples: [ChargeSessionSampleSummary],
296
        minimumYSpan: Double,
297
        value: (ChargeSessionSampleSummary) -> Double
298
    ) -> ActiveSessionSeriesSnapshot? {
299
        let sortedSamples = samples.sorted { lhs, rhs in
300
            if lhs.bucketIndex != rhs.bucketIndex {
301
                return lhs.bucketIndex < rhs.bucketIndex
302
            }
303
            return lhs.timestamp < rhs.timestamp
304
        }
305

            
306
        guard
307
            let firstSample = sortedSamples.first,
308
            let lastSample = sortedSamples.last
309
        else {
310
            return nil
311
        }
312

            
313
        let points = sortedSamples.enumerated().map { index, sample in
314
            Measurements.Measurement.Point(
315
                id: index,
316
                timestamp: sample.timestamp,
317
                value: value(sample),
318
                kind: .sample
319
            )
320
        }
321

            
322
        let minimumValue = points.map(\.value).min() ?? 0
323
        let maximumValue = points.map(\.value).max() ?? minimumValue
324
        let context = ChartContext()
325
        context.setBounds(
326
            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
327
            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
328
            yMin: CGFloat(minimumValue),
329
            yMax: CGFloat(maximumValue)
330
        )
331
        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
332

            
333
        return ActiveSessionSeriesSnapshot(
334
            points: points,
335
            context: context,
336
            minimumValue: minimumValue,
337
            maximumValue: maximumValue
338
        )
339
    }
340

            
341
    private func storedSeriesChart(
342
        title: String,
343
        unit: String,
344
        strokeColor: Color,
345
        areaChart: Bool = false,
346
        snapshot: ActiveSessionSeriesSnapshot
347
    ) -> some View {
348
        VStack(alignment: .leading, spacing: 8) {
349
            HStack(alignment: .firstTextBaseline) {
350
                Text(title)
351
                    .font(.subheadline.weight(.semibold))
352
                Spacer()
353
                Text(
354
                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) - \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
355
                )
356
                .font(.caption2)
357
                .foregroundColor(.secondary)
358
            }
359

            
360
            TimeSeriesChart(
361
                points: snapshot.points,
362
                context: snapshot.context,
363
                areaChart: areaChart,
364
                strokeColor: strokeColor
365
            )
366
            .frame(height: 118)
367
            .padding(.horizontal, 6)
368
            .padding(.vertical, 8)
369
            .background(
370
                RoundedRectangle(cornerRadius: 16)
371
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
372
            )
373

            
374
            HStack {
375
                Text(snapshot.startLabel)
376
                Spacer()
377
                Text(snapshot.endLabel)
378
            }
379
            .font(.caption2)
380
            .foregroundColor(.secondary)
381
        }
382
    }
383

            
384
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
385
        let formatter = DateComponentsFormatter()
386
        let effectiveDuration = max(session.effectiveDuration, 0)
387
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
388
        formatter.unitsStyle = .abbreviated
389
        formatter.zeroFormattingBehavior = .dropAll
390
        return formatter.string(from: effectiveDuration) ?? "0m"
391
    }
392

            
393
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
394
        if session.autoStopEnabled == false {
395
            return "Manual"
396
        }
397

            
398
        if let sessionWarning = sessionWarning(for: session),
399
           sessionWarning.contains("idle-current") {
400
            return "Blocked by charger setup"
401
        }
402

            
403
        if session.stopThresholdAmps > 0 {
404
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
405
        }
406

            
407
        return "Learning"
408
    }
409

            
410
    private func sessionWarning(for session: ChargeSessionSummary) -> String? {
411
        guard session.chargingTransportMode == .wireless,
412
              let chargerID = session.chargerID,
413
              let charger = appData.chargedDeviceSummary(id: chargerID),
414
              charger.chargerIdleCurrentAmps == nil else {
415
            return nil
416
        }
417

            
418
        return "This charger has no idle-current measurement, so the wireless stop threshold cannot be learned or auto-applied for this session."
419
    }
420

            
421
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
422
        guard session.chargingTransportMode == .wireless else {
423
            return nil
424
        }
425

            
426
        var components: [String] = []
427
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
428
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
429
        }
430
        if session.usesEstimatedWirelessEfficiency {
431
            components.append("Estimated from wired baseline and checkpoints")
432
        }
433
        if session.shouldWarnAboutLowWirelessEfficiency {
434
            components.append("Low wireless efficiency, so capacity confidence is reduced")
435
        }
436

            
437
        return components.isEmpty ? nil : components.joined(separator: " - ")
438
    }
439

            
440
    private func statusTint(for session: ChargeSessionSummary) -> Color {
441
        switch session.status {
442
        case .active:
443
            return .green
444
        case .paused:
445
            return .orange
446
        case .completed:
447
            return .teal
448
        case .abandoned:
449
            return .secondary
450
        }
451
    }
452
}
453

            
454
private struct ActiveSessionSeriesSnapshot {
455
    let points: [Measurements.Measurement.Point]
456
    let context: ChartContext
457
    let minimumValue: Double
458
    let maximumValue: Double
459

            
460
    var lastValue: Double {
461
        points.last?.value ?? 0
462
    }
463

            
464
    var startLabel: String {
465
        guard let firstTimestamp = points.first?.timestamp else { return "" }
466
        return firstTimestamp.formatted(date: .omitted, time: .shortened)
467
    }
468

            
469
    var endLabel: String {
470
        guard let lastTimestamp = points.last?.timestamp else { return "" }
471
        return lastTimestamp.formatted(date: .omitted, time: .shortened)
472
    }
473
}
474

            
475
private struct ActiveSessionTargetNotificationEditorSheetView: View {
476
    @Environment(\.dismiss) private var dismiss
477
    @EnvironmentObject private var appData: AppData
478

            
479
    let sessionID: UUID
480
    let initialTargetPercent: Double?
481

            
482
    @State private var targetPercent: Double
483

            
484
    init(sessionID: UUID, initialTargetPercent: Double?) {
485
        self.sessionID = sessionID
486
        self.initialTargetPercent = initialTargetPercent
487
        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
488
    }
489

            
490
    var body: some View {
491
        NavigationView {
492
            Form {
493
                Section(
494
                    header: ContextInfoHeader(
495
                        title: "Target Level",
496
                        message: "A local notification will be generated on synced devices when the estimated battery level reaches this target."
497
                    )
498
                ) {
499
                    VStack(alignment: .leading, spacing: 12) {
500
                        Text("\(targetPercent.format(decimalDigits: 0))%")
501
                            .font(.title3.weight(.bold))
502
                        Slider(value: $targetPercent, in: 20...100, step: 1)
503
                    }
504
                }
505
            }
506
            .navigationTitle("Battery Target")
507
            .navigationBarTitleDisplayMode(.inline)
508
            .toolbar {
509
                ToolbarItem(placement: .cancellationAction) {
510
                    Button("Cancel") {
511
                        dismiss()
512
                    }
513
                }
514

            
515
                ToolbarItem(placement: .confirmationAction) {
516
                    Button("Save") {
517
                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
518
                            dismiss()
519
                        }
520
                    }
521
                }
522
            }
523
        }
524
        .navigationViewStyle(StackNavigationViewStyle())
525
    }
526
}
527

            
528
private struct ActiveDeviceSessionStopRequest: Identifiable {
529
    let sessionID: UUID
530
    let title: String
531
    let confirmTitle: String
532
    let explanation: String
533

            
534
    var id: UUID {
535
        sessionID
536
    }
537
}