// // 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 { GeometryReader { proxy in let landscape = isLandscape(size: proxy.size) Group { if landscape { landscapeDeck(size: proxy.size) } else { portraitContent } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationBarHidden(landscape) } .background(meterBackground) .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 portraitContent: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { connectionCard(showsActions: meter.operationalState == .dataIsAvailable) if meter.operationalState == .dataIsAvailable { 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) } } } .padding() } } private func landscapeDeck(size: CGSize) -> some View { TabView { landscapeFace { connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } if meter.operationalState == .dataIsAvailable { landscapeFace { LiveView(compactLayout: true, availableSize: size) .padding(16) .frame(maxWidth: .infinity, alignment: .topLeading) .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) } if meter.measurements.power.context.isValid { landscapeFace { MeasurementChartView() .environmentObject(meter.measurements) .frame(height: max(250, size.height - 44)) .padding(10) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20) } } } } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) } private func landscapeFace(@ViewBuilder content: () -> Content) -> some View { content() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.horizontal, 12) .padding(.vertical, 12) .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 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: "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) } } if meter.operationalState == .dataIsAvailable { MeterInfoCard(title: "Live Device Details", tint: .indigo) { 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 { 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("Device 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) } }