1 contributor
674 lines | 27.556kb
//
//  MeterLiveContentView.swift
//  USB Meter
//
//  Created by Bogdan Timofte on 09/03/2020.
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
//

import SwiftUI

struct MeterLiveContentView: View {
    @EnvironmentObject private var meter: Meter
    @State private var powerAverageSheetVisibility = false
    @State private var rssiHistorySheetVisibility = false
    var compactLayout: Bool = false
    var availableSize: CGSize? = nil

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            HStack {
                Text("Live Data")
                    .font(.headline)
                Spacer()
                statusBadge
            }

            MeterInfoCardView(title: "Detected Meter", tint: .indigo) {
                MeterInfoRowView(label: "Name", value: meter.name.isEmpty ? "Meter" : meter.name)
                MeterInfoRowView(label: "Model", value: meter.deviceModelSummary)
                MeterInfoRowView(label: "Advertised Model", value: meter.modelString)
                MeterInfoRowView(label: "MAC", value: meter.btSerial.macAddress.description)
                MeterInfoRowView(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen))
                MeterInfoRowView(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt))
            }
            .frame(maxWidth: .infinity, alignment: .leading)

            LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
                if shouldShowVoltageCard {
                    liveMetricCard(
                        title: "Voltage",
                        symbol: "bolt.fill",
                        color: .green,
                        value: "\(meter.voltage.format(decimalDigits: 3)) V",
                        range: metricRange(
                            min: meter.measurements.voltage.context.minValue,
                            max: meter.measurements.voltage.context.maxValue,
                            unit: "V"
                        )
                    )
                }

                if shouldShowCurrentCard {
                    liveMetricCard(
                        title: "Current",
                        symbol: "waveform.path.ecg",
                        color: .blue,
                        value: "\(meter.current.format(decimalDigits: 3)) A",
                        range: metricRange(
                            min: meter.measurements.current.context.minValue,
                            max: meter.measurements.current.context.maxValue,
                            unit: "A"
                        )
                    )
                }

                if shouldShowPowerCard {
                    liveMetricCard(
                        title: "Power",
                        symbol: "flame.fill",
                        color: .pink,
                        value: "\(meter.power.format(decimalDigits: 3)) W",
                        range: metricRange(
                            min: meter.measurements.power.context.minValue,
                            max: meter.measurements.power.context.maxValue,
                            unit: "W"
                        ),
                        action: {
                            powerAverageSheetVisibility = true
                        }
                    )
                }

                if shouldShowTemperatureCard {
                    liveMetricCard(
                        title: "Temperature",
                        symbol: "thermometer.medium",
                        color: .orange,
                        value: meter.primaryTemperatureDescription,
                        range: temperatureRange(
                            min: meter.measurements.temperature.context.minValue,
                            max: meter.measurements.temperature.context.maxValue
                        )
                    )
                }

                if shouldShowLoadCard {
                    liveMetricCard(
                        title: "Load",
                        customSymbol: AnyView(LoadResistanceIconView(color: .yellow)),
                        color: .yellow,
                        value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
                        detailText: "Measured resistance"
                    )
                }

                liveMetricCard(
                    title: "RSSI",
                    symbol: "dot.radiowaves.left.and.right",
                    color: .mint,
                    value: "\(meter.btSerial.averageRSSI) dBm",
                    range: metricRange(
                        min: meter.measurements.rssi.context.minValue,
                        max: meter.measurements.rssi.context.maxValue,
                        unit: "dBm",
                        decimalDigits: 0
                    ),
                    valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
                    action: {
                        rssiHistorySheetVisibility = true
                    }
                )

                if meter.supportsChargerDetection && hasLiveMetrics {
                    liveMetricCard(
                        title: "Detected Charger",
                        symbol: "powerplug.fill",
                        color: .indigo,
                        value: meter.chargerTypeDescription,
                        detailText: "Source handshake",
                        valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
                        valueLineLimit: 2,
                        valueMonospacedDigits: false,
                        valueMinimumScaleFactor: 0.72
                    )
                }
            }
        }
        .frame(maxWidth: .infinity, alignment: .topLeading)
        .sheet(isPresented: $powerAverageSheetVisibility) {
            PowerAverageSheetView(visibility: $powerAverageSheetVisibility)
                .environmentObject(meter.measurements)
        }
        .sheet(isPresented: $rssiHistorySheetVisibility) {
            RSSIHistorySheetView(visibility: $rssiHistorySheetVisibility)
                .environmentObject(meter.measurements)
        }
    }

    private var hasLiveMetrics: Bool {
        meter.operationalState == .dataIsAvailable
    }

    private var shouldShowVoltageCard: Bool {
        hasLiveMetrics && meter.measurements.voltage.context.isValid && meter.voltage.isFinite
    }

    private var shouldShowCurrentCard: Bool {
        hasLiveMetrics && meter.measurements.current.context.isValid && meter.current.isFinite
    }

    private var shouldShowPowerCard: Bool {
        hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite
    }

    private var shouldShowTemperatureCard: Bool {
        hasLiveMetrics && meter.displayedTemperatureValue.isFinite
    }

    private var shouldShowLoadCard: Bool {
        hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0
    }

    private var liveMetricColumns: [GridItem] {
        if compactLayout {
            return Array(repeating: GridItem(.flexible(), spacing: 10), count: 3)
        }

        return [GridItem(.flexible()), GridItem(.flexible())]
    }

    private var statusBadge: some View {
        Text(meter.operationalState == .dataIsAvailable ? "Live" : "Waiting")
            .font(.caption.weight(.semibold))
            .padding(.horizontal, 10)
            .padding(.vertical, 6)
            .foregroundColor(meter.operationalState == .dataIsAvailable ? .green : .secondary)
            .meterCard(
                tint: meter.operationalState == .dataIsAvailable ? .green : .secondary,
                fillOpacity: 0.12,
                strokeOpacity: 0.16,
                cornerRadius: 999
            )
    }

    private var showsCompactMetricRange: Bool {
        compactLayout && (availableSize?.height ?? 0) >= 380
    }

    private var shouldShowMetricRange: Bool {
        !compactLayout || showsCompactMetricRange
    }

    private func liveMetricCard(
        title: String,
        symbol: String? = nil,
        customSymbol: AnyView? = nil,
        color: Color,
        value: String,
        range: MeterLiveMetricRange? = nil,
        detailText: String? = nil,
        valueFont: Font? = nil,
        valueLineLimit: Int = 1,
        valueMonospacedDigits: Bool = true,
        valueMinimumScaleFactor: CGFloat = 0.85,
        action: (() -> Void)? = nil
    ) -> some View {
        let cardContent = VStack(alignment: .leading, spacing: 10) {
            HStack(spacing: compactLayout ? 8 : 10) {
                Group {
                    if let customSymbol {
                        customSymbol
                    } else if let symbol {
                        Image(systemName: symbol)
                            .font(.system(size: compactLayout ? 14 : 15, weight: .semibold))
                            .foregroundColor(color)
                    }
                }
                    .frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34)
                .background(Circle().fill(color.opacity(0.12)))

                Text(title)
                    .font((compactLayout ? Font.caption : .subheadline).weight(.semibold))
                    .foregroundColor(.secondary)
                    .lineLimit(1)

                Spacer(minLength: 0)
            }

            Group {
                if valueMonospacedDigits {
                    Text(value)
                        .monospacedDigit()
                } else {
                    Text(value)
                }
            }
            .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold))
            .lineLimit(valueLineLimit)
            .minimumScaleFactor(valueMinimumScaleFactor)

            if shouldShowMetricRange {
                if let range {
                    metricRangeTable(range)
                } else if let detailText, !detailText.isEmpty {
                    Text(detailText)
                        .font(.caption)
                        .foregroundColor(.secondary)
                        .lineLimit(2)
                }
            }
        }
        .frame(
            maxWidth: .infinity,
            minHeight: compactLayout ? (shouldShowMetricRange ? 132 : 96) : 128,
            alignment: .leading
        )
        .padding(compactLayout ? 12 : 16)
        .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12)

        if let action {
            return AnyView(
                Button(action: action) {
                    cardContent
                }
                .buttonStyle(.plain)
            )
        }

        return AnyView(cardContent)
    }

    private func metricRangeTable(_ range: MeterLiveMetricRange) -> some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack(spacing: 12) {
                Text(range.minLabel)
                Spacer(minLength: 0)
                Text(range.maxLabel)
            }
            .font(.caption2.weight(.semibold))
            .foregroundColor(.secondary)

            HStack(spacing: 12) {
                Text(range.minValue)
                    .monospacedDigit()
                Spacer(minLength: 0)
                Text(range.maxValue)
                    .monospacedDigit()
            }
            .font(.caption.weight(.medium))
            .foregroundColor(.primary)
        }
    }

    private func metricRange(min: Double, max: Double, unit: String, decimalDigits: Int = 3) -> MeterLiveMetricRange? {
        guard min.isFinite, max.isFinite else { return nil }

        return MeterLiveMetricRange(
            minLabel: "Min",
            maxLabel: "Max",
            minValue: "\(min.format(decimalDigits: decimalDigits)) \(unit)",
            maxValue: "\(max.format(decimalDigits: decimalDigits)) \(unit)"
        )
    }

    private func temperatureRange(min: Double, max: Double) -> MeterLiveMetricRange? {
        guard min.isFinite, max.isFinite else { return nil }

        let unitSuffix = temperatureUnitSuffix()

        return MeterLiveMetricRange(
            minLabel: "Min",
            maxLabel: "Max",
            minValue: "\(min.format(decimalDigits: 0))\(unitSuffix)",
            maxValue: "\(max.format(decimalDigits: 0))\(unitSuffix)"
        )
    }

    private func meterHistoryText(for date: Date?) -> String {
        guard let date else {
            return "Never"
        }
        return date.format(as: "yyyy-MM-dd HH:mm")
    }

    private func temperatureUnitSuffix() -> String {
        if meter.supportsManualTemperatureUnitSelection {
            return "°"
        }

        let locale = Locale.autoupdatingCurrent
        if #available(iOS 16.0, *) {
            switch locale.measurementSystem {
            case .us:
                return "°F"
            default:
                return "°C"
            }
        }

        let regionCode = locale.regionCode ?? ""
        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
        return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
    }
}

private struct PowerAverageSheetView: View {
    @EnvironmentObject private var measurements: Measurements

    @Binding var visibility: Bool

    @State private var selectedSampleCount: Int = 20

    var body: some View {
        let bufferedSamples = measurements.powerSampleCount()

        NavigationView {
            ScrollView {
                VStack(alignment: .leading, spacing: 14) {
                    VStack(alignment: .leading, spacing: 8) {
                        Text("Power Average")
                            .font(.system(.title3, design: .rounded).weight(.bold))
                        Text("Inspect the recent power buffer, choose how many values to include, and compute the average power over that window.")
                            .font(.footnote)
                            .foregroundColor(.secondary)
                    }
                    .padding(18)
                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)

                    MeterInfoCardView(title: "Average Calculator", tint: .pink) {
                        if bufferedSamples == 0 {
                            Text("No power samples are available yet.")
                                .font(.footnote)
                                .foregroundColor(.secondary)
                        } else {
                            VStack(alignment: .leading, spacing: 14) {
                                VStack(alignment: .leading, spacing: 8) {
                                    Text("Values used")
                                        .font(.subheadline.weight(.semibold))

                                    Picker("Values used", selection: selectedSampleCountBinding(bufferedSamples: bufferedSamples)) {
                                        ForEach(availableSampleOptions(bufferedSamples: bufferedSamples), id: \.self) { option in
                                            Text(sampleOptionTitle(option, bufferedSamples: bufferedSamples)).tag(option)
                                        }
                                    }
                                    .pickerStyle(.menu)
                                }

                                VStack(alignment: .leading, spacing: 6) {
                                    Text(averagePowerLabel(bufferedSamples: bufferedSamples))
                                        .font(.system(.title2, design: .rounded).weight(.bold))
                                        .monospacedDigit()

                                    Text("Buffered samples: \(bufferedSamples)")
                                        .font(.caption)
                                        .foregroundColor(.secondary)
                                }
                            }
                        }
                    }

                    MeterInfoCardView(title: "Buffer Actions", tint: .secondary) {
                        Text("Reset clears the captured live measurement buffer for power, voltage, current, temperature, and RSSI.")
                            .font(.footnote)
                            .foregroundColor(.secondary)

                        Button("Reset Buffer") {
                            measurements.resetSeries()
                            selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false))
                        }
                        .foregroundColor(.red)
                    }
                }
                .padding()
            }
            .background(
                LinearGradient(
                    colors: [.pink.opacity(0.14), Color.clear],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                .ignoresSafeArea()
            )
            .navigationBarItems(
                leading: Button("Done") { visibility.toggle() }
            )
            .navigationBarTitle("Power", displayMode: .inline)
        }
        .navigationViewStyle(StackNavigationViewStyle())
        .onAppear {
            selectedSampleCount = defaultSampleCount(bufferedSamples: bufferedSamples)
        }
        .onChange(of: bufferedSamples) { newValue in
            selectedSampleCount = min(max(1, selectedSampleCount), max(1, newValue))
        }
    }

    private func availableSampleOptions(bufferedSamples: Int) -> [Int] {
        guard bufferedSamples > 0 else { return [] }

        let filtered = measurements.averagePowerSampleOptions.filter { $0 < bufferedSamples }
        return (filtered + [bufferedSamples]).sorted()
    }

    private func defaultSampleCount(bufferedSamples: Int) -> Int {
        guard bufferedSamples > 0 else { return 20 }
        return min(20, bufferedSamples)
    }

    private func selectedSampleCountBinding(bufferedSamples: Int) -> Binding<Int> {
        Binding(
            get: {
                let availableOptions = availableSampleOptions(bufferedSamples: bufferedSamples)
                guard !availableOptions.isEmpty else { return defaultSampleCount(bufferedSamples: bufferedSamples) }
                if availableOptions.contains(selectedSampleCount) {
                    return selectedSampleCount
                }
                return min(availableOptions.last ?? bufferedSamples, defaultSampleCount(bufferedSamples: bufferedSamples))
            },
            set: { newValue in
                selectedSampleCount = newValue
            }
        )
    }

    private func sampleOptionTitle(_ option: Int, bufferedSamples: Int) -> String {
        if option == bufferedSamples {
            return "All (\(option))"
        }
        return "\(option) values"
    }

    private func averagePowerLabel(bufferedSamples: Int) -> String {
        guard let average = measurements.averagePower(forRecentSampleCount: selectedSampleCount, flushPendingValues: false) else {
            return "No data"
        }

        let effectiveSampleCount = min(selectedSampleCount, bufferedSamples)
        return "\(average.format(decimalDigits: 3)) W avg (\(effectiveSampleCount))"
    }
}

private struct RSSIHistorySheetView: View {
    @EnvironmentObject private var measurements: Measurements

    @Binding var visibility: Bool

    private let xLabels: Int = 4
    private let yLabels: Int = 4

    var body: some View {
        let points = measurements.rssi.points
        let samplePoints = measurements.rssi.samplePoints
        let chartContext = buildChartContext(for: samplePoints)

        NavigationView {
            ScrollView {
                VStack(alignment: .leading, spacing: 14) {
                    VStack(alignment: .leading, spacing: 8) {
                        Text("RSSI History")
                            .font(.system(.title3, design: .rounded).weight(.bold))
                        Text("Signal strength captured over time while the meter stays connected.")
                            .font(.footnote)
                            .foregroundColor(.secondary)
                    }
                    .padding(18)
                    .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24)

                    if samplePoints.isEmpty {
                        Text("No RSSI samples have been captured yet.")
                            .font(.footnote)
                            .foregroundColor(.secondary)
                            .padding(18)
                            .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20)
                    } else {
                        MeterInfoCardView(title: "Signal Chart", tint: .mint) {
                            VStack(alignment: .leading, spacing: 12) {
                                HStack(spacing: 12) {
                                    signalSummaryChip(title: "Current", value: "\(Int(samplePoints.last?.value ?? 0)) dBm")
                                    signalSummaryChip(title: "Min", value: "\(Int(samplePoints.map(\.value).min() ?? 0)) dBm")
                                    signalSummaryChip(title: "Max", value: "\(Int(samplePoints.map(\.value).max() ?? 0)) dBm")
                                }

                                HStack(spacing: 8) {
                                    rssiYAxisView(context: chartContext)
                                        .frame(width: 52, height: 220)

                                    ZStack {
                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
                                            .fill(Color.primary.opacity(0.05))

                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)

                                        rssiHorizontalGuides(context: chartContext)
                                        rssiVerticalGuides(context: chartContext)
                                        Chart(points: points, context: chartContext, strokeColor: .mint)
                                            .opacity(0.82)
                                    }
                                    .frame(maxWidth: .infinity)
                                    .frame(height: 220)
                                }

                                rssiXAxisLabelsView(context: chartContext)
                                    .frame(height: 28)
                            }
                        }
                    }
                }
                .padding()
            }
            .background(
                LinearGradient(
                    colors: [.mint.opacity(0.14), Color.clear],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                .ignoresSafeArea()
            )
            .navigationBarItems(
                leading: Button("Done") { visibility.toggle() }
            )
            .navigationBarTitle("RSSI", displayMode: .inline)
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }

    private func buildChartContext(for samplePoints: [Measurements.Measurement.Point]) -> ChartContext {
        let context = ChartContext()
        let upperBound = max(samplePoints.last?.timestamp ?? Date(), Date())
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-60)
        let minimumValue = samplePoints.map(\.value).min() ?? -100
        let maximumValue = samplePoints.map(\.value).max() ?? -40
        let padding = max((maximumValue - minimumValue) * 0.12, 4)

        context.setBounds(
            xMin: CGFloat(lowerBound.timeIntervalSince1970),
            xMax: CGFloat(max(upperBound.timeIntervalSince1970, lowerBound.timeIntervalSince1970 + 1)),
            yMin: CGFloat(minimumValue - padding),
            yMax: CGFloat(maximumValue + padding)
        )
        return context
    }

    private func signalSummaryChip(title: String, value: String) -> some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(title)
                .font(.caption.weight(.semibold))
                .foregroundColor(.secondary)
            Text(value)
                .font(.subheadline.weight(.bold))
                .monospacedDigit()
        }
        .padding(.horizontal, 12)
        .padding(.vertical, 10)
        .meterCard(tint: .mint, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
    }

    private func rssiXAxisLabelsView(context: ChartContext) -> some View {
        let labels = (1...xLabels).map {
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: xLabels)).format(as: "HH:mm:ss")
        }

        return HStack {
            ForEach(Array(labels.enumerated()), id: \.offset) { item in
                Text(item.element)
                    .font(.caption2.weight(.semibold))
                    .monospacedDigit()
                    .frame(maxWidth: .infinity)
            }
        }
        .foregroundColor(.secondary)
    }

    private func rssiYAxisView(context: ChartContext) -> some View {
        VStack(spacing: 0) {
            ForEach((1...yLabels).reversed(), id: \.self) { labelIndex in
                Spacer(minLength: 0)
                Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(decimalDigits: 0))")
                    .font(.caption2.weight(.semibold))
                    .monospacedDigit()
                    .foregroundColor(.primary)
                Spacer(minLength: 0)
            }
        }
        .padding(.vertical, 12)
        .background(
            RoundedRectangle(cornerRadius: 16, style: .continuous)
                .fill(Color.mint.opacity(0.12))
        )
        .overlay(
            RoundedRectangle(cornerRadius: 16, style: .continuous)
                .stroke(Color.mint.opacity(0.20), lineWidth: 1)
        )
    }

    private func rssiHorizontalGuides(context: ChartContext) -> some View {
        GeometryReader { geometry in
            Path { path in
                for labelIndex in 1...yLabels {
                    let value = context.yAxisLabel(for: labelIndex, of: yLabels)
                    let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
                    let y = context.placeInRect(point: anchorPoint).y * geometry.size.height
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
                }
            }
            .stroke(Color.secondary.opacity(0.30), lineWidth: 0.8)
        }
    }

    private func rssiVerticalGuides(context: ChartContext) -> some View {
        GeometryReader { geometry in
            Path { path in
                for labelIndex in 2..<xLabels {
                    let value = context.xAxisLabel(for: labelIndex, of: xLabels)
                    let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
                    let x = context.placeInRect(point: anchorPoint).x * geometry.size.width
                    path.move(to: CGPoint(x: x, y: 0))
                    path.addLine(to: CGPoint(x: x, y: geometry.size.height))
                }
            }
            .stroke(Color.secondary.opacity(0.26), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
        }
    }
}