// // 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 @Environment(\.dismiss) private var dismiss private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac @State var dataGroupsViewVisibility: Bool = false @State var recordingViewVisibility: Bool = false @State var measurementsViewVisibility: Bool = false @State private var selectedMeterTab: MeterTab = .connection @State private var navBarTitle: String = "Meter" @State private var navBarShowRSSI: Bool = false @State private var navBarRSSI: Int = 0 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) 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) } NavigationLink(destination: MeterSettingsView().environmentObject(meter)) { Image(systemName: "gearshape.fill") .foregroundColor(.accentColor) } .buttonStyle(.plain) } .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: 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(@ViewBuilder content: () -> Content) -> some View { ScrollView { content() .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.horizontal, pageHorizontalPadding) .padding(.vertical, pageVerticalPadding) } } private func landscapeFace(@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: 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) } } // 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) } NavigationLink(destination: MeterSettingsView().environmentObject(meter)) { Image(systemName: "gearshape.fill") } } } } else { content } } }