1 contributor
377 lines | 13.403kb
//
//  SessionTrimEditorView.swift
//  USB Meter
//

import SwiftUI

struct SessionTrimEditorView: View {

    let session: ChargeSessionSummary
    let liveTimeRange: ClosedRange<Date>?
    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<Date>? = 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<Date>,
        sliderValue: Binding<Double>
    ) -> 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)
    }
}