1 contributor
293 lines | 10.23kb
//
//  SidebarView.swift
//  USB Meter
//

import SwiftUI
import Combine

private enum SidebarCreationSheet: Identifiable {
    case meter
    case device
    case charger

    var id: String {
        switch self {
        case .meter:
            return "meter"
        case .device:
            return "device"
        case .charger:
            return "charger"
        }
    }
}

struct SidebarView: View {
    @EnvironmentObject private var appData: AppData
    @State private var isUSBMetersExpanded = true
    @State private var isDevicesExpanded = true
    @State private var isChargersExpanded = true
    @State private var isHelpExpanded = false
    @State private var dismissedAutoHelpReason: SidebarHelpReason?
    @State private var now = Date()
    @State private var creationSheet: SidebarCreationSheet?
    private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    private let noDevicesHelpDelay: TimeInterval = 12

    var body: some View {
        SidebarListView(backgroundTint: appData.bluetoothManager.managerState.color) {
            usbMetersSection
        } helpSection: {
            helpSection
        } debugSection: {
            debugSection
        }
        .onAppear {
            appData.bluetoothManager.start()
            now = Date()
        }
        .onReceive(helpRefreshTimer) { currentDate in
            now = currentDate
        }
        .onChange(of: activeHelpAutoReason) { newReason in
            if newReason == nil {
                dismissedAutoHelpReason = nil
            }
        }
        .sheet(item: $creationSheet) { sheet in
            switch sheet {
            case .meter:
                MeterEditorSheetView()
                    .environmentObject(appData)
            case .device:
                ChargedDeviceEditorSheetView(
                    meterMACAddress: nil
                )
                .environmentObject(appData)
            case .charger:
                ChargerEditorSheetView()
                    .environmentObject(appData)
            }
        }
    }

    private var usbMetersSection: some View {
        Group {
            SidebarUSBMetersSectionView(
                meters: appData.meterSummaries,
                managerState: appData.bluetoothManager.managerState,
                hasLiveMeters: appData.meters.isEmpty == false,
                scanStartedAt: appData.bluetoothManager.scanStartedAt,
                now: now,
                noDevicesHelpDelay: noDevicesHelpDelay,
                isExpanded: isUSBMetersExpanded,
                onToggle: {
                    withAnimation(.easeInOut(duration: 0.22)) {
                        isUSBMetersExpanded.toggle()
                    }
                },
                onAddMeter: { creationSheet = .meter }
            )

            SidebarChargedDevicesSectionView(
                title: "Devices",
                mode: .device,
                chargedDevices: appData.deviceSummaries,
                emptyStateText: "No devices yet. Open Charge Record on a live meter or use the add button here to create one and start learning capacity.",
                tint: .orange,
                isExpanded: isDevicesExpanded,
                onToggle: {
                    withAnimation(.easeInOut(duration: 0.22)) {
                        isDevicesExpanded.toggle()
                    }
                },
                onAdd: { creationSheet = .device }
            )

            SidebarChargedDevicesSectionView(
                title: "Chargers",
                mode: .charger,
                chargedDevices: appData.chargerSummaries,
                emptyStateText: "No chargers yet. Add one here so wireless sessions can track both the charged device and the charger being used.",
                tint: .pink,
                isExpanded: isChargersExpanded,
                onToggle: {
                    withAnimation(.easeInOut(duration: 0.22)) {
                        isChargersExpanded.toggle()
                    }
                },
                onAdd: { creationSheet = .charger }
            )
        }
    }

    private var helpSection: some View {
        SidebarHelpSectionView(
            activeReason: activeHelpAutoReason,
            isExpanded: helpIsExpanded,
            bluetoothStatusTint: appData.bluetoothManager.managerState.color,
            bluetoothStatusText: bluetoothStatusText,
            cloudSyncHelpTitle: appData.cloudAvailability.helpTitle,
            cloudSyncHelpMessage: appData.cloudAvailability.helpMessage,
            onToggle: toggleHelpSection,
            onOpenSettings: openSettings
        ) {
            appData.bluetoothManager.managerState.helpView
        } deviceHelpDestination: {
            DeviceHelpView()
        }
    }

    private var debugSection: some View {
        SidebarDebugSectionView()
    }

    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: SidebarHelpReason? {
        SidebarAutoHelpResolver.activeReason(
            managerState: appData.bluetoothManager.managerState,
            cloudAvailability: appData.cloudAvailability,
            hasLiveMeters: appData.meters.isEmpty == false,
            scanStartedAt: appData.bluetoothManager.scanStartedAt,
            now: now,
            noDevicesHelpDelay: noDevicesHelpDelay
        )
    }

    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)
    }
}

// MARK: - Meter Editor Sheet

struct MeterEditorSheetView: View {
    @EnvironmentObject private var appData: AppData
    @Environment(\.dismiss) private var dismiss

    let existingMeterSummary: AppData.MeterSummary?

    @State private var customName: String
    @State private var macAddress: String
    @State private var advertisedName: String
    @State private var selectedModel: Model

    init(existingMeterSummary: AppData.MeterSummary? = nil) {
        self.existingMeterSummary = existingMeterSummary
        _customName = State(initialValue: existingMeterSummary?.displayName ?? "")
        _macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "")
        _advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "")
        _selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary))
    }

    var body: some View {
        NavigationView {
            Form {
                Section(
                    header: ContextInfoHeader(
                        title: "Identity",
                        message: "Use the real BLE MAC address format `AA:BB:CC:DD:EE:FF`. Saved meters remain visible in the sidebar even when they are currently offline."
                    )
                ) {
                    TextField("Display name", text: $customName)
                    TextField("MAC Address", text: $macAddress)
                        .textInputAutocapitalization(.characters)
                        .disableAutocorrection(true)
                        .disabled(existingMeterSummary != nil)

                    Picker("Model", selection: $selectedModel) {
                        ForEach(Model.allCases, id: \.self) { model in
                            Text(model.canonicalName)
                                .tag(model)
                        }
                    }

                    TextField("Advertised name", text: $advertisedName)
                }
            }
            .navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") {
                        dismiss()
                    }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button(existingMeterSummary == nil ? "Save" : "Update") {
                        let normalizedMAC = AppData.normalizedMACAddress(macAddress)
                        let didSave = appData.createKnownMeter(
                            macAddress: normalizedMAC,
                            customName: customName,
                            modelName: selectedModel.canonicalName,
                            advertisedName: advertisedName
                        )
                        if didSave {
                            dismiss()
                        }
                    }
                    .disabled(isSaveDisabled)
                }
            }
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }

    private var isSaveDisabled: Bool {
        AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false
    }

    private static func model(for summary: String?) -> Model {
        if summary?.contains("UM34C") == true {
            return .UM34C
        }
        if summary?.contains("TC66C") == true {
            return .TC66C
        }
        return .UM25C
    }
}