1 contributor
379 lines | 15.13kb
//
//  MeterView.swift
//  USB Meter
//
//  Created by Bogdan Timofte on 04/03/2020.
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
//
// MARK: Parent frame https://stackoverflow.com/questions/56832865/how-to-access-parents-frame-in-swiftui

import SwiftUI
import CoreBluetooth

struct MeterView: View {
    
    @EnvironmentObject private var meter: Meter
    
    @State var dataGroupsViewVisibility: Bool = false
    @State var recordingViewVisibility: Bool = false
    @State var measurementsViewVisibility: Bool = false
    private var myBounds: CGRect { UIScreen.main.bounds }
    private let actionStripPadding: CGFloat = 10
    private let actionDividerWidth: CGFloat = 1
    private let actionButtonMaxWidth: CGFloat = 156
    private let actionButtonMinWidth: CGFloat = 88
    private let actionButtonHeight: CGFloat = 108

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                connectionCard

                if meter.operationalState == .dataIsAvailable {
                    actionGrid

                    LiveView()
                        .padding(18)
                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)

                    if meter.measurements.power.context.isValid {
                        MeasurementChartView()
                            .environmentObject(meter.measurements)
                            .frame(minHeight: myBounds.height / 3.4)
                            .padding(16)
                            .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
                    }

                    ControlView()
                        .padding(16)
                        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.20)
                }
            }
            .padding()
        }
        .background(
            LinearGradient(
                colors: [
                    meter.color.opacity(0.22),
                    Color.secondary.opacity(0.08),
                    Color.clear
                ],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()
        )
        .navigationBarTitle("Meter")
        .navigationBarItems(trailing: HStack (spacing: 6) {
            if meter.operationalState > .notPresent {
                RSSIView(RSSI: meter.btSerial.RSSI)
                    .frame(width: 18, height: 18)
                    .padding(.leading, 6)
                    .padding(.vertical)
            }
            NavigationLink(destination: MeterInfoView().environmentObject(meter)) {
                Image(systemName: "info.circle.fill")
                    .padding(.vertical)
            }
            NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
                Image(systemName: "gearshape.fill")
                    .padding(.vertical)
            }
        })
    }

    private var connectionCard: some View {
        VStack(alignment: .leading, spacing: 18) {
            HStack(alignment: .top) {
                VStack(alignment: .leading, spacing: 6) {
                    Text(meter.name)
                        .font(.system(.title2, design: .rounded).weight(.bold))
                    Text(meter.deviceModelSummary)
                        .font(.subheadline.weight(.semibold))
                        .foregroundColor(.secondary)
                }
                Spacer()
                VStack(alignment: .trailing, spacing: 6) {
                    Text(statusText)
                        .font(.caption.weight(.bold))
                        .padding(.horizontal, 12)
                        .padding(.vertical, 6)
                        .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
                    if meter.operationalState > .notPresent {
                        Text("RSSI \(meter.btSerial.RSSI)")
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }
                }
            }

            connectionActionArea
        }
        .padding(20)
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
    }

    private var actionGrid: some View {
        GeometryReader { proxy in
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
            let stripWidth = actionStripWidth(for: buttonWidth)

            HStack {
                Spacer(minLength: 0)
                HStack(spacing: 0) {
                    meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth) {
                        dataGroupsViewVisibility.toggle()
                    }
                    .sheet(isPresented: $dataGroupsViewVisibility) {
                        DataGroupsView(visibility: $dataGroupsViewVisibility)
                            .environmentObject(meter)
                    }

                    if meter.supportsRecordingView {
                        actionStripDivider
                        meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth) {
                            recordingViewVisibility.toggle()
                        }
                        .sheet(isPresented: $recordingViewVisibility) {
                            RecordingView(visibility: $recordingViewVisibility)
                                .environmentObject(meter)
                        }
                    }

                    actionStripDivider
                    meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth) {
                        measurementsViewVisibility.toggle()
                    }
                    .sheet(isPresented: $measurementsViewVisibility) {
                        MeasurementsView(visibility: $measurementsViewVisibility)
                            .environmentObject(meter.measurements)
                    }
                }
                .padding(actionStripPadding)
                .frame(width: stripWidth)
                .meterCard(tint: Color.secondary, fillOpacity: 0.10, strokeOpacity: 0.16)
                Spacer(minLength: 0)
            }
        }
        .frame(height: actionButtonHeight + (actionStripPadding * 2))
    }
    
    private var connectionActionArea: some View {
        let connected = meter.operationalState >= .peripheralConnectionPending
        let tint = connected ? disconnectActionTint : connectActionTint

        return Group {
            if meter.operationalState == .notPresent {
                HStack(spacing: 10) {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundColor(.orange)
                    Text("Not found at this time.")
                        .fontWeight(.semibold)
                    Spacer()
                }
                .padding(16)
                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
            } else {
                Button(action: {
                    if meter.operationalState < .peripheralConnectionPending {
                        meter.connect()
                    } else {
                        meter.disconnect()
                    }
                }) {
                    HStack(spacing: 12) {
                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
                            .foregroundColor(tint)
                            .frame(width: 30, height: 30)
                            .background(Circle().fill(tint.opacity(0.12)))
                        Text(connected ? "Disconnect" : "Connect")
                            .fontWeight(.semibold)
                            .foregroundColor(.primary)
                        Spacer()
                    }
                    .padding(.horizontal, 18)
                    .padding(.vertical, 14)
                    .frame(maxWidth: .infinity)
                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
                }
                .buttonStyle(.plain)
            }
        }
    }

    fileprivate func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, action: @escaping () -> Void) -> some View {
        Button(action: action) {
            VStack(spacing: 10) {
                Image(systemName: icon)
                    .font(.system(size: 20, weight: .semibold))
                    .frame(width: 40, height: 40)
                    .background(Circle().fill(tint.opacity(0.14)))
                Text(title)
                    .font(.footnote.weight(.semibold))
                    .multilineTextAlignment(.center)
                    .lineLimit(2)
                    .minimumScaleFactor(0.9)
            }
            .foregroundColor(tint)
            .frame(width: width, height: actionButtonHeight)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }

    private var visibleActionButtonCount: CGFloat {
        meter.supportsRecordingView ? 3 : 2
    }

    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
    }

    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
    }

    private var actionStripDivider: some View {
        Rectangle()
            .fill(Color.secondary.opacity(0.16))
            .frame(width: actionDividerWidth, height: actionButtonHeight - 24)
    }

    private var connectActionTint: Color {
        Color(red: 0.20, green: 0.46, blue: 0.43)
    }

    private var disconnectActionTint: Color {
        Color(red: 0.66, green: 0.39, blue: 0.35)
    }

    private var statusText: String {
        switch meter.operationalState {
        case .notPresent:
            return "Missing"
        case .peripheralNotConnected:
            return "Ready"
        case .peripheralConnectionPending:
            return "Connecting"
        case .peripheralConnected:
            return "Linked"
        case .peripheralReady:
            return "Preparing"
        case .comunicating:
            return "Syncing"
        case .dataIsAvailable:
            return "Live"
        }
    }

    private var statusColor: Color {
        Meter.operationalColor(for: meter.operationalState)
    }
}

private struct MeterInfoView: View {
    @EnvironmentObject private var meter: Meter

    var body: some View {
        ScrollView {
            VStack(spacing: 14) {
                MeterInfoCard(title: "Overview", tint: meter.color) {
                    MeterInfoRow(label: "Name", value: meter.name)
                    MeterInfoRow(label: "Displayed Model", value: meter.deviceModelSummary)
                    MeterInfoRow(label: "Advertised Model", value: meter.modelString)
                    MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
                    MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
                }

                MeterInfoCard(title: "Identifiers", tint: .blue) {
                    MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
                    if meter.modelNumber != 0 {
                        MeterInfoRow(label: "Model Code", value: "\(meter.modelNumber)")
                    }
                }

                MeterInfoCard(title: "Screen Reporting", tint: .orange) {
                    if meter.reportsCurrentScreenIndex {
                        MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
                        Text("The active screen index is reported by the meter and mapped by the app to a known label.")
                            .font(.footnote)
                            .foregroundColor(.secondary)
                    } else {
                        MeterInfoRow(label: "Current Screen", value: "Not Reported")
                        Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
                            .font(.footnote)
                            .foregroundColor(.secondary)
                    }
                }

                if meter.operationalState == .dataIsAvailable {
                    MeterInfoCard(title: "Live Device Details", tint: .indigo) {
                        if !meter.firmwareVersion.isEmpty {
                            MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
                        }
                        if meter.serialNumber != 0 {
                            MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
                        }
                        if meter.bootCount != 0 {
                            MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
                        }
                    }
                } else {
                    MeterInfoCard(title: "Live Device Details", tint: .secondary) {
                        Text("Connect to the meter to load firmware, serial, and boot details.")
                            .font(.footnote)
                            .foregroundColor(.secondary)
                    }
                }
            }
            .padding()
        }
        .background(
            LinearGradient(
                colors: [meter.color.opacity(0.14), Color.clear],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()
        )
        .navigationBarTitle("Meter Info")
        .navigationBarItems(trailing: RSSIView(RSSI: meter.btSerial.RSSI).frame(width: 18, height: 18))
    }
}

private struct MeterInfoCard<Content: View>: View {
    let title: String
    let tint: Color
    @ViewBuilder var content: Content

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(title)
                .font(.headline)
            content
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(18)
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
    }
}

private struct MeterInfoRow: View {
    let label: String
    let value: String

    var body: some View {
        HStack {
            Text(label)
            Spacer()
            Text(value)
                .foregroundColor(.secondary)
                .multilineTextAlignment(.trailing)
        }
        .font(.footnote)
    }
}