1 contributor
564 lines | 19.541kb
//
//  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 struct TabBarStyle {
        let showsTitles: Bool
        let horizontalPadding: CGFloat
        let topPadding: CGFloat
        let bottomPadding: CGFloat
        let chipHorizontalPadding: CGFloat
        let chipVerticalPadding: CGFloat
        let outerPadding: CGFloat
        let maxWidth: CGFloat
        let barBackgroundOpacity: CGFloat
        let materialOpacity: CGFloat
        let shadowOpacity: CGFloat
        let floatingInset: CGFloat

        static let portrait = TabBarStyle(
            showsTitles: true,
            horizontalPadding: 16,
            topPadding: 10,
            bottomPadding: 8,
            chipHorizontalPadding: 10,
            chipVerticalPadding: 7,
            outerPadding: 6,
            maxWidth: 420,
            barBackgroundOpacity: 0.10,
            materialOpacity: 0.78,
            shadowOpacity: 0,
            floatingInset: 0
        )

        static let landscapeInline = TabBarStyle(
            showsTitles: true,
            horizontalPadding: 12,
            topPadding: 10,
            bottomPadding: 8,
            chipHorizontalPadding: 10,
            chipVerticalPadding: 7,
            outerPadding: 6,
            maxWidth: 420,
            barBackgroundOpacity: 0.10,
            materialOpacity: 0.78,
            shadowOpacity: 0,
            floatingInset: 0
        )

        static let landscapeFloating = TabBarStyle(
            showsTitles: false,
            horizontalPadding: 16,
            topPadding: 10,
            bottomPadding: 0,
            chipHorizontalPadding: 11,
            chipVerticalPadding: 11,
            outerPadding: 7,
            maxWidth: 260,
            barBackgroundOpacity: 0.02,
            materialOpacity: 0,
            shadowOpacity: 0.18,
            floatingInset: 12
        )
    }

    private enum MeterTab: String, Hashable {
        case home
        case live
        case chart
        case settings

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

        var systemImage: String {
            switch self {
            case .home: 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
    #if os(iOS)
    private static let isPhone: Bool = UIDevice.current.userInterfaceIdiom == .phone
    #else
    private static let isPhone: Bool = false
    #endif
    
    @State private var selectedMeterTab: MeterTab = .home
    @State private var navBarTitle: String = "Meter"
    @State private var navBarShowRSSI: Bool = false
    @State private var navBarRSSI: Int = 0
    @State private var landscapeTabBarHeight: CGFloat = 0

    var body: some View {
        GeometryReader { proxy in
            let landscape = isLandscape(size: proxy.size)
            let usesOverlayTabBar = landscape && Self.isPhone
            let tabBarStyle = tabBarStyle(for: landscape, usesOverlayTabBar: usesOverlayTabBar)

            VStack(spacing: 0) {
                if Self.isMacIPadApp {
                    macNavigationHeader
                }
                Group {
                    if landscape {
                        landscapeDeck(
                            size: proxy.size,
                            usesOverlayTabBar: usesOverlayTabBar,
                            tabBarStyle: tabBarStyle
                        )
                    } else {
                        portraitContent(size: proxy.size, tabBarStyle: tabBarStyle)
                    }
                }
                .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
            }
        }
        .onChange(of: selectedMeterTab) { newTab in
            meter.preferredTabIdentifier = newTab.rawValue
        }
    }

    // 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()

            MeterConnectionToolbarButton(
                operationalState: meter.operationalState,
                showsTitle: true,
                connectAction: { meter.connect() },
                disconnectAction: { meter.disconnect() }
            )

            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, tabBarStyle: TabBarStyle) -> some View {
        portraitSegmentedDeck(size: size, tabBarStyle: tabBarStyle)
    }

    @ViewBuilder
    private func landscapeDeck(size: CGSize, usesOverlayTabBar: Bool, tabBarStyle: TabBarStyle) -> some View {
        if usesOverlayTabBar {
            landscapeOverlaySegmentedDeck(size: size, tabBarStyle: tabBarStyle)
        } else {
            landscapeSegmentedDeck(size: size, tabBarStyle: tabBarStyle)
        }
    }

    private func landscapeOverlaySegmentedDeck(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
        ZStack(alignment: .top) {
            landscapeSegmentedContent(size: size)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                .padding(.top, landscapeContentTopPadding(for: tabBarStyle))
                .id(displayedMeterTab)
                .transition(.opacity.combined(with: .move(edge: .trailing)))

            segmentedTabBar(style: tabBarStyle, showsConnectionAction: !Self.isMacIPadApp)
        }
        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
        .onAppear {
            restoreSelectedTab()
        }
        .onPreferenceChange(MeterTabBarHeightPreferenceKey.self) { height in
            if height > 0 {
                landscapeTabBarHeight = height
            }
        }
    }

    private func landscapeSegmentedDeck(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
        VStack(spacing: 0) {
            segmentedTabBar(style: tabBarStyle, showsConnectionAction: !Self.isMacIPadApp)

            landscapeSegmentedContent(size: size)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                .id(displayedMeterTab)
                .transition(.opacity.combined(with: .move(edge: .trailing)))
        }
        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
        .onAppear {
            restoreSelectedTab()
        }
    }

    private func portraitSegmentedDeck(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
        VStack(spacing: 0) {
            segmentedTabBar(style: tabBarStyle)

            portraitSegmentedContent(size: size)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                .id(displayedMeterTab)
                .transition(.opacity.combined(with: .move(edge: .trailing)))
        }
        .animation(.easeInOut(duration: 0.22), value: displayedMeterTab)
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
        .onAppear {
            restoreSelectedTab()
        }
    }

    private func segmentedTabBar(style: TabBarStyle, showsConnectionAction: Bool = false) -> some View {
        let isFloating = style.floatingInset > 0
        let cornerRadius = style.showsTitles ? 14.0 : 22.0

        return HStack {
            Spacer(minLength: 0)

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

                    Button {
                        withAnimation(.easeInOut(duration: 0.2)) {
                            selectedMeterTab = tab
                        }
                    } label: {
                        HStack(spacing: 6) {
                            Image(systemName: tab.systemImage)
                                .font(.subheadline.weight(.semibold))
                            if style.showsTitles {
                                Text(tab.title)
                                    .font(.subheadline.weight(.semibold))
                                    .lineLimit(1)
                            }
                        }
                        .foregroundColor(
                            isSelected
                            ? .white
                            : (isFloating ? .white.opacity(0.82) : .primary)
                        )
                        .padding(.horizontal, style.chipHorizontalPadding)
                        .padding(.vertical, style.chipVerticalPadding)
                        .frame(maxWidth: .infinity)
                        .background(
                            Capsule()
                                .fill(
                                    isSelected
                                    ? meter.color.opacity(isFloating ? 0.94 : 1)
                                    : (isFloating ? Color.white.opacity(0.045) : Color.secondary.opacity(0.12))
                                )
                        )
                    }
                    .buttonStyle(.plain)
                    .accessibilityLabel(tab.title)
                }
            }
            .frame(maxWidth: style.maxWidth)
            .padding(style.outerPadding)
            .background(
                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
                    .fill(
                        isFloating
                        ? LinearGradient(
                            colors: [
                                Color.white.opacity(0.14),
                                Color.white.opacity(0.06)
                            ],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        )
                        : LinearGradient(
                            colors: [
                                Color.secondary.opacity(style.barBackgroundOpacity),
                                Color.secondary.opacity(style.barBackgroundOpacity)
                            ],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        )
                    )
            )
            .overlay {
                RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
                    .stroke(
                        isFloating ? Color.white.opacity(0.10) : Color.clear,
                        lineWidth: 1
                    )
            }
            .background {
                if !isFloating {
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
                        .fill(.ultraThinMaterial)
                }
            }
            .shadow(color: Color.black.opacity(style.shadowOpacity), radius: isFloating ? 28 : 24, x: 0, y: isFloating ? 16 : 12)

            Spacer(minLength: 0)
        }
        .padding(.horizontal, style.horizontalPadding)
        .padding(.top, style.topPadding)
        .padding(.bottom, style.bottomPadding)
        .background(
            GeometryReader { geometry in
                Color.clear
                    .preference(key: MeterTabBarHeightPreferenceKey.self, value: geometry.size.height)
            }
        )
        .padding(.horizontal, style.floatingInset)
        .background {
            if style.floatingInset == 0 {
                Rectangle()
                    .fill(.ultraThinMaterial)
                    .opacity(style.materialOpacity)
                    .ignoresSafeArea(edges: .top)
            }
        }
        .overlay(alignment: .bottom) {
            if style.floatingInset == 0 {
                Rectangle()
                    .fill(Color.secondary.opacity(0.12))
                    .frame(height: 1)
            }
        }
        .overlay(alignment: .trailing) {
            if showsConnectionAction {
                MeterConnectionToolbarButton(
                    operationalState: meter.operationalState,
                    showsTitle: false,
                    connectAction: { meter.connect() },
                    disconnectAction: { meter.disconnect() }
                )
                .font(.title3.weight(.semibold))
                .padding(.trailing, style.horizontalPadding + style.floatingInset + 4)
                .padding(.top, style.topPadding)
                .padding(.bottom, style.bottomPadding)
            }
        }
    }

    @ViewBuilder
    private func landscapeSegmentedContent(size: CGSize) -> some View {
        switch displayedMeterTab {
        case .home:
            MeterHomeTabView(size: size, isLandscape: true)
        case .live:
            MeterLiveTabView(size: size, isLandscape: true)
        case .chart:
            MeterChartTabView(size: size, isLandscape: true)
        case .settings:
            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
                withAnimation(.easeInOut(duration: 0.22)) {
                    selectedMeterTab = .home
                }
            }
        }
    }

    @ViewBuilder
    private func portraitSegmentedContent(size: CGSize) -> some View {
        switch displayedMeterTab {
        case .home:
            MeterHomeTabView(size: size, isLandscape: false)
        case .live:
            MeterLiveTabView(size: size, isLandscape: false)
        case .chart:
            MeterChartTabView(size: size, isLandscape: false)
        case .settings:
            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
                withAnimation(.easeInOut(duration: 0.22)) {
                    selectedMeterTab = .home
                }
            }
        }
    }

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

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

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

        tabs.append(.settings)

        return tabs
    }

    private var displayedMeterTab: MeterTab {
        if availableMeterTabs.contains(selectedMeterTab) {
            return selectedMeterTab
        }
        return .home
    }

    private func restoreSelectedTab() {
        guard let restoredTab = MeterTab(rawValue: meter.preferredTabIdentifier) else {
            meter.preferredTabIdentifier = MeterTab.home.rawValue
            selectedMeterTab = .home
            return
        }

        selectedMeterTab = restoredTab
    }

    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 tabBarStyle(for landscape: Bool, usesOverlayTabBar: Bool) -> TabBarStyle {
        if usesOverlayTabBar {
            return .landscapeFloating
        }

        if landscape {
            return .landscapeInline
        }

        return .portrait
    }

    private func landscapeContentTopPadding(for style: TabBarStyle) -> CGFloat {
        if style.floatingInset > 0 {
            return max(landscapeTabBarHeight * 0.44, 26)
        }

        return max(landscapeTabBarHeight - 6, 0)
    }

}

private struct MeterTabBarHeightPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

// 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) {
                        MeterConnectionToolbarButton(
                            operationalState: meter.operationalState,
                            showsTitle: false,
                            connectAction: { meter.connect() },
                            disconnectAction: { meter.disconnect() }
                        )
                        .font(.body.weight(.semibold))
                        if showRSSI {
                            RSSIView(RSSI: rssi)
                                .frame(width: 18, height: 18)
                        }
                    }
                }
        } else {
            content
        }
    }
}