// // SessionTrimEditorView.swift // USB Meter // import SwiftUI struct SessionTrimEditorView: View { let session: ChargeSessionSummary let liveTimeRange: ClosedRange? let onApply: (Date?, Date?) -> Void let onDismiss: () -> Void @State private var trimStart: Date @State private var trimEnd: Date private var fullStart: Date { liveTimeRange?.lowerBound ?? session.startedAt } private var fullEnd: Date { liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt) } private var sessionDuration: TimeInterval { max(fullEnd.timeIntervalSince(fullStart), 1) } private var startFraction: Double { trimStart.timeIntervalSince(fullStart) / sessionDuration } private var endFraction: Double { trimEnd.timeIntervalSince(fullStart) / sessionDuration } // Energy preview from cumulative sample values private var previewEnergyWh: Double { let sorted = session.aggregatedSamples.sorted { $0.timestamp < $1.timestamp } let baseline = sorted.last { $0.timestamp <= trimStart } guard let endSample = sorted.last(where: { $0.timestamp <= trimEnd }) else { return 0 } return max(endSample.measuredEnergyWh - (baseline?.measuredEnergyWh ?? 0), 0) } private var trimmedDuration: TimeInterval { max(trimEnd.timeIntervalSince(trimStart), 0) } private var checkpointsToRemove: [ChargeCheckpointSummary] { session.checkpoints.filter { $0.timestamp < trimStart || $0.timestamp > trimEnd } } private var isModified: Bool { trimStart != (session.trimStart ?? fullStart) || trimEnd != (session.trimEnd ?? fullEnd) } init( session: ChargeSessionSummary, detectedWindow: ChargingWindowDetector.DetectedWindow? = nil, liveTimeRange: ClosedRange? = nil, onApply: @escaping (Date?, Date?) -> Void, onDismiss: @escaping () -> Void ) { self.session = session self.liveTimeRange = liveTimeRange self.onApply = onApply self.onDismiss = onDismiss let fullStart = liveTimeRange?.lowerBound ?? session.startedAt let fullEnd = liveTimeRange?.upperBound ?? (session.endedAt ?? session.lastObservedAt) let start = session.trimStart ?? detectedWindow?.start ?? fullStart let end = session.trimEnd ?? detectedWindow?.end ?? fullEnd _trimStart = State(initialValue: start) _trimEnd = State(initialValue: end) } var body: some View { VStack(spacing: 0) { header ScrollView { VStack(spacing: 16) { chartWithHandles rangeControls previewMetrics if !checkpointsToRemove.isEmpty { checkpointWarning } } .padding(16) } applyBar } .background(Color(.systemGroupedBackground).ignoresSafeArea()) } // MARK: - Header private var header: some View { HStack { Button("Cancel", action: onDismiss) .foregroundColor(.secondary) Spacer() Text("Trim Session") .font(.headline) Spacer() Button("Reset") { withAnimation(.spring(response: 0.3)) { trimStart = fullStart trimEnd = fullEnd } } .foregroundColor(.orange) .disabled(trimStart == fullStart && trimEnd == fullEnd) } .padding(.horizontal, 18) .padding(.vertical, 14) .background(.regularMaterial) } // MARK: - Chart with trim overlay private var chartWithHandles: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 6) { Image(systemName: "scissors") .foregroundColor(.blue) Text("Session Window") .font(.headline) } GeometryReader { geo in let chartW = geo.size.width ZStack(alignment: .topLeading) { // Background chart — full session MeasurementChartView( sizing: .provided(size: geo.size, compact: true), timeRange: fullStart...fullEnd, showsRangeSelector: false, rebasesEnergyToVisibleRangeStart: false ) // Dimmed region before trimStart Rectangle() .fill(Color.black.opacity(0.35)) .frame(width: max(startFraction * chartW, 0)) .allowsHitTesting(false) // Dimmed region after trimEnd let endX = endFraction * chartW Rectangle() .fill(Color.black.opacity(0.35)) .frame(width: max(chartW - endX, 0)) .offset(x: endX) .allowsHitTesting(false) // Start handle trimHandle( color: .green, symbol: "arrow.right.to.line", xFraction: startFraction, chartWidth: chartW, onDrag: { dx in let newFrac = max(0, min(startFraction + dx / chartW, endFraction - 0.01)) trimStart = fullStart.addingTimeInterval(newFrac * sessionDuration) } ) // End handle trimHandle( color: .red, symbol: "arrow.left.to.line", xFraction: endFraction, chartWidth: chartW, onDrag: { dx in let newFrac = min(1, max(endFraction + dx / chartW, startFraction + 0.01)) trimEnd = fullStart.addingTimeInterval(newFrac * sessionDuration) } ) } .clipped() } .frame(height: 260) } .padding(16) .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground))) } @ViewBuilder private func trimHandle( color: Color, symbol: String, xFraction: Double, chartWidth: CGFloat, onDrag: @escaping (CGFloat) -> Void ) -> some View { let xPos = CGFloat(xFraction) * chartWidth ZStack(alignment: .top) { // Vertical line Rectangle() .fill(color) .frame(width: 2) .frame(maxHeight: .infinity) .offset(x: xPos - 1) .allowsHitTesting(false) // Drag knob Circle() .fill(color) .frame(width: 28, height: 28) .overlay( Image(systemName: symbol) .font(.system(size: 11, weight: .bold)) .foregroundColor(.white) ) .shadow(radius: 3) .offset(x: xPos - 14) .gesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { value in onDrag(value.translation.width) } ) } } // MARK: - Range controls private var rangeControls: some View { VStack(spacing: 12) { rangeRow( label: "Start", color: .green, symbol: "arrow.right.to.line", date: $trimStart, sliderValue: Binding( get: { startFraction }, set: { v in let clamped = max(0, min(v, endFraction - 0.01)) trimStart = fullStart.addingTimeInterval(clamped * sessionDuration) } ) ) rangeRow( label: "End", color: .red, symbol: "arrow.left.to.line", date: $trimEnd, sliderValue: Binding( get: { endFraction }, set: { v in let clamped = min(1, max(v, startFraction + 0.01)) trimEnd = fullStart.addingTimeInterval(clamped * sessionDuration) } ) ) } .padding(16) .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground))) } private func rangeRow( label: String, color: Color, symbol: String, date: Binding, sliderValue: Binding ) -> some View { VStack(spacing: 6) { HStack { Image(systemName: symbol) .foregroundColor(color) .frame(width: 20) Text(label) .font(.subheadline.weight(.semibold)) Spacer() Text(date.wrappedValue.format()) .font(.caption.monospacedDigit()) .foregroundColor(.secondary) } Slider(value: sliderValue, in: 0...1) .tint(color) } } // MARK: - Preview metrics private var previewMetrics: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 6) { Image(systemName: "waveform.path.ecg") .foregroundColor(.teal) Text("Trimmed Metrics") .font(.headline) } let columns = [GridItem(.flexible()), GridItem(.flexible())] LazyVGrid(columns: columns, spacing: 8) { previewCell(label: "Energy", value: "\(previewEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue) previewCell(label: "Duration", value: formatDuration(trimmedDuration), tint: .teal) } } .padding(16) .background(RoundedRectangle(cornerRadius: 14).fill(Color(.secondarySystemGroupedBackground))) } private func previewCell(label: String, value: String, tint: Color) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label) .font(.caption) .foregroundColor(.secondary) Text(value) .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundColor(tint) .monospacedDigit() } .frame(maxWidth: .infinity, alignment: .leading) .padding(10) .background(RoundedRectangle(cornerRadius: 10).fill(tint.opacity(0.10))) } // MARK: - Checkpoint warning private var checkpointWarning: some View { HStack(alignment: .top, spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) VStack(alignment: .leading, spacing: 3) { Text("\(checkpointsToRemove.count) checkpoint\(checkpointsToRemove.count == 1 ? "" : "s") outside the selected window will be removed.") .font(.subheadline) ForEach(checkpointsToRemove) { cp in Text("• \(cp.timestamp.format()) — \(cp.batteryPercent.format(decimalDigits: 0))%") .font(.caption) .foregroundColor(.secondary) } } } .padding(14) .background( RoundedRectangle(cornerRadius: 12) .fill(Color.orange.opacity(0.12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.orange.opacity(0.25), lineWidth: 1)) ) } // MARK: - Apply bar private var applyBar: some View { VStack(spacing: 0) { Divider() Button { let newStart = trimStart == fullStart ? nil : trimStart let newEnd = trimEnd == fullEnd ? nil : trimEnd onApply(newStart, newEnd) } label: { Label("Apply Trim", systemImage: "scissors") .font(.body.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.vertical, 14) } .buttonStyle(.borderedProminent) .tint(.blue) .disabled(!isModified) .padding(16) } .background(.regularMaterial) } // MARK: - Helpers private func formatDuration(_ duration: TimeInterval) -> String { let totalSeconds = Int(duration.rounded(.down)) let hours = totalSeconds / 3600 let minutes = (totalSeconds % 3600) / 60 let seconds = totalSeconds % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%02d:%02d", minutes, seconds) } }