Newer Older
378 lines | 13.423kb
Bogdan Timofte authored a month ago
1
//
2
//  SessionTrimEditorView.swift
3
//  USB Meter
4
//
5

            
6
import SwiftUI
7

            
8
struct SessionTrimEditorView: View {
9

            
10
    let session: ChargeSessionSummary
11
    let liveTimeRange: ClosedRange<Date>?
12
    let onApply: (Date?, Date?) -> Void
13
    let onDismiss: () -> Void
14

            
15
    @State private var trimStart: Date
16
    @State private var trimEnd: Date
17

            
18
    private var fullStart: Date { liveTimeRange?.lowerBound ?? session.startedAt }
19
    private var fullEnd: Date   { liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt) }
20
    private var sessionDuration: TimeInterval { max(fullEnd.timeIntervalSince(fullStart), 1) }
21

            
22
    private var startFraction: Double {
23
        trimStart.timeIntervalSince(fullStart) / sessionDuration
24
    }
25
    private var endFraction: Double {
26
        trimEnd.timeIntervalSince(fullStart) / sessionDuration
27
    }
28

            
29
    // Energy preview from cumulative sample values
30
    private var previewEnergyWh: Double {
31
        let sorted = session.aggregatedSamples.sorted { $0.timestamp < $1.timestamp }
32
        let baseline = sorted.last { $0.timestamp <= trimStart }
33
        guard let endSample = sorted.last(where: { $0.timestamp <= trimEnd }) else { return 0 }
34
        return max(endSample.measuredEnergyWh - (baseline?.measuredEnergyWh ?? 0), 0)
35
    }
36

            
37
    private var trimmedDuration: TimeInterval {
38
        max(trimEnd.timeIntervalSince(trimStart), 0)
39
    }
40

            
41
    private var checkpointsToRemove: [ChargeCheckpointSummary] {
42
        session.checkpoints.filter { $0.timestamp < trimStart || $0.timestamp > trimEnd }
43
    }
44

            
45
    private var isModified: Bool {
46
        trimStart != (session.trimStart ?? fullStart) ||
47
        trimEnd   != (session.trimEnd   ?? fullEnd)
48
    }
49

            
50
    init(
51
        session: ChargeSessionSummary,
52
        detectedWindow: ChargingWindowDetector.DetectedWindow? = nil,
53
        liveTimeRange: ClosedRange<Date>? = nil,
54
        onApply: @escaping (Date?, Date?) -> Void,
55
        onDismiss: @escaping () -> Void
56
    ) {
57
        self.session   = session
58
        self.liveTimeRange = liveTimeRange
59
        self.onApply   = onApply
60
        self.onDismiss = onDismiss
61

            
62
        let fullStart = liveTimeRange?.lowerBound ?? session.startedAt
63
        let fullEnd = liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt)
64
        let start = session.trimStart
65
            ?? detectedWindow?.start
66
            ?? fullStart
67
        let end = session.trimEnd
68
            ?? detectedWindow?.end
69
            ?? fullEnd
70

            
71
        _trimStart = State(initialValue: start)
72
        _trimEnd   = State(initialValue: end)
73
    }
74

            
75
    var body: some View {
76
        VStack(spacing: 0) {
77
            header
78
            ScrollView {
79
                VStack(spacing: 16) {
80
                    chartWithHandles
81
                    rangeControls
82
                    previewMetrics
83
                    if !checkpointsToRemove.isEmpty {
84
                        checkpointWarning
85
                    }
86
                }
87
                .padding(16)
88
            }
89
            applyBar
90
        }
91
        .background(Color(.systemGroupedBackground).ignoresSafeArea())
92
    }
93

            
94
    // MARK: - Header
95

            
96
    private var header: some View {
97
        HStack {
98
            Button("Cancel", action: onDismiss)
99
                .foregroundColor(.secondary)
100
            Spacer()
101
            Text("Trim Session")
102
                .font(.headline)
103
            Spacer()
104
            Button("Reset") {
105
                withAnimation(.spring(response: 0.3)) {
106
                    trimStart = fullStart
107
                    trimEnd   = fullEnd
108
                }
109
            }
110
            .foregroundColor(.orange)
111
            .disabled(trimStart == fullStart && trimEnd == fullEnd)
112
        }
113
        .padding(.horizontal, 18)
114
        .padding(.vertical, 14)
115
        .background(.regularMaterial)
116
    }
117

            
118
    // MARK: - Chart with trim overlay
119

            
120
    private var chartWithHandles: some View {
121
        VStack(alignment: .leading, spacing: 10) {
122
            HStack(spacing: 6) {
123
                Image(systemName: "scissors")
124
                    .foregroundColor(.blue)
125
                Text("Session Window")
126
                    .font(.headline)
127
            }
128

            
129
            GeometryReader { geo in
130
                let chartW = geo.size.width
131
                ZStack(alignment: .topLeading) {
132
                    // Background chart — full session
133
                    MeasurementChartView(
134
                        compactLayout: true,
135
                        availableSize: geo.size,
136
                        timeRange: fullStart...fullEnd,
137
                        showsRangeSelector: false,
138
                        rebasesEnergyToVisibleRangeStart: false
139
                    )
140

            
141
                    // Dimmed region before trimStart
142
                    Rectangle()
143
                        .fill(Color.black.opacity(0.35))
144
                        .frame(width: max(startFraction * chartW, 0))
145
                        .allowsHitTesting(false)
146

            
147
                    // Dimmed region after trimEnd
148
                    let endX = endFraction * chartW
149
                    Rectangle()
150
                        .fill(Color.black.opacity(0.35))
151
                        .frame(width: max(chartW - endX, 0))
152
                        .offset(x: endX)
153
                        .allowsHitTesting(false)
154

            
155
                    // Start handle
156
                    trimHandle(
157
                        color: .green,
158
                        symbol: "arrow.right.to.line",
159
                        xFraction: startFraction,
160
                        chartWidth: chartW,
161
                        onDrag: { dx in
162
                            let newFrac = max(0, min(startFraction + dx / chartW, endFraction - 0.01))
163
                            trimStart = fullStart.addingTimeInterval(newFrac * sessionDuration)
164
                        }
165
                    )
166

            
167
                    // End handle
168
                    trimHandle(
169
                        color: .red,
170
                        symbol: "arrow.left.to.line",
171
                        xFraction: endFraction,
172
                        chartWidth: chartW,
173
                        onDrag: { dx in
174
                            let newFrac = min(1, max(endFraction + dx / chartW, startFraction + 0.01))
175
                            trimEnd = fullStart.addingTimeInterval(newFrac * sessionDuration)
176
                        }
177
                    )
178
                }
179
                .clipped()
180
            }
181
            .frame(height: 260)
182
        }
183
        .padding(16)
184
        .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground)))
185
    }
186

            
187
    @ViewBuilder
188
    private func trimHandle(
189
        color: Color,
190
        symbol: String,
191
        xFraction: Double,
192
        chartWidth: CGFloat,
193
        onDrag: @escaping (CGFloat) -> Void
194
    ) -> some View {
195
        let xPos = CGFloat(xFraction) * chartWidth
196

            
197
        ZStack(alignment: .top) {
198
            // Vertical line
199
            Rectangle()
200
                .fill(color)
201
                .frame(width: 2)
202
                .frame(maxHeight: .infinity)
203
                .offset(x: xPos - 1)
204
                .allowsHitTesting(false)
205

            
206
            // Drag knob
207
            Circle()
208
                .fill(color)
209
                .frame(width: 28, height: 28)
210
                .overlay(
211
                    Image(systemName: symbol)
212
                        .font(.system(size: 11, weight: .bold))
213
                        .foregroundColor(.white)
214
                )
215
                .shadow(radius: 3)
216
                .offset(x: xPos - 14)
217
                .gesture(
218
                    DragGesture(minimumDistance: 0, coordinateSpace: .local)
219
                        .onChanged { value in
220
                            onDrag(value.translation.width)
221
                        }
222
                )
223
        }
224
    }
225

            
226
    // MARK: - Range controls
227

            
228
    private var rangeControls: some View {
229
        VStack(spacing: 12) {
230
            rangeRow(
231
                label: "Start",
232
                color: .green,
233
                symbol: "arrow.right.to.line",
234
                date: $trimStart,
235
                sliderValue: Binding(
236
                    get: { startFraction },
237
                    set: { v in
238
                        let clamped = max(0, min(v, endFraction - 0.01))
239
                        trimStart = fullStart.addingTimeInterval(clamped * sessionDuration)
240
                    }
241
                )
242
            )
243
            rangeRow(
244
                label: "End",
245
                color: .red,
246
                symbol: "arrow.left.to.line",
247
                date: $trimEnd,
248
                sliderValue: Binding(
249
                    get: { endFraction },
250
                    set: { v in
251
                        let clamped = min(1, max(v, startFraction + 0.01))
252
                        trimEnd = fullStart.addingTimeInterval(clamped * sessionDuration)
253
                    }
254
                )
255
            )
256
        }
257
        .padding(16)
258
        .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground)))
259
    }
260

            
261
    private func rangeRow(
262
        label: String,
263
        color: Color,
264
        symbol: String,
265
        date: Binding<Date>,
266
        sliderValue: Binding<Double>
267
    ) -> some View {
268
        VStack(spacing: 6) {
269
            HStack {
270
                Image(systemName: symbol)
271
                    .foregroundColor(color)
272
                    .frame(width: 20)
273
                Text(label)
274
                    .font(.subheadline.weight(.semibold))
275
                Spacer()
276
                Text(date.wrappedValue.format())
277
                    .font(.caption.monospacedDigit())
278
                    .foregroundColor(.secondary)
279
            }
280
            Slider(value: sliderValue, in: 0...1)
281
                .tint(color)
282
        }
283
    }
284

            
285
    // MARK: - Preview metrics
286

            
287
    private var previewMetrics: some View {
288
        VStack(alignment: .leading, spacing: 12) {
289
            HStack(spacing: 6) {
290
                Image(systemName: "waveform.path.ecg")
291
                    .foregroundColor(.teal)
292
                Text("Trimmed Metrics")
293
                    .font(.headline)
294
            }
295

            
296
            let columns = [GridItem(.flexible()), GridItem(.flexible())]
297
            LazyVGrid(columns: columns, spacing: 8) {
298
                previewCell(label: "Energy", value: "\(previewEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue)
299
                previewCell(label: "Duration", value: formatDuration(trimmedDuration), tint: .teal)
300
            }
301
        }
302
        .padding(16)
303
        .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground)))
304
    }
305

            
306
    private func previewCell(label: String, value: String, tint: Color) -> some View {
307
        VStack(alignment: .leading, spacing: 4) {
308
            Text(label)
309
                .font(.caption)
310
                .foregroundColor(.secondary)
311
            Text(value)
312
                .font(.system(.subheadline, design: .rounded).weight(.semibold))
313
                .foregroundColor(tint)
314
                .monospacedDigit()
315
        }
316
        .frame(maxWidth: .infinity, alignment: .leading)
317
        .padding(10)
318
        .background(RoundedRectangle(cornerRadius: 10).fill(tint.opacity(0.10)))
319
    }
320

            
321
    // MARK: - Checkpoint warning
322

            
323
    private var checkpointWarning: some View {
324
        HStack(alignment: .top, spacing: 10) {
325
            Image(systemName: "exclamationmark.triangle.fill")
326
                .foregroundColor(.orange)
327
            VStack(alignment: .leading, spacing: 3) {
328
                Text("\(checkpointsToRemove.count) checkpoint\(checkpointsToRemove.count == 1 ? "" : "s") outside the selected window will be removed.")
329
                    .font(.subheadline)
330
                ForEach(checkpointsToRemove) { cp in
331
                    Text("• \(cp.timestamp.format()) — \(cp.batteryPercent.format(decimalDigits: 0))%")
332
                        .font(.caption)
333
                        .foregroundColor(.secondary)
334
                }
335
            }
336
        }
337
        .padding(14)
338
        .background(
339
            RoundedRectangle(cornerRadius: 12)
340
                .fill(Color.orange.opacity(0.12))
341
                .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.orange.opacity(0.25), lineWidth: 1))
342
        )
343
    }
344

            
345
    // MARK: - Apply bar
346

            
347
    private var applyBar: some View {
348
        VStack(spacing: 0) {
349
            Divider()
350
            Button {
351
                let newStart = trimStart == fullStart ? nil : trimStart
352
                let newEnd   = trimEnd   == fullEnd   ? nil : trimEnd
353
                onApply(newStart, newEnd)
354
            } label: {
355
                Label("Apply Trim", systemImage: "scissors")
356
                    .font(.body.weight(.semibold))
357
                    .frame(maxWidth: .infinity)
358
                    .padding(.vertical, 14)
359
            }
360
            .buttonStyle(.borderedProminent)
361
            .tint(.blue)
362
            .disabled(!isModified)
363
            .padding(16)
364
        }
365
        .background(.regularMaterial)
366
    }
367

            
368
    // MARK: - Helpers
369

            
370
    private func formatDuration(_ duration: TimeInterval) -> String {
371
        let totalSeconds = Int(duration.rounded(.down))
372
        let hours = totalSeconds / 3600
373
        let minutes = (totalSeconds % 3600) / 60
374
        let seconds = totalSeconds % 60
375
        if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) }
376
        return String(format: "%02d:%02d", minutes, seconds)
377
    }
378
}