// // 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() .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 } }