import React, { useState, useCallback, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, FlatList, Linking, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useBLE } from '@/contexts/BLEContext'; import { WPDevice } from '@/services/ble/types'; import { WiFiSignalIndicator, getSignalStrengthLabel, getSignalStrengthColor, } from '@/components/WiFiSignalIndicator'; import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows, } from '@/constants/theme'; export type SelectionMode = 'single' | 'multiple' | 'none'; export interface BLEScannerProps { /** Callback when device(s) are selected */ onDevicesSelected?: (devices: WPDevice[]) => void; /** Callback when a single device is selected (convenience for single mode) */ onDeviceSelected?: (device: WPDevice) => void; /** Selection mode: 'single', 'multiple', or 'none' */ selectionMode?: SelectionMode; /** Whether to auto-start scanning when component mounts */ autoScan?: boolean; /** Custom filter function for devices */ deviceFilter?: (device: WPDevice) => boolean; /** Device IDs that are already connected/added (will show as disabled) */ disabledDeviceIds?: Set; /** Show instructions header */ showInstructions?: boolean; /** Custom empty state message */ emptyStateMessage?: string; /** Compact mode with smaller styling */ compact?: boolean; /** Test ID for testing */ testID?: string; } interface DeviceItemProps { device: WPDevice; isSelected: boolean; isDisabled: boolean; selectionMode: SelectionMode; onPress: () => void; compact: boolean; } function DeviceItem({ device, isSelected, isDisabled, selectionMode, onPress, compact, }: DeviceItemProps) { return ( {selectionMode !== 'none' && ( {isSelected && ( )} )} {device.name} {device.wellId && ( Well ID: {device.wellId} )} {getSignalStrengthLabel(device.rssi)} ({device.rssi} dBm) {isDisabled && ( Added )} ); } export function BLEScanner({ onDevicesSelected, onDeviceSelected, selectionMode = 'multiple', autoScan = false, deviceFilter, disabledDeviceIds = new Set(), showInstructions = false, emptyStateMessage = 'No sensors found. Make sure your WP sensor is powered on and nearby.', compact = false, testID, }: BLEScannerProps) { const { foundDevices, isScanning, isBLEAvailable, error, permissionError, scanDevices, stopScan, clearError, } = useBLE(); const [selectedDevices, setSelectedDevices] = useState>(new Set()); const [hasScanned, setHasScanned] = useState(false); // Filter devices if filter function provided const filteredDevices = deviceFilter ? foundDevices.filter(deviceFilter) : foundDevices; // Auto-scan on mount if enabled useEffect(() => { if (autoScan && !hasScanned) { handleScan(); } }, [autoScan, hasScanned]); // Select all devices by default in multiple selection mode when scan completes useEffect(() => { if ( selectionMode === 'multiple' && filteredDevices.length > 0 && !isScanning && hasScanned ) { const selectableIds = filteredDevices .filter((d) => !disabledDeviceIds.has(d.id)) .map((d) => d.id); setSelectedDevices(new Set(selectableIds)); } }, [filteredDevices, isScanning, hasScanned, selectionMode, disabledDeviceIds]); // Notify parent of selection changes useEffect(() => { const selected = filteredDevices.filter((d) => selectedDevices.has(d.id)); if (selectionMode === 'single' && selected.length === 1 && onDeviceSelected) { onDeviceSelected(selected[0]); } if (onDevicesSelected) { onDevicesSelected(selected); } }, [selectedDevices, filteredDevices, selectionMode, onDeviceSelected, onDevicesSelected]); const handleScan = useCallback(async () => { try { clearError(); setHasScanned(true); setSelectedDevices(new Set()); await scanDevices(); } catch { // Error is handled by BLE context } }, [clearError, scanDevices]); const handleDevicePress = useCallback( (deviceId: string) => { if (selectionMode === 'none') return; if (selectionMode === 'single') { setSelectedDevices(new Set([deviceId])); } else { setSelectedDevices((prev) => { const next = new Set(prev); if (next.has(deviceId)) { next.delete(deviceId); } else { next.add(deviceId); } return next; }); } }, [selectionMode] ); const toggleSelectAll = useCallback(() => { const selectableDevices = filteredDevices.filter( (d) => !disabledDeviceIds.has(d.id) ); if (selectedDevices.size === selectableDevices.length) { setSelectedDevices(new Set()); } else { setSelectedDevices(new Set(selectableDevices.map((d) => d.id))); } }, [filteredDevices, disabledDeviceIds, selectedDevices]); const handleOpenSettings = useCallback(() => { Linking.openSettings(); }, []); const renderDevice = useCallback( ({ item }: { item: WPDevice }) => ( handleDevicePress(item.id)} compact={compact} /> ), [selectedDevices, disabledDeviceIds, selectionMode, handleDevicePress, compact] ); const keyExtractor = useCallback((item: WPDevice) => item.id, []); const selectableCount = filteredDevices.filter( (d) => !disabledDeviceIds.has(d.id) ).length; const allSelected = selectedDevices.size === selectableCount && selectableCount > 0; return ( {/* Simulator Warning */} {!isBLEAvailable && ( Simulator mode - showing mock sensors )} {/* Permission Error */} {permissionError && error && ( {error} Open Settings )} {/* Instructions */} {showInstructions && !isScanning && filteredDevices.length === 0 && ( Scanning for Sensors Make sure sensors are powered on Stay within 10 meters of sensors Bluetooth must be enabled )} {/* Scan Button (when not scanning and no devices) */} {!isScanning && filteredDevices.length === 0 && ( Scan for Sensors )} {/* Scanning State */} {isScanning && ( Scanning for WP sensors... Stop Scan )} {/* Device List Header */} {!isScanning && filteredDevices.length > 0 && ( Found Sensors ({filteredDevices.length}) {selectionMode === 'multiple' && selectableCount > 0 && ( {allSelected ? 'Deselect All' : 'Select All'} )} Rescan )} {/* Device List */} {!isScanning && filteredDevices.length > 0 && ( )} {/* Empty State (after scan, no devices) */} {!isScanning && hasScanned && filteredDevices.length === 0 && !error && ( No Sensors Found {emptyStateMessage} Try Again )} ); } const styles = StyleSheet.create({ container: { flex: 1, }, containerCompact: { flex: 0, }, // Simulator Warning simulatorWarning: { flexDirection: 'row', alignItems: 'center', backgroundColor: AppColors.warningLight, paddingVertical: Spacing.xs, paddingHorizontal: Spacing.md, gap: Spacing.xs, borderRadius: BorderRadius.sm, marginBottom: Spacing.sm, }, simulatorWarningText: { fontSize: FontSizes.xs, color: AppColors.warning, fontWeight: FontWeights.medium, }, // Permission Error permissionError: { backgroundColor: AppColors.errorLight, padding: Spacing.md, borderRadius: BorderRadius.md, marginBottom: Spacing.md, gap: Spacing.sm, }, permissionErrorContent: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.xs, }, permissionErrorText: { fontSize: FontSizes.sm, color: AppColors.error, fontWeight: FontWeights.medium, flex: 1, lineHeight: 18, }, settingsButton: { flexDirection: 'row', alignItems: 'center', backgroundColor: AppColors.white, paddingVertical: Spacing.xs, paddingHorizontal: Spacing.sm, borderRadius: BorderRadius.sm, gap: Spacing.xs, alignSelf: 'flex-start', }, settingsButtonText: { fontSize: FontSizes.xs, fontWeight: FontWeights.semibold, color: AppColors.error, }, // Instructions instructionsCard: { backgroundColor: AppColors.surface, borderRadius: BorderRadius.lg, padding: Spacing.md, marginBottom: Spacing.md, gap: Spacing.sm, ...Shadows.xs, }, instructionsCardCompact: { padding: Spacing.sm, gap: Spacing.xs, }, instructionsTitle: { fontSize: FontSizes.sm, fontWeight: FontWeights.semibold, color: AppColors.textPrimary, marginBottom: Spacing.xs, }, instruction: { flexDirection: 'row', alignItems: 'center', gap: Spacing.sm, }, instructionText: { fontSize: FontSizes.sm, color: AppColors.textSecondary, }, // Scan Button scanButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: AppColors.primary, paddingVertical: Spacing.md, borderRadius: BorderRadius.lg, gap: Spacing.sm, ...Shadows.md, }, scanButtonCompact: { paddingVertical: Spacing.sm, }, scanButtonText: { fontSize: FontSizes.base, fontWeight: FontWeights.semibold, color: AppColors.white, }, scanButtonTextCompact: { fontSize: FontSizes.sm, }, // Scanning State scanningCard: { backgroundColor: AppColors.surface, borderRadius: BorderRadius.lg, padding: Spacing.xl, alignItems: 'center', ...Shadows.sm, }, scanningCardCompact: { padding: Spacing.md, }, scanningText: { fontSize: FontSizes.base, color: AppColors.textSecondary, marginTop: Spacing.md, marginBottom: Spacing.sm, }, scanningTextCompact: { fontSize: FontSizes.sm, marginTop: Spacing.sm, }, stopScanButton: { paddingVertical: Spacing.xs, paddingHorizontal: Spacing.md, }, stopScanText: { fontSize: FontSizes.sm, fontWeight: FontWeights.medium, color: AppColors.error, }, // List Header listHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: Spacing.sm, }, listHeaderTitle: { fontSize: FontSizes.sm, fontWeight: FontWeights.semibold, color: AppColors.textSecondary, textTransform: 'uppercase', letterSpacing: 0.5, }, listHeaderActions: { flexDirection: 'row', alignItems: 'center', gap: Spacing.md, }, selectAllButton: { flexDirection: 'row', alignItems: 'center', gap: 4, }, selectAllText: { fontSize: FontSizes.sm, fontWeight: FontWeights.medium, color: AppColors.primary, }, rescanButton: { flexDirection: 'row', alignItems: 'center', gap: 4, }, rescanText: { fontSize: FontSizes.sm, fontWeight: FontWeights.medium, color: AppColors.primary, }, // Device List deviceList: { gap: Spacing.sm, }, // Device Card deviceCard: { backgroundColor: AppColors.surface, borderRadius: BorderRadius.lg, padding: Spacing.md, flexDirection: 'row', alignItems: 'center', ...Shadows.xs, }, deviceCardCompact: { padding: Spacing.sm, }, deviceCardSelected: { borderWidth: 2, borderColor: AppColors.primary, }, deviceCardDisabled: { opacity: 0.6, }, // Checkbox checkboxContainer: { marginRight: Spacing.sm, }, checkbox: { width: 24, height: 24, borderRadius: BorderRadius.sm, borderWidth: 2, borderColor: AppColors.border, justifyContent: 'center', alignItems: 'center', backgroundColor: AppColors.white, }, checkboxCompact: { width: 20, height: 20, }, checkboxSelected: { backgroundColor: AppColors.primary, borderColor: AppColors.primary, }, checkboxDisabled: { backgroundColor: AppColors.surfaceSecondary, borderColor: AppColors.border, }, // Device Info deviceInfo: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: Spacing.md, }, deviceIcon: { width: 48, height: 48, borderRadius: BorderRadius.lg, backgroundColor: AppColors.primaryLighter, justifyContent: 'center', alignItems: 'center', }, deviceIconCompact: { width: 36, height: 36, }, deviceDetails: { flex: 1, }, deviceName: { fontSize: FontSizes.base, fontWeight: FontWeights.semibold, color: AppColors.textPrimary, marginBottom: 2, }, deviceNameCompact: { fontSize: FontSizes.sm, }, deviceNameDisabled: { color: AppColors.textMuted, }, deviceMeta: { fontSize: FontSizes.xs, color: AppColors.textMuted, marginBottom: 4, }, deviceMetaCompact: { marginBottom: 2, }, signalRow: { flexDirection: 'row', alignItems: 'center', gap: 6, }, signalLabel: { fontSize: FontSizes.xs, fontWeight: FontWeights.semibold, }, signalDbm: { fontSize: FontSizes.xs, color: AppColors.textMuted, }, // Disabled Badge disabledBadge: { backgroundColor: AppColors.successLight, paddingVertical: Spacing.xs, paddingHorizontal: Spacing.sm, borderRadius: BorderRadius.sm, }, disabledBadgeText: { fontSize: FontSizes.xs, fontWeight: FontWeights.medium, color: AppColors.success, }, // Empty State emptyState: { alignItems: 'center', padding: Spacing.xl, backgroundColor: AppColors.surface, borderRadius: BorderRadius.lg, ...Shadows.sm, }, emptyStateCompact: { padding: Spacing.md, }, emptyIconContainer: { width: 80, height: 80, borderRadius: 40, backgroundColor: AppColors.surfaceSecondary, justifyContent: 'center', alignItems: 'center', marginBottom: Spacing.md, }, emptyTitle: { fontSize: FontSizes.lg, fontWeight: FontWeights.semibold, color: AppColors.textPrimary, marginBottom: Spacing.xs, }, emptyTitleCompact: { fontSize: FontSizes.base, }, emptyText: { fontSize: FontSizes.sm, color: AppColors.textMuted, textAlign: 'center', lineHeight: 20, marginBottom: Spacing.md, }, emptyTextCompact: { fontSize: FontSizes.xs, }, retryButton: { flexDirection: 'row', alignItems: 'center', gap: Spacing.xs, paddingVertical: Spacing.sm, paddingHorizontal: Spacing.md, borderRadius: BorderRadius.md, borderWidth: 1, borderColor: AppColors.primary, }, retryButtonCompact: { paddingVertical: Spacing.xs, paddingHorizontal: Spacing.sm, }, retryButtonText: { fontSize: FontSizes.sm, fontWeight: FontWeights.medium, color: AppColors.primary, }, }); export default BLEScanner;