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 {
@EnvironmentObject private var meter: Meter
@State var dataGroupsViewVisibility: Bool = false
@State var recordingViewVisibility: Bool = false
@State var measurementsViewVisibility: Bool = false
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
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
connectionCard
if meter.operationalState == .dataIsAvailable {
actionGrid
LiveView()
.padding(18)
.meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
if meter.measurements.power.context.isValid {
MeasurementChartView()
.environmentObject(meter.measurements)
.frame(minHeight: myBounds.height / 3.4)
.padding(16)
.meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
}
ControlView()
.padding(16)
.meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.20)
}
}
.padding()
}
.background(
LinearGradient(
colors: [
meter.color.opacity(0.22),
Color.secondary.opacity(0.08),
Color.clear
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.navigationBarTitle("Meter")
.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: MeterInfoView().environmentObject(meter)) {
Image(systemName: "info.circle.fill")
.padding(.vertical)
}
NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
Image(systemName: "gearshape.fill")
.padding(.vertical)
}
})
}
private var connectionCard: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text(meter.name)
.font(.system(.title2, design: .rounded).weight(.bold))
Text(meter.deviceModelSummary)
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 6) {
Text(statusText)
.font(.caption.weight(.bold))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
if meter.operationalState > .notPresent {
Text("RSSI \(meter.btSerial.RSSI)")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
connectionActionArea
}
.padding(20)
.meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
}
private var actionGrid: some View {
GeometryReader { proxy in
let buttonWidth = actionButtonWidth(for: proxy.size.width)
let stripWidth = actionStripWidth(for: buttonWidth)
HStack {
Spacer(minLength: 0)
HStack(spacing: 0) {
meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth) {
dataGroupsViewVisibility.toggle()
}
.sheet(isPresented: $dataGroupsViewVisibility) {
DataGroupsView(visibility: $dataGroupsViewVisibility)
.environmentObject(meter)
}
if meter.supportsRecordingView {
actionStripDivider
meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth) {
recordingViewVisibility.toggle()
}
.sheet(isPresented: $recordingViewVisibility) {
RecordingView(visibility: $recordingViewVisibility)
.environmentObject(meter)
}
}
actionStripDivider
meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth) {
measurementsViewVisibility.toggle()
}
.sheet(isPresented: $measurementsViewVisibility) {
MeasurementsView(visibility: $measurementsViewVisibility)
.environmentObject(meter.measurements)
}
}
.padding(actionStripPadding)
.frame(width: stripWidth)
.meterCard(tint: Color.secondary, fillOpacity: 0.10, strokeOpacity: 0.16)
Spacer(minLength: 0)
}
}
.frame(height: actionButtonHeight + (actionStripPadding * 2))
}
private var connectionActionArea: 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(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, 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, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 20, weight: .semibold))
.frame(width: 40, height: 40)
.background(Circle().fill(tint.opacity(0.14)))
Text(title)
.font(.footnote.weight(.semibold))
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.9)
}
.foregroundColor(tint)
.frame(width: width, height: actionButtonHeight)
.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 var actionStripDivider: some View {
Rectangle()
.fill(Color.secondary.opacity(0.16))
.frame(width: actionDividerWidth, height: actionButtonHeight - 24)
}
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 MeterInfoView: View {
@EnvironmentObject private var meter: Meter
var body: some View {
ScrollView {
VStack(spacing: 14) {
MeterInfoCard(title: "Overview", tint: meter.color) {
MeterInfoRow(label: "Name", value: meter.name)
MeterInfoRow(label: "Displayed Model", value: meter.deviceModelSummary)
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 Code", 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)
}
}
if meter.operationalState == .dataIsAvailable {
MeterInfoCard(title: "Live Device Details", tint: .indigo) {
if !meter.firmwareVersion.isEmpty {
MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
}
if meter.serialNumber != 0 {
MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
}
if meter.bootCount != 0 {
MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
}
}
} else {
MeterInfoCard(title: "Live Device Details", tint: .secondary) {
Text("Connect to the meter to load firmware, serial, and boot details.")
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
.padding()
}
.background(
LinearGradient(
colors: [meter.color.opacity(0.14), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.navigationBarTitle("Meter Info")
.navigationBarItems(trailing: RSSIView(RSSI: meter.btSerial.RSSI).frame(width: 18, height: 18))
}
}
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)
}
}