1 contributor
364 lines | 11.853kb
//
//  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
        case settings

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

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

    private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
    
    @State private var selectedMeterTab: MeterTab = .connection
    @State private var navBarTitle: String = "Meter"
    @State private var navBarShowRSSI: Bool = false
    @State private var navBarRSSI: Int = 0

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

        }
        .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:
            MeterConnectionTabView(size: size, isLandscape: true)
        case .live:
            if meter.operationalState == .dataIsAvailable {
                MeterLiveTabView(size: size, isLandscape: true)
            } else {
                MeterConnectionTabView(size: size, isLandscape: true)
            }
        case .chart:
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
                MeterChartTabView(size: size, isLandscape: true)
            } else {
                MeterConnectionTabView(size: size, isLandscape: true)
            }
        case .settings:
            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
                withAnimation(.easeInOut(duration: 0.22)) {
                    selectedMeterTab = .connection
                }
            }
        }
    }

    @ViewBuilder
    private func portraitSegmentedContent(size: CGSize) -> some View {
        switch selectedMeterTab {
        case .connection:
            MeterConnectionTabView(size: size, isLandscape: false)
        case .live:
            if meter.operationalState == .dataIsAvailable {
                MeterLiveTabView(size: size, isLandscape: false)
            } else {
                MeterConnectionTabView(size: size, isLandscape: false)
            }
        case .chart:
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
                MeterChartTabView(size: size, isLandscape: false)
            } else {
                MeterConnectionTabView(size: size, isLandscape: false)
            }
        case .settings:
            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
                withAnimation(.easeInOut(duration: 0.22)) {
                    selectedMeterTab = .connection
                }
            }
        }
    }

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

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

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

        tabs.append(.settings)

        return tabs
    }

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

    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
    }

}

// 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)
                        }
                    }
                }
        } else {
            content
        }
    }
}