USB-Meter / USB Meter / Views / ChargedDevices / ConsumptionMonitorView.swift
Newer Older
432 lines | 17.066kb
Bogdan Timofte authored a month ago
1
//
2
//  ConsumptionMonitorView.swift
3
//  USB Meter
4
//
5

            
6
import SwiftUI
7
import Charts
8

            
9
// MARK: - Shared helpers (file-private)
10

            
11
private func formattedDuration(_ duration: TimeInterval) -> String {
12
    let formatter = DateComponentsFormatter()
13
    formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second]
14
    formatter.unitsStyle = .abbreviated
15
    formatter.zeroFormattingBehavior = .pad
16
    return formatter.string(from: max(duration, 0)) ?? "0m"
17
}
18

            
19
private func energyLabel(_ wattHours: Double) -> String {
20
    wattHours >= 1000
21
        ? "\((wattHours / 1000).format(decimalDigits: 3)) kWh"
22
        : "\(wattHours.format(decimalDigits: 2)) Wh"
23
}
24

            
25
@available(iOS 16, *)
26
private func consumptionChart(samples: [ConsumptionMonitorSample], tint: Color) -> some View {
27
    let duration = samples.last.map { $0.timestamp.timeIntervalSince(samples[0].timestamp) } ?? 0
28
    return Chart(samples) { sample in
29
        LineMark(
30
            x: .value("Time", sample.timestamp),
31
            y: .value("W", sample.averagePowerWatts)
32
        )
33
        .foregroundStyle(tint)
34
        .interpolationMethod(.catmullRom)
35
    }
36
    .frame(height: 140)
37
    .chartYScale(domain: .automatic(includesZero: false))
38
    .chartXAxis {
39
        if duration > 3600 {
40
            AxisMarks(values: .stride(by: .hour)) { _ in
41
                AxisGridLine()
42
                AxisValueLabel(format: .dateTime.hour())
43
            }
44
        } else {
45
            AxisMarks(values: .stride(by: .minute, count: 10)) { _ in
46
                AxisGridLine()
47
                AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .omitted)).minute())
48
            }
49
        }
50
    }
51
    .chartYAxis {
52
        AxisMarks { value in
53
            AxisGridLine()
54
            AxisValueLabel {
55
                if let v = value.as(Double.self) {
56
                    Text("\(v.format(decimalDigits: 1)) W")
57
                }
58
            }
59
        }
60
    }
61
}
62

            
63
// MARK: - Main View
64

            
65
struct ConsumptionMonitorView: View {
66
    @EnvironmentObject private var appData: AppData
67

            
68
    @State private var selectedMeterMACAddress: String?
69
    @State private var selectedDeviceID: UUID?
70
    @State private var discardConfirmationVisibility = false
71

            
72
    let preferredMeterMACAddress: String?
73

            
74
    init(preferredMeterMACAddress: String? = nil) {
75
        self.preferredMeterMACAddress = preferredMeterMACAddress
76
        _selectedMeterMACAddress = State(initialValue: preferredMeterMACAddress)
77
    }
78

            
79
    var body: some View {
80
        ScrollView {
81
            VStack(spacing: 18) {
82
                if let session = activeSession {
83
                    activeSessionCard(session)
84
                    liveMetricsCard(session)
85
                } else {
86
                    setupCard
87
                }
88
                savedSessionsList
89
            }
90
            .padding()
91
        }
92
        .background(
93
            LinearGradient(
94
                colors: [.purple.opacity(0.16), Color.clear],
95
                startPoint: .topLeading,
96
                endPoint: .bottomTrailing
97
            )
98
            .ignoresSafeArea()
99
        )
100
        .navigationTitle("Consumption Monitor")
101
        .navigationBarTitleDisplayMode(.inline)
102
        .confirmationDialog(
103
            "Stop and discard this session?",
104
            isPresented: $discardConfirmationVisibility,
105
            titleVisibility: .visible
106
        ) {
107
            Button("Discard", role: .destructive) {
108
                if let session = activeSession {
109
                    _ = appData.stopConsumptionMonitor(for: session.meterMACAddress, save: false)
110
                }
111
            }
112
            Button("Cancel", role: .cancel) {}
113
        } message: {
114
            Text("The current session data will be lost and nothing will be saved.")
115
        }
116
    }
117

            
118
    // MARK: - Computed
119

            
120
    private var liveMeterSummaries: [AppData.MeterSummary] {
121
        appData.meterSummaries.filter { $0.meter != nil }
122
    }
123

            
124
    private var availableDevices: [ChargedDeviceSummary] {
125
        appData.deviceSummaries
126
    }
127

            
128
    private var activeSession: ConsumptionMonitorLiveSession? {
129
        let candidates = [selectedMeterMACAddress, preferredMeterMACAddress].compactMap { $0 }
130
        for mac in candidates {
131
            if let session = appData.consumptionMonitorSession(for: mac) { return session }
132
        }
133
        for summary in liveMeterSummaries {
134
            if let session = appData.consumptionMonitorSession(for: summary.macAddress) { return session }
135
        }
136
        return nil
137
    }
138

            
139
    private var selectedDevice: ChargedDeviceSummary? {
140
        guard let id = selectedDeviceID else { return nil }
141
        return availableDevices.first { $0.id == id }
142
    }
143

            
144
    private var selectedMeterSummary: AppData.MeterSummary? {
145
        guard let mac = selectedMeterMACAddress else { return nil }
146
        return liveMeterSummaries.first { $0.macAddress == mac }
147
    }
148

            
149
    private var savedSessions: [ConsumptionMonitorSessionSummary] {
150
        guard let id = selectedDeviceID else { return [] }
151
        return appData.chargedDeviceSummary(id: id)?.consumptionSessions.filter { !$0.isOpen } ?? []
152
    }
153

            
154
    private var canStart: Bool {
155
        selectedDeviceID != nil && selectedMeterSummary != nil && activeSession == nil
156
    }
157

            
158
    // MARK: - Setup Card
159

            
160
    private var setupCard: some View {
161
        MeterInfoCardView(title: "New Session", tint: .purple) {
162
            VStack(alignment: .leading, spacing: 12) {
163
                if liveMeterSummaries.isEmpty {
164
                    Text("Connect a live meter first to start a consumption monitor session.")
165
                        .font(.footnote)
166
                        .foregroundColor(.secondary)
167
                } else {
168
                    Text("Device")
169
                        .font(.subheadline.weight(.semibold))
170

            
171
                    if availableDevices.isEmpty {
172
                        Text("No devices available. Add a device in the sidebar first.")
173
                            .font(.caption)
174
                            .foregroundColor(.secondary)
175
                    } else {
176
                        Picker("Device", selection: $selectedDeviceID) {
177
                            Text("Select Device").tag(Optional<UUID>.none)
178
                            ForEach(availableDevices) { device in
179
                                Text(device.name).tag(Optional(device.id))
180
                            }
181
                        }
182
                        .pickerStyle(.menu)
183
                    }
184

            
185
                    Text("Meter")
186
                        .font(.subheadline.weight(.semibold))
187

            
188
                    Picker("Meter", selection: $selectedMeterMACAddress) {
189
                        Text("Select Meter").tag(Optional<String>.none)
190
                        ForEach(liveMeterSummaries) { summary in
191
                            Text(summary.displayName).tag(Optional(summary.macAddress))
192
                        }
193
                    }
194
                    .pickerStyle(.menu)
195

            
196
                    Button("Start Session") {
197
                        startSession()
198
                    }
199
                    .disabled(!canStart)
200
                    .buttonStyle(.borderedProminent)
201
                    .tint(.purple)
202
                }
203
            }
204

            
205
            if activeSession == nil, selectedDevice != nil, selectedMeterSummary == nil {
206
                Text("Select a meter to begin.")
207
                    .font(.caption)
208
                    .foregroundColor(.secondary)
209
            } else if activeSession == nil, selectedMeterSummary != nil, selectedDevice == nil {
210
                Text("Select the device you want to monitor.")
211
                    .font(.caption)
212
                    .foregroundColor(.secondary)
213
            } else if activeSession == nil, canStart {
214
                Text("Samples are recorded every 60 seconds at a rate of 60 per hour.")
215
                    .font(.caption)
216
                    .foregroundColor(.secondary)
217
            }
218
        }
219
    }
220

            
221
    // MARK: - Active Session Card
222

            
223
    private func activeSessionCard(_ session: ConsumptionMonitorLiveSession) -> some View {
224
        MeterInfoCardView(
225
            title: "Session Running",
226
            infoMessage: "Samples are stored every 60 seconds. The session persists across app restarts until you stop it.",
227
            tint: .purple
228
        ) {
229
            if let device = appData.chargedDeviceSummary(id: session.chargedDeviceID) {
230
                MeterInfoRowView(label: "Device", value: device.name)
231
            }
232
            if let summary = liveMeterSummaries.first(where: { $0.macAddress == session.meterMACAddress }) {
233
                MeterInfoRowView(label: "Meter", value: summary.displayName)
234
            }
235
            MeterInfoRowView(label: "Duration", value: formattedDuration(session.elapsedDuration))
236
            MeterInfoRowView(label: "Samples", value: "\(session.committedSampleCount) × 60 s")
237
            MeterInfoRowView(label: "Total Energy", value: energyLabel(session.cumulativeEnergyWh))
238

            
239
            HStack(spacing: 12) {
240
                Button("Save & Stop") {
241
                    _ = appData.stopConsumptionMonitor(for: session.meterMACAddress, save: true)
242
                }
243
                .disabled(session.committedSampleCount == 0)
244

            
245
                Button("Discard") {
246
                    discardConfirmationVisibility = true
247
                }
248
                .foregroundColor(.red)
249
            }
250
            .buttonStyle(.borderedProminent)
251
            .tint(.purple)
252
        }
253
    }
254

            
255
    // MARK: - Live Metrics Card
256

            
257
    private func liveMetricsCard(_ session: ConsumptionMonitorLiveSession) -> some View {
258
        VStack(spacing: 18) {
259
            MeterInfoCardView(title: "Live Reading", tint: .indigo) {
260
                MeterInfoRowView(label: "Power", value: "\(session.currentPowerWatts.format(decimalDigits: 3)) W")
261
                MeterInfoRowView(label: "Current", value: "\(session.currentCurrentAmps.format(decimalDigits: 3)) A")
262
                MeterInfoRowView(label: "Voltage", value: "\(session.currentVoltageVolts.format(decimalDigits: 3)) V")
263
            }
264

            
265
            if session.committedSamples.count >= 2 {
266
                liveChartCard(session.committedSamples)
267
            }
268

            
269
            if session.cumulativeEnergyWh > 0 {
270
                projectionCard(
271
                    averagePowerWatts: session.cumulativeEnergyWh / max(session.elapsedDuration / 3600, 0.001),
272
                    totalEnergyWh: session.cumulativeEnergyWh
273
                )
274
            }
275
        }
276
    }
277

            
278
    @ViewBuilder
279
    private func liveChartCard(_ samples: [ConsumptionMonitorSample]) -> some View {
280
        if #available(iOS 16, *) {
281
            MeterInfoCardView(title: "Power Over Time", tint: .purple) {
282
                consumptionChart(samples: samples, tint: .purple)
283
            }
284
        }
285
    }
286

            
287
    // MARK: - Projections Card
288

            
289
    private func projectionCard(averagePowerWatts: Double, totalEnergyWh: Double) -> some View {
290
        MeterInfoCardView(title: "Consumption Projection", tint: .teal) {
291
            MeterInfoRowView(label: "Average Power", value: "\(averagePowerWatts.format(decimalDigits: 3)) W")
292
            MeterInfoRowView(label: "24 Hours", value: energyLabel(averagePowerWatts * 24))
293
            MeterInfoRowView(label: "7 Days", value: energyLabel(averagePowerWatts * 24 * 7))
294
            MeterInfoRowView(label: "30 Days", value: energyLabel(averagePowerWatts * 24 * 30))
295
            MeterInfoRowView(label: "1 Year", value: energyLabel(averagePowerWatts * 24 * 365))
296
        }
297
    }
298

            
299
    // MARK: - Saved Sessions List
300

            
301
    @ViewBuilder
302
    private var savedSessionsList: some View {
303
        if !savedSessions.isEmpty {
304
            MeterInfoCardView(title: "Saved Sessions", tint: .purple) {
305
                ForEach(savedSessions) { session in
306
                    NavigationLink(destination: ConsumptionSessionDetailView(session: session)) {
307
                        HStack {
308
                            VStack(alignment: .leading, spacing: 2) {
309
                                Text(session.startedAt, style: .date)
310
                                    .font(.subheadline.weight(.semibold))
311
                                Text("\(formattedDuration(session.duration)) · \(energyLabel(session.totalEnergyWh)) total")
312
                                    .font(.caption)
313
                                    .foregroundColor(.secondary)
314
                            }
315
                            Spacer()
316
                            Image(systemName: "chevron.right")
317
                                .font(.caption.weight(.semibold))
318
                                .foregroundColor(.secondary)
319
                        }
320
                        .padding(.vertical, 4)
321
                    }
322
                    .buttonStyle(.plain)
323
                }
324
            }
325
        }
326
    }
327

            
328
    // MARK: - Actions
329

            
330
    private func startSession() {
331
        guard let deviceID = selectedDeviceID,
332
              let meterSummary = selectedMeterSummary,
333
              let meter = meterSummary.meter else { return }
334
        _ = appData.startConsumptionMonitor(for: deviceID, on: meter)
335
    }
336
}
337

            
338
// MARK: - Session Detail
339

            
340
struct ConsumptionSessionDetailView: View {
341
    @EnvironmentObject private var appData: AppData
342

            
343
    let session: ConsumptionMonitorSessionSummary
344

            
345
    @State private var deleteConfirmationVisibility = false
346

            
347
    var body: some View {
348
        ScrollView {
349
            VStack(spacing: 18) {
350
                overviewCard
351
                if session.averagePowerWatts > 0 {
352
                    projectionCard
353
                }
354
                if session.samples.count >= 2 {
355
                    chartCard
356
                }
357
                statsCard
358
            }
359
            .padding()
360
        }
361
        .background(
362
            LinearGradient(
363
                colors: [.purple.opacity(0.14), Color.clear],
364
                startPoint: .topLeading,
365
                endPoint: .bottomTrailing
366
            )
367
            .ignoresSafeArea()
368
        )
369
        .navigationTitle("Consumption Session")
370
        .navigationBarTitleDisplayMode(.inline)
371
        .toolbar {
372
            ToolbarItem(placement: .destructiveAction) {
373
                Button(role: .destructive) {
374
                    deleteConfirmationVisibility = true
375
                } label: {
376
                    Image(systemName: "trash")
377
                }
378
            }
379
        }
380
        .confirmationDialog("Delete this session?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
381
            Button("Delete", role: .destructive) {
382
                _ = appData.deleteConsumptionSession(id: session.id, deviceID: session.chargedDeviceID)
383
            }
384
            Button("Cancel", role: .cancel) {}
385
        }
386
    }
387

            
388
    // MARK: - Cards
389

            
390
    private var overviewCard: some View {
391
        MeterInfoCardView(title: "Overview", tint: .purple) {
392
            MeterInfoRowView(label: "Started", value: session.startedAt.formatted(date: .abbreviated, time: .shortened))
393
            if let endedAt = session.endedAt {
394
                MeterInfoRowView(label: "Ended", value: endedAt.formatted(date: .abbreviated, time: .shortened))
395
            }
396
            MeterInfoRowView(label: "Duration", value: formattedDuration(session.duration))
397
            MeterInfoRowView(label: "Samples", value: "\(session.sampleCount) × 60 s")
398
            MeterInfoRowView(label: "Total Energy", value: energyLabel(session.totalEnergyWh))
399
            if let meterName = session.meterName {
400
                MeterInfoRowView(label: "Meter", value: meterName)
401
            }
402
        }
403
    }
404

            
405
    private var projectionCard: some View {
406
        MeterInfoCardView(title: "Consumption Projection", tint: .teal) {
407
            MeterInfoRowView(label: "Average Power", value: "\(session.averagePowerWatts.format(decimalDigits: 3)) W")
408
            MeterInfoRowView(label: "24 Hours", value: energyLabel(session.projectedDailyEnergyWh))
409
            MeterInfoRowView(label: "7 Days", value: energyLabel(session.projectedWeeklyEnergyWh))
410
            MeterInfoRowView(label: "30 Days", value: energyLabel(session.projectedMonthlyEnergyWh))
411
            MeterInfoRowView(label: "1 Year", value: energyLabel(session.projectedYearlyEnergyWh))
412
        }
413
    }
414

            
415
    private var statsCard: some View {
416
        MeterInfoCardView(title: "Statistics", tint: .indigo) {
417
            MeterInfoRowView(label: "Min Power", value: "\(session.minimumPowerWatts.format(decimalDigits: 3)) W")
418
            MeterInfoRowView(label: "Max Power", value: "\(session.maximumPowerWatts.format(decimalDigits: 3)) W")
419
            MeterInfoRowView(label: "Avg Current", value: "\(session.averageCurrentAmps.format(decimalDigits: 3)) A")
420
            MeterInfoRowView(label: "Avg Voltage", value: "\(session.averageVoltageVolts.format(decimalDigits: 3)) V")
421
        }
422
    }
423

            
424
    @ViewBuilder
425
    private var chartCard: some View {
426
        if #available(iOS 16, *) {
427
            MeterInfoCardView(title: "Power Over Time", tint: .purple) {
428
                consumptionChart(samples: session.samples, tint: .purple)
429
            }
430
        }
431
    }
432
}