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