Sergei 1e9ebd14ff Add sensor setup analytics tracking
Implemented comprehensive analytics system for tracking sensor setup process
including scan events, setup steps, and completion metrics.

Features:
- Analytics service with event tracking for sensor setup
- Metrics calculation (success rate, duration, common errors)
- Integration in add-sensor and setup-wifi screens
- Tracks: scan start/complete, setup start/complete, individual steps,
  retries, skips, and cancellations
- Comprehensive test coverage

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 16:20:48 -08:00

1098 lines
32 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 { analytics } from '@/services/analytics';
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);
const [setupStartTime, setSetupStartTime] = useState<number | null>(null);
// 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
),
};
}));
// Track step progress
if (id) {
const sensorIndex = sensors.findIndex(s => s.deviceId === deviceId);
const stepStatusMap: Record<SensorSetupStep['status'], 'started' | 'completed' | 'failed'> = {
pending: 'started',
in_progress: 'started',
completed: 'completed',
failed: 'failed',
};
analytics.trackSensorSetupStep({
beneficiaryId: id,
sensorIndex,
totalSensors: sensors.length,
step: stepName,
stepStatus: stepStatusMap[stepStatus],
error,
});
}
}, [id, sensors]);
// 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) {
// Use the error message from the API response
const errorMessage = attachResponse.error?.message || 'Failed to register sensor';
throw new Error(errorMessage);
}
}
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) {
// Track setup completion
if (id && setupStartTime) {
const successCount = finalSensors.filter(s => s.status === 'success').length;
const failureCount = finalSensors.filter(s => s.status === 'error').length;
const skippedCount = finalSensors.filter(s => s.status === 'skipped').length;
const totalDuration = Date.now() - setupStartTime;
// Calculate average sensor setup time (only successful ones)
const successfulSensors = finalSensors.filter(s => s.status === 'success' && s.startTime && s.endTime);
const averageSensorSetupTime = successfulSensors.length > 0
? successfulSensors.reduce((sum, s) => sum + (s.endTime! - s.startTime!), 0) / successfulSensors.length
: undefined;
analytics.trackSensorSetupComplete({
beneficiaryId: id,
totalSensors: finalSensors.length,
successCount,
failureCount,
skippedCount,
totalDuration,
averageSensorSetupTime,
});
}
setPhase('results');
}
}, [sensors, currentIndex, selectedNetwork, password, isPaused, processSensor, id, setupStartTime]);
// 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
}
// Track setup start time
setSetupStartTime(Date.now());
// 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) {
const sensor = sensors[index];
// Track retry
if (id && sensor.error) {
analytics.trackSensorSetupRetry({
beneficiaryId: id,
sensorId: deviceId,
sensorName: sensor.deviceName,
previousError: sensor.error,
});
}
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) => {
const sensor = sensors.find(s => s.deviceId === deviceId);
// Track skip
if (id && sensor?.error) {
analytics.trackSensorSetupSkip({
beneficiaryId: id,
sensorId: deviceId,
sensorName: sensor.deviceName,
error: sensor.error,
});
}
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: () => {
// Track cancellation
if (id) {
analytics.trackSensorSetupCancelled({
beneficiaryId: id,
currentSensorIndex: currentIndex,
totalSensors: sensors.length,
reason: 'user_cancelled',
});
}
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,
},
});