1 contributor
//
// 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(
compactLayout: true,
availableSize: geo.size,
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)
}
}