1 contributor
759 lines | 28.026kb
//
//  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 {
    private enum MeterTab: Hashable {
        case connection
        case live
        case chart

        var title: String {
            switch self {
            case .connection: return "Home"
            case .live: return "Live"
            case .chart: return "Chart"
            }
        }

        var systemImage: String {
            switch self {
            case .connection: return "house.fill"
            case .live: return "waveform.path.ecg"
            case .chart: return "chart.xyaxis.line"
            }
        }
    }
    
    @EnvironmentObject private var meter: Meter
    @Environment(\.dismiss) private var dismiss

    private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
    
    @State var dataGroupsViewVisibility: Bool = false
    @State var recordingViewVisibility: Bool = false
    @State var measurementsViewVisibility: Bool = false
    @State private var selectedMeterTab: MeterTab = .connection
    @State private var navBarTitle: String = "Meter"
    @State private var navBarShowRSSI: Bool = false
    @State private var navBarRSSI: Int = 0
    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
    private let pageHorizontalPadding: CGFloat = 12
    private let pageVerticalPadding: CGFloat = 12
    private let contentCardPadding: CGFloat = 16

    var body: some View {
        GeometryReader { proxy in
            let landscape = isLandscape(size: proxy.size)

            VStack(spacing: 0) {
                if Self.isMacIPadApp {
                    macNavigationHeader
                }
                Group {
                    if landscape {
                        landscapeDeck(size: proxy.size)
                    } else {
                        portraitContent(size: proxy.size)
                    }
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
            }
            #if !targetEnvironment(macCatalyst)
            .navigationBarHidden(Self.isMacIPadApp || landscape)
            #endif
        }
        .background(meterBackground)
        .modifier(IOSOnlyNavBar(
            apply: !Self.isMacIPadApp,
            title: navBarTitle,
            showRSSI: navBarShowRSSI,
            rssi: navBarRSSI,
            meter: meter
        ))
        .onAppear {
            navBarTitle = meter.name.isEmpty ? "Meter" : meter.name
            navBarShowRSSI = meter.operationalState > .notPresent
            navBarRSSI = meter.btSerial.averageRSSI
        }
        .onChange(of: meter.name) { name in
            navBarTitle = name.isEmpty ? "Meter" : name
        }
        .onChange(of: meter.operationalState) { state in
            navBarShowRSSI = state > .notPresent
        }
        .onChange(of: meter.btSerial.averageRSSI) { newRSSI in
            if abs(newRSSI - navBarRSSI) >= 5 {
                navBarRSSI = newRSSI
            }
        }
    }

    // MARK: - Custom navigation header for Designed-for-iPad on Mac

    private var macNavigationHeader: some View {
        HStack(spacing: 12) {
            Button {
                dismiss()
            } label: {
                HStack(spacing: 4) {
                    Image(systemName: "chevron.left")
                        .font(.body.weight(.semibold))
                    Text("USB Meters")
                }
                .foregroundColor(.accentColor)
            }
            .buttonStyle(.plain)

            Text(meter.name.isEmpty ? "Meter" : meter.name)
                .font(.headline)
                .lineLimit(1)

            Spacer()

            if meter.operationalState > .notPresent {
                RSSIView(RSSI: meter.btSerial.averageRSSI)
                    .frame(width: 18, height: 18)
            }

            NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
                Image(systemName: "gearshape.fill")
                    .foregroundColor(.accentColor)
            }
            .buttonStyle(.plain)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 10)
        .background(
            Rectangle()
                .fill(.ultraThinMaterial)
                .ignoresSafeArea(edges: .top)
        )
        .overlay(alignment: .bottom) {
            Rectangle()
                .fill(Color.secondary.opacity(0.12))
                .frame(height: 1)
        }
    }

    private func portraitContent(size: CGSize) -> some View {
        portraitSegmentedDeck(size: size)
    }

    private func landscapeDeck(size: CGSize) -> some View {
        landscapeSegmentedDeck(size: size)
    }

    private func landscapeSegmentedDeck(size: CGSize) -> some View {
        VStack(spacing: 0) {
            segmentedTabBar(horizontalPadding: 12)

            landscapeSegmentedContent(size: size)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                .id(selectedMeterTab)
                .transition(.opacity.combined(with: .move(edge: .trailing)))
        }
        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
        .onAppear {
            normalizeSelectedTab()
        }
        .onChange(of: availableMeterTabs) { _ in
            normalizeSelectedTab()
        }
    }

    private func portraitSegmentedDeck(size: CGSize) -> some View {
        VStack(spacing: 0) {
            segmentedTabBar(horizontalPadding: 16)

            portraitSegmentedContent(size: size)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                .id(selectedMeterTab)
                .transition(.opacity.combined(with: .move(edge: .trailing)))
        }
        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
        .onAppear {
            normalizeSelectedTab()
        }
        .onChange(of: availableMeterTabs) { _ in
            normalizeSelectedTab()
        }
    }

    private func segmentedTabBar(horizontalPadding: CGFloat) -> some View {
        HStack {
            Spacer(minLength: 0)

            HStack(spacing: 8) {
                ForEach(availableMeterTabs, id: \.self) { tab in
                    let isSelected = selectedMeterTab == tab

                    Button {
                        withAnimation(.easeInOut(duration: 0.2)) {
                            selectedMeterTab = tab
                        }
                    } label: {
                        HStack(spacing: 6) {
                            Image(systemName: tab.systemImage)
                                .font(.subheadline.weight(.semibold))
                            Text(tab.title)
                                .font(.subheadline.weight(.semibold))
                                .lineLimit(1)
                        }
                        .foregroundColor(isSelected ? .white : .primary)
                        .padding(.horizontal, 10)
                        .padding(.vertical, 7)
                        .frame(maxWidth: .infinity)
                        .background(
                            Capsule()
                                .fill(isSelected ? meter.color : Color.secondary.opacity(0.12))
                        )
                    }
                    .buttonStyle(.plain)
                    .accessibilityLabel(tab.title)
                }
            }
            .frame(maxWidth: 420)
            .padding(6)
            .background(
                RoundedRectangle(cornerRadius: 14, style: .continuous)
                    .fill(Color.secondary.opacity(0.10))
            )

            Spacer(minLength: 0)
        }
        .padding(.horizontal, horizontalPadding)
        .padding(.top, 10)
        .padding(.bottom, 8)
        .background(
            Rectangle()
                .fill(.ultraThinMaterial)
                .opacity(0.78)
                .ignoresSafeArea(edges: .top)
        )
        .overlay(alignment: .bottom) {
            Rectangle()
                .fill(Color.secondary.opacity(0.12))
                .frame(height: 1)
        }
    }

    @ViewBuilder
    private func landscapeSegmentedContent(size: CGSize) -> some View {
        switch selectedMeterTab {
        case .connection:
            landscapeConnectionPage
        case .live:
            if meter.operationalState == .dataIsAvailable {
                landscapeLivePage(size: size)
            } else {
                landscapeConnectionPage
            }
        case .chart:
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
                landscapeChartPage(size: size)
            } else {
                landscapeConnectionPage
            }
        }
    }

    @ViewBuilder
    private func portraitSegmentedContent(size: CGSize) -> some View {
        switch selectedMeterTab {
        case .connection:
            portraitConnectionPage(size: size)
        case .live:
            if meter.operationalState == .dataIsAvailable {
                portraitLivePage(size: size)
            } else {
                portraitConnectionPage(size: size)
            }
        case .chart:
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
                portraitChartPage
            } else {
                portraitConnectionPage(size: size)
            }
        }
    }

    private func portraitConnectionPage(size: CGSize) -> some View {
        portraitFace {
            VStack(alignment: .leading, spacing: 12) {
                connectionCard(
                    compact: prefersCompactPortraitConnection(for: size),
                    showsActions: meter.operationalState == .dataIsAvailable
                )

                homeInfoPreview
            }
        }
    }

    private func portraitLivePage(size: CGSize) -> some View {
        portraitFace {
            LiveView(compactLayout: prefersCompactPortraitConnection(for: size), availableSize: size)
                .padding(contentCardPadding)
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
        }
    }

    private var portraitChartPage: some View {
        portraitFace {
            MeasurementChartView()
                .environmentObject(meter.measurements)
                .frame(minHeight: myBounds.height / 3.4)
                .padding(contentCardPadding)
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
        }
    }

    private var landscapeConnectionPage: some View {
        landscapeFace {
            VStack(alignment: .leading, spacing: 12) {
                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)

                homeInfoPreview
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        }
    }

    private var homeInfoPreview: some View {
        VStack(spacing: 14) {
            MeterInfoCard(title: "Overview", tint: meter.color) {
                MeterInfoRow(label: "Name", value: meter.name)
                MeterInfoRow(label: "Device Model", value: meter.deviceModelName)
                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 Identifier", 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)
                }
            }

            MeterInfoCard(title: "Live Device Details", tint: .indigo) {
                if meter.operationalState == .dataIsAvailable {
                    if !meter.firmwareVersion.isEmpty {
                        MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
                    }
                    if meter.supportsChargerDetection {
                        MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription)
                    }
                    if meter.serialNumber != 0 {
                        MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
                    }
                    if meter.bootCount != 0 {
                        MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
                    }
                } else {
                    Text("Connect to the meter to load firmware, serial, and boot details.")
                        .font(.footnote)
                        .foregroundColor(.secondary)
                }
            }
        }
        .padding(.horizontal, pageHorizontalPadding)
    }

    private func landscapeLivePage(size: CGSize) -> some View {
        landscapeFace {
            LiveView(compactLayout: true, availableSize: size)
                .padding(contentCardPadding)
                .frame(maxWidth: .infinity, alignment: .topLeading)
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
        }
    }

    private func landscapeChartPage(size: CGSize) -> some View {
        landscapeFace {
            MeasurementChartView()
                .environmentObject(meter.measurements)
                .frame(height: max(250, size.height - 44))
                .padding(contentCardPadding)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
        }
    }

    private var availableMeterTabs: [MeterTab] {
        var tabs: [MeterTab] = [.connection]

        if meter.operationalState == .dataIsAvailable {
            tabs.append(.live)

            if meter.measurements.power.context.isValid {
                tabs.append(.chart)
            }
        }

        return tabs
    }

    private func normalizeSelectedTab() {
        guard availableMeterTabs.contains(selectedMeterTab) else {
            withAnimation(.easeInOut(duration: 0.22)) {
                selectedMeterTab = .connection
            }
            return
        }
    }

    private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
        size.height < 760 || size.width < 380
    }

    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
        ScrollView {
            content()
                .frame(maxWidth: .infinity, alignment: .topLeading)
                .padding(.horizontal, pageHorizontalPadding)
                .padding(.vertical, pageVerticalPadding)
        }
    }

    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
        content()
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        .padding(.horizontal, pageHorizontalPadding)
        .padding(.vertical, pageVerticalPadding)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
    }

    private var meterBackground: some View {
        LinearGradient(
            colors: [
                meter.color.opacity(0.22),
                Color.secondary.opacity(0.08),
                Color.clear
            ],
            startPoint: .topLeading,
            endPoint: .bottomTrailing
        )
        .ignoresSafeArea()
    }

    private func isLandscape(size: CGSize) -> Bool {
        size.width > size.height
    }

    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
            HStack(alignment: .top) {
                meterIdentity(compact: compact)
                Spacer()
                statusBadge
            }

            if compact {
                Spacer(minLength: 0)
            }

            connectionActionArea(compact: compact)

            if showsActions {
                VStack(spacing: compact ? 10 : 12) {
                    Rectangle()
                        .fill(Color.secondary.opacity(0.12))
                        .frame(height: 1)

                    actionGrid(compact: compact, embedded: true)
                }
            }
        }
        .padding(compact ? 16 : 20)
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
    }

    private func meterIdentity(compact: Bool) -> some View {
        HStack(alignment: .firstTextBaseline, spacing: 8) {
            Text(meter.name)
                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
                .lineLimit(1)
                .minimumScaleFactor(0.8)

            Text(meter.deviceModelName)
                .font((compact ? Font.caption : .subheadline).weight(.semibold))
                .foregroundColor(.secondary)
                .lineLimit(1)
                .minimumScaleFactor(0.8)
        }
    }

    private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
        let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight

        return GeometryReader { proxy in
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
            let stripWidth = actionStripWidth(for: buttonWidth)
            let stripContent = HStack(spacing: 0) {
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
                    dataGroupsViewVisibility.toggle()
                }
                .sheet(isPresented: $dataGroupsViewVisibility) {
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
                        .environmentObject(meter)
                }

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

                actionStripDivider(height: currentActionHeight)
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
                    measurementsViewVisibility.toggle()
                }
                .sheet(isPresented: $measurementsViewVisibility) {
                    MeasurementsView(visibility: $measurementsViewVisibility)
                        .environmentObject(meter.measurements)
                }
            }
            .padding(actionStripPadding)
            .frame(width: stripWidth)

            HStack {
                Spacer(minLength: 0)
                stripContent
                    .meterCard(
                        tint: embedded ? meter.color : Color.secondary,
                        fillOpacity: embedded ? 0.08 : 0.10,
                        strokeOpacity: embedded ? 0.14 : 0.16,
                        cornerRadius: embedded ? 24 : 22
                    )
                Spacer(minLength: 0)
            }
        }
        .frame(height: currentActionHeight + (actionStripPadding * 2))
    }

    private func connectionActionArea(compact: Bool = false) -> 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(compact ? 12 : 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, compact ? 10 : 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, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
        Button(action: action) {
            VStack(spacing: compact ? 8 : 10) {
                Image(systemName: icon)
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
                    .background(Circle().fill(tint.opacity(0.14)))
                Text(title)
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
                    .multilineTextAlignment(.center)
                    .lineLimit(2)
                    .minimumScaleFactor(0.9)
            }
            .foregroundColor(tint)
            .frame(width: width, height: height)
            .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 func actionStripDivider(height: CGFloat) -> some View {
        Rectangle()
            .fill(Color.secondary.opacity(0.16))
            .frame(width: actionDividerWidth, height: max(44, height - 22))
    }

    private var statusBadge: some View {
        Text(statusText)
            .font(.caption.weight(.bold))
            .padding(.horizontal, 12)
            .padding(.vertical, 6)
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
    }

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

// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)

private struct IOSOnlyNavBar: ViewModifier {
    let apply: Bool
    let title: String
    let showRSSI: Bool
    let rssi: Int
    let meter: Meter

    @ViewBuilder
    func body(content: Content) -> some View {
        if apply {
            content
                .navigationBarTitle(title)
                .toolbar {
                    ToolbarItemGroup(placement: .navigationBarTrailing) {
                        if showRSSI {
                            RSSIView(RSSI: rssi)
                                .frame(width: 18, height: 18)
                        }
                        NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
                            Image(systemName: "gearshape.fill")
                        }
                    }
                }
        } else {
            content
        }
    }
}