Refactor Setup WiFi screen for batch sensor processing

- Add SensorSetupState and BatchSetupState types for tracking sensor setup progress
- Create BatchSetupProgress component with step-by-step progress UI
- Implement sequential sensor processing with:
  - Connect → Unlock → Set WiFi → Attach → Reboot steps
  - Error handling with Retry/Skip options for each sensor
  - Pause on failure, resume on retry/skip
  - Cancel all functionality
- Add results screen showing success/failed sensors
- Support processing multiple sensors with same WiFi credentials

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-19 22:47:48 -08:00
parent b738d86419
commit be1c2eb7f5
3 changed files with 996 additions and 78 deletions

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { import {
View, View,
Text, Text,
@ -16,6 +16,12 @@ import * as Device from 'expo-device';
import { useBLE } from '@/contexts/BLEContext'; import { useBLE } from '@/contexts/BLEContext';
import { api } from '@/services/api'; import { api } from '@/services/api';
import type { WiFiNetwork } from '@/services/ble'; import type { WiFiNetwork } from '@/services/ble';
import type {
SensorSetupState,
SensorSetupStep,
SensorSetupStatus,
} from '@/types';
import BatchSetupProgress from '@/components/BatchSetupProgress';
import { import {
AppColors, AppColors,
BorderRadius, BorderRadius,
@ -33,13 +39,44 @@ interface DeviceParam {
wellId?: number; 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() { export default function SetupWiFiScreen() {
const { id, devices: devicesParam } = useLocalSearchParams<{ const { id, devices: devicesParam } = useLocalSearchParams<{
id: string; id: string;
devices: string; // JSON string of DeviceParam[] devices: string; // JSON string of DeviceParam[]
}>(); }>();
const { getWiFiList, setWiFi, disconnectDevice } = useBLE(); const {
getWiFiList,
setWiFi,
connectDevice,
disconnectDevice,
rebootDevice,
} = useBLE();
// Parse devices from navigation params // Parse devices from navigation params
const selectedDevices: DeviceParam[] = React.useMemo(() => { const selectedDevices: DeviceParam[] = React.useMemo(() => {
@ -52,28 +89,37 @@ export default function SetupWiFiScreen() {
} }
}, [devicesParam]); }, [devicesParam]);
// Use first device for WiFi scanning (all devices will use same WiFi) // Use first device for WiFi scanning
const firstDevice = selectedDevices[0]; const firstDevice = selectedDevices[0];
const deviceId = firstDevice?.id; const deviceId = firstDevice?.id;
const deviceName = firstDevice?.name;
const wellId = firstDevice?.wellId?.toString();
// UI Phase
const [phase, setPhase] = useState<SetupPhase>('wifi_selection');
// WiFi selection state
const [networks, setNetworks] = useState<WiFiNetwork[]>([]); const [networks, setNetworks] = useState<WiFiNetwork[]>([]);
const [isLoadingNetworks, setIsLoadingNetworks] = useState(false); const [isLoadingNetworks, setIsLoadingNetworks] = useState(false);
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null); const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork | null>(null);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isConnecting, setIsConnecting] = 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);
useEffect(() => { useEffect(() => {
loadWiFiNetworks(); loadWiFiNetworks();
}, []); }, []);
const loadWiFiNetworks = async () => { const loadWiFiNetworks = async () => {
if (!deviceId) return;
setIsLoadingNetworks(true); setIsLoadingNetworks(true);
try { try {
const wifiList = await getWiFiList(deviceId!); const wifiList = await getWiFiList(deviceId);
setNetworks(wifiList); setNetworks(wifiList);
} catch (error: any) { } catch (error: any) {
console.error('[SetupWiFi] Failed to get WiFi list:', error); console.error('[SetupWiFi] Failed to get WiFi list:', error);
@ -88,71 +134,296 @@ export default function SetupWiFiScreen() {
setPassword(''); setPassword('');
}; };
const handleConnect = async () => { // 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 } = sensor;
const isSimulator = !Device.isDevice;
console.log(`[SetupWiFi] [${deviceName}] Starting setup...`);
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');
const wifiSuccess = await setWiFi(deviceId, ssid, pwd);
if (!wifiSuccess) throw new Error('Failed to configure WiFi');
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) {
const attachResponse = await api.attachDeviceToBeneficiary(
id!,
wellId,
ssid,
pwd
);
if (!attachResponse.ok) {
throw new Error('Failed to register sensor');
}
} else {
console.log(`[SetupWiFi] [${deviceName}] Simulator mode - skipping API attach`);
}
updateSensorStep(deviceId, 'attach', 'completed');
if (shouldCancelRef.current) return false;
// Step 5: Reboot
updateSensorStep(deviceId, 'reboot', 'in_progress');
updateSensorStatus(deviceId, 'rebooting');
await rebootDevice(deviceId);
updateSensorStep(deviceId, 'reboot', 'completed');
// Success!
updateSensorStatus(deviceId, 'success');
console.log(`[SetupWiFi] [${deviceName}] Setup completed successfully`);
return true;
} catch (error: any) {
console.error(`[SetupWiFi] [${deviceName}] Setup failed:`, error);
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) {
console.log('[SetupWiFi] Batch setup cancelled');
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 = () => {
if (!selectedNetwork) { if (!selectedNetwork) {
Alert.alert('Error', 'Please select a WiFi network'); Alert.alert('Error', 'Please select a WiFi network');
return; return;
} }
if (!password) { if (!password) {
Alert.alert('Error', 'Please enter WiFi password'); Alert.alert('Error', 'Please enter WiFi password');
return; return;
} }
setIsConnecting(true); // Initialize sensor states
const initialStates = selectedDevices.map(createSensorState);
setSensors(initialStates);
setCurrentIndex(0);
setIsPaused(false);
setPhase('batch_setup');
};
try { // Start processing after phase change
// Step 1: Set WiFi on the device via BLE useEffect(() => {
const success = await setWiFi(deviceId!, selectedNetwork.ssid, password); if (phase === 'batch_setup' && sensors.length > 0 && !setupInProgressRef.current) {
runBatchSetup();
if (!success) {
throw new Error('Failed to configure WiFi on sensor');
}
// Step 2: Attach device to beneficiary via API (skip in simulator/mock mode)
const isSimulator = !Device.isDevice;
if (!isSimulator) {
const attachResponse = await api.attachDeviceToBeneficiary(
id!,
parseInt(wellId!, 10),
selectedNetwork.ssid,
password
);
if (!attachResponse.ok) {
throw new Error('Failed to attach sensor to beneficiary');
}
} else {
console.log('[SetupWiFi] Simulator mode - skipping API attach');
}
// Step 3: Disconnect BLE connection (sensor will reboot and connect to WiFi)
await disconnectDevice(deviceId!);
// Success!
Alert.alert(
'Success!',
`${deviceName} has been configured and attached.\n\nThe sensor will now reboot and connect to "${selectedNetwork.ssid}". This may take a minute.`,
[
{
text: 'Done',
onPress: () => {
// Navigate back to Equipment screen
router.replace(`/(tabs)/beneficiaries/${id}/equipment` as any);
},
},
]
);
} catch (error: any) {
console.error('[SetupWiFi] Failed to connect:', error);
Alert.alert(
'Connection Failed',
error.message || 'Failed to configure WiFi. Please check the password and try again.'
);
} finally {
setIsConnecting(false);
} }
}, [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);
}; };
const getSignalStrength = (rssi: number): string => { const getSignalStrength = (rssi: number): string => {
@ -176,6 +447,129 @@ export default function SetupWiFiScreen() {
return 'wifi-outline'; return 'wifi-outline';
}; };
// Results screen
if (phase === 'results') {
const successSensors = sensors.filter(s => s.status === 'success');
const failedSensors = sensors.filter(s => s.status === 'error' || s.status === 'skipped');
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
<View style={styles.placeholder} />
<Text style={styles.headerTitle}>Setup Complete</Text>
<View style={styles.placeholder} />
</View>
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
{/* Success Summary */}
<View style={styles.resultsSummary}>
<View style={[styles.summaryIcon, { backgroundColor: AppColors.successLight }]}>
<Ionicons
name={successSensors.length > 0 ? 'checkmark-circle' : 'alert-circle'}
size={48}
color={successSensors.length > 0 ? AppColors.success : AppColors.warning}
/>
</View>
<Text style={styles.summaryTitle}>
{successSensors.length === sensors.length
? 'All Sensors Connected!'
: successSensors.length > 0
? 'Partial Success'
: 'Setup Failed'}
</Text>
<Text style={styles.summarySubtitle}>
{successSensors.length} of {sensors.length} sensors configured
</Text>
</View>
{/* Success List */}
{successSensors.length > 0 && (
<View style={styles.resultsSection}>
<Text style={styles.resultsSectionTitle}>Successfully Connected</Text>
{successSensors.map(sensor => (
<View key={sensor.deviceId} style={styles.resultItem}>
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
<Text style={styles.resultItemText}>{sensor.deviceName}</Text>
</View>
))}
</View>
)}
{/* Failed List */}
{failedSensors.length > 0 && (
<View style={styles.resultsSection}>
<Text style={styles.resultsSectionTitle}>Failed</Text>
{failedSensors.map(sensor => (
<View key={sensor.deviceId} style={styles.resultItem}>
<Ionicons
name={sensor.status === 'skipped' ? 'remove-circle' : 'close-circle'}
size={20}
color={sensor.status === 'skipped' ? AppColors.warning : AppColors.error}
/>
<View style={styles.resultItemContent}>
<Text style={styles.resultItemText}>{sensor.deviceName}</Text>
{sensor.error && (
<Text style={styles.resultItemError}>{sensor.error}</Text>
)}
{sensor.status === 'skipped' && (
<Text style={styles.resultItemError}>Skipped</Text>
)}
</View>
</View>
))}
</View>
)}
{/* Info */}
<View style={styles.helpCard}>
<View style={styles.helpHeader}>
<Ionicons name="information-circle" size={20} color={AppColors.info} />
<Text style={styles.helpTitle}>What's Next</Text>
</View>
<Text style={styles.helpText}>
{successSensors.length > 0
? '• Successfully connected sensors will appear in your Equipment list\n• It may take up to 1 minute for sensors to come online\n• You can configure sensor locations in Device Settings'
: '• Return to the Equipment screen and try adding sensors again\n• Make sure sensors are powered on and nearby'}
</Text>
</View>
</ScrollView>
{/* Done Button */}
<View style={styles.bottomActions}>
<TouchableOpacity style={styles.doneButton} onPress={handleDone}>
<Text style={styles.doneButtonText}>Done</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
// 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 ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */} {/* Header */}
@ -183,7 +577,6 @@ export default function SetupWiFiScreen() {
<TouchableOpacity <TouchableOpacity
style={styles.backButton} style={styles.backButton}
onPress={() => { onPress={() => {
// Disconnect all BLE devices before going back
selectedDevices.forEach(d => disconnectDevice(d.id)); selectedDevices.forEach(d => disconnectDevice(d.id));
router.back(); router.back();
}} }}
@ -203,8 +596,8 @@ export default function SetupWiFiScreen() {
<View style={styles.deviceInfo}> <View style={styles.deviceInfo}>
{selectedDevices.length === 1 ? ( {selectedDevices.length === 1 ? (
<> <>
<Text style={styles.deviceName}>{deviceName}</Text> <Text style={styles.deviceName}>{firstDevice?.name}</Text>
<Text style={styles.deviceMeta}>Well ID: {wellId}</Text> <Text style={styles.deviceMeta}>Well ID: {firstDevice?.wellId}</Text>
</> </>
) : ( ) : (
<> <>
@ -318,22 +711,17 @@ export default function SetupWiFiScreen() {
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.connectButton, styles.connectButton,
(!password || isConnecting) && styles.connectButtonDisabled, !password && styles.connectButtonDisabled,
]} ]}
onPress={handleConnect} onPress={handleStartBatchSetup}
disabled={!password || isConnecting} disabled={!password}
> >
{isConnecting ? ( <Ionicons name="checkmark" size={20} color={AppColors.white} />
<> <Text style={styles.connectButtonText}>
<ActivityIndicator size="small" color={AppColors.white} /> {selectedDevices.length === 1
<Text style={styles.connectButtonText}>Connecting...</Text> ? 'Connect & Complete Setup'
</> : `Connect All ${selectedDevices.length} Sensors`}
) : ( </Text>
<>
<Ionicons name="checkmark" size={20} color={AppColors.white} />
<Text style={styles.connectButtonText}>Connect & Complete Setup</Text>
</>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@ -388,6 +776,10 @@ const styles = StyleSheet.create({
padding: Spacing.lg, padding: Spacing.lg,
paddingBottom: Spacing.xxl, paddingBottom: Spacing.xxl,
}, },
batchContent: {
flex: 1,
padding: Spacing.lg,
},
// Device Card // Device Card
deviceCard: { deviceCard: {
flexDirection: 'row', flexDirection: 'row',
@ -596,4 +988,79 @@ const styles = StyleSheet.create({
color: AppColors.info, color: AppColors.info,
lineHeight: 20, lineHeight: 20,
}, },
// Results Screen
resultsSummary: {
alignItems: 'center',
paddingVertical: Spacing.xl,
},
summaryIcon: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
summaryTitle: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
summarySubtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
resultsSection: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.md,
...Shadows.xs,
},
resultsSectionTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
marginBottom: Spacing.sm,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
resultItem: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: Spacing.xs,
gap: Spacing.sm,
},
resultItemContent: {
flex: 1,
},
resultItemText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
},
resultItemError: {
fontSize: FontSizes.xs,
color: AppColors.error,
marginTop: 2,
},
bottomActions: {
padding: Spacing.lg,
borderTopWidth: 1,
borderTopColor: AppColors.border,
backgroundColor: AppColors.surface,
},
doneButton: {
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
alignItems: 'center',
...Shadows.md,
},
doneButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
}); });

View File

@ -0,0 +1,394 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { SensorSetupState, SensorSetupStep } from '@/types';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
interface BatchSetupProgressProps {
sensors: SensorSetupState[];
currentIndex: number;
ssid: string;
isPaused: boolean;
onRetry?: (deviceId: string) => void;
onSkip?: (deviceId: string) => void;
onCancelAll?: () => void;
}
const STEP_LABELS: Record<SensorSetupStep['name'], string> = {
connect: 'Connecting',
unlock: 'Unlocking',
wifi: 'Setting WiFi',
attach: 'Registering',
reboot: 'Rebooting',
};
function StepIndicator({ step }: { step: SensorSetupStep }) {
const getIcon = () => {
switch (step.status) {
case 'completed':
return <Ionicons name="checkmark" size={12} color={AppColors.success} />;
case 'in_progress':
return <ActivityIndicator size={10} color={AppColors.primary} />;
case 'failed':
return <Ionicons name="close" size={12} color={AppColors.error} />;
default:
return <View style={styles.pendingDot} />;
}
};
const getTextColor = () => {
switch (step.status) {
case 'completed':
return AppColors.success;
case 'in_progress':
return AppColors.primary;
case 'failed':
return AppColors.error;
default:
return AppColors.textMuted;
}
};
return (
<View style={styles.stepRow}>
<View style={styles.stepIcon}>{getIcon()}</View>
<Text style={[styles.stepLabel, { color: getTextColor() }]}>
{STEP_LABELS[step.name]}
</Text>
{step.error && (
<Text style={styles.stepError}>{step.error}</Text>
)}
</View>
);
}
function SensorCard({
sensor,
isActive,
onRetry,
onSkip,
}: {
sensor: SensorSetupState;
isActive: boolean;
onRetry?: () => void;
onSkip?: () => void;
}) {
const getStatusColor = () => {
switch (sensor.status) {
case 'success':
return AppColors.success;
case 'error':
return AppColors.error;
case 'skipped':
return AppColors.warning;
case 'pending':
return AppColors.textMuted;
default:
return AppColors.primary;
}
};
const getStatusIcon = () => {
switch (sensor.status) {
case 'success':
return <Ionicons name="checkmark-circle" size={24} color={AppColors.success} />;
case 'error':
return <Ionicons name="close-circle" size={24} color={AppColors.error} />;
case 'skipped':
return <Ionicons name="remove-circle" size={24} color={AppColors.warning} />;
case 'pending':
return <Ionicons name="ellipse-outline" size={24} color={AppColors.textMuted} />;
default:
return <ActivityIndicator size={20} color={AppColors.primary} />;
}
};
const showActions = sensor.status === 'error' && onRetry && onSkip;
return (
<View style={[styles.sensorCard, isActive && styles.sensorCardActive]}>
<View style={styles.sensorHeader}>
<View style={styles.sensorIcon}>
<Ionicons name="water" size={20} color={getStatusColor()} />
</View>
<View style={styles.sensorInfo}>
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
{sensor.wellId && (
<Text style={styles.sensorMeta}>Well ID: {sensor.wellId}</Text>
)}
</View>
<View style={styles.statusIcon}>{getStatusIcon()}</View>
</View>
{/* Show steps for active or completed sensors */}
{(isActive || sensor.status === 'success' || sensor.status === 'error') && (
<View style={styles.stepsContainer}>
{sensor.steps.map((step, index) => (
<StepIndicator key={step.name} step={step} />
))}
</View>
)}
{/* Error message */}
{sensor.error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={AppColors.error} />
<Text style={styles.errorText}>{sensor.error}</Text>
</View>
)}
{/* Action buttons for failed sensors */}
{showActions && (
<View style={styles.actionButtons}>
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
<Ionicons name="refresh" size={16} color={AppColors.primary} />
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={onSkip}>
<Ionicons name="arrow-forward" size={16} color={AppColors.textMuted} />
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
export default function BatchSetupProgress({
sensors,
currentIndex,
ssid,
isPaused,
onRetry,
onSkip,
onCancelAll,
}: BatchSetupProgressProps) {
const completedCount = sensors.filter(s => s.status === 'success').length;
const failedCount = sensors.filter(s => s.status === 'error').length;
const skippedCount = sensors.filter(s => s.status === 'skipped').length;
const totalProcessed = completedCount + failedCount + skippedCount;
const progress = (totalProcessed / sensors.length) * 100;
return (
<View style={styles.container}>
{/* Progress Header */}
<View style={styles.progressHeader}>
<Text style={styles.progressTitle}>
Connecting sensors to "{ssid}"...
</Text>
<Text style={styles.progressSubtitle}>
{totalProcessed} of {sensors.length} complete
</Text>
{/* Progress bar */}
<View style={styles.progressBarContainer}>
<View style={[styles.progressBar, { width: `${progress}%` }]} />
</View>
</View>
{/* Sensors List */}
<ScrollView
style={styles.sensorsList}
contentContainerStyle={styles.sensorsListContent}
showsVerticalScrollIndicator={false}
>
{sensors.map((sensor, index) => (
<SensorCard
key={sensor.deviceId}
sensor={sensor}
isActive={index === currentIndex && !isPaused}
onRetry={onRetry ? () => onRetry(sensor.deviceId) : undefined}
onSkip={onSkip ? () => onSkip(sensor.deviceId) : undefined}
/>
))}
</ScrollView>
{/* Cancel button */}
{onCancelAll && (
<TouchableOpacity style={styles.cancelAllButton} onPress={onCancelAll}>
<Text style={styles.cancelAllText}>Cancel Setup</Text>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
progressHeader: {
marginBottom: Spacing.lg,
},
progressTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
progressSubtitle: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginBottom: Spacing.md,
},
progressBarContainer: {
height: 4,
backgroundColor: AppColors.border,
borderRadius: 2,
overflow: 'hidden',
},
progressBar: {
height: '100%',
backgroundColor: AppColors.primary,
borderRadius: 2,
},
sensorsList: {
flex: 1,
},
sensorsListContent: {
gap: Spacing.md,
paddingBottom: Spacing.lg,
},
sensorCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
...Shadows.xs,
},
sensorCardActive: {
borderWidth: 2,
borderColor: AppColors.primary,
},
sensorHeader: {
flexDirection: 'row',
alignItems: 'center',
},
sensorIcon: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.sm,
},
sensorInfo: {
flex: 1,
},
sensorName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
sensorMeta: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
statusIcon: {
marginLeft: Spacing.sm,
},
stepsContainer: {
marginTop: Spacing.md,
paddingTop: Spacing.sm,
borderTopWidth: 1,
borderTopColor: AppColors.border,
gap: Spacing.xs,
},
stepRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
stepIcon: {
width: 16,
height: 16,
justifyContent: 'center',
alignItems: 'center',
},
pendingDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: AppColors.textMuted,
},
stepLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
},
stepError: {
fontSize: FontSizes.xs,
color: AppColors.error,
flex: 1,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: Spacing.sm,
padding: Spacing.sm,
backgroundColor: AppColors.errorLight,
borderRadius: BorderRadius.sm,
gap: Spacing.xs,
},
errorText: {
fontSize: FontSizes.xs,
color: AppColors.error,
flex: 1,
},
actionButtons: {
flexDirection: 'row',
marginTop: Spacing.md,
gap: Spacing.md,
},
retryButton: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.primaryLighter,
borderRadius: BorderRadius.md,
gap: Spacing.xs,
},
retryText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
skipButton: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.surfaceSecondary,
borderRadius: BorderRadius.md,
gap: Spacing.xs,
},
skipText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textMuted,
},
cancelAllButton: {
alignItems: 'center',
paddingVertical: Spacing.sm,
marginTop: Spacing.md,
},
cancelAllText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.error,
},
});

View File

@ -67,6 +67,18 @@ export type EquipmentStatus =
| 'active' // Equipment activated and working | 'active' // Equipment activated and working
| 'demo'; // Demo mode (DEMO-00000) | 'demo'; // Demo mode (DEMO-00000)
// Deployment (location where beneficiary can be monitored)
export interface Deployment {
id: number;
beneficiary_id: number;
name: string; // e.g., "Home", "Office", "Vacation Home"
address?: string;
is_primary: boolean; // One deployment per beneficiary is primary
legacy_deployment_id?: number; // Link to Legacy API deployment
created_at: string;
updated_at: string;
}
// Beneficiary Types (elderly people being monitored) // Beneficiary Types (elderly people being monitored)
export interface Beneficiary { export interface Beneficiary {
id: number; id: number;
@ -193,3 +205,48 @@ export interface ApiResponse<T> {
error?: ApiError; error?: ApiError;
ok: boolean; ok: boolean;
} }
// Batch Sensor Setup Types
/** States a sensor can be in during batch setup */
export type SensorSetupStatus =
| 'pending' // Waiting in queue
| 'connecting' // BLE connection in progress
| 'unlocking' // Sending PIN command
| 'setting_wifi' // Configuring WiFi
| 'attaching' // Calling Legacy API to link to deployment
| 'rebooting' // Restarting sensor
| 'success' // Completed successfully
| 'error' // Failed (with error message)
| 'skipped'; // User chose to skip after error
/** Step within a sensor's setup process */
export interface SensorSetupStep {
name: 'connect' | 'unlock' | 'wifi' | 'attach' | 'reboot';
status: 'pending' | 'in_progress' | 'completed' | 'failed';
error?: string;
}
/** State of a single sensor during batch setup */
export interface SensorSetupState {
deviceId: string;
deviceName: string;
wellId?: number;
mac: string;
status: SensorSetupStatus;
steps: SensorSetupStep[];
error?: string;
startTime?: number;
endTime?: number;
}
/** Overall batch setup state */
export interface BatchSetupState {
sensors: SensorSetupState[];
currentIndex: number;
ssid: string;
password: string;
isPaused: boolean;
isComplete: boolean;
startTime: number;
}