1 contributor
298 lines | 10.355kb
//
//  LiveView.swift
//  USB Meter
//
//  Created by Bogdan Timofte on 09/03/2020.
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
//

import SwiftUI

struct LiveView: View {
    private struct MetricRange {
        let minLabel: String
        let maxLabel: String
        let minValue: String
        let maxValue: String
    }

    private struct LoadResistanceSymbol: View {
        let color: Color

        var body: some View {
            GeometryReader { proxy in
                let width = proxy.size.width
                let height = proxy.size.height
                let midY = height / 2
                let startX = width * 0.10
                let endX = width * 0.90
                let boxMinX = width * 0.28
                let boxMaxX = width * 0.72
                let boxHeight = height * 0.34
                let boxRect = CGRect(
                    x: boxMinX,
                    y: midY - (boxHeight / 2),
                    width: boxMaxX - boxMinX,
                    height: boxHeight
                )
                let strokeWidth = max(1.2, height * 0.055)

                ZStack {
                    Path { path in
                        path.move(to: CGPoint(x: startX, y: midY))
                        path.addLine(to: CGPoint(x: boxRect.minX, y: midY))
                        path.move(to: CGPoint(x: boxRect.maxX, y: midY))
                        path.addLine(to: CGPoint(x: endX, y: midY))
                    }
                    .stroke(
                        color,
                        style: StrokeStyle(
                            lineWidth: strokeWidth,
                            lineCap: .round,
                            lineJoin: .round
                        )
                    )

                    Path { path in
                        path.addRect(boxRect)
                    }
                    .stroke(
                        color,
                        style: StrokeStyle(
                            lineWidth: strokeWidth,
                            lineCap: .round,
                            lineJoin: .round
                        )
                    )
                }
            }
            .padding(4)
        }
    }
    
    @EnvironmentObject private var meter: Meter
    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
            }

            LazyVGrid(columns: liveMetricColumns, spacing: compactLayout ? 10 : 12) {
                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"
                    )
                )

                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"
                    )
                )

                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"
                    )
                )

                liveMetricCard(
                    title: "Temperature",
                    symbol: "thermometer.medium",
                    color: .orange,
                    value: meter.primaryTemperatureDescription,
                    range: temperatureRange()
                )

                liveMetricCard(
                    title: "Load",
                    customSymbol: AnyView(LoadResistanceSymbol(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.RSSI) dBm",
                    range: MetricRange(
                        minLabel: "Min",
                        maxLabel: "Max",
                        minValue: "\(meter.btSerial.minRSSI) dBm",
                        maxValue: "\(meter.btSerial.maxRSSI) dBm"
                    ),
                    valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold)
                )
            }
        }
        .frame(maxWidth: .infinity, alignment: .topLeading)
    }

    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: MetricRange? = nil,
        detailText: String? = nil,
        valueFont: Font? = nil,
        valueLineLimit: Int = 1,
        valueMonospacedDigits: Bool = true,
        valueMinimumScaleFactor: CGFloat = 0.85
    ) -> some View {
        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)
    }

    private func metricRangeTable(_ range: MetricRange) -> 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) -> MetricRange? {
        guard min.isFinite, max.isFinite else { return nil }

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

    private func temperatureRange() -> MetricRange? {
        let value = meter.primaryTemperatureDescription
        guard !value.isEmpty else { return nil }

        return MetricRange(
            minLabel: "Min",
            maxLabel: "Max",
            minValue: value,
            maxValue: value
        )
    }
}