1 contributor
971 lines | 35.727kb
//
//  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
        case settings

        var title: String {
            switch self {
            case .connection: return "Home"
            case .live: return "Live"
            case .chart: return "Chart"
            case .settings: return "Settings"
            }
        }

        var systemImage: String {
            switch self {
            case .connection: return "house.fill"
            case .live: return "waveform.path.ecg"
            case .chart: return "chart.xyaxis.line"
            case .settings: return "gearshape.fill"
            }
        }
    }
    
    @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
    @State private var editingName = false
    @State private var editingScreenTimeout = false
    @State private var editingScreenBrightness = false
    private var myBounds: CGRect { UIScreen.main.bounds }
    private let actionStripPadding: CGFloat = 10
    private let actionDividerWidth: CGFloat = 1
    private let actionButtonMaxWidth: CGFloat = 156
    private let actionButtonMinWidth: CGFloat = 88
    private let actionButtonHeight: CGFloat = 108
    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)
            }

        }
        .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
            }
        case .settings:
            landscapeSettingsPage(size: size)
        }
    }

    @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)
            }
        case .settings:
            portraitSettingsPage(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)
        }
    }

    @ViewBuilder
    private func portraitSettingsPage(size: CGSize) -> some View {
        settingsTabContent
    }

    @ViewBuilder
    private func landscapeSettingsPage(size: CGSize) -> some View {
        settingsTabContent
    }

    private var settingsTabContent: some View {
        VStack(spacing: 0) {
            if Self.isMacIPadApp {
                settingsMacHeader
            }
            ScrollView {
                VStack(spacing: 14) {
                    settingsCard(title: "Name", tint: meter.color) {
                        HStack {
                            Spacer()
                            if !editingName {
                                Text(meter.name)
                                    .foregroundColor(.secondary)
                            }
                            ChevronView(rotate: $editingName)
                        }
                        if editingName {
                            EditNameView(editingName: $editingName, newName: meter.name)
                        }
                    }

                    if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
                        settingsCard(title: "Meter Temperature Unit", tint: .orange) {
                            Text("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.")
                                .font(.footnote)
                                .foregroundColor(.secondary)
                            Picker("", selection: $meter.tc66TemperatureUnitPreference) {
                                ForEach(TemperatureUnitPreference.allCases) { unit in
                                    Text(unit.title).tag(unit)
                                }
                            }
                            .pickerStyle(SegmentedPickerStyle())
                        }
                    }

                    if meter.operationalState == .dataIsAvailable {
                        settingsCard(
                            title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls",
                            tint: .indigo
                        ) {
                            if meter.reportsCurrentScreenIndex {
                                Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
                                    .font(.footnote)
                                    .foregroundColor(.secondary)
                            } else {
                                Text("Use these controls when you want to switch device pages without crowding the main meter view.")
                                    .font(.footnote)
                                    .foregroundColor(.secondary)
                            }

                            ControlView(showsHeader: false)
                        }
                    }

                    if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
                        settingsCard(title: "Screen Timeout", tint: .purple) {
                            HStack {
                                Spacer()
                                if !editingScreenTimeout {
                                    Text(meter.screenTimeout > 0 ? "\(meter.screenTimeout) Minutes" : "Off")
                                        .foregroundColor(.secondary)
                                }
                                ChevronView(rotate: $editingScreenTimeout)
                            }
                            if editingScreenTimeout {
                                EditScreenTimeoutView()
                            }
                        }

                        settingsCard(title: "Screen Brightness", tint: .yellow) {
                            HStack {
                                Spacer()
                                if !editingScreenBrightness {
                                    Text("\(meter.screenBrightness)")
                                        .foregroundColor(.secondary)
                                }
                                ChevronView(rotate: $editingScreenBrightness)
                            }
                            if editingScreenBrightness {
                                EditScreenBrightnessView()
                            }
                        }
                    }
                }
                .padding()
            }
            .background(
                LinearGradient(
                    colors: [meter.color.opacity(0.14), Color.clear],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                .ignoresSafeArea()
            )
        }
    }

    private var settingsMacHeader: some View {
        HStack(spacing: 12) {
            Button {
                selectedMeterTab = .connection
            } label: {
                HStack(spacing: 4) {
                    Image(systemName: "chevron.left")
                        .font(.body.weight(.semibold))
                    Text("Back")
                }
                .foregroundColor(.accentColor)
            }
            .buttonStyle(.plain)

            Text("Meter Settings")
                .font(.headline)
                .lineLimit(1)

            Spacer()

            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 settingsCard<Content: View>(
        title: String,
        tint: Color,
        @ViewBuilder content: () -> Content
    ) -> some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(title)
                .font(.headline)
            content()
        }
        .padding(18)
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
    }

    private var availableMeterTabs: [MeterTab] {
        var tabs: [MeterTab] = [.connection]

        if meter.operationalState == .dataIsAvailable {
            tabs.append(.live)

            if meter.measurements.power.context.isValid {
                tabs.append(.chart)
            }
        }

        tabs.append(.settings)

        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<Content: View>(@ViewBuilder content: () -> Content) -> some View {
        ScrollView {
            content()
                .frame(maxWidth: .infinity, alignment: .topLeading)
                .padding(.horizontal, pageHorizontalPadding)
                .padding(.vertical, pageVerticalPadding)
        }
    }

    private func landscapeFace<Content: View>(@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<Content: View>: 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)
    }
}

private struct EditNameView: View {

    @EnvironmentObject private var meter: Meter

    @Binding var editingName: Bool
    @State var newName: String

    var body: some View {
        TextField("Name", text: self.$newName, onCommit: {
            self.meter.name = self.newName
            self.editingName = false
        })
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .lineLimit(1)
            .disableAutocorrection(true)
            .multilineTextAlignment(.center)
    }
}

private struct EditScreenTimeoutView: View {

    @EnvironmentObject private var meter: Meter

    var body: some View {
        Picker("", selection: self.$meter.screenTimeout ) {
            ForEach(1...9, id: \.self) { value in
                Text("\(value)").tag(value)
            }
            Text("Off").tag(0)
        }
        .pickerStyle(SegmentedPickerStyle())
    }
}

private struct EditScreenBrightnessView: View {

    @EnvironmentObject private var meter: Meter

    var body: some View {
        Picker("", selection: self.$meter.screenBrightness ) {
            ForEach(0...5, id: \.self) { value in
                Text("\(value)").tag(value)
            }
        }
        .pickerStyle(SegmentedPickerStyle())
    }
}

// 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)
                        }
                    }
                }
        } else {
            content
        }
    }
}