USB-Meter / USB Meter / Views / ContentView.swift
1 contributor
541 lines | 19.274kb
//
//  ContentView.swift
//  USB Meter
//
//  Created by Bogdan Timofte on 01/03/2020.
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
//

//MARK: Bluetooth Icon: https://upload.wikimedia.org/wikipedia/commons/d/da/Bluetooth.svg

import SwiftUI
import Combine

struct ContentView: View {
    private enum HelpAutoReason: String {
        case bluetoothPermission
        case cloudSyncUnavailable
        case noDevicesDetected

        var tint: Color {
            switch self {
            case .bluetoothPermission:
                return .orange
            case .cloudSyncUnavailable:
                return .indigo
            case .noDevicesDetected:
                return .yellow
            }
        }

        var symbol: String {
            switch self {
            case .bluetoothPermission:
                return "bolt.horizontal.circle.fill"
            case .cloudSyncUnavailable:
                return "icloud.slash.fill"
            case .noDevicesDetected:
                return "magnifyingglass.circle.fill"
            }
        }

        var badgeTitle: String {
            switch self {
            case .bluetoothPermission:
                return "Required"
            case .cloudSyncUnavailable:
                return "Sync Off"
            case .noDevicesDetected:
                return "Suggested"
            }
        }
    }

    @EnvironmentObject private var appData: AppData
    @State private var isHelpExpanded = false
    @State private var dismissedAutoHelpReason: HelpAutoReason?
    @State private var now = Date()
    private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    private let noDevicesHelpDelay: TimeInterval = 12

    var body: some View {
        NavigationView {
            ScrollView {
                VStack(alignment: .leading, spacing: 18) {
                    headerCard
                    helpSection
                    devicesSection
                    debugSection
                }
                .padding()
            }
            .background(
                LinearGradient(
                    colors: [
                        appData.bluetoothManager.managerState.color.opacity(0.18),
                        Color.clear
                    ],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                .ignoresSafeArea()
            )
            .navigationBarTitle(Text("USB Meters"), displayMode: .inline)
        }
        .onAppear {
            appData.bluetoothManager.start()
            now = Date()
        }
        .onReceive(helpRefreshTimer) { currentDate in
            now = currentDate
        }
        .onChange(of: activeHelpAutoReason) { newReason in
            if newReason == nil {
                dismissedAutoHelpReason = nil
            }
        }
    }

    private var headerCard: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("USB Meters")
                .font(.system(.title2, design: .rounded).weight(.bold))
            Text("Browse nearby supported meters and jump into live diagnostics, charge records, and device controls.")
                .font(.footnote)
                .foregroundColor(.secondary)
            HStack {
                Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
                    .font(.footnote.weight(.semibold))
                    .foregroundColor(appData.bluetoothManager.managerState.color)
                Spacer()
                Text(bluetoothStatusText)
                    .font(.caption.weight(.semibold))
                    .foregroundColor(.secondary)
            }
        }
        .padding(18)
        .meterCard(tint: appData.bluetoothManager.managerState.color, fillOpacity: 0.22, strokeOpacity: 0.26)
    }

    private var helpSection: some View {
        VStack(alignment: .leading, spacing: 12) {
            Button(action: toggleHelpSection) {
                HStack(spacing: 14) {
                    Image(systemName: helpSectionSymbol)
                        .font(.system(size: 18, weight: .semibold))
                        .foregroundColor(helpSectionTint)
                        .frame(width: 42, height: 42)
                        .background(Circle().fill(helpSectionTint.opacity(0.18)))

                    VStack(alignment: .leading, spacing: 4) {
                        Text("Help")
                            .font(.headline)
                        Text(helpSectionSummary)
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }

                    Spacer()

                    if let activeHelpAutoReason {
                        Text(activeHelpAutoReason.badgeTitle)
                            .font(.caption2.weight(.bold))
                            .foregroundColor(activeHelpAutoReason.tint)
                            .padding(.horizontal, 10)
                            .padding(.vertical, 6)
                            .background(
                                Capsule(style: .continuous)
                                    .fill(activeHelpAutoReason.tint.opacity(0.12))
                            )
                            .overlay(
                                Capsule(style: .continuous)
                                    .stroke(activeHelpAutoReason.tint.opacity(0.22), lineWidth: 1)
                            )
                    }

                    Image(systemName: helpIsExpanded ? "chevron.up" : "chevron.down")
                        .font(.footnote.weight(.bold))
                        .foregroundColor(.secondary)
                }
                .padding(14)
                .meterCard(tint: helpSectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
            }
            .buttonStyle(.plain)

            if helpIsExpanded {
                if let activeHelpAutoReason {
                    helpNoticeCard(for: activeHelpAutoReason)
                }

                if activeHelpAutoReason == .cloudSyncUnavailable {
                    Button(action: openSettings) {
                        sidebarLinkCard(
                            title: "Open Settings",
                            subtitle: "Check Apple ID, iCloud Drive, and any restrictions affecting Cloud sync.",
                            symbol: "gearshape.fill",
                            tint: .indigo
                        )
                    }
                    .buttonStyle(.plain)
                }

                NavigationLink(destination: appData.bluetoothManager.managerState.helpView) {
                    sidebarLinkCard(
                        title: "Bluetooth",
                        subtitle: "Permissions, adapter state, and connection tips.",
                        symbol: "bolt.horizontal.circle.fill",
                        tint: appData.bluetoothManager.managerState.color
                    )
                }
                .buttonStyle(.plain)

                NavigationLink(destination: DeviceHelpView()) {
                    sidebarLinkCard(
                        title: "Device",
                        subtitle: "Quick checks when a meter is not responding as expected.",
                        symbol: "questionmark.circle.fill",
                        tint: .orange
                    )
                }
                .buttonStyle(.plain)
            }
        }
        .animation(.easeInOut(duration: 0.22), value: helpIsExpanded)
    }

    private var devicesSection: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Text("Known Meters")
                    .font(.headline)
                Spacer()
                Text("\(appData.knownMeters.count)")
                    .font(.caption.weight(.bold))
                    .padding(.horizontal, 10)
                    .padding(.vertical, 6)
                    .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
            }

            if appData.knownMeters.isEmpty {
                Text(devicesEmptyStateText)
                    .font(.footnote)
                    .foregroundColor(.secondary)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(18)
                    .meterCard(
                        tint: isWaitingForFirstDiscovery ? .blue : .secondary,
                        fillOpacity: 0.14,
                        strokeOpacity: 0.20
                    )
            } else {
                ForEach(appData.knownMeters) { knownMeter in
                    if let meter = knownMeter.meter {
                        NavigationLink(destination: MeterView().environmentObject(meter)) {
                            MeterRowView()
                                .environmentObject(meter)
                        }
                        .buttonStyle(.plain)
                    } else {
                        knownMeterCard(for: knownMeter)
                    }
                }
            }
        }
    }

    private var debugSection: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Debug")
                .font(.headline)
            debugLink
        }
    }

    private var debugLink: some View {
        NavigationLink(destination: MeterMappingDebugView()) {
            sidebarLinkCard(
                title: "Meter Sync Debug",
                subtitle: "Inspect meter name sync data and iCloud KVS visibility as seen by this device.",
                symbol: "list.bullet.rectangle",
                tint: .purple
            )
        }
        .buttonStyle(.plain)
    }

    private var bluetoothStatusText: String {
        switch appData.bluetoothManager.managerState {
        case .poweredOff:
            return "Off"
        case .poweredOn:
            return "On"
        case .resetting:
            return "Resetting"
        case .unauthorized:
            return "Unauthorized"
        case .unknown:
            return "Unknown"
        case .unsupported:
            return "Unsupported"
        @unknown default:
            return "Other"
        }
    }

    private var helpIsExpanded: Bool {
        isHelpExpanded || shouldAutoExpandHelp
    }

    private var shouldAutoExpandHelp: Bool {
        guard let activeHelpAutoReason else {
            return false
        }
        return dismissedAutoHelpReason != activeHelpAutoReason
    }

    private var activeHelpAutoReason: HelpAutoReason? {
        if appData.bluetoothManager.managerState == .unauthorized {
            return .bluetoothPermission
        }
        if shouldPromptForCloudSync {
            return .cloudSyncUnavailable
        }
        if hasWaitedLongEnoughForDevices {
            return .noDevicesDetected
        }
        return nil
    }

    private var shouldPromptForCloudSync: Bool {
        switch appData.cloudAvailability {
        case .noAccount, .error:
            return true
        case .unknown, .available:
            return false
        }
    }

    private var hasWaitedLongEnoughForDevices: Bool {
        guard appData.bluetoothManager.managerState == .poweredOn else {
            return false
        }
        guard appData.meters.isEmpty else {
            return false
        }
        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
            return false
        }
        return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay
    }

    private var isWaitingForFirstDiscovery: Bool {
        guard appData.bluetoothManager.managerState == .poweredOn else {
            return false
        }
        guard appData.meters.isEmpty else {
            return false
        }
        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
            return false
        }
        return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay
    }

    private var devicesEmptyStateText: String {
        if isWaitingForFirstDiscovery {
            return "Scanning for nearby supported meters..."
        }
        return "No known meters yet. Nearby supported meters will appear here and remain available after they disappear."
    }

    private var helpSectionTint: Color {
        activeHelpAutoReason?.tint ?? .secondary
    }

    private var helpSectionSymbol: String {
        activeHelpAutoReason?.symbol ?? "questionmark.circle.fill"
    }

    private var helpSectionSummary: String {
        switch activeHelpAutoReason {
        case .bluetoothPermission:
            return "Bluetooth permission is needed before scanning can begin."
        case .cloudSyncUnavailable:
            return appData.cloudAvailability.helpMessage
        case .noDevicesDetected:
            return "No supported devices were found after \(Int(noDevicesHelpDelay)) seconds."
        case nil:
            return "Connection tips and quick checks when discovery needs help."
        }
    }

    private func toggleHelpSection() {
        withAnimation(.easeInOut(duration: 0.22)) {
            if shouldAutoExpandHelp {
                dismissedAutoHelpReason = activeHelpAutoReason
                isHelpExpanded = false
            } else {
                isHelpExpanded.toggle()
            }
        }
    }

    private func openSettings() {
        guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
            return
        }
        UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil)
    }

    private func helpNoticeCard(for reason: HelpAutoReason) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(helpNoticeTitle(for: reason))
                .font(.subheadline.weight(.semibold))
            Text(helpNoticeDetail(for: reason))
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(14)
        .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
    }

    private func helpNoticeTitle(for reason: HelpAutoReason) -> String {
        switch reason {
        case .bluetoothPermission:
            return "Bluetooth access needs attention"
        case .cloudSyncUnavailable:
            return appData.cloudAvailability.helpTitle
        case .noDevicesDetected:
            return "No supported meters found yet"
        }
    }

    private func helpNoticeDetail(for reason: HelpAutoReason) -> String {
        switch reason {
        case .bluetoothPermission:
            return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked."
        case .cloudSyncUnavailable:
            return appData.cloudAvailability.helpMessage
        case .noDevicesDetected:
            return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone."
        }
    }

    private func sidebarLinkCard(
        title: String,
        subtitle: String,
        symbol: String,
        tint: Color
    ) -> some View {
        HStack(spacing: 14) {
            Image(systemName: symbol)
                .font(.system(size: 18, weight: .semibold))
                .foregroundColor(tint)
                .frame(width: 42, height: 42)
                .background(Circle().fill(tint.opacity(0.18)))

            VStack(alignment: .leading, spacing: 4) {
                Text(title)
                    .font(.headline)
                Text(subtitle)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }

            Spacer()

            Image(systemName: "chevron.right")
                .font(.footnote.weight(.bold))
                .foregroundColor(.secondary)
        }
        .padding(14)
        .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
    }

    private func knownMeterCard(for knownMeter: AppData.KnownMeterSummary) -> some View {
        HStack(spacing: 14) {
            Image(systemName: "sensor.tag.radiowaves.forward.fill")
                .font(.system(size: 18, weight: .semibold))
                .foregroundColor(knownMeterTint(for: knownMeter))
                .frame(width: 42, height: 42)
                .background(
                    Circle()
                        .fill(knownMeterTint(for: knownMeter).opacity(0.18))
                )
                .overlay(alignment: .bottomTrailing) {
                    Circle()
                        .fill(Color.red)
                        .frame(width: 12, height: 12)
                        .overlay(
                            Circle()
                                .stroke(Color(uiColor: .systemBackground), lineWidth: 2)
                        )
                }

            VStack(alignment: .leading, spacing: 4) {
                Text(knownMeter.displayName)
                    .font(.headline)
                Text(knownMeter.modelSummary)
                    .font(.caption)
                    .foregroundColor(.secondary)
                if let advertisedName = knownMeter.advertisedName, advertisedName != knownMeter.modelSummary {
                    Text("Advertised as \(advertisedName)")
                        .font(.caption2)
                        .foregroundColor(.secondary)
                }
            }

            Spacer()

            VStack(alignment: .trailing, spacing: 4) {
                HStack(spacing: 6) {
                    Circle()
                        .fill(Color.red)
                        .frame(width: 8, height: 8)
                    Text("Missing")
                        .font(.caption.weight(.semibold))
                        .foregroundColor(.secondary)
                }
                .padding(.horizontal, 10)
                .padding(.vertical, 6)
                .background(
                    Capsule(style: .continuous)
                        .fill(Color.red.opacity(0.12))
                )
                .overlay(
                    Capsule(style: .continuous)
                        .stroke(Color.red.opacity(0.22), lineWidth: 1)
                )
                Text(knownMeter.macAddress)
                    .font(.caption2)
                    .foregroundColor(.secondary)
                if let lastSeen = knownMeter.lastSeen {
                    Text("Seen \(lastSeen.format(as: "yyyy-MM-dd HH:mm"))")
                        .font(.caption2)
                        .foregroundColor(.secondary)
                }
            }
        }
        .padding(14)
        .meterCard(
            tint: knownMeterTint(for: knownMeter),
            fillOpacity: 0.16,
            strokeOpacity: 0.22,
            cornerRadius: 18
        )
    }

    private func knownMeterTint(for knownMeter: AppData.KnownMeterSummary) -> Color {
        switch knownMeter.modelSummary {
        case "UM25C":
            return .blue
        case "UM34C":
            return .yellow
        case "TC66C":
            return Model.TC66C.color
        default:
            return .secondary
        }
    }
}