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

            
24
    var body: some View {
25
        Group {
26
            if let chargedDevice {
27
                ScrollView {
28
                    VStack(spacing: 14) {
29
                        if sessions.isEmpty {
30
                            emptyState
31
                        } else {
32
                            summaryHeader(chargedDevice)
33

            
34
                            ForEach(sessions, id: \.id) { session in
35
                                sessionCard(session, chargedDevice: chargedDevice)
36
                            }
37
                        }
38
                    }
39
                    .padding()
40
                }
41
                .background(
42
                    LinearGradient(
43
                        colors: [tint(for: chargedDevice).opacity(0.14), Color.clear],
44
                        startPoint: .topLeading,
45
                        endPoint: .bottomTrailing
46
                    )
47
                    .ignoresSafeArea()
48
                )
49
                .navigationTitle("Sessions")
50
            } else {
51
                Text("This device is no longer available.")
52
                    .foregroundColor(.secondary)
53
                    .navigationTitle("Sessions")
54
            }
55
        }
56
        .alert(item: $pendingSessionDeletion) { session in
57
            Alert(
58
                title: Text("Delete Session?"),
59
                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
60
                primaryButton: .destructive(Text("Delete")) {
61
                    _ = appData.deleteChargeSession(sessionID: session.id)
62
                },
63
                secondaryButton: .cancel()
64
            )
65
        }
66
    }
67

            
68
    private var emptyState: some View {
69
        VStack(spacing: 10) {
70
            Image(systemName: "clock")
71
                .font(.system(size: 34, weight: .semibold))
72
                .foregroundColor(.secondary)
73
            Text("No Closed Sessions")
74
                .font(.headline)
75
            Text("Completed and abandoned sessions will appear here after they are closed.")
76
                .font(.footnote)
77
                .foregroundColor(.secondary)
78
                .multilineTextAlignment(.center)
79
        }
80
        .frame(maxWidth: .infinity)
81
        .padding(24)
82
        .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 18)
83
    }
84

            
85
    private func summaryHeader(_ chargedDevice: ChargedDeviceSummary) -> some View {
86
        let totalEnergyWh = sessions.reduce(0) { $0 + $1.effectiveOrMeasuredEnergyWh }
87
        let completedCount = sessions.filter { $0.status == .completed }.count
88

            
89
        return MeterInfoCardView(title: chargedDevice.name, tint: tint(for: chargedDevice)) {
90
            MeterInfoRowView(label: "Closed Sessions", value: "\(sessions.count)")
91
            MeterInfoRowView(label: "Completed", value: "\(completedCount)")
92
            MeterInfoRowView(label: "Total Energy", value: "\(totalEnergyWh.format(decimalDigits: 2)) Wh")
93
        }
94
    }
95

            
96
    private func sessionCard(_ session: ChargeSessionSummary, chargedDevice: ChargedDeviceSummary) -> some View {
97
        VStack(alignment: .leading, spacing: 10) {
98
            NavigationLink(
99
                destination: ChargedDeviceSessionDetailView(
100
                    chargedDeviceID: chargedDevice.id,
101
                    sessionID: session.id
102
                )
103
            ) {
104
                VStack(alignment: .leading, spacing: 10) {
105
                    HStack(alignment: .firstTextBaseline, spacing: 10) {
106
                        Text(session.startedAt.format())
107
                            .font(.subheadline.weight(.semibold))
108
                            .foregroundColor(.primary)
109

            
110
                        Text(session.status.title)
111
                            .font(.caption2.weight(.semibold))
112
                            .foregroundColor(statusTint(for: session))
113
                            .padding(.horizontal, 8)
114
                            .padding(.vertical, 4)
115
                            .background(
116
                                Capsule()
117
                                    .fill(statusTint(for: session).opacity(0.16))
118
                            )
119

            
120
                        Spacer()
121

            
122
                        Image(systemName: "chevron.right")
123
                            .font(.caption.weight(.semibold))
124
                            .foregroundColor(.secondary)
125
                    }
126

            
127
                    Text(sessionSummaryLine(session, chargedDevice: chargedDevice))
128
                        .font(.caption)
129
                        .foregroundColor(.secondary)
130

            
131
                    LazyVGrid(columns: metricColumns, spacing: 8) {
132
                        metricCell(label: "Energy", value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh", tint: .teal)
133
                        metricCell(label: "Duration", value: sessionDurationText(session), tint: .orange)
134
                        if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
135
                            metricCell(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W", tint: .blue)
136
                        }
137
                        if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
138
                            metricCell(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A", tint: .indigo)
139
                        }
140
                    }
141
                }
142
            }
143
            .buttonStyle(.plain)
144

            
145
            Divider()
146

            
147
            HStack {
148
                if !session.displayedAggregatedSamples.isEmpty {
149
                    Label("\(session.displayedAggregatedSamples.count) curve points", systemImage: "chart.xyaxis.line")
150
                        .font(.caption2)
151
                        .foregroundColor(.secondary)
152
                }
153

            
154
                Spacer()
155

            
156
                Button(role: .destructive) {
157
                    pendingSessionDeletion = session
158
                } label: {
159
                    Image(systemName: "trash")
160
                        .font(.caption.weight(.semibold))
161
                        .foregroundColor(.red)
162
                        .frame(width: 30, height: 30)
163
                        .background(
164
                            Circle()
165
                                .fill(Color.red.opacity(0.10))
166
                        )
167
                }
168
                .buttonStyle(.plain)
169
                .help("Delete session")
170
            }
171
        }
172
        .padding(14)
173
        .meterCard(tint: statusTint(for: session), fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
174
    }
175

            
176
    private var metricColumns: [GridItem] {
177
        [
178
            GridItem(.flexible(minimum: 92), spacing: 8),
179
            GridItem(.flexible(minimum: 92), spacing: 8)
180
        ]
181
    }
182

            
183
    private func metricCell(label: String, value: String, tint: Color) -> some View {
184
        VStack(alignment: .leading, spacing: 4) {
185
            Text(label)
186
                .font(.caption2)
187
                .foregroundColor(.secondary)
188
            Text(value)
189
                .font(.footnote.weight(.semibold))
190
                .foregroundColor(.primary)
191
                .monospacedDigit()
192
                .lineLimit(1)
193
                .minimumScaleFactor(0.8)
194
        }
195
        .frame(maxWidth: .infinity, alignment: .leading)
196
        .padding(10)
197
        .meterCard(tint: tint, fillOpacity: 0.08, strokeOpacity: 0.12, cornerRadius: 12)
198
    }
199

            
200
    private func sessionSummaryLine(
201
        _ session: ChargeSessionSummary,
202
        chargedDevice: ChargedDeviceSummary
203
    ) -> String {
204
        var components: [String] = []
205

            
206
        if let batteryDeltaPercent = session.batteryDeltaPercent {
207
            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
208
        }
209
        if let capacityEstimateWh = session.capacityEstimateWh {
210
            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
211
        }
212
        if chargedDevice.shouldShowChargingTransport(session.chargingTransportMode) {
213
            components.append(session.chargingTransportMode.title)
214
        }
215
        if chargedDevice.shouldShowChargingStateMode(session.chargingStateMode) {
216
            components.append(session.chargingStateMode.title)
217
        }
218
        if session.isTrimmed {
219
            components.append("Trimmed")
220
        }
221
        components.append(session.sourceMode.title)
222

            
223
        return components.joined(separator: " - ")
224
    }
225

            
226
    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
227
        let formatter = DateComponentsFormatter()
228
        let effectiveDuration = max(session.effectiveDuration, 0)
229
        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
230
        formatter.unitsStyle = .abbreviated
231
        formatter.zeroFormattingBehavior = .dropAll
232
        return formatter.string(from: effectiveDuration) ?? "0m"
233
    }
234

            
235
    private func statusTint(for session: ChargeSessionSummary) -> Color {
236
        switch session.status {
237
        case .active:
238
            return .green
239
        case .paused:
240
            return .orange
241
        case .completed:
242
            return .teal
243
        case .abandoned:
244
            return .secondary
245
        }
246
    }
247

            
248
    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
249
        switch chargedDevice.deviceClass {
250
        case .iphone:
251
            return .blue
252
        case .watch:
253
            return .green
254
        case .powerbank:
255
            return .orange
256
        case .charger:
257
            return .pink
258
        case .other:
259
            return .secondary
260
        }
261
    }
262
}
263