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

            
8
import SwiftUI
9

            
10
struct ChargedDeviceSessionsView: View {
11
    @EnvironmentObject private var appData: AppData
12
    @State private var pendingSessionDeletion: ChargeSessionSummary?
13

            
14
    let chargedDeviceID: UUID
15

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

            
20
    private var sessions: [ChargeSessionSummary] {
21
        chargedDevice?.sessions.filter { !$0.status.isOpen } ?? []
22
    }
23

            
Bogdan Timofte authored a month ago
24
    /// Maps session ID → capacity delta vs the closest preceding session that has an estimate.
25
    private var capacityDeltas: [UUID: Double] {
26
        let sorted = sessions.sorted { $0.startedAt < $1.startedAt }
27
        var result: [UUID: Double] = [:]
28
        var previousCapacity: Double? = nil
29
        for session in sorted {
30
            if let current = session.capacityEstimateWh {
31
                if let prev = previousCapacity {
32
                    result[session.id] = current - prev
33
                }
34
                previousCapacity = current
35
            }
36
        }
37
        return result
38
    }
39

            
Bogdan Timofte authored a month ago
40
    var body: some View {
41
        Group {
42
            if let chargedDevice {
43
                ScrollView {
44
                    VStack(spacing: 14) {
45
                        if sessions.isEmpty {
46
                            emptyState
47
                        } else {
48
                            summaryHeader(chargedDevice)
49

            
Bogdan Timofte authored a month ago
50
                            let deltas = capacityDeltas
Bogdan Timofte authored a month ago
51
                            ForEach(sessions, id: \.id) { session in
Bogdan Timofte authored a month ago
52
                                sessionCard(session, chargedDevice: chargedDevice, capacityDelta: deltas[session.id])
Bogdan Timofte authored a month ago
53
                            }
54
                        }
55
                    }
56
                    .padding()
57
                }
58
                .background(
59
                    LinearGradient(
60
                        colors: [tint(for: chargedDevice).opacity(0.14), Color.clear],
61
                        startPoint: .topLeading,
62
                        endPoint: .bottomTrailing
63
                    )
64
                    .ignoresSafeArea()
65
                )
66
                .navigationTitle("Sessions")
67
            } else {
68
                Text("This device is no longer available.")
69
                    .foregroundColor(.secondary)
70
                    .navigationTitle("Sessions")
71
            }
72
        }
73
        .alert(item: $pendingSessionDeletion) { session in
74
            Alert(
75
                title: Text("Delete Session?"),
76
                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
77
                primaryButton: .destructive(Text("Delete")) {
78
                    _ = appData.deleteChargeSession(sessionID: session.id)
79
                },
80
                secondaryButton: .cancel()
81
            )
82
        }
83
    }
84

            
85
    private var emptyState: some View {
86
        VStack(spacing: 10) {
87
            Image(systemName: "clock")
88
                .font(.system(size: 34, weight: .semibold))
89
                .foregroundColor(.secondary)
90
            Text("No Closed Sessions")
91
                .font(.headline)
92
            Text("Completed and abandoned sessions will appear here after they are closed.")
93
                .font(.footnote)
94
                .foregroundColor(.secondary)
95
                .multilineTextAlignment(.center)
96
        }
97
        .frame(maxWidth: .infinity)
98
        .padding(24)
99
        .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 18)
100
    }
101

            
102
    private func summaryHeader(_ chargedDevice: ChargedDeviceSummary) -> some View {
103
        let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
104
        let completedCount = sessions.filter { $0.status == .completed }.count
105

            
106
        return MeterInfoCardView(title: chargedDevice.name, tint: tint(for: chargedDevice)) {
107
            MeterInfoRowView(label: "Closed Sessions", value: "\(sessions.count)")
108
            MeterInfoRowView(label: "Completed", value: "\(completedCount)")
109
            MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
110
        }
111
    }
112

            
Bogdan Timofte authored a month ago
113
    // MARK: - Session Card
114

            
115
    private func sessionCard(
116
        _ session: ChargeSessionSummary,
117
        chargedDevice: ChargedDeviceSummary,
118
        capacityDelta: Double?
119
    ) -> some View {
120
        let sessionTint = statusTint(for: session)
121

            
122
        return VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
123
            NavigationLink(
Bogdan Timofte authored a month ago
124
                destination: ChargeSessionDetailView(
Bogdan Timofte authored a month ago
125
                    chargedDeviceID: chargedDevice.id,
126
                    sessionID: session.id
127
                )
128
            ) {
129
                VStack(alignment: .leading, spacing: 10) {
Bogdan Timofte authored a month ago
130
                    // Header: date + status badge
Bogdan Timofte authored a month ago
131
                    HStack(alignment: .firstTextBaseline, spacing: 10) {
132
                        Text(session.startedAt.format())
133
                            .font(.subheadline.weight(.semibold))
134
                            .foregroundColor(.primary)
135

            
136
                        Text(session.status.title)
137
                            .font(.caption2.weight(.semibold))
Bogdan Timofte authored a month ago
138
                            .foregroundColor(sessionTint)
Bogdan Timofte authored a month ago
139
                            .padding(.horizontal, 8)
140
                            .padding(.vertical, 4)
Bogdan Timofte authored a month ago
141
                            .background(Capsule().fill(sessionTint.opacity(0.16)))
Bogdan Timofte authored a month ago
142

            
Bogdan Timofte authored a month ago
143
                        if session.wasConflictHealed {
144
                            Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
145
                                .font(.caption2.weight(.semibold))
146
                                .foregroundColor(.orange)
147
                                .help("This session was automatically closed because a newer session was started on another device while offline.")
148
                        }
149

            
Bogdan Timofte authored a month ago
150
                        Spacer()
151

            
152
                        Image(systemName: "chevron.right")
153
                            .font(.caption.weight(.semibold))
154
                            .foregroundColor(.secondary)
155
                    }
156

            
Bogdan Timofte authored a month ago
157
                    // Primary metrics: Energy + Duration
158
                    HStack(spacing: 8) {
159
                        primaryMetricCell(
160
                            label: "Energy",
161
                            value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh",
162
                            tint: .teal
163
                        )
164
                        primaryMetricCell(
165
                            label: "Duration",
166
                            value: sessionDurationText(session),
167
                            tint: .orange
168
                        )
169
                    }
Bogdan Timofte authored a month ago
170

            
Bogdan Timofte authored a month ago
171
                    // Charge bar (if start/end battery % known)
172
                    if let chargeRange = chargeBarRange(for: session) {
173
                        chargeBarView(range: chargeRange, tint: sessionTint)
174
                    }
175

            
176
                    // Capacity estimate + battery delta chips
177
                    let chips = chipContent(session: session, capacityDelta: capacityDelta)
178
                    if !chips.isEmpty {
179
                        chipsRow(chips)
180
                    }
181

            
182
                    // Secondary info line
183
                    let secondary = secondaryInfoLine(session, chargedDevice: chargedDevice)
184
                    if !secondary.isEmpty {
185
                        Text(secondary)
186
                            .font(.caption)
187
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
188
                    }
189
                }
190
            }
191
            .buttonStyle(.plain)
192

            
193
            Divider()
194

            
195
            HStack {
196
                if !session.displayedAggregatedSamples.isEmpty {
197
                    Label("\(session.displayedAggregatedSamples.count) curve points", systemImage: "chart.xyaxis.line")
198
                        .font(.caption2)
199
                        .foregroundColor(.secondary)
200
                }
201

            
202
                Spacer()
203

            
204
                Button(role: .destructive) {
205
                    pendingSessionDeletion = session
206
                } label: {
207
                    Image(systemName: "trash")
208
                        .font(.caption.weight(.semibold))
209
                        .foregroundColor(.red)
210
                        .frame(width: 30, height: 30)
Bogdan Timofte authored a month ago
211
                        .background(Circle().fill(Color.red.opacity(0.10)))
Bogdan Timofte authored a month ago
212
                }
213
                .buttonStyle(.plain)
214
                .help("Delete session")
215
            }
216
        }
217
        .padding(14)
Bogdan Timofte authored a month ago
218
        .meterCard(tint: sessionTint, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
Bogdan Timofte authored a month ago
219
    }
220

            
Bogdan Timofte authored a month ago
221
    // MARK: - Primary metric cell
Bogdan Timofte authored a month ago
222

            
Bogdan Timofte authored a month ago
223
    private func primaryMetricCell(label: String, value: String, tint: Color) -> some View {
224
        VStack(alignment: .leading, spacing: 3) {
Bogdan Timofte authored a month ago
225
            Text(label)
226
                .font(.caption2)
227
                .foregroundColor(.secondary)
228
            Text(value)
Bogdan Timofte authored a month ago
229
                .font(.subheadline.weight(.bold))
Bogdan Timofte authored a month ago
230
                .foregroundColor(.primary)
231
                .monospacedDigit()
232
                .lineLimit(1)
233
                .minimumScaleFactor(0.8)
234
        }
235
        .frame(maxWidth: .infinity, alignment: .leading)
236
        .padding(10)
237
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
238
    }
239

            
Bogdan Timofte authored a month ago
240
    // MARK: - Charge bar
241

            
242
    /// Returns (startPercent, endPercent) if we have enough data to render a charge bar.
243
    private func chargeBarRange(for session: ChargeSessionSummary) -> (start: Double, end: Double)? {
244
        let start = session.startBatteryPercent
245
        let end = session.endBatteryPercent
246

            
247
        if let s = start, let e = end, e > s {
248
            return (s, e)
249
        }
250

            
251
        // Fall back to first / last checkpoint
252
        let sorted = session.checkpoints.sorted { $0.timestamp < $1.timestamp }
253
        if sorted.count >= 2,
254
           let first = sorted.first,
255
           let last = sorted.last,
256
           last.batteryPercent > first.batteryPercent {
257
            return (first.batteryPercent, last.batteryPercent)
258
        }
259

            
260
        return nil
261
    }
262

            
263
    private func chargeBarView(range: (start: Double, end: Double), tint: Color) -> some View {
264
        VStack(alignment: .leading, spacing: 4) {
265
            GeometryReader { geo in
266
                let w = geo.size.width
267
                let startX = w * CGFloat(range.start / 100)
268
                let fillWidth = max(w * CGFloat((range.end - range.start) / 100), 4)
269
                ZStack(alignment: .leading) {
270
                    Capsule()
271
                        .fill(Color.primary.opacity(0.08))
272
                    // Filled charged portion
273
                    Rectangle()
274
                        .fill(LinearGradient(
275
                            colors: [tint.opacity(0.6), tint],
276
                            startPoint: .leading,
277
                            endPoint: .trailing
278
                        ))
279
                        .frame(width: fillWidth)
280
                        .offset(x: startX)
281
                    // Start marker line
282
                    if range.start > 3 {
283
                        Rectangle()
284
                            .fill(Color.white.opacity(0.5))
285
                            .frame(width: 1.5, height: 14)
286
                            .offset(x: startX - 0.75)
287
                    }
288
                }
289
                .clipShape(Capsule())
290
            }
291
            .frame(height: 14)
292

            
293
            // Labels
294
            HStack {
295
                Text("\(Int(range.start.rounded()))%")
296
                    .font(.caption2)
297
                    .foregroundColor(.secondary)
298
                Spacer()
299
                Text("+\(Int((range.end - range.start).rounded()))%")
300
                    .font(.caption2.weight(.semibold))
301
                    .foregroundColor(tint)
302
                Spacer()
303
                Text("\(Int(range.end.rounded()))%")
304
                    .font(.caption2)
305
                    .foregroundColor(.secondary)
306
            }
307
        }
308
    }
309

            
310
    // MARK: - Chips
311

            
312
    private struct ChipContent {
313
        let label: String
314
        let tint: Color
315
    }
316

            
317
    private func chipContent(session: ChargeSessionSummary, capacityDelta: Double?) -> [ChipContent] {
318
        var chips: [ChipContent] = []
319

            
320
        if let capacityWh = session.capacityEstimateWh {
321
            var label = "\(capacityWh.format(decimalDigits: 1)) Wh"
322
            if let delta = capacityDelta {
323
                let sign = delta >= 0 ? "+" : ""
324
                label += " (\(sign)\(delta.format(decimalDigits: 1)))"
325
            }
326
            chips.append(ChipContent(label: label, tint: .orange))
327
        }
328

            
329
        if let batteryDelta = session.batteryDeltaPercent {
330
            let sign = batteryDelta >= 0 ? "+" : ""
331
            chips.append(ChipContent(label: "\(sign)\(Int(batteryDelta.rounded()))% charged", tint: .teal))
332
        }
333

            
334
        return chips
335
    }
336

            
337
    private func chipsRow(_ chips: [ChipContent]) -> some View {
338
        HStack(spacing: 6) {
339
            ForEach(chips.indices, id: \.self) { i in
340
                let chip = chips[i]
341
                Text(chip.label)
342
                    .font(.caption2.weight(.semibold))
343
                    .foregroundColor(chip.tint)
344
                    .padding(.horizontal, 8)
345
                    .padding(.vertical, 4)
346
                    .background(
347
                        RoundedRectangle(cornerRadius: 8)
348
                            .fill(chip.tint.opacity(0.14))
349
                            .overlay(RoundedRectangle(cornerRadius: 8).stroke(chip.tint.opacity(0.22), lineWidth: 1))
350
                    )
351
            }
352
            Spacer()
353
        }
354
    }
355

            
356
    // MARK: - Secondary info line
357

            
358
    private func secondaryInfoLine(
Bogdan Timofte authored a month ago
359
        _ session: ChargeSessionSummary,
360
        chargedDevice: ChargedDeviceSummary
361
    ) -> String {
362
        var components: [String] = []
363

            
364
        if chargedDevice.shouldShowChargingTransport(session.chargingTransportMode) {
365
            components.append(session.chargingTransportMode.title)
366
        }
367
        if chargedDevice.shouldShowChargingStateMode(session.chargingStateMode) {
368
            components.append(session.chargingStateMode.title)
369
        }
370
        if session.isTrimmed {
371
            components.append("Trimmed")
372
        }
Bogdan Timofte authored a month ago
373
        if session.wasConflictHealed {
374
            components.append("Auto-closed (sync conflict)")
375
        }
Bogdan Timofte authored a month ago
376
        components.append(session.sourceMode.title)
377

            
Bogdan Timofte authored a month ago
378
        return components.joined(separator: " · ")
Bogdan Timofte authored a month ago
379
    }
380

            
Bogdan Timofte authored a month ago
381
    // MARK: - Helpers
382

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

            
392
    private func statusTint(for session: ChargeSessionSummary) -> Color {
393
        switch session.status {
Bogdan Timofte authored a month ago
394
        case .active:   return .green
395
        case .paused:   return .orange
396
        case .completed: return .teal
397
        case .abandoned: return .secondary
Bogdan Timofte authored a month ago
398
        }
399
    }
400

            
401
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
402
        switch chargedDevice.deviceClass {
Bogdan Timofte authored a month ago
403
        case .iphone:    return .blue
404
        case .watch:     return .green
405
        case .powerbank: return .orange
406
        case .charger:   return .pink
407
        case .other:     return .secondary
Bogdan Timofte authored a month ago
408
        }
409
    }
410
}