Sergei e420631eba Improve BLE scan UI with WiFiSignalIndicator component
- Replace simple icon-based signal display with WiFiSignalIndicator bars
- Add human-readable signal strength labels (Excellent, Good, Fair, Weak)
- Display dBm values in parentheses for technical reference
- Add comprehensive tests for signal strength UI integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 17:18:37 -08:00

746 lines
21 KiB
TypeScript

import React, { useState, useCallback, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
Linking,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams, useFocusEffect } from 'expo-router';
import { useBLE } from '@/contexts/BLEContext';
import { analytics } from '@/services/analytics';
import {
WiFiSignalIndicator,
getSignalStrengthLabel,
getSignalStrengthColor,
} from '@/components/WiFiSignalIndicator';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
export default function AddSensorScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const {
foundDevices,
isScanning,
connectedDevices,
isBLEAvailable,
error,
permissionError,
scanDevices,
stopScan,
clearError,
} = useBLE();
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
const [scanStartTime, setScanStartTime] = useState<number | null>(null);
// Select all devices by default when scan completes
useEffect(() => {
if (foundDevices.length > 0 && !isScanning) {
setSelectedDevices(new Set(foundDevices.map(d => d.id)));
// Track scan completion
if (scanStartTime && id) {
analytics.trackSensorScanComplete({
beneficiaryId: id,
scanDuration: Date.now() - scanStartTime,
sensorsFound: foundDevices.length,
selectedCount: foundDevices.length,
});
}
}
}, [foundDevices, isScanning, scanStartTime, id]);
// Cleanup: Stop scan when screen loses focus or component unmounts
useFocusEffect(
useCallback(() => {
// Cleanup function - called when screen loses focus
return () => {
if (isScanning) {
stopScan();
}
};
}, [isScanning, stopScan])
);
const toggleDeviceSelection = (deviceId: string) => {
setSelectedDevices(prev => {
const next = new Set(prev);
if (next.has(deviceId)) {
next.delete(deviceId);
} else {
next.add(deviceId);
}
return next;
});
};
const toggleSelectAll = () => {
if (selectedDevices.size === foundDevices.length) {
setSelectedDevices(new Set());
} else {
setSelectedDevices(new Set(foundDevices.map(d => d.id)));
}
};
const selectedCount = selectedDevices.size;
const handleScan = async () => {
try {
// Clear any previous errors
clearError();
// Track scan start
if (id) {
analytics.trackSensorScanStart(id);
setScanStartTime(Date.now());
}
// Perform scan
await scanDevices();
} catch (error: any) {
// Error is already set in BLE context, but show alert for critical issues
if (!permissionError) {
// Non-permission errors - show generic alert
Alert.alert('Scan Failed', error.message || 'Failed to scan for sensors. Please try again.');
}
// Permission errors are already shown with proper dialogs by checkBLEReadiness
}
};
const handleOpenSettings = () => {
Linking.openSettings();
};
const handleAddSelected = () => {
if (selectedCount === 0) {
Alert.alert('No Sensors Selected', 'Please select at least one sensor to add.');
return;
}
const devices = foundDevices.filter(d => selectedDevices.has(d.id));
// Track setup initiation
if (id && scanStartTime) {
analytics.trackSensorSetupStart({
beneficiaryId: id,
sensorCount: selectedCount,
scanDuration: Date.now() - scanStartTime,
});
}
// Navigate to Setup WiFi screen with selected devices
router.push({
pathname: `/(tabs)/beneficiaries/${id}/setup-wifi` as any,
params: {
devices: JSON.stringify(devices.map(d => ({
id: d.id,
name: d.name,
mac: d.mac,
wellId: d.wellId,
}))),
},
});
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Add Sensor</Text>
<View style={styles.placeholder} />
</View>
{/* Simulator Warning */}
{!isBLEAvailable && (
<View style={styles.simulatorWarning}>
<Ionicons name="information-circle" size={18} color={AppColors.warning} />
<Text style={styles.simulatorWarningText}>
Running in Simulator - showing mock sensors
</Text>
</View>
)}
{/* Permission Error Banner */}
{permissionError && error && (
<View style={styles.permissionError}>
<View style={styles.permissionErrorContent}>
<Ionicons name="warning" size={20} color={AppColors.error} />
<Text style={styles.permissionErrorText}>{error}</Text>
</View>
<TouchableOpacity style={styles.settingsButton} onPress={handleOpenSettings}>
<Text style={styles.settingsButtonText}>Open Settings</Text>
<Ionicons name="settings-outline" size={16} color={AppColors.error} />
</TouchableOpacity>
</View>
)}
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
{/* Instructions */}
<View style={styles.instructionsCard}>
<Text style={styles.instructionsTitle}>How to Add a Sensor</Text>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>1</Text>
</View>
<Text style={styles.stepText}>Make sure the WP sensor is powered on and nearby</Text>
</View>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>2</Text>
</View>
<Text style={styles.stepText}>Tap &ldquo;Scan for Sensors&rdquo; to search for available devices</Text>
</View>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>3</Text>
</View>
<Text style={styles.stepText}>Select your sensor from the list to connect</Text>
</View>
<View style={styles.step}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>4</Text>
</View>
<Text style={styles.stepText}>Configure WiFi settings to complete setup</Text>
</View>
</View>
{/* Scan Button */}
{!isScanning && foundDevices.length === 0 && (
<TouchableOpacity style={styles.scanButton} onPress={handleScan}>
<Ionicons name="bluetooth" size={24} color={AppColors.white} />
<Text style={styles.scanButtonText}>Scan for Sensors</Text>
</TouchableOpacity>
)}
{/* Scanning Indicator */}
{isScanning && (
<View style={styles.scanningCard}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.scanningText}>Scanning for WP sensors...</Text>
<TouchableOpacity style={styles.stopScanButton} onPress={stopScan}>
<Text style={styles.stopScanText}>Stop Scan</Text>
</TouchableOpacity>
</View>
)}
{/* Found Devices */}
{foundDevices.length > 0 && !isScanning && (
<>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Found Sensors ({foundDevices.length})</Text>
<View style={styles.sectionActions}>
<TouchableOpacity style={styles.selectAllButton} onPress={toggleSelectAll}>
<Ionicons
name={selectedDevices.size === foundDevices.length ? 'checkbox' : 'square-outline'}
size={18}
color={AppColors.primary}
/>
<Text style={styles.selectAllText}>
{selectedDevices.size === foundDevices.length ? 'Deselect All' : 'Select All'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.rescanButton} onPress={handleScan}>
<Ionicons name="refresh" size={18} color={AppColors.primary} />
<Text style={styles.rescanText}>Rescan</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.devicesList}>
{foundDevices.map((device) => {
const isConnected = connectedDevices.has(device.id);
const isSelected = selectedDevices.has(device.id);
return (
<TouchableOpacity
key={device.id}
style={[
styles.deviceCard,
isSelected && styles.deviceCardSelected,
isConnected && styles.deviceCardConnected,
]}
onPress={() => toggleDeviceSelection(device.id)}
disabled={isConnected}
activeOpacity={0.7}
>
<View style={styles.checkboxContainer}>
<View style={[
styles.checkbox,
isSelected && styles.checkboxSelected,
isConnected && styles.checkboxDisabled,
]}>
{isSelected && (
<Ionicons name="checkmark" size={16} color={AppColors.white} />
)}
</View>
</View>
<View style={styles.deviceInfo}>
<View style={styles.deviceIcon}>
<Ionicons name="water" size={24} color={AppColors.primary} />
</View>
<View style={styles.deviceDetails}>
<Text style={styles.deviceName}>{device.name}</Text>
{device.wellId && (
<Text style={styles.deviceMeta}>Well ID: {device.wellId}</Text>
)}
<View style={styles.signalRow}>
<WiFiSignalIndicator rssi={device.rssi} size="small" />
<Text style={[styles.signalLabel, { color: getSignalStrengthColor(device.rssi) }]}>
{getSignalStrengthLabel(device.rssi)}
</Text>
<Text style={styles.signalDbm}>
({device.rssi} dBm)
</Text>
</View>
</View>
</View>
{isConnected && (
<View style={styles.alreadyAddedBadge}>
<Text style={styles.alreadyAddedText}>Added</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
{/* Add Selected Button */}
<TouchableOpacity
style={[
styles.addSelectedButton,
selectedCount === 0 && styles.addSelectedButtonDisabled,
]}
onPress={handleAddSelected}
disabled={selectedCount === 0}
>
<Ionicons name="add-circle" size={24} color={AppColors.white} />
<Text style={styles.addSelectedButtonText}>
Add Selected ({selectedCount})
</Text>
</TouchableOpacity>
</>
)}
{/* Empty State (after scan completed, no devices found) */}
{!isScanning && foundDevices.length === 0 && (
<View style={styles.emptyState}>
<View style={styles.emptyIconContainer}>
<Ionicons name="search-outline" size={48} color={AppColors.textMuted} />
</View>
<Text style={styles.emptyTitle}>No Sensors Found</Text>
<Text style={styles.emptyText}>
Make sure your WP sensor is powered on and within range, then try scanning again.
</Text>
</View>
)}
{/* Help Card */}
<View style={styles.helpCard}>
<View style={styles.helpHeader}>
<Ionicons name="help-circle" size={20} color={AppColors.info} />
<Text style={styles.helpTitle}>Troubleshooting</Text>
</View>
<Text style={styles.helpText}>
Sensor not showing up? Make sure it&apos;s powered on and the LED is blinking{'\n'}
Weak signal? Move closer to the sensor{'\n'}
Connection fails? Try restarting the sensor{'\n'}
Still having issues? Contact support for assistance
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 32,
},
simulatorWarning: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.warningLight,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.md,
gap: Spacing.xs,
borderBottomWidth: 1,
borderBottomColor: AppColors.warning,
},
simulatorWarningText: {
fontSize: FontSizes.xs,
color: AppColors.warning,
fontWeight: FontWeights.medium,
flex: 1,
},
permissionError: {
backgroundColor: AppColors.errorLight,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
borderBottomWidth: 1,
borderBottomColor: AppColors.error,
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: 20,
},
settingsButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.white,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.md,
borderRadius: BorderRadius.sm,
gap: Spacing.xs,
alignSelf: 'flex-start',
},
settingsButtonText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.error,
},
content: {
flex: 1,
},
scrollContent: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
// Instructions
instructionsCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.lg,
...Shadows.sm,
},
instructionsTitle: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.md,
},
step: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: Spacing.sm,
gap: Spacing.sm,
},
stepNumber: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
},
stepNumberText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.bold,
color: AppColors.white,
},
stepText: {
flex: 1,
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
lineHeight: 20,
paddingTop: 2,
},
// Scan Button
scanButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
marginBottom: Spacing.lg,
gap: Spacing.sm,
...Shadows.md,
},
scanButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// Scanning
scanningCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
alignItems: 'center',
marginBottom: Spacing.lg,
...Shadows.sm,
},
scanningText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
marginTop: Spacing.md,
marginBottom: Spacing.md,
},
stopScanButton: {
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
},
stopScanText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.error,
},
// Section Header
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: Spacing.md,
},
sectionTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
sectionActions: {
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,
},
// Devices List
devicesList: {
gap: Spacing.md,
marginBottom: Spacing.lg,
},
deviceCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
flexDirection: 'row',
alignItems: 'center',
...Shadows.xs,
},
deviceCardSelected: {
borderWidth: 2,
borderColor: AppColors.primary,
},
deviceCardConnected: {
borderWidth: 2,
borderColor: AppColors.success,
opacity: 0.6,
},
checkboxContainer: {
marginRight: Spacing.sm,
},
checkbox: {
width: 24,
height: 24,
borderRadius: BorderRadius.sm,
borderWidth: 2,
borderColor: AppColors.border,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: AppColors.white,
},
checkboxSelected: {
backgroundColor: AppColors.primary,
borderColor: AppColors.primary,
},
checkboxDisabled: {
backgroundColor: AppColors.surfaceSecondary,
borderColor: AppColors.border,
},
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',
},
deviceDetails: {
flex: 1,
},
deviceName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: 2,
},
deviceMeta: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginBottom: 4,
},
signalRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
signalLabel: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.semibold,
},
signalDbm: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
alreadyAddedBadge: {
backgroundColor: AppColors.successLight,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.sm,
borderRadius: BorderRadius.sm,
},
alreadyAddedText: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
color: AppColors.success,
},
// Add Selected Button
addSelectedButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
marginTop: Spacing.md,
marginBottom: Spacing.lg,
gap: Spacing.sm,
...Shadows.md,
},
addSelectedButtonDisabled: {
backgroundColor: AppColors.textMuted,
},
addSelectedButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
// Empty State
emptyState: {
alignItems: 'center',
padding: Spacing.xl,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
marginBottom: Spacing.lg,
...Shadows.sm,
},
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,
},
emptyText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
textAlign: 'center',
lineHeight: 20,
},
// Help Card
helpCard: {
backgroundColor: AppColors.infoLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
},
helpHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: Spacing.xs,
},
helpTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.info,
},
helpText: {
fontSize: FontSizes.sm,
color: AppColors.info,
lineHeight: 20,
},
});