1 contributor
995 lines | 36.215kb
//
//  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
        }
    }
}