USB-Meter / USB Meter / Views / ChargedDevices / Sessions / ChargedDeviceSessionsView.swift
Newer Older
400 lines | 15.062kb
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

            
143
                        Spacer()
144

            
145
                        Image(systemName: "chevron.right")
146
                            .font(.caption.weight(.semibold))
147
                            .foregroundColor(.secondary)
148
                    }
149

            
Bogdan Timofte authored a month ago
150
                    // Primary metrics: Energy + Duration
151
                    HStack(spacing: 8) {
152
                        primaryMetricCell(
153
                            label: "Energy",
154
                            value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh",
155
                            tint: .teal
156
                        )
157
                        primaryMetricCell(
158
                            label: "Duration",
159
                            value: sessionDurationText(session),
160
                            tint: .orange
161
                        )
162
                    }
Bogdan Timofte authored a month ago
163

            
Bogdan Timofte authored a month ago
164
                    // Charge bar (if start/end battery % known)
165
                    if let chargeRange = chargeBarRange(for: session) {
166
                        chargeBarView(range: chargeRange, tint: sessionTint)
167
                    }
168

            
169
                    // Capacity estimate + battery delta chips
170
                    let chips = chipContent(session: session, capacityDelta: capacityDelta)
171
                    if !chips.isEmpty {
172
                        chipsRow(chips)
173
                    }
174

            
175
                    // Secondary info line
176
                    let secondary = secondaryInfoLine(session, chargedDevice: chargedDevice)
177
                    if !secondary.isEmpty {
178
                        Text(secondary)
179
                            .font(.caption)
180
                            .foregroundColor(.secondary)
Bogdan Timofte authored a month ago
181
                    }
182
                }
183
            }
184
            .buttonStyle(.plain)
185

            
186
            Divider()
187

            
188
            HStack {
189
                if !session.displayedAggregatedSamples.isEmpty {
190
                    Label("\(session.displayedAggregatedSamples.count) curve points", systemImage: "chart.xyaxis.line")
191
                        .font(.caption2)
192
                        .foregroundColor(.secondary)
193
                }
194

            
195
                Spacer()
196

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

            
Bogdan Timofte authored a month ago
214
    // MARK: - Primary metric cell
Bogdan Timofte authored a month ago
215

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

            
Bogdan Timofte authored a month ago
233
    // MARK: - Charge bar
234

            
235
    /// Returns (startPercent, endPercent) if we have enough data to render a charge bar.
236
    private func chargeBarRange(for session: ChargeSessionSummary) -> (start: Double, end: Double)? {
237
        let start = session.startBatteryPercent
238
        let end = session.endBatteryPercent
239

            
240
        if let s = start, let e = end, e > s {
241
            return (s, e)
242
        }
243

            
244
        // Fall back to first / last checkpoint
245
        let sorted = session.checkpoints.sorted { $0.timestamp < $1.timestamp }
246
        if sorted.count >= 2,
247
           let first = sorted.first,
248
           let last = sorted.last,
249
           last.batteryPercent > first.batteryPercent {
250
            return (first.batteryPercent, last.batteryPercent)
251
        }
252

            
253
        return nil
254
    }
255

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

            
286
            // Labels
287
            HStack {
288
                Text("\(Int(range.start.rounded()))%")
289
                    .font(.caption2)
290
                    .foregroundColor(.secondary)
291
                Spacer()
292
                Text("+\(Int((range.end - range.start).rounded()))%")
293
                    .font(.caption2.weight(.semibold))
294
                    .foregroundColor(tint)
295
                Spacer()
296
                Text("\(Int(range.end.rounded()))%")
297
                    .font(.caption2)
298
                    .foregroundColor(.secondary)
299
            }
300
        }
301
    }
302

            
303
    // MARK: - Chips
304

            
305
    private struct ChipContent {
306
        let label: String
307
        let tint: Color
308
    }
309

            
310
    private func chipContent(session: ChargeSessionSummary, capacityDelta: Double?) -> [ChipContent] {
311
        var chips: [ChipContent] = []
312

            
313
        if let capacityWh = session.capacityEstimateWh {
314
            var label = "\(capacityWh.format(decimalDigits: 1)) Wh"
315
            if let delta = capacityDelta {
316
                let sign = delta >= 0 ? "+" : ""
317
                label += " (\(sign)\(delta.format(decimalDigits: 1)))"
318
            }
319
            chips.append(ChipContent(label: label, tint: .orange))
320
        }
321

            
322
        if let batteryDelta = session.batteryDeltaPercent {
323
            let sign = batteryDelta >= 0 ? "+" : ""
324
            chips.append(ChipContent(label: "\(sign)\(Int(batteryDelta.rounded()))% charged", tint: .teal))
325
        }
326

            
327
        return chips
328
    }
329

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

            
349
    // MARK: - Secondary info line
350

            
351
    private func secondaryInfoLine(
Bogdan Timofte authored a month ago
352
        _ session: ChargeSessionSummary,
353
        chargedDevice: ChargedDeviceSummary
354
    ) -> String {
355
        var components: [String] = []
356

            
357
        if chargedDevice.shouldShowChargingTransport(session.chargingTransportMode) {
358
            components.append(session.chargingTransportMode.title)
359
        }
360
        if chargedDevice.shouldShowChargingStateMode(session.chargingStateMode) {
361
            components.append(session.chargingStateMode.title)
362
        }
363
        if session.isTrimmed {
364
            components.append("Trimmed")
365
        }
366
        components.append(session.sourceMode.title)
367

            
Bogdan Timofte authored a month ago
368
        return components.joined(separator: " · ")
Bogdan Timofte authored a month ago
369
    }
370

            
Bogdan Timofte authored a month ago
371
    // MARK: - Helpers
372

            
Bogdan Timofte authored a month ago
373
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
374
        let formatter = DateComponentsFormatter()
375
        let effectiveDuration = max(session.effectiveDuration, 0)
376
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
377
        formatter.unitsStyle = .abbreviated
378
        formatter.zeroFormattingBehavior = .dropAll
379
        return formatter.string(from: effectiveDuration) ?? "0m"
380
    }
381

            
382
    private func statusTint(for session: ChargeSessionSummary) -> Color {
383
        switch session.status {
Bogdan Timofte authored a month ago
384
        case .active:   return .green
385
        case .paused:   return .orange
386
        case .completed: return .teal
387
        case .abandoned: return .secondary
Bogdan Timofte authored a month ago
388
        }
389
    }
390

            
391
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
392
        switch chargedDevice.deviceClass {
Bogdan Timofte authored a month ago
393
        case .iphone:    return .blue
394
        case .watch:     return .green
395
        case .powerbank: return .orange
396
        case .charger:   return .pink
397
        case .other:     return .secondary
Bogdan Timofte authored a month ago
398
        }
399
    }
400
}