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

            
8
import SwiftUI
9

            
10
struct ChargedDeviceSessionDetailView: View {
11
    @EnvironmentObject private var appData: AppData
12
    @State private var pendingSessionDeletion: ChargeSessionSummary?
13
    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
14

            
15
    let chargedDeviceID: UUID
16
    let sessionID: UUID
17

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

            
22
    private var session: ChargeSessionSummary? {
23
        chargedDevice?.sessions.first(where: { $0.id == sessionID })
24
    }
25

            
26
    var body: some View {
27
        Group {
28
            if let chargedDevice, let session {
29
                ScrollView {
30
                    VStack(spacing: 16) {
31
                        overviewCard(session, chargedDevice: chargedDevice)
32
                        energyCard(session, chargedDevice: chargedDevice)
33
                        observedMetricsCard(session, chargedDevice: chargedDevice)
34
                        batteryCard(session)
35

            
36
                        if !session.displayedAggregatedSamples.isEmpty {
37
                            storedCurveCard(session)
38
                        }
39

            
40
                        managementCard(session)
41
                    }
42
                    .padding()
43
                }
44
                .background(
45
                    LinearGradient(
46
                        colors: [statusTint(for: session).opacity(0.14), Color.clear],
47
                        startPoint: .topLeading,
48
                        endPoint: .bottomTrailing
49
                    )
50
                    .ignoresSafeArea()
51
                )
52
                .navigationTitle("Session Details")
53
            } else {
54
                Text("This session is no longer available.")
55
                    .foregroundColor(.secondary)
56
                    .navigationTitle("Session")
57
            }
58
        }
59
        .alert(item: $pendingSessionDeletion) { session in
60
            Alert(
61
                title: Text("Delete Session?"),
62
                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
63
                primaryButton: .destructive(Text("Delete")) {
64
                    _ = appData.deleteChargeSession(sessionID: session.id)
65
                },
66
                secondaryButton: .cancel()
67
            )
68
        }
69
        .alert(item: $pendingCheckpointDeletion) { checkpoint in
70
            Alert(
71
                title: Text("Delete Battery Checkpoint"),
72
                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
73
                primaryButton: .destructive(Text("Delete")) {
74
                    _ = appData.deleteBatteryCheckpoint(
75
                        checkpointID: checkpoint.id,
76
                        for: checkpoint.sessionID
77
                    )
78
                },
79
                secondaryButton: .cancel()
80
            )
81
        }
82
    }
83

            
84
    private func overviewCard(
85
        _ session: ChargeSessionSummary,
86
        chargedDevice: ChargedDeviceSummary
87
    ) -> some View {
88
        MeterInfoCardView(title: "Overview", tint: statusTint(for: session)) {
89
            MeterInfoRowView(label: "Device", value: chargedDevice.name)
90
            MeterInfoRowView(label: "Status", value: session.status.title)
91
            MeterInfoRowView(label: "Started", value: session.startedAt.format())
92
            if let endedAt = session.endedAt {
93
                MeterInfoRowView(label: "Ended", value: endedAt.format())
94
            }
95
            MeterInfoRowView(label: "Duration", value: sessionDurationText(session))
96
            MeterInfoRowView(label: "Charging Type", value: session.chargingTransportMode.title)
97
            MeterInfoRowView(label: "Charging Mode", value: session.chargingStateMode.title)
98
            MeterInfoRowView(label: "Source", value: session.sourceMode.title)
99
            MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: session))
100
            if session.isTrimmed {
101
                MeterInfoRowView(label: "Trim Start", value: session.effectiveTrimStart.format())
102
                MeterInfoRowView(label: "Trim End", value: session.effectiveTrimEnd.format())
103
            }
104
            if let meterName = session.meterName {
105
                MeterInfoRowView(label: "Meter", value: meterName)
106
            } else if let meterMACAddress = session.meterMACAddress {
107
                MeterInfoRowView(label: "Meter", value: meterMACAddress)
108
            }
109
            if let meterModel = session.meterModel {
110
                MeterInfoRowView(label: "Meter Model", value: meterModel)
111
            }
112
        }
113
    }
114

            
115
    private func energyCard(
116
        _ session: ChargeSessionSummary,
117
        chargedDevice: ChargedDeviceSummary
118
    ) -> some View {
119
        MeterInfoCardView(title: "Energy", tint: .teal) {
120
            MeterInfoRowView(label: "Battery Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
121
            MeterInfoRowView(label: "Measured Energy", value: "\(session.measuredEnergyWh.format(decimalDigits: 3)) Wh")
122
            MeterInfoRowView(label: "Measured Charge", value: "\(session.measuredChargeAh.format(decimalDigits: 3)) Ah")
123
            if let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
124
               abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
125
                MeterInfoRowView(label: "Effective Battery Energy", value: "\(effectiveBatteryEnergyWh.format(decimalDigits: 3)) Wh")
126
            }
127
            if let capacityEstimateWh = session.capacityEstimateWh {
128
                MeterInfoRowView(label: "Capacity Estimate", value: "\(capacityEstimateWh.format(decimalDigits: 2)) Wh")
129
            }
130
            if let chargerID = session.chargerID,
131
               let charger = appData.chargedDeviceSummary(id: chargerID) {
132
                MeterInfoRowView(label: chargedDevice.hasMultipleChargingTransports ? "Wireless Charger" : "Charger", value: charger.name)
133
            }
134
            if let wirelessSessionHint = wirelessSessionHint(for: session) {
135
                Text(wirelessSessionHint)
136
                    .font(.caption2)
137
                    .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
138
            }
139
        }
140
    }
141

            
142
    private func observedMetricsCard(
143
        _ session: ChargeSessionSummary,
144
        chargedDevice: ChargedDeviceSummary
145
    ) -> some View {
146
        MeterInfoCardView(title: "Observed Metrics", tint: .blue) {
147
            if let minimumObservedCurrentAmps = session.minimumObservedCurrentAmps {
148
                MeterInfoRowView(label: "Min Current", value: "\(minimumObservedCurrentAmps.format(decimalDigits: 2)) A")
149
            }
150
            if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
151
                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
152
            }
153
            if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
154
                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
155
            }
156
            if let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
157
                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
158
            }
159
            if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
160
                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
161
            }
162
            if let completionCurrentAmps = session.completionCurrentAmps {
163
                MeterInfoRowView(label: "Completion Current", value: "\(completionCurrentAmps.format(decimalDigits: 2)) A")
164
            }
165
            if session.selectedDataGroup != nil {
166
                MeterInfoRowView(label: "Data Group", value: "\(session.selectedDataGroup ?? 0)")
167
            }
168
        }
169
    }
170

            
171
    private func batteryCard(_ session: ChargeSessionSummary) -> some View {
172
        MeterInfoCardView(title: "Battery", tint: .orange) {
173
            if let startBatteryPercent = session.startBatteryPercent {
174
                MeterInfoRowView(label: "Start Battery", value: "\(startBatteryPercent.format(decimalDigits: 0))%")
175
            }
176
            if let endBatteryPercent = session.endBatteryPercent {
177
                MeterInfoRowView(label: "End Battery", value: "\(endBatteryPercent.format(decimalDigits: 0))%")
178
            }
179
            if let batteryDeltaPercent = session.batteryDeltaPercent {
180
                MeterInfoRowView(label: "Battery Delta", value: "\(batteryDeltaPercent.format(decimalDigits: 0))%")
181
            }
182

            
183
            BatteryCheckpointSectionView(
184
                sessionID: session.id,
185
                checkpoints: session.checkpoints,
186
                message: "These checkpoints were saved with this closed session and feed capacity estimation and charge-level prediction.",
187
                canAddCheckpoint: false,
188
                requirementMessage: nil,
189
                effectiveEnergyWhOverride: nil,
190
                measuredChargeAhOverride: nil,
191
                onDelete: { checkpoint in
192
                    pendingCheckpointDeletion = checkpoint
193
                }
194
            )
195
        }
196
    }
197

            
198
    private func managementCard(_ session: ChargeSessionSummary) -> some View {
199
        MeterInfoCardView(title: "Administration", tint: .red) {
200
            Button(role: .destructive) {
201
                pendingSessionDeletion = session
202
            } label: {
203
                Label("Delete Session", systemImage: "trash")
204
                    .font(.subheadline.weight(.semibold))
205
                    .frame(maxWidth: .infinity)
206
                    .padding(.vertical, 10)
207
                    .meterCard(tint: .red, fillOpacity: 0.12, strokeOpacity: 0.20, cornerRadius: 14)
208
            }
209
            .buttonStyle(.plain)
210
        }
211
    }
212

            
213
    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
214
        let displayedSamples = session.displayedAggregatedSamples
215
        let currentSeries = storedSeriesSnapshot(
216
            from: displayedSamples,
217
            minimumYSpan: 0.15
218
        ) { $0.averageCurrentAmps }
219
        let energySeries = storedSeriesSnapshot(
220
            from: displayedSamples,
221
            minimumYSpan: 0.2
222
        ) { $0.measuredEnergyWh }
223

            
224
        return VStack(alignment: .leading, spacing: 14) {
225
            HStack(alignment: .firstTextBaseline) {
226
                VStack(alignment: .leading, spacing: 4) {
227
                    Text("Session Curve")
228
                        .font(.headline)
229
                    Text(session.isTrimmed ? "Showing the saved trim window." : "Persisted aggregate samples for this session.")
230
                        .font(.caption)
231
                        .foregroundColor(.secondary)
232
                }
233

            
234
                Spacer()
235

            
236
                Text("\(displayedSamples.count) points")
237
                    .font(.caption.weight(.semibold))
238
                    .foregroundColor(.secondary)
239
            }
240

            
241
            if let currentSeries {
242
                storedSeriesChart(
243
                    title: "Current",
244
                    unit: "A",
245
                    strokeColor: .blue,
246
                    snapshot: currentSeries
247
                )
248
            }
249

            
250
            if let energySeries {
251
                storedSeriesChart(
252
                    title: "Energy",
253
                    unit: "Wh",
254
                    strokeColor: .teal,
255
                    areaChart: true,
256
                    snapshot: energySeries
257
                )
258
            }
259
        }
260
        .frame(maxWidth: .infinity, alignment: .leading)
261
        .padding(18)
262
        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
263
    }
264

            
265
    private func storedSeriesSnapshot(
266
        from samples: [ChargeSessionSampleSummary],
267
        minimumYSpan: Double,
268
        value: (ChargeSessionSampleSummary) -> Double
269
    ) -> StoredSessionSeriesSnapshot? {
270
        let sortedSamples = samples.sorted { lhs, rhs in
271
            if lhs.bucketIndex != rhs.bucketIndex {
272
                return lhs.bucketIndex < rhs.bucketIndex
273
            }
274
            return lhs.timestamp < rhs.timestamp
275
        }
276

            
277
        guard
278
            let firstSample = sortedSamples.first,
279
            let lastSample = sortedSamples.last
280
        else {
281
            return nil
282
        }
283

            
284
        let points = sortedSamples.enumerated().map { index, sample in
285
            Measurements.Measurement.Point(
286
                id: index,
287
                timestamp: sample.timestamp,
288
                value: value(sample),
289
                kind: .sample
290
            )
291
        }
292

            
293
        let minimumValue = points.map(\.value).min() ?? 0
294
        let maximumValue = points.map(\.value).max() ?? minimumValue
295
        let context = ChartContext()
296
        context.setBounds(
297
            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
298
            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
299
            yMin: CGFloat(minimumValue),
300
            yMax: CGFloat(maximumValue)
301
        )
302
        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
303

            
304
        return StoredSessionSeriesSnapshot(
305
            points: points,
306
            context: context,
307
            minimumValue: minimumValue,
308
            maximumValue: maximumValue
309
        )
310
    }
311

            
312
    private func storedSeriesChart(
313
        title: String,
314
        unit: String,
315
        strokeColor: Color,
316
        areaChart: Bool = false,
317
        snapshot: StoredSessionSeriesSnapshot
318
    ) -> some View {
319
        VStack(alignment: .leading, spacing: 8) {
320
            HStack(alignment: .firstTextBaseline) {
321
                Text(title)
322
                    .font(.subheadline.weight(.semibold))
323
                Spacer()
324
                Text(
325
                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) - \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
326
                )
327
                .font(.caption2)
328
                .foregroundColor(.secondary)
329
            }
330

            
331
            TimeSeriesChart(
332
                points: snapshot.points,
333
                context: snapshot.context,
334
                areaChart: areaChart,
335
                strokeColor: strokeColor
336
            )
337
            .frame(height: 118)
338
            .padding(.horizontal, 6)
339
            .padding(.vertical, 8)
340
            .background(
341
                RoundedRectangle(cornerRadius: 16)
342
                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
343
            )
344

            
345
            HStack {
346
                Text(snapshot.startLabel)
347
                Spacer()
348
                Text(snapshot.endLabel)
349
            }
350
            .font(.caption2)
351
            .foregroundColor(.secondary)
352
        }
353
    }
354

            
355
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
356
        let formatter = DateComponentsFormatter()
357
        let effectiveDuration = max(session.effectiveDuration, 0)
358
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
359
        formatter.unitsStyle = .abbreviated
360
        formatter.zeroFormattingBehavior = .dropAll
361
        return formatter.string(from: effectiveDuration) ?? "0m"
362
    }
363

            
364
    private func autoStopDescription(for session: ChargeSessionSummary) -> String {
365
        if session.autoStopEnabled == false {
366
            return "Manual"
367
        }
368
        if session.stopThresholdAmps > 0 {
369
            return "\(session.stopThresholdAmps.format(decimalDigits: 2)) A"
370
        }
371
        return "Learning"
372
    }
373

            
374
    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
375
        guard session.chargingTransportMode == .wireless else {
376
            return nil
377
        }
378

            
379
        var components: [String] = []
380
        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
381
            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
382
        }
383
        if session.usesEstimatedWirelessEfficiency {
384
            components.append("Estimated from wired baseline and checkpoints")
385
        }
386
        if session.shouldWarnAboutLowWirelessEfficiency {
387
            components.append("Low wireless efficiency, so capacity confidence is reduced")
388
        }
389

            
390
        return components.isEmpty ? nil : components.joined(separator: " - ")
391
    }
392

            
393
    private func statusTint(for session: ChargeSessionSummary) -> Color {
394
        switch session.status {
395
        case .active:
396
            return .green
397
        case .paused:
398
            return .orange
399
        case .completed:
400
            return .teal
401
        case .abandoned:
402
            return .secondary
403
        }
404
    }
405
}
406

            
407
private struct StoredSessionSeriesSnapshot {
408
    let points: [Measurements.Measurement.Point]
409
    let context: ChartContext
410
    let minimumValue: Double
411
    let maximumValue: Double
412

            
413
    var lastValue: Double {
414
        points.last?.value ?? 0
415
    }
416

            
417
    var startLabel: String {
418
        guard let firstTimestamp = points.first?.timestamp else { return "" }
419
        return firstTimestamp.formatted(date: .omitted, time: .shortened)
420
    }
421

            
422
    var endLabel: String {
423
        guard let lastTimestamp = points.last?.timestamp else { return "" }
424
        return lastTimestamp.formatted(date: .omitted, time: .shortened)
425
    }
426
}
427