- Fix saveWiFiPassword to use encrypted passwords map instead of decrypted - Fix getWiFiPassword to decrypt from encrypted storage - Fix test expectations for migration and encryption functions - Remove unused error variables to fix linting warnings - All 27 tests now passing with proper encryption/decryption flow The WiFi credentials cache feature was already implemented but had bugs where encrypted and decrypted password maps were being mixed. This commit ensures proper encryption is maintained throughout the storage lifecycle.
1014 lines
29 KiB
TypeScript
1014 lines
29 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
Alert,
|
|
ActivityIndicator,
|
|
TextInput,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { router, useLocalSearchParams } from 'expo-router';
|
|
import * as Device from 'expo-device';
|
|
import { useBLE } from '@/contexts/BLEContext';
|
|
import { api } from '@/services/api';
|
|
import * as wifiPasswordStore from '@/services/wifiPasswordStore';
|
|
import type { WiFiNetwork } from '@/services/ble';
|
|
import type {
|
|
SensorSetupState,
|
|
SensorSetupStep,
|
|
SensorSetupStatus,
|
|
} from '@/types';
|
|
import BatchSetupProgress from '@/components/BatchSetupProgress';
|
|
import SetupResultsScreen from '@/components/SetupResultsScreen';
|
|
import {
|
|
AppColors,
|
|
BorderRadius,
|
|
FontSizes,
|
|
FontWeights,
|
|
Spacing,
|
|
Shadows,
|
|
} from '@/constants/theme';
|
|
|
|
// Type for device passed via navigation params
|
|
interface DeviceParam {
|
|
id: string;
|
|
name: string;
|
|
mac: string;
|
|
wellId?: number;
|
|
}
|
|
|
|
type SetupPhase = 'wifi_selection' | 'batch_setup' | 'results';
|
|
|
|
// Initialize steps for a sensor
|
|
function createInitialSteps(): SensorSetupStep[] {
|
|
return [
|
|
{ name: 'connect', status: 'pending' },
|
|
{ name: 'unlock', status: 'pending' },
|
|
{ name: 'wifi', status: 'pending' },
|
|
{ name: 'attach', status: 'pending' },
|
|
{ name: 'reboot', status: 'pending' },
|
|
];
|
|
}
|
|
|
|
// Initialize sensor state
|
|
function createSensorState(device: DeviceParam): SensorSetupState {
|
|
return {
|
|
deviceId: device.id,
|
|
deviceName: device.name,
|
|
wellId: device.wellId,
|
|
mac: device.mac,
|
|
status: 'pending',
|
|
steps: createInitialSteps(),
|
|
};
|
|
}
|
|
|
|
export default function SetupWiFiScreen() {
|
|
const { id, devices: devicesParam } = useLocalSearchParams<{
|
|
id: string;
|
|
devices: string; // JSON string of DeviceParam[]
|
|
}>();
|
|
|
|
const {
|
|
getWiFiList,
|
|
setWiFi,
|
|
connectDevice,
|
|
disconnectDevice,
|
|
rebootDevice,
|
|
} = useBLE();
|
|
|
|
// Parse devices from navigation params
|
|
const selectedDevices: DeviceParam[] = React.useMemo(() => {
|
|
if (!devicesParam) return [];
|
|
try {
|
|
return JSON.parse(devicesParam);
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}, [devicesParam]);
|
|
|
|
// Use first device for WiFi scanning
|
|
const firstDevice = selectedDevices[0];
|
|
const deviceId = firstDevice?.id;
|
|
|
|
// UI Phase
|
|
const [phase, setPhase] = useState<SetupPhase>('wifi_selection');
|
|
|
|
// WiFi selection state
|
|
const [networks, setNetworks] = useState<WiFiNetwork[]>([]);
|
|
const [isLoadingNetworks, setIsLoadingNetworks] = useState(false);
|
|
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null);
|
|
const [password, setPassword] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
// Batch setup state
|
|
const [sensors, setSensors] = useState<SensorSetupState[]>([]);
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [isPaused, setIsPaused] = useState(false);
|
|
const setupInProgressRef = useRef(false);
|
|
const shouldCancelRef = useRef(false);
|
|
|
|
// Saved WiFi passwords map (SSID -> password)
|
|
// Using useState to trigger re-renders when passwords are loaded
|
|
const [savedPasswords, setSavedPasswords] = useState<Record<string, string>>({});
|
|
const [passwordsLoaded, setPasswordsLoaded] = useState(false);
|
|
// Also keep ref for saving (to avoid stale closures)
|
|
const savedPasswordsRef = useRef<Record<string, string>>({});
|
|
|
|
// Load saved WiFi passwords on mount
|
|
useEffect(() => {
|
|
const loadSavedPasswords = async () => {
|
|
try {
|
|
// Migrate from AsyncStorage to SecureStore if needed
|
|
await wifiPasswordStore.migrateFromAsyncStorage();
|
|
|
|
// Load all saved passwords from SecureStore
|
|
const passwords = await wifiPasswordStore.getAllWiFiPasswords();
|
|
savedPasswordsRef.current = passwords;
|
|
setSavedPasswords(passwords);
|
|
} catch (error) {
|
|
} finally {
|
|
setPasswordsLoaded(true);
|
|
}
|
|
};
|
|
loadSavedPasswords();
|
|
loadWiFiNetworks();
|
|
}, []);
|
|
|
|
const loadWiFiNetworks = async () => {
|
|
if (!deviceId) {
|
|
return;
|
|
}
|
|
setIsLoadingNetworks(true);
|
|
|
|
try {
|
|
// First connect to the device before requesting WiFi list
|
|
const connected = await connectDevice(deviceId);
|
|
|
|
if (!connected) {
|
|
throw new Error('Could not connect to sensor. Please move closer and try again.');
|
|
}
|
|
|
|
const wifiList = await getWiFiList(deviceId);
|
|
setNetworks(wifiList);
|
|
} catch (error: any) {
|
|
Alert.alert('Error', error.message || 'Failed to get WiFi networks. Please try again.');
|
|
} finally {
|
|
setIsLoadingNetworks(false);
|
|
}
|
|
};
|
|
|
|
const handleSelectNetwork = (network: WiFiNetwork) => {
|
|
setSelectedNetwork(network);
|
|
// Auto-fill saved password for this network (use state, not ref)
|
|
const savedPwd = savedPasswords[network.ssid];
|
|
if (savedPwd) {
|
|
setPassword(savedPwd);
|
|
} else {
|
|
setPassword('');
|
|
}
|
|
};
|
|
|
|
// Auto-fill password when passwords finish loading (if network already selected)
|
|
useEffect(() => {
|
|
if (passwordsLoaded && selectedNetwork && !password) {
|
|
const savedPwd = savedPasswords[selectedNetwork.ssid];
|
|
if (savedPwd) {
|
|
setPassword(savedPwd);
|
|
}
|
|
}
|
|
}, [passwordsLoaded, savedPasswords, selectedNetwork]);
|
|
|
|
// Update a specific step for a sensor
|
|
const updateSensorStep = useCallback((
|
|
deviceId: string,
|
|
stepName: SensorSetupStep['name'],
|
|
stepStatus: SensorSetupStep['status'],
|
|
error?: string
|
|
) => {
|
|
setSensors(prev => prev.map(sensor => {
|
|
if (sensor.deviceId !== deviceId) return sensor;
|
|
return {
|
|
...sensor,
|
|
steps: sensor.steps.map(step =>
|
|
step.name === stepName
|
|
? { ...step, status: stepStatus, error }
|
|
: step
|
|
),
|
|
};
|
|
}));
|
|
}, []);
|
|
|
|
// Update sensor status
|
|
const updateSensorStatus = useCallback((
|
|
deviceId: string,
|
|
status: SensorSetupStatus,
|
|
error?: string
|
|
) => {
|
|
setSensors(prev => prev.map(sensor =>
|
|
sensor.deviceId === deviceId
|
|
? { ...sensor, status, error, endTime: Date.now() }
|
|
: sensor
|
|
));
|
|
}, []);
|
|
|
|
// Process a single sensor
|
|
const processSensor = useCallback(async (
|
|
sensor: SensorSetupState,
|
|
ssid: string,
|
|
pwd: string
|
|
): Promise<boolean> => {
|
|
const { deviceId, wellId, deviceName, mac } = sensor;
|
|
const isSimulator = !Device.isDevice;
|
|
|
|
// Set start time
|
|
setSensors(prev => prev.map(s =>
|
|
s.deviceId === deviceId
|
|
? { ...s, startTime: Date.now() }
|
|
: s
|
|
));
|
|
|
|
try {
|
|
// Step 1: Connect
|
|
updateSensorStep(deviceId, 'connect', 'in_progress');
|
|
updateSensorStatus(deviceId, 'connecting');
|
|
const connected = await connectDevice(deviceId);
|
|
if (!connected) throw new Error('Could not connect to sensor');
|
|
updateSensorStep(deviceId, 'connect', 'completed');
|
|
|
|
if (shouldCancelRef.current) return false;
|
|
|
|
// Step 2: Unlock (PIN is handled by connectDevice in BLE manager)
|
|
updateSensorStep(deviceId, 'unlock', 'in_progress');
|
|
updateSensorStatus(deviceId, 'unlocking');
|
|
// PIN unlock is automatic in connectDevice, mark as completed
|
|
updateSensorStep(deviceId, 'unlock', 'completed');
|
|
|
|
if (shouldCancelRef.current) return false;
|
|
|
|
// Step 3: Set WiFi
|
|
updateSensorStep(deviceId, 'wifi', 'in_progress');
|
|
updateSensorStatus(deviceId, 'setting_wifi');
|
|
// setWiFi now throws with detailed error message if it fails
|
|
await setWiFi(deviceId, ssid, pwd);
|
|
updateSensorStep(deviceId, 'wifi', 'completed');
|
|
|
|
if (shouldCancelRef.current) return false;
|
|
|
|
// Step 4: Attach to deployment via API
|
|
updateSensorStep(deviceId, 'attach', 'in_progress');
|
|
updateSensorStatus(deviceId, 'attaching');
|
|
|
|
if (!isSimulator && wellId && mac) {
|
|
const attachResponse = await api.attachDeviceToBeneficiary(
|
|
id!,
|
|
wellId,
|
|
mac
|
|
);
|
|
if (!attachResponse.ok) {
|
|
const errorDetail = attachResponse.error || 'Unknown API error';
|
|
throw new Error(`Failed to register sensor: ${errorDetail}`);
|
|
}
|
|
}
|
|
updateSensorStep(deviceId, 'attach', 'completed');
|
|
|
|
if (shouldCancelRef.current) return false;
|
|
|
|
// Step 5: Reboot
|
|
// The reboot command will cause the sensor to disconnect (this is expected!)
|
|
// We send the command and immediately mark it as success - no need to wait for response
|
|
updateSensorStep(deviceId, 'reboot', 'in_progress');
|
|
updateSensorStatus(deviceId, 'rebooting');
|
|
|
|
try {
|
|
await rebootDevice(deviceId);
|
|
} catch (rebootError: any) {
|
|
// Ignore BLE errors during reboot - the device disconnects on purpose
|
|
}
|
|
|
|
updateSensorStep(deviceId, 'reboot', 'completed');
|
|
|
|
// Success! The sensor is now rebooting and will connect to WiFi
|
|
updateSensorStatus(deviceId, 'success');
|
|
return true;
|
|
|
|
} catch (error: any) {
|
|
const errorMsg = error.message || 'Unknown error';
|
|
|
|
// Find current step and mark as failed
|
|
setSensors(prev => prev.map(s => {
|
|
if (s.deviceId !== deviceId) return s;
|
|
const currentStep = s.steps.find(step => step.status === 'in_progress');
|
|
return {
|
|
...s,
|
|
status: 'error' as SensorSetupStatus,
|
|
error: errorMsg,
|
|
steps: s.steps.map(step =>
|
|
step.status === 'in_progress'
|
|
? { ...step, status: 'failed' as const, error: errorMsg }
|
|
: step
|
|
),
|
|
};
|
|
}));
|
|
|
|
// Disconnect on error
|
|
try {
|
|
await disconnectDevice(deviceId);
|
|
} catch (e) {
|
|
// Ignore disconnect errors
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}, [
|
|
id, connectDevice, disconnectDevice, setWiFi, rebootDevice,
|
|
updateSensorStep, updateSensorStatus
|
|
]);
|
|
|
|
// Run batch setup sequentially
|
|
const runBatchSetup = useCallback(async () => {
|
|
if (setupInProgressRef.current) return;
|
|
setupInProgressRef.current = true;
|
|
shouldCancelRef.current = false;
|
|
|
|
const ssid = selectedNetwork!.ssid;
|
|
const pwd = password;
|
|
|
|
for (let i = currentIndex; i < sensors.length; i++) {
|
|
if (shouldCancelRef.current) {
|
|
break;
|
|
}
|
|
|
|
setCurrentIndex(i);
|
|
const sensor = sensors[i];
|
|
|
|
// Skip already processed sensors
|
|
if (sensor.status === 'success' || sensor.status === 'skipped') {
|
|
continue;
|
|
}
|
|
|
|
// If sensor has error and we're not retrying, pause
|
|
if (sensor.status === 'error' && isPaused) {
|
|
break;
|
|
}
|
|
|
|
// Reset sensor state if retrying
|
|
if (sensor.status === 'error') {
|
|
setSensors(prev => prev.map(s =>
|
|
s.deviceId === sensor.deviceId
|
|
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
|
|
: s
|
|
));
|
|
}
|
|
|
|
const success = await processSensor(
|
|
sensors[i],
|
|
ssid,
|
|
pwd
|
|
);
|
|
|
|
// Check for cancellation after each sensor
|
|
if (shouldCancelRef.current) break;
|
|
|
|
// If failed, pause for user input
|
|
if (!success) {
|
|
setIsPaused(true);
|
|
setupInProgressRef.current = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// All done
|
|
setupInProgressRef.current = false;
|
|
|
|
// Check if we should show results
|
|
const finalSensors = sensors;
|
|
const allProcessed = finalSensors.every(
|
|
s => s.status === 'success' || s.status === 'error' || s.status === 'skipped'
|
|
);
|
|
|
|
if (allProcessed || shouldCancelRef.current) {
|
|
setPhase('results');
|
|
}
|
|
}, [sensors, currentIndex, selectedNetwork, password, isPaused, processSensor]);
|
|
|
|
// Start batch setup
|
|
const handleStartBatchSetup = async () => {
|
|
if (!selectedNetwork) {
|
|
Alert.alert('Error', 'Please select a WiFi network');
|
|
return;
|
|
}
|
|
if (!password) {
|
|
Alert.alert('Error', 'Please enter WiFi password');
|
|
return;
|
|
}
|
|
|
|
// Save password for this network (by SSID) to SecureStore
|
|
try {
|
|
await wifiPasswordStore.saveWiFiPassword(selectedNetwork.ssid, password);
|
|
|
|
// Update local state
|
|
const updatedPasswords = { ...savedPasswords, [selectedNetwork.ssid]: password };
|
|
savedPasswordsRef.current = updatedPasswords;
|
|
setSavedPasswords(updatedPasswords);
|
|
|
|
} catch (error) {
|
|
// Continue with setup even if save fails
|
|
}
|
|
|
|
// Initialize sensor states
|
|
const initialStates = selectedDevices.map(createSensorState);
|
|
setSensors(initialStates);
|
|
setCurrentIndex(0);
|
|
setIsPaused(false);
|
|
setPhase('batch_setup');
|
|
};
|
|
|
|
// Start processing after phase change
|
|
useEffect(() => {
|
|
if (phase === 'batch_setup' && sensors.length > 0 && !setupInProgressRef.current) {
|
|
runBatchSetup();
|
|
}
|
|
}, [phase, sensors.length, runBatchSetup]);
|
|
|
|
// Retry failed sensor
|
|
const handleRetry = (deviceId: string) => {
|
|
const index = sensors.findIndex(s => s.deviceId === deviceId);
|
|
if (index >= 0) {
|
|
setSensors(prev => prev.map(s =>
|
|
s.deviceId === deviceId
|
|
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
|
|
: s
|
|
));
|
|
setCurrentIndex(index);
|
|
setIsPaused(false);
|
|
runBatchSetup();
|
|
}
|
|
};
|
|
|
|
// Skip failed sensor
|
|
const handleSkip = (deviceId: string) => {
|
|
setSensors(prev => prev.map(s =>
|
|
s.deviceId === deviceId
|
|
? { ...s, status: 'skipped' as SensorSetupStatus }
|
|
: s
|
|
));
|
|
setIsPaused(false);
|
|
|
|
// Move to next sensor
|
|
const nextIndex = currentIndex + 1;
|
|
if (nextIndex < sensors.length) {
|
|
setCurrentIndex(nextIndex);
|
|
runBatchSetup();
|
|
} else {
|
|
setPhase('results');
|
|
}
|
|
};
|
|
|
|
// Cancel all
|
|
const handleCancelAll = () => {
|
|
Alert.alert(
|
|
'Cancel Setup',
|
|
'Are you sure you want to cancel? Progress will be lost.',
|
|
[
|
|
{ text: 'Continue Setup', style: 'cancel' },
|
|
{
|
|
text: 'Cancel',
|
|
style: 'destructive',
|
|
onPress: () => {
|
|
shouldCancelRef.current = true;
|
|
setupInProgressRef.current = false;
|
|
// Disconnect all devices
|
|
selectedDevices.forEach(d => disconnectDevice(d.id));
|
|
router.back();
|
|
},
|
|
},
|
|
]
|
|
);
|
|
};
|
|
|
|
// Done - navigate back
|
|
const handleDone = () => {
|
|
router.replace(`/(tabs)/beneficiaries/${id}/equipment` as any);
|
|
};
|
|
|
|
// Retry a single sensor from results screen
|
|
const handleRetryFromResults = (deviceId: string) => {
|
|
const index = sensors.findIndex(s => s.deviceId === deviceId);
|
|
if (index >= 0) {
|
|
// Reset the sensor state
|
|
setSensors(prev => prev.map(s =>
|
|
s.deviceId === deviceId
|
|
? { ...s, status: 'pending' as SensorSetupStatus, error: undefined, steps: createInitialSteps() }
|
|
: s
|
|
));
|
|
setCurrentIndex(index);
|
|
setIsPaused(false);
|
|
// Go back to batch setup phase
|
|
setPhase('batch_setup');
|
|
}
|
|
};
|
|
|
|
const getSignalStrength = (rssi: number): string => {
|
|
if (rssi >= -50) return 'Excellent';
|
|
if (rssi >= -60) return 'Good';
|
|
if (rssi >= -70) return 'Fair';
|
|
return 'Weak';
|
|
};
|
|
|
|
const getSignalColor = (rssi: number) => {
|
|
if (rssi >= -50) return AppColors.success;
|
|
if (rssi >= -60) return AppColors.info;
|
|
if (rssi >= -70) return AppColors.warning;
|
|
return AppColors.error;
|
|
};
|
|
|
|
const getSignalIcon = (rssi: number) => {
|
|
if (rssi >= -50) return 'wifi';
|
|
if (rssi >= -60) return 'wifi';
|
|
if (rssi >= -70) return 'wifi-outline';
|
|
return 'wifi-outline';
|
|
};
|
|
|
|
// Results screen
|
|
if (phase === 'results') {
|
|
return (
|
|
<SetupResultsScreen
|
|
sensors={sensors}
|
|
onRetry={handleRetryFromResults}
|
|
onDone={handleDone}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Batch setup progress screen
|
|
if (phase === 'batch_setup') {
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
<View style={styles.header}>
|
|
<View style={styles.placeholder} />
|
|
<Text style={styles.headerTitle}>Setting Up Sensors</Text>
|
|
<View style={styles.placeholder} />
|
|
</View>
|
|
|
|
<View style={styles.batchContent}>
|
|
<BatchSetupProgress
|
|
sensors={sensors}
|
|
currentIndex={currentIndex}
|
|
ssid={selectedNetwork?.ssid || ''}
|
|
isPaused={isPaused}
|
|
onRetry={handleRetry}
|
|
onSkip={handleSkip}
|
|
onCancelAll={handleCancelAll}
|
|
/>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
// WiFi selection screen (default)
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<TouchableOpacity
|
|
style={styles.backButton}
|
|
onPress={() => {
|
|
selectedDevices.forEach(d => disconnectDevice(d.id));
|
|
router.back();
|
|
}}
|
|
>
|
|
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
|
</TouchableOpacity>
|
|
<Text style={styles.headerTitle}>Setup WiFi</Text>
|
|
<View style={styles.placeholder} />
|
|
</View>
|
|
|
|
<KeyboardAvoidingView
|
|
style={styles.content}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : 'padding'}
|
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 20}
|
|
>
|
|
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
|
{/* Device Info Card */}
|
|
<View style={styles.deviceCard}>
|
|
<View style={styles.deviceIcon}>
|
|
<Ionicons name="water" size={32} color={AppColors.primary} />
|
|
</View>
|
|
<View style={styles.deviceInfo}>
|
|
{selectedDevices.length === 1 ? (
|
|
<>
|
|
<Text style={styles.deviceName}>{firstDevice?.name}</Text>
|
|
<Text style={styles.deviceMeta}>Well ID: {firstDevice?.wellId}</Text>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text style={styles.deviceName}>{selectedDevices.length} Sensors Selected</Text>
|
|
<Text style={styles.deviceMeta}>
|
|
{selectedDevices.map(d => d.name).join(', ')}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Instructions */}
|
|
<View style={styles.instructionsCard}>
|
|
<Text style={styles.instructionsText}>
|
|
{selectedDevices.length === 1
|
|
? 'Select the WiFi network your sensor should connect to. Make sure the network has internet access.'
|
|
: `Select the WiFi network for all ${selectedDevices.length} sensors. They will all be configured with the same WiFi credentials.`}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* WiFi Networks List */}
|
|
{isLoadingNetworks ? (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color={AppColors.primary} />
|
|
<Text style={styles.loadingText}>Scanning for WiFi networks...</Text>
|
|
</View>
|
|
) : (
|
|
<>
|
|
<View style={styles.sectionHeader}>
|
|
<Text style={styles.sectionTitle}>Available Networks ({networks.length})</Text>
|
|
<TouchableOpacity style={styles.refreshButton} onPress={loadWiFiNetworks}>
|
|
<Ionicons name="refresh" size={18} color={AppColors.primary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{networks.length === 0 ? (
|
|
<View style={styles.emptyState}>
|
|
<Ionicons name="wifi-outline" size={48} color={AppColors.textMuted} />
|
|
<Text style={styles.emptyText}>No WiFi networks found</Text>
|
|
<TouchableOpacity style={styles.retryButton} onPress={loadWiFiNetworks}>
|
|
<Text style={styles.retryText}>Try Again</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : (
|
|
<View style={styles.networksList}>
|
|
{networks.map((network, index) => {
|
|
const isSelected = selectedNetwork?.ssid === network.ssid;
|
|
const hasSavedPassword = passwordsLoaded && savedPasswords[network.ssid];
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
key={`${network.ssid}-${index}`}
|
|
style={[
|
|
styles.networkCard,
|
|
isSelected && styles.networkCardSelected,
|
|
]}
|
|
onPress={() => handleSelectNetwork(network)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<View style={styles.networkInfo}>
|
|
<Ionicons
|
|
name={getSignalIcon(network.rssi)}
|
|
size={24}
|
|
color={getSignalColor(network.rssi)}
|
|
/>
|
|
<View style={styles.networkDetails}>
|
|
<View style={styles.networkNameRow}>
|
|
<Text style={styles.networkName}>{network.ssid}</Text>
|
|
{hasSavedPassword && (
|
|
<Ionicons name="key" size={14} color={AppColors.success} style={styles.savedPasswordIcon} />
|
|
)}
|
|
</View>
|
|
<Text style={[styles.signalText, { color: getSignalColor(network.rssi) }]}>
|
|
{getSignalStrength(network.rssi)} ({network.rssi} dBm)
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
{isSelected && (
|
|
<Ionicons name="checkmark-circle" size={24} color={AppColors.primary} />
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Password Input (shown when network selected) */}
|
|
{selectedNetwork && (
|
|
<View style={styles.passwordCard}>
|
|
<Text style={styles.passwordLabel}>WiFi Password</Text>
|
|
<View style={styles.passwordInputContainer}>
|
|
<TextInput
|
|
style={styles.passwordInput}
|
|
value={password}
|
|
onChangeText={setPassword}
|
|
placeholder="Enter password"
|
|
secureTextEntry={!showPassword}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
<TouchableOpacity
|
|
style={styles.togglePasswordButton}
|
|
onPress={() => setShowPassword(!showPassword)}
|
|
>
|
|
<Ionicons
|
|
name={showPassword ? 'eye-off' : 'eye'}
|
|
size={20}
|
|
color={AppColors.textMuted}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Connect Button */}
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.connectButton,
|
|
!password && styles.connectButtonDisabled,
|
|
]}
|
|
onPress={handleStartBatchSetup}
|
|
disabled={!password}
|
|
>
|
|
<Ionicons name="checkmark" size={20} color={AppColors.white} />
|
|
<Text style={styles.connectButtonText}>
|
|
{selectedDevices.length === 1
|
|
? 'Connect & Complete Setup'
|
|
: `Connect All ${selectedDevices.length} Sensors`}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{/* Help Card */}
|
|
<View style={styles.helpCard}>
|
|
<View style={styles.helpHeader}>
|
|
<Ionicons name="information-circle" size={20} color={AppColors.info} />
|
|
<Text style={styles.helpTitle}>Important</Text>
|
|
</View>
|
|
<Text style={styles.helpText}>
|
|
• The sensor will reboot after WiFi is configured{'\n'}
|
|
• It may take up to 1 minute for the sensor to connect{'\n'}
|
|
• Make sure the WiFi password is correct{'\n'}
|
|
• The network must have internet access
|
|
</Text>
|
|
</View>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
</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,
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
},
|
|
scrollContent: {
|
|
padding: Spacing.lg,
|
|
paddingBottom: Spacing.xxl,
|
|
},
|
|
batchContent: {
|
|
flex: 1,
|
|
padding: Spacing.lg,
|
|
},
|
|
// Device Card
|
|
deviceCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.xl,
|
|
padding: Spacing.lg,
|
|
marginBottom: Spacing.lg,
|
|
...Shadows.sm,
|
|
},
|
|
deviceIcon: {
|
|
width: 60,
|
|
height: 60,
|
|
borderRadius: BorderRadius.lg,
|
|
backgroundColor: AppColors.primaryLighter,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginRight: Spacing.md,
|
|
},
|
|
deviceInfo: {
|
|
flex: 1,
|
|
},
|
|
deviceName: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.textPrimary,
|
|
marginBottom: 2,
|
|
},
|
|
deviceMeta: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textMuted,
|
|
},
|
|
// Instructions
|
|
instructionsCard: {
|
|
backgroundColor: AppColors.infoLight,
|
|
borderRadius: BorderRadius.lg,
|
|
padding: Spacing.md,
|
|
marginBottom: Spacing.lg,
|
|
},
|
|
instructionsText: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.info,
|
|
lineHeight: 20,
|
|
},
|
|
// Loading
|
|
loadingContainer: {
|
|
alignItems: 'center',
|
|
padding: Spacing.xl,
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.xl,
|
|
...Shadows.sm,
|
|
},
|
|
loadingText: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textSecondary,
|
|
marginTop: Spacing.md,
|
|
},
|
|
// 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,
|
|
},
|
|
refreshButton: {
|
|
padding: Spacing.xs,
|
|
},
|
|
// Empty State
|
|
emptyState: {
|
|
alignItems: 'center',
|
|
padding: Spacing.xl,
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.xl,
|
|
...Shadows.sm,
|
|
},
|
|
emptyText: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textMuted,
|
|
marginTop: Spacing.md,
|
|
marginBottom: Spacing.md,
|
|
},
|
|
retryButton: {
|
|
paddingVertical: Spacing.sm,
|
|
paddingHorizontal: Spacing.lg,
|
|
},
|
|
retryText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.primary,
|
|
},
|
|
// Networks List
|
|
networksList: {
|
|
gap: Spacing.sm,
|
|
marginBottom: Spacing.lg,
|
|
},
|
|
networkCard: {
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.lg,
|
|
padding: Spacing.md,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
...Shadows.xs,
|
|
},
|
|
networkCardSelected: {
|
|
borderWidth: 2,
|
|
borderColor: AppColors.primary,
|
|
},
|
|
networkInfo: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: Spacing.md,
|
|
},
|
|
networkDetails: {
|
|
flex: 1,
|
|
},
|
|
networkNameRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
networkName: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.textPrimary,
|
|
marginBottom: 2,
|
|
},
|
|
savedPasswordIcon: {
|
|
marginLeft: Spacing.xs,
|
|
marginBottom: 2,
|
|
},
|
|
signalText: {
|
|
fontSize: FontSizes.xs,
|
|
fontWeight: FontWeights.medium,
|
|
},
|
|
// Password Input
|
|
passwordCard: {
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.xl,
|
|
padding: Spacing.lg,
|
|
marginBottom: Spacing.lg,
|
|
...Shadows.sm,
|
|
},
|
|
passwordLabel: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.textSecondary,
|
|
marginBottom: Spacing.sm,
|
|
},
|
|
passwordInputContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: AppColors.background,
|
|
borderRadius: BorderRadius.md,
|
|
borderWidth: 1,
|
|
borderColor: AppColors.border,
|
|
marginBottom: Spacing.md,
|
|
},
|
|
passwordInput: {
|
|
flex: 1,
|
|
paddingVertical: Spacing.sm,
|
|
paddingHorizontal: Spacing.md,
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textPrimary,
|
|
},
|
|
togglePasswordButton: {
|
|
padding: Spacing.md,
|
|
},
|
|
// Connect Button
|
|
connectButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: AppColors.primary,
|
|
paddingVertical: Spacing.md,
|
|
borderRadius: BorderRadius.lg,
|
|
gap: Spacing.sm,
|
|
...Shadows.md,
|
|
},
|
|
connectButtonDisabled: {
|
|
opacity: 0.5,
|
|
},
|
|
connectButtonText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.white,
|
|
},
|
|
// 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,
|
|
},
|
|
});
|