USB-Meter / USB Meter / Views / ChargedDevices / Sessions / ChargedDeviceSessionsView.swift
Newer Older
412 lines | 15.749kb
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")
Bogdan Timofte authored a month ago
67
                .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
68
            } else {
69
                Text("This device is no longer available.")
70
                    .foregroundColor(.secondary)
71
                    .navigationTitle("Sessions")
Bogdan Timofte authored a month ago
72
                    .navigationBarTitleDisplayMode(.inline)
Bogdan Timofte authored a month ago
73
            }
74
        }
75
        .alert(item: $pendingSessionDeletion) { session in
76
            Alert(
77
                title: Text("Delete Session?"),
78
                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
79
                primaryButton: .destructive(Text("Delete")) {
80
                    _ = appData.deleteChargeSession(sessionID: session.id)
81
                },
82
                secondaryButton: .cancel()
83
            )
84
        }
85
    }
86

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

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

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

            
Bogdan Timofte authored a month ago
115
    // MARK: - Session Card
116

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

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

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

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

            
Bogdan Timofte authored a month ago
152
                        Spacer()
153

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

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

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

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

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

            
195
            Divider()
196

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

            
204
                Spacer()
205

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

            
Bogdan Timofte authored a month ago
223
    // MARK: - Primary metric cell
Bogdan Timofte authored a month ago
224

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

            
Bogdan Timofte authored a month ago
242
    // MARK: - Charge bar
243

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

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

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

            
262
        return nil
263
    }
264

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

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

            
312
    // MARK: - Chips
313

            
314
    private struct ChipContent {
315
        let label: String
316
        let tint: Color
317
    }
318

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

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

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

            
336
        return chips
337
    }
338

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

            
358
    // MARK: - Secondary info line
359

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

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

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

            
Bogdan Timofte authored a month ago
383
    // MARK: - Helpers
384

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

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

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