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 {
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
// True only on Mac iPad App (Designed for iPad), false on Catalyst
private static let isTrueMacApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac && !ProcessInfo.processInfo.isMacCatalystApp
@State private var selectedMeterTab: MeterTab = .home
@State private var navBarTitle: String = "Meter"
@State private var navBarShowRSSI: Bool = false
@State private var navBarRSSI: Int = 0
@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) {
// Use custom header only on true Mac iPad App (Designed for iPad on Mac)
if Self.isTrueMacApp {
macNavigationHeader
}
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)
}
#if !targetEnvironment(macCatalyst)
.navigationBarHidden(Self.isTrueMacApp && landscape)
#else
.navigationBarHidden(landscape)
#endif
}
.background(meterBackground)
.modifier(IOSOnlyNavBar(
apply: !Self.isTrueMacApp,
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
}
}
.onChange(of: selectedMeterTab) { newTab in
meter.preferredTabIdentifier = newTab.rawValue
}
}
// 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()
MeterConnectionToolbarButton(
operationalState: meter.operationalState,
showsTitle: true,
connectAction: { meter.connect() },
disconnectAction: { meter.disconnect() }
)
if meter.operationalState > .notPresent {
RSSIView(RSSI: meter.btSerial.averageRSSI)
.frame(width: 18, height: 18)
}
}
.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,
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) {
if Self.isTrueMacApp {
offlineMacHeader(name: summary.displayName)
}
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))
#if !targetEnvironment(macCatalyst)
.navigationBarHidden(Self.isTrueMacApp)
#else
.navigationBarHidden(false)
#endif
.navigationBarTitle(summary.displayName, displayMode: .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<Content: View>(
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 offlineMacHeader(name: String) -> 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(name).font(.headline).lineLimit(1)
Spacer()
}
.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 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())
}
}
// 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, displayMode: .inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
MeterConnectionToolbarButton(
operationalState: meter.operationalState,
showsTitle: false,
connectAction: { meter.connect() },
disconnectAction: { meter.disconnect() }
)
.font(.body.weight(.semibold))
if showRSSI {
RSSIView(RSSI: rssi)
.frame(width: 18, height: 18)
}
}
}
#if targetEnvironment(macCatalyst)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {}
}
#endif
} else {
content
}
}
}