1 contributor
//
// 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
}
}
}