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
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
@State var dataGroupsViewVisibility: Bool = false
@State var recordingViewVisibility: Bool = false
@State var measurementsViewVisibility: Bool = false
@State private var selectedMeterTab: MeterTab = .connection
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)
Group {
if landscape {
landscapeDeck(size: proxy.size)
} else {
portraitContent(size: proxy.size)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
#if !targetEnvironment(macCatalyst)
.navigationBarHidden(landscape)
#endif
}
.background(meterBackground)
.navigationBarTitle(meter.name.isEmpty ? "Meter" : meter.name)
.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: MeterSettingsView().environmentObject(meter)) {
Image(systemName: "gearshape.fill")
.padding(.vertical)
}
})
}
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)
}
}