// // 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 struct TabBarStyle { let horizontalPadding: CGFloat let topPadding: CGFloat let bottomPadding: CGFloat let chipHorizontalPadding: CGFloat let chipVerticalPadding: CGFloat let outerPadding: CGFloat let barBackgroundOpacity: CGFloat let materialOpacity: CGFloat let shadowOpacity: CGFloat let floatingInset: CGFloat static let portrait = TabBarStyle( horizontalPadding: 16, topPadding: 10, bottomPadding: 8, chipHorizontalPadding: 10, chipVerticalPadding: 7, outerPadding: 6, barBackgroundOpacity: 0.10, materialOpacity: 0.78, shadowOpacity: 0, floatingInset: 0 ) static let portraitCompact = TabBarStyle( horizontalPadding: 16, topPadding: 10, bottomPadding: 8, chipHorizontalPadding: 12, chipVerticalPadding: 10, outerPadding: 6, barBackgroundOpacity: 0.14, materialOpacity: 0.90, shadowOpacity: 0, floatingInset: 0 ) static let landscapeInline = TabBarStyle( horizontalPadding: 12, topPadding: 10, bottomPadding: 8, chipHorizontalPadding: 10, chipVerticalPadding: 7, outerPadding: 6, barBackgroundOpacity: 0.10, materialOpacity: 0.78, shadowOpacity: 0, floatingInset: 0 ) static let landscapeFloating = TabBarStyle( horizontalPadding: 16, topPadding: 10, bottomPadding: 0, chipHorizontalPadding: 11, chipVerticalPadding: 11, outerPadding: 7, barBackgroundOpacity: 0.16, materialOpacity: 0.88, shadowOpacity: 0.12, floatingInset: 12 ) } private enum MeterTab: String, Hashable { case home case live case chart case chargeRecord case dataGroups case settings var systemImage: String { switch self { case .home: return "house.fill" case .live: return "waveform.path.ecg" case .chart: return "chart.xyaxis.line" case .chargeRecord: return "gauge.with.dots.needle.50percent" case .dataGroups: return "square.grid.2x2.fill" case .settings: return "gearshape.fill" } } } @EnvironmentObject private var meter: Meter @Environment(\.dismiss) private var dismiss private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac #if os(iOS) private static let isPhone: Bool = UIDevice.current.userInterfaceIdiom == .phone #else private static let isPhone: Bool = false #endif @State private var selectedMeterTab: MeterTab = .home @State private var landscapeTabBarHeight: CGFloat = 0 // Offline mode state private enum OfflineTab: String { case info, settings } @State private var selectedOfflineTab: OfflineTab = .info @State private var offlineEditingName: Bool = false @State private var offlineName: String = "" @State private var offlineDeleteConfirmation: Bool = false @State private var offlineTemperatureUnit: TemperatureUnitPreference = .celsius private let offlineSummary: AppData.MeterSummary? init() { offlineSummary = nil } init(offlineSummary: AppData.MeterSummary) { self.offlineSummary = offlineSummary } var body: some View { if let summary = offlineSummary { offlineBody(summary: summary) } else { liveBody } } private var liveBody: some View { GeometryReader { proxy in let landscape = isLandscape(size: proxy.size) let usesOverlayTabBar = landscape && Self.isPhone let tabBarStyle = tabBarStyle( for: landscape, usesOverlayTabBar: usesOverlayTabBar, size: proxy.size ) let tabBarPresentation = tabBarPresentation( for: proxy.size, usesOverlayTabBar: usesOverlayTabBar ) VStack(spacing: 0) { Group { if landscape { landscapeDeck( size: proxy.size, usesOverlayTabBar: usesOverlayTabBar, tabBarStyle: tabBarStyle, tabBarPresentation: tabBarPresentation ) } else { portraitContent( size: proxy.size, tabBarStyle: tabBarStyle, tabBarPresentation: tabBarPresentation ) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } .background(meterBackground) .navigationTitle(meter.name.isEmpty ? "Meter" : meter.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { MeterConnectionToolbarButton( operationalState: meter.operationalState, showsTitle: false, connectAction: { meter.connect() }, disconnectAction: { meter.disconnect() } ) .font(.body.weight(.semibold)) if meter.operationalState > .notPresent { RSSIView(RSSI: meter.btSerial.averageRSSI) .frame(width: 18, height: 18) } } } .onChange(of: selectedMeterTab) { newTab in meter.preferredTabIdentifier = newTab.rawValue } } private func portraitContent( size: CGSize, tabBarStyle: TabBarStyle, tabBarPresentation: AdaptiveTabBarPresentation ) -> some View { portraitSegmentedDeck( size: size, tabBarStyle: tabBarStyle, tabBarPresentation: tabBarPresentation ) } @ViewBuilder private func landscapeDeck( size: CGSize, usesOverlayTabBar: Bool, tabBarStyle: TabBarStyle, tabBarPresentation: AdaptiveTabBarPresentation ) -> some View { if usesOverlayTabBar { landscapeOverlaySegmentedDeck( size: size, tabBarStyle: tabBarStyle, tabBarPresentation: tabBarPresentation ) } else { landscapeSegmentedDeck( size: size, tabBarStyle: tabBarStyle, tabBarPresentation: tabBarPresentation ) } } private func landscapeOverlaySegmentedDeck( size: CGSize, tabBarStyle: TabBarStyle, tabBarPresentation: AdaptiveTabBarPresentation ) -> some View { ZStack(alignment: .top) { landscapeSegmentedContent(size: size) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.top, landscapeContentTopPadding(for: tabBarStyle)) .id(displayedMeterTab) .transition(.opacity.combined(with: .move(edge: .trailing))) segmentedTabBar( style: tabBarStyle, presentation: tabBarPresentation, showsConnectionAction: !Self.isMacIPadApp ) } .animation(.easeInOut(duration: 0.22), value: displayedMeterTab) .animation(.easeInOut(duration: 0.22), value: availableMeterTabs) .onAppear { restoreSelectedTab() } .onPreferenceChange(MeterTabBarHeightPreferenceKey.self) { height in if height > 0 { landscapeTabBarHeight = height } } } private func landscapeSegmentedDeck( size: CGSize, tabBarStyle: TabBarStyle, tabBarPresentation: AdaptiveTabBarPresentation ) -> some View { VStack(spacing: 0) { segmentedTabBar( style: tabBarStyle, presentation: tabBarPresentation, showsConnectionAction: !Self.isMacIPadApp ) landscapeSegmentedContent(size: size) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .id(displayedMeterTab) .transition(.opacity.combined(with: .move(edge: .trailing))) } .animation(.easeInOut(duration: 0.22), value: displayedMeterTab) .animation(.easeInOut(duration: 0.22), value: availableMeterTabs) .onAppear { restoreSelectedTab() } } private func portraitSegmentedDeck( size: CGSize, tabBarStyle: TabBarStyle, tabBarPresentation: AdaptiveTabBarPresentation ) -> some View { VStack(spacing: 0) { segmentedTabBar( style: tabBarStyle, presentation: tabBarPresentation ) portraitSegmentedContent(size: size) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .id(displayedMeterTab) .transition(.opacity.combined(with: .move(edge: .trailing))) } .animation(.easeInOut(duration: 0.22), value: displayedMeterTab) .animation(.easeInOut(duration: 0.22), value: availableMeterTabs) .onAppear { restoreSelectedTab() } } private func segmentedTabBar( style: TabBarStyle, presentation: AdaptiveTabBarPresentation, showsConnectionAction: Bool = false ) -> some View { let isFloating = style.floatingInset > 0 let cornerRadius = presentation.showsTitles ? 14.0 : 22.0 let unselectedForegroundColor = isFloating ? Color.primary.opacity(0.86) : Color.primary let unselectedChipFill = isFloating ? Color.black.opacity(0.08) : Color.secondary.opacity(0.12) return HStack { Spacer(minLength: 0) HStack(spacing: 8) { ForEach(availableMeterTabs, id: \.self) { tab in let isSelected = displayedMeterTab == tab let isUnavailable = requiresLiveData(tab) && !isLiveDataAvailable Button { withAnimation(.easeInOut(duration: 0.2)) { selectedMeterTab = tab } } label: { HStack(spacing: 6) { Image(systemName: tab.systemImage) .font(.subheadline.weight(.semibold)) if presentation.showsTitles { Text(title(for: tab)) .font(.subheadline.weight(.semibold)) .lineLimit(1) } } .foregroundColor( isSelected ? .white : (isUnavailable ? Color.secondary.opacity(0.5) : unselectedForegroundColor) ) .padding(.horizontal, style.chipHorizontalPadding) .padding(.vertical, style.chipVerticalPadding) .frame(maxWidth: .infinity) .background( Capsule() .fill( isSelected ? meter.color.opacity(isFloating ? 0.94 : 1) : (isUnavailable ? Color.secondary.opacity(0.06) : unselectedChipFill) ) ) } .buttonStyle(.plain) .accessibilityLabel(title(for: tab)) } } .frame(maxWidth: presentation.maxWidth) .padding(style.outerPadding) .background( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill( isFloating ? LinearGradient( colors: [ Color.white.opacity(0.76), Color.white.opacity(0.52) ], startPoint: .topLeading, endPoint: .bottomTrailing ) : LinearGradient( colors: [ Color.secondary.opacity(style.barBackgroundOpacity), Color.secondary.opacity(style.barBackgroundOpacity) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) ) .overlay { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke( isFloating ? Color.black.opacity(0.08) : Color.clear, lineWidth: 1 ) } .background { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(.ultraThinMaterial) .opacity(style.materialOpacity) } .shadow(color: Color.black.opacity(style.shadowOpacity), radius: isFloating ? 28 : 24, x: 0, y: isFloating ? 16 : 12) Spacer(minLength: 0) } .padding(.horizontal, style.horizontalPadding) .padding(.top, style.topPadding) .padding(.bottom, style.bottomPadding) .background( GeometryReader { geometry in Color.clear .preference(key: MeterTabBarHeightPreferenceKey.self, value: geometry.size.height) } ) .padding(.horizontal, style.floatingInset) .background { if style.floatingInset == 0 { Rectangle() .fill(.ultraThinMaterial) .opacity(style.materialOpacity) .ignoresSafeArea(edges: .top) } } .overlay(alignment: .bottom) { if style.floatingInset == 0 { Rectangle() .fill(Color.secondary.opacity(0.12)) .frame(height: 1) } } .overlay(alignment: .trailing) { if showsConnectionAction { MeterConnectionToolbarButton( operationalState: meter.operationalState, showsTitle: false, connectAction: { meter.connect() }, disconnectAction: { meter.disconnect() } ) .font(.title3.weight(.semibold)) .padding(.trailing, style.horizontalPadding + style.floatingInset + 4) .padding(.top, style.topPadding) .padding(.bottom, style.bottomPadding) } } } @ViewBuilder private func landscapeSegmentedContent(size: CGSize) -> some View { switch displayedMeterTab { case .home: MeterHomeTabView( size: size, isLandscape: true, showChargeRecordTab: { withAnimation(.easeInOut(duration: 0.22)) { selectedMeterTab = .chargeRecord } }, showDataGroupsTab: { withAnimation(.easeInOut(duration: 0.22)) { selectedMeterTab = .dataGroups } } ) case .live: MeterLiveTabView(size: size, isLandscape: true) case .chart: MeterChartTabView(size: size, isLandscape: true) case .chargeRecord: MeterChargeRecordTabView().equatable() case .dataGroups: MeterDataGroupsTabView() case .settings: MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) { withAnimation(.easeInOut(duration: 0.22)) { selectedMeterTab = .home } } } } @ViewBuilder private func portraitSegmentedContent(size: CGSize) -> some View { switch displayedMeterTab { case .home: MeterHomeTabView( size: size, isLandscape: false, showChargeRecordTab: { withAnimation(.easeInOut(duration: 0.22)) { selectedMeterTab = .chargeRecord } }, showDataGroupsTab: { withAnimation(.easeInOut(duration: 0.22)) { selectedMeterTab = .dataGroups } } ) case .live: MeterLiveTabView(size: size, isLandscape: false) case .chart: MeterChartTabView(size: size, isLandscape: false) case .chargeRecord: MeterChargeRecordTabView().equatable() case .dataGroups: MeterDataGroupsTabView() case .settings: MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) { withAnimation(.easeInOut(duration: 0.22)) { selectedMeterTab = .home } } } } private var availableMeterTabs: [MeterTab] { var tabs: [MeterTab] = [.home, .live, .chart] if meter.supportsRecordingView { tabs.append(.chargeRecord) } tabs.append(.dataGroups) tabs.append(.settings) return tabs } private var displayedMeterTab: MeterTab { if availableMeterTabs.contains(selectedMeterTab) { return selectedMeterTab } return .home } private func restoreSelectedTab() { guard let restoredTab = MeterTab(rawValue: meter.preferredTabIdentifier) else { meter.preferredTabIdentifier = MeterTab.home.rawValue selectedMeterTab = .home return } selectedMeterTab = availableMeterTabs.contains(restoredTab) ? restoredTab : .home } 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 tabBarStyle(for landscape: Bool, usesOverlayTabBar: Bool, size: CGSize) -> TabBarStyle { if usesOverlayTabBar { return .landscapeFloating } if landscape { return .landscapeInline } if Self.isPhone && size.width < 390 { return .portraitCompact } return .portrait } private func tabBarPresentation(for size: CGSize, usesOverlayTabBar: Bool) -> AdaptiveTabBarPresentation { if usesOverlayTabBar { return AdaptiveTabBarPresentation( showsTitles: false, maxWidth: 260 ) } return AdaptiveTabBarPresentation.standard(for: size) } private func landscapeContentTopPadding(for style: TabBarStyle) -> CGFloat { if style.floatingInset > 0 { return max(landscapeTabBarHeight * 0.44, 26) } return max(landscapeTabBarHeight - 6, 0) } private func title(for tab: MeterTab) -> String { switch tab { case .home: return "Home" case .live: return "Live" case .chart: return "Chart" case .chargeRecord: return "Charge Record" case .dataGroups: return meter.dataGroupsTitle case .settings: return "Settings" } } private func requiresLiveData(_ tab: MeterTab) -> Bool { switch tab { case .live, .chart: return true case .home, .chargeRecord, .dataGroups, .settings: return false } } private var isLiveDataAvailable: Bool { meter.operationalState >= .dataIsAvailable } // MARK: - Offline mode @ViewBuilder private func offlineBody(summary: AppData.MeterSummary) -> some View { VStack(spacing: 0) { offlineTabBar(tint: summary.tint) offlineTabContent(summary: summary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .id(selectedOfflineTab) .transition(.opacity.combined(with: .move(edge: .trailing))) .animation(.easeInOut(duration: 0.22), value: selectedOfflineTab) } .background(offlineBackground(tint: summary.tint)) .navigationTitle(summary.displayName) .navigationBarTitleDisplayMode(.inline) .onAppear { offlineName = summary.displayName offlineTemperatureUnit = appData.temperatureUnitPreference(for: summary.macAddress) } } private func offlineTabBar(tint: Color) -> some View { HStack { Spacer(minLength: 0) HStack(spacing: 8) { ForEach([OfflineTab.info, OfflineTab.settings], id: \.rawValue) { tab in let isSelected = selectedOfflineTab == tab Button { withAnimation(.easeInOut(duration: 0.2)) { selectedOfflineTab = tab } } label: { HStack(spacing: 6) { Image(systemName: tab == .info ? "house.fill" : "gearshape.fill") .font(.subheadline.weight(.semibold)) Text(tab == .info ? "Info" : "Settings") .font(.subheadline.weight(.semibold)) .lineLimit(1) } .foregroundColor(isSelected ? .white : .primary) .padding(.horizontal, 10) .padding(.vertical, 7) .frame(maxWidth: .infinity) .background(Capsule().fill(isSelected ? tint : Color.secondary.opacity(0.12))) } .buttonStyle(.plain) } } .padding(6) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color.secondary.opacity(0.10)) ) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(.ultraThinMaterial) .opacity(0.78) ) Spacer(minLength: 0) } .padding(.horizontal, 16) .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 offlineTabContent(summary: AppData.MeterSummary) -> some View { switch selectedOfflineTab { case .info: ScrollView { VStack(alignment: .leading, spacing: 20) { offlineStatusHeader(summary: summary) MeterInfoCardView(title: "Meter", tint: summary.tint) { MeterInfoRowView(label: "Name", value: summary.displayName) MeterInfoRowView(label: "Model", value: summary.modelSummary.isEmpty ? "Unknown" : summary.modelSummary) if let advertisedName = summary.advertisedName { MeterInfoRowView(label: "Advertised Name", value: advertisedName) } MeterInfoRowView(label: "MAC", value: summary.macAddress) MeterInfoRowView(label: "Last Seen", value: historyText(for: summary.lastSeen)) MeterInfoRowView(label: "Last Connected", value: historyText(for: summary.lastConnected)) } } .padding(16) } case .settings: offlineSettingsContent(summary: summary) } } private func offlineSettingsContent(summary: AppData.MeterSummary) -> some View { let isTC66 = summary.modelSummary == "TC66C" return ScrollView { VStack(spacing: 14) { offlineSettingsCard(title: "Name", tint: summary.tint) { HStack { Spacer() if !offlineEditingName { Text(offlineName).foregroundColor(.secondary) } ChevronView(rotate: $offlineEditingName) } if offlineEditingName { TextField("Name", text: $offlineName, onCommit: { appData.setMeterName(offlineName, for: summary.macAddress) offlineEditingName = false }) .textFieldStyle(RoundedBorderTextFieldStyle()) .lineLimit(1) .disableAutocorrection(true) .multilineTextAlignment(.center) } } if isTC66 { offlineSettingsCard( title: "Meter Temperature Unit", infoMessage: "TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.", tint: .orange ) { Picker("", selection: $offlineTemperatureUnit) { ForEach(TemperatureUnitPreference.allCases) { unit in Text(unit.title).tag(unit) } } .pickerStyle(SegmentedPickerStyle()) .onChange(of: offlineTemperatureUnit) { newValue in appData.setTemperatureUnitPreference(newValue, for: summary.macAddress) } } } offlineSettingsCard( title: "Danger Zone", infoMessage: "Delete this meter from the sidebar and clear its saved metadata. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.", tint: .red ) { Button("Delete Meter") { offlineDeleteConfirmation = true } .frame(maxWidth: .infinity) .padding(.vertical, 10) .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14) .buttonStyle(.plain) } } .padding() } .alert("Delete Meter?", isPresented: $offlineDeleteConfirmation) { Button("Delete", role: .destructive) { appData.deleteMeter(macAddress: summary.macAddress) dismiss() } Button("Cancel", role: .cancel) {} } message: { Text("This removes the saved meter entry. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.") } } private func offlineSettingsCard( title: String, infoMessage: String? = nil, tint: Color, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Text(title).font(.headline) if let infoMessage { ContextInfoButton(title: title, message: infoMessage) } } content() } .padding(18) .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24) } private func offlineStatusHeader(summary: AppData.MeterSummary) -> some View { HStack(spacing: 12) { Image(systemName: "sensor.tag.radiowaves.forward.fill") .font(.system(size: 22, weight: .semibold)) .foregroundColor(.secondary) VStack(alignment: .leading, spacing: 4) { Text(summary.displayName) .font(.title3.weight(.semibold)) .lineLimit(1) Text(summary.modelSummary.isEmpty ? "Known Meter" : summary.modelSummary) .font(.caption) .foregroundColor(.secondary) } Spacer() HStack(spacing: 6) { Circle().fill(Color.secondary).frame(width: 8, height: 8) Text("Offline") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } .padding(.horizontal, 10) .padding(.vertical, 6) .background(Capsule(style: .continuous).fill(Color.secondary.opacity(0.12))) .overlay(Capsule(style: .continuous).stroke(Color.secondary.opacity(0.22), lineWidth: 1)) } .padding(14) .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 18) } private func offlineBackground(tint: Color) -> some View { LinearGradient( colors: [tint.opacity(0.14), Color.secondary.opacity(0.06), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() } private func historyText(for date: Date?) -> String { guard let date else { return "Never" } return date.format(as: "yyyy-MM-dd HH:mm") } } private struct MeterTabBarHeightPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } }