USB-Meter / USB Meter / Views / ContentView.swift
1 contributor
626 lines | 24.623kb
//
//  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
import UIKit

struct ContentView: View {
    private enum SidebarItem: Hashable {
        case overview
        case meter(String)
        case bluetoothHelp
        case deviceChecklist
        case discoveryChecklist
    }

    private struct MeterSidebarEntry: Identifiable, Hashable {
        let id: String
        let macAddress: String
        let displayName: String
        let modelSummary: String
        let meterColor: Color
        let statusText: String
        let statusColor: Color
        let isLive: Bool
        let lastSeenAt: Date?
    }
    
    @EnvironmentObject private var appData: AppData
    @State private var selectedSidebarItem: SidebarItem? = .overview
    @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 {
            sidebar
            detailContent(for: selectedSidebarItem ?? .overview)
        }
        .navigationViewStyle(DoubleColumnNavigationViewStyle())
        .onAppear {
            appData.bluetoothManager.start()
            now = Date()
        }
        .onReceive(helpRefreshTimer) { currentDate in
            now = currentDate
        }
        .onChange(of: visibleMeterIDs) { _ in
            sanitizeSelection()
        }
        .onChange(of: appData.bluetoothManager.managerState) { _ in
            sanitizeSelection()
        }
    }

    private var sidebar: some View {
        List(selection: $selectedSidebarItem) {
            Section(header: Text("Start")) {
                NavigationLink(tag: SidebarItem.overview, selection: $selectedSidebarItem) {
                    detailContent(for: .overview)
                } label: {
                    Label("Overview", systemImage: "house.fill")
                }
            }

            Section(header: Text("Meters")) {
                if visibleMeters.isEmpty {
                    HStack(spacing: 10) {
                        Image(systemName: isWaitingForFirstDiscovery ? "dot.radiowaves.left.and.right" : "questionmark.circle")
                            .foregroundColor(isWaitingForFirstDiscovery ? .blue : .secondary)
                        Text(devicesEmptyStateText)
                            .font(.footnote)
                            .foregroundColor(.secondary)
                    }
                } else {
                    ForEach(visibleMeters) { meter in
                        NavigationLink(tag: SidebarItem.meter(meter.id), selection: $selectedSidebarItem) {
                            detailContent(for: .meter(meter.id))
                        } label: {
                            meterSidebarRow(for: meter)
                        }
                        .buttonStyle(.plain)
                        .listRowInsets(EdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10))
                        .listRowBackground(Color.clear)
                    }
                }
            }

            if shouldShowAssistanceSection {
                Section(header: Text("Assistance")) {
                    if shouldShowBluetoothHelpEntry {
                        NavigationLink(tag: SidebarItem.bluetoothHelp, selection: $selectedSidebarItem) {
                            appData.bluetoothManager.managerState.helpView
                        } label: {
                            Label("Bluetooth Checklist", systemImage: "bolt.horizontal.circle.fill")
                        }
                    }

                    if shouldShowDeviceChecklistEntry {
                        NavigationLink(tag: SidebarItem.deviceChecklist, selection: $selectedSidebarItem) {
                            DeviceHelpView()
                        } label: {
                            Label("Device Checklist", systemImage: "checklist")
                        }
                    }

                    if shouldShowDiscoveryChecklistEntry {
                        NavigationLink(tag: SidebarItem.discoveryChecklist, selection: $selectedSidebarItem) {
                            DiscoveryChecklistView()
                        } label: {
                            Label("Discovery Checklist", systemImage: "magnifyingglass.circle")
                        }
                    }
                }
            }
        }
        .listStyle(SidebarListStyle())
        .navigationTitle("USB Meters")
    }

    @ViewBuilder
    private func detailContent(for item: SidebarItem) -> some View {
        switch item {
        case .overview:
            overviewDetail
        case .meter(let macAddress):
            if let meter = liveMeter(forMacAddress: macAddress) {
                MeterView().environmentObject(meter)
            } else if let meter = meterEntry(for: macAddress),
                      let known = appData.knownMetersByMAC[macAddress] {
                offlineMeterDetail(for: meter, known: known)
            } else {
                unavailableMeterDetail
            }
        case .bluetoothHelp:
            appData.bluetoothManager.managerState.helpView
        case .deviceChecklist:
            DeviceHelpView()
        case .discoveryChecklist:
            DiscoveryChecklistView()
        }
    }

    private var overviewDetail: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                VStack(alignment: .leading, spacing: 8) {
                    Text("USB Meter")
                        .font(.system(.title2, design: .rounded).weight(.bold))
                    Text("Discover nearby supported meters and open one to see live diagnostics, records, and controls.")
                        .font(.footnote)
                        .foregroundColor(.secondary)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(18)
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22)

                if shouldShowBluetoothHelpEntry {
                    overviewHintCard(
                        title: "Bluetooth needs attention",
                        detail: "Open Bluetooth Checklist from the sidebar to resolve the current Bluetooth state.",
                        tint: appData.bluetoothManager.managerState.color,
                        symbol: "bolt.horizontal.circle.fill"
                    )
                } else {
                    overviewHintCard(
                        title: "Discovered devices",
                        detail: visibleMeters.isEmpty ? devicesEmptyStateText : "\(visibleMeters.count) known device(s) available in the sidebar.",
                        tint: visibleMeters.isEmpty ? .secondary : .green,
                        symbol: visibleMeters.isEmpty ? "dot.radiowaves.left.and.right" : "sensor.tag.radiowaves.forward.fill"
                    )
                }

                overviewHintCard(
                    title: "Quick start",
                    detail: "1. Power on your USB meter.\n2. Keep it close to this device.\n3. Select it from Discovered Devices in the sidebar.",
                    tint: .orange,
                    symbol: "list.number"
                )

                if shouldShowDeviceChecklistEntry || shouldShowDiscoveryChecklistEntry {
                    overviewHintCard(
                        title: "Need help finding devices?",
                        detail: "Use the Assistance entries from the sidebar for guided troubleshooting checklists.",
                        tint: .yellow,
                        symbol: "questionmark.circle.fill"
                    )
                }
            }
            .padding()
        }
        .background(
            LinearGradient(
                colors: [.blue.opacity(0.12), Color.clear],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()
        )
        .navigationBarTitle(Text("Overview"), displayMode: .inline)
    }

    private var unavailableMeterDetail: some View {
        VStack(spacing: 10) {
            Image(systemName: "exclamationmark.triangle.fill")
                .font(.system(size: 30, weight: .bold))
                .foregroundColor(.orange)
            Text("Device no longer available")
                .font(.headline)
            Text("Select another device from the sidebar or return to Overview.")
                .font(.footnote)
                .foregroundColor(.secondary)
        }
        .padding(24)
    }

    private func offlineMeterDetail(for meter: MeterSidebarEntry, known: KnownMeterCatalogItem) -> some View {
        let isConnectedElsewhere = isConnectedElsewhere(known)

        return ScrollView {
            VStack(alignment: .leading, spacing: 14) {
                VStack(alignment: .leading, spacing: 8) {
                    HStack(spacing: 8) {
                        Circle()
                            .fill(meter.statusColor)
                            .frame(width: 10, height: 10)
                        Text(isConnectedElsewhere ? "Connected Elsewhere" : "Unavailable On This Device")
                            .font(.headline)
                    }

                    Text(meter.displayName)
                        .font(.title3.weight(.semibold))

                    Text(meter.modelSummary)
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(18)
                .meterCard(tint: meter.meterColor, fillOpacity: 0.14, strokeOpacity: 0.22)

                VStack(alignment: .leading, spacing: 10) {
                    HStack {
                        Text("iCloud Debug")
                            .font(.headline)
                        Spacer()
                        Button {
                            UIPasteboard.general.string = offlineDebugText(for: meter, known: known, isConnectedElsewhere: isConnectedElsewhere)
                        } label: {
                            Label("Copy", systemImage: "doc.on.doc")
                                .font(.caption.weight(.semibold))
                        }
                        .buttonStyle(.plain)
                    }
                    Text(offlineDebugText(for: meter, known: known, isConnectedElsewhere: isConnectedElsewhere))
                        .font(.system(.footnote, design: .monospaced))
                        .textSelection(.enabled)
                        .frame(maxWidth: .infinity, alignment: .leading)
                }
                .padding(18)
                .meterCard(tint: .indigo, fillOpacity: 0.14, strokeOpacity: 0.22)

                Text("When this meter appears over BLE on this device, the live Meter View opens automatically from the BT layer and no CloudKit state will override it.")
                    .font(.footnote)
                    .foregroundColor(.secondary)
                    .padding(.horizontal, 4)
            }
            .padding()
        }
        .background(
            LinearGradient(
                colors: [meter.meterColor.opacity(0.10), Color.clear],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()
        )
    }

    private var visibleMeters: [MeterSidebarEntry] {
        var entriesByMAC: [String: MeterSidebarEntry] = [:]

        for known in appData.knownMetersByMAC.values {
            let isConnectedElsewhere = isConnectedElsewhere(known)
            entriesByMAC[known.macAddress] = MeterSidebarEntry(
                id: known.macAddress,
                macAddress: known.macAddress,
                displayName: known.displayName,
                modelSummary: known.modelType ?? "Unknown model",
                meterColor: meterColor(forModelType: known.modelType),
                statusText: isConnectedElsewhere ? "Elsewhere" : "Offline",
                statusColor: isConnectedElsewhere ? .indigo : .secondary,
                isLive: false,
                lastSeenAt: known.lastSeenAt
            )
        }

        for meter in appData.meters.values {
            let mac = meter.btSerial.macAddress.description
            let known = appData.knownMetersByMAC[mac]
            let cloudElsewhere = known.map(isConnectedElsewhere) ?? false
            let liveConnected = meter.operationalState >= .peripheralConnected
            let effectiveElsewhere = cloudElsewhere && !liveConnected
            entriesByMAC[mac] = MeterSidebarEntry(
                id: mac,
                macAddress: mac,
                displayName: meter.name,
                modelSummary: meter.deviceModelSummary,
                meterColor: meter.color,
                statusText: effectiveElsewhere ? "Elsewhere" : statusText(for: meter.operationalState),
                statusColor: effectiveElsewhere ? .indigo : Meter.operationalColor(for: meter.operationalState),
                isLive: true,
                lastSeenAt: meter.lastSeen
            )
        }

        return entriesByMAC.values.sorted { lhs, rhs in
            lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
        }
    }

    private func isConnectedElsewhere(_ known: KnownMeterCatalogItem) -> Bool {
        guard let connectedBy = known.connectedByDeviceID, !connectedBy.isEmpty else {
            return false
        }
        guard connectedBy != AppData.myDeviceID else {
            return false
        }
        guard let expiry = known.connectedExpiryAt else {
            return false
        }
        return expiry > Date()
    }

    private func formatDate(_ value: Date?) -> String {
        guard let value else { return "(empty)" }
        return value.formatted(date: .abbreviated, time: .standard)
    }

    private func offlineDebugText(for meter: MeterSidebarEntry, known: KnownMeterCatalogItem, isConnectedElsewhere: Bool) -> String {
        [
            "Local Device ID: \(AppData.myDeviceID)",
            "Local Device Name: \(AppData.myDeviceName)",
            "Now: \(formatDate(Date()))",
            "MAC: \(meter.macAddress)",
            "Display Name: \(meter.displayName)",
            "Model: \(meter.modelSummary)",
            "Connected By Device ID: \(known.connectedByDeviceID ?? "(empty)")",
            "Connected By Device Name: \(known.connectedByDeviceName ?? "(empty)")",
            "Connected At: \(formatDate(known.connectedAt))",
            "Connected Expiry: \(formatDate(known.connectedExpiryAt))",
            "Last Seen At: \(formatDate(known.lastSeenAt))",
            "Last Seen By Device ID: \(known.lastSeenByDeviceID ?? "(empty)")",
            "Last Seen By Device Name: \(known.lastSeenByDeviceName ?? "(empty)")",
            "Last Seen Peripheral Name: \(known.lastSeenPeripheralName ?? "(empty)")",
            "Connected Elsewhere Decision: \(isConnectedElsewhere ? "true (foreign device + valid expiry)" : "false (missing foreign owner or expired claim)")"
        ].joined(separator: "\n")
    }

    private var visibleMeterIDs: [String] {
        visibleMeters.map(\.id)
    }

    private var shouldShowBluetoothHelpEntry: Bool {
        switch appData.bluetoothManager.managerState {
        case .poweredOn:
            return false
        case .unknown:
            return false
        default:
            return true
        }
    }

    private var shouldShowDeviceChecklistEntry: Bool {
        hasWaitedLongEnoughForDevices
    }

    private var shouldShowDiscoveryChecklistEntry: Bool {
        hasWaitedLongEnoughForDevices
    }

    private var shouldShowAssistanceSection: Bool {
        shouldShowBluetoothHelpEntry || shouldShowDeviceChecklistEntry || shouldShowDiscoveryChecklistEntry
    }

    private func liveMeter(forMacAddress macAddress: String) -> Meter? {
        appData.meters.values.first { $0.btSerial.macAddress.description == macAddress }
    }

    private func meterEntry(for macAddress: String) -> MeterSidebarEntry? {
        visibleMeters.first { $0.macAddress == macAddress }
    }

    private func sanitizeSelection() {
        guard let selectedSidebarItem else {
            return
        }

        switch selectedSidebarItem {
        case .meter(let meterID):
            if meterEntry(for: meterID) == nil {
                self.selectedSidebarItem = .overview
            }
        case .bluetoothHelp:
            if !shouldShowBluetoothHelpEntry {
                self.selectedSidebarItem = .overview
            }
        case .deviceChecklist:
            if !shouldShowDeviceChecklistEntry {
                self.selectedSidebarItem = .overview
            }
        case .discoveryChecklist:
            if !shouldShowDiscoveryChecklistEntry {
                self.selectedSidebarItem = .overview
            }
        case .overview:
            break
        }
    }

    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 supported meters are visible yet."
    }

    private func meterSidebarRow(for meter: MeterSidebarEntry) -> some View {
        HStack(spacing: 14) {
            Image(systemName: meter.isLive ? "sensor.tag.radiowaves.forward.fill" : "sensor.tag.radiowaves.forward")
                .font(.system(size: 18, weight: .semibold))
                .foregroundColor(meter.meterColor)
                .frame(width: 42, height: 42)
                .background(
                    Circle()
                        .fill(meter.meterColor.opacity(0.18))
                )
                .overlay(alignment: .bottomTrailing) {
                    Circle()
                        .fill(meter.statusColor)
                        .frame(width: 12, height: 12)
                        .overlay(
                            Circle()
                                .stroke(Color(uiColor: .systemBackground), lineWidth: 2)
                        )
                }

            VStack(alignment: .leading, spacing: 4) {
                Text(meter.displayName)
                    .font(.headline)
                Text(meter.modelSummary)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }

            Spacer()

            VStack(alignment: .trailing, spacing: 4) {
                HStack(spacing: 6) {
                    Circle()
                        .fill(meter.statusColor)
                        .frame(width: 8, height: 8)
                    Text(meter.statusText)
                        .font(.caption.weight(.semibold))
                        .foregroundColor(.secondary)
                }
                .padding(.horizontal, 10)
                .padding(.vertical, 6)
                .background(
                    Capsule(style: .continuous)
                        .fill(meter.statusColor.opacity(0.12))
                )
                .overlay(
                    Capsule(style: .continuous)
                        .stroke(meter.statusColor.opacity(0.22), lineWidth: 1)
                )
                Text(meter.macAddress)
                    .font(.caption2.monospaced())
                    .foregroundColor(.secondary)
                    .lineLimit(1)
                    .truncationMode(.middle)
            }
        }
        .padding(14)
        .meterCard(
            tint: meter.meterColor,
            fillOpacity: meter.isLive ? 0.16 : 0.10,
            strokeOpacity: meter.isLive ? 0.22 : 0.16,
            cornerRadius: 18
        )
    }

    private func meterColor(forModelType modelType: String?) -> Color {
        guard let modelType = modelType?.uppercased() else { return .secondary }
        if modelType.contains("UM25") { return Model.UM25C.color }
        if modelType.contains("UM34") { return Model.UM34C.color }
        if modelType.contains("TC66") || modelType.contains("PW0316") { return Model.TC66C.color }
        return .secondary
    }

    private func statusText(for state: Meter.OperationalState) -> String {
        switch state {
        case .offline:
            return "Offline"
        case .connectedElsewhere:
            return "Elsewhere"
        case .peripheralNotConnected:
            return "Available"
        case .peripheralConnectionPending:
            return "Connecting"
        case .peripheralConnected:
            return "Linked"
        case .peripheralReady:
            return "Ready"
        case .comunicating:
            return "Syncing"
        case .dataIsAvailable:
            return "Live"
        }
    }

    private func overviewHintCard(title: String, detail: String, tint: Color, symbol: String) -> some View {
        HStack(alignment: .top, spacing: 12) {
            Image(systemName: symbol)
                .font(.system(size: 16, weight: .semibold))
                .foregroundColor(tint)
                .frame(width: 34, height: 34)
                .background(Circle().fill(tint.opacity(0.16)))

            VStack(alignment: .leading, spacing: 5) {
                Text(title)
                    .font(.headline)
                Text(detail)
                    .font(.footnote)
                    .foregroundColor(.secondary)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(16)
        .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
    }
}

private struct DiscoveryChecklistView: View {
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 14) {
                Text("Discovery Checklist")
                    .font(.system(.title3, design: .rounded).weight(.bold))
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(18)
                    .meterCard(tint: .yellow, fillOpacity: 0.18, strokeOpacity: 0.24)

                checklistCard(
                    title: "Keep the meter close",
                    body: "For first pairing, keep the meter near your phone or Mac and away from strong interference."
                )
                checklistCard(
                    title: "Wake up Bluetooth advertising",
                    body: "On some models, opening the Bluetooth menu on the meter restarts advertising for discovery."
                )
                checklistCard(
                    title: "Avoid competing connections",
                    body: "Disconnect the meter from other phones/apps before trying discovery in this app."
                )
            }
            .padding()
        }
        .background(
            LinearGradient(
                colors: [.yellow.opacity(0.14), Color.clear],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            .ignoresSafeArea()
        )
        .navigationTitle("Discovery Help")
    }

    private func checklistCard(title: String, body: String) -> some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(title)
                .font(.headline)
            Text(body)
                .font(.footnote)
                .foregroundColor(.secondary)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(18)
        .meterCard(tint: .yellow, fillOpacity: 0.14, strokeOpacity: 0.20)
    }
}