- setWiFi() now throws detailed errors instead of returning false - Shows specific error messages: "WiFi credentials rejected", timeout etc. - Added logging throughout BLE WiFi configuration flow - Fixed WiFi network deduplication (keeps strongest signal) - Ignore "Operation cancelled" error (normal cleanup behavior) - BatchSetupProgress shows actual error in hint field 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
948 lines
26 KiB
TypeScript
948 lines
26 KiB
TypeScript
import React, { useRef, useEffect, useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
Animated,
|
|
DimensionValue,
|
|
Modal,
|
|
} 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';
|
|
|
|
// User-friendly error messages based on error type
|
|
function getErrorMessage(error: string | undefined): { title: string; description: string; hint: string } {
|
|
if (!error) {
|
|
return {
|
|
title: 'Unknown Error',
|
|
description: 'Something went wrong.',
|
|
hint: 'Try again or skip this sensor.',
|
|
};
|
|
}
|
|
|
|
const lowerError = error.toLowerCase();
|
|
|
|
if (lowerError.includes('connect') || lowerError.includes('connection')) {
|
|
return {
|
|
title: 'Connection Failed',
|
|
description: 'Could not connect to the sensor via Bluetooth.',
|
|
hint: 'Move closer to the sensor and ensure it\'s powered on.',
|
|
};
|
|
}
|
|
|
|
if (lowerError.includes('unlock') || lowerError.includes('pin')) {
|
|
return {
|
|
title: 'Unlock Failed',
|
|
description: 'Could not unlock the sensor for configuration.',
|
|
hint: 'The sensor may need to be reset. Try again.',
|
|
};
|
|
}
|
|
|
|
if (lowerError.includes('wifi') || lowerError.includes('network')) {
|
|
return {
|
|
title: 'WiFi Configuration Failed',
|
|
description: 'Could not set up the WiFi connection on the sensor.',
|
|
hint: 'Check that the WiFi password is correct.',
|
|
};
|
|
}
|
|
|
|
if (lowerError.includes('register') || lowerError.includes('attach') || lowerError.includes('api')) {
|
|
return {
|
|
title: 'Registration Failed',
|
|
description: 'Could not register the sensor with your account.',
|
|
hint: 'Check your internet connection and try again.',
|
|
};
|
|
}
|
|
|
|
if (lowerError.includes('timeout') || lowerError.includes('respond')) {
|
|
return {
|
|
title: 'Sensor Not Responding',
|
|
description: 'The sensor stopped responding during setup.',
|
|
hint: 'Move closer or check if the sensor is still powered on.',
|
|
};
|
|
}
|
|
|
|
if (lowerError.includes('reboot')) {
|
|
return {
|
|
title: 'Reboot Failed',
|
|
description: 'Could not restart the sensor.',
|
|
hint: 'The sensor may still work. Try checking it in Equipment.',
|
|
};
|
|
}
|
|
|
|
// Show the actual error message for debugging
|
|
return {
|
|
title: 'Setup Failed',
|
|
description: error,
|
|
hint: `Technical details: ${error}`,
|
|
};
|
|
}
|
|
|
|
interface BatchSetupProgressProps {
|
|
sensors: SensorSetupState[];
|
|
currentIndex: number;
|
|
ssid: string;
|
|
isPaused: boolean;
|
|
onRetry?: (deviceId: string) => void;
|
|
onSkip?: (deviceId: string) => void;
|
|
onCancelAll?: () => void;
|
|
}
|
|
|
|
// Error Action Modal Component
|
|
function ErrorActionModal({
|
|
visible,
|
|
sensor,
|
|
onRetry,
|
|
onSkip,
|
|
onCancelAll,
|
|
}: {
|
|
visible: boolean;
|
|
sensor: SensorSetupState | null;
|
|
onRetry: () => void;
|
|
onSkip: () => void;
|
|
onCancelAll: () => void;
|
|
}) {
|
|
if (!sensor) return null;
|
|
|
|
const errorInfo = getErrorMessage(sensor.error);
|
|
|
|
return (
|
|
<Modal
|
|
visible={visible}
|
|
transparent
|
|
animationType="fade"
|
|
statusBarTranslucent
|
|
>
|
|
<View style={modalStyles.overlay}>
|
|
<View style={modalStyles.container}>
|
|
{/* Error Icon */}
|
|
<View style={modalStyles.iconContainer}>
|
|
<Ionicons name="alert-circle" size={48} color={AppColors.error} />
|
|
</View>
|
|
|
|
{/* Error Title */}
|
|
<Text style={modalStyles.title}>{errorInfo.title}</Text>
|
|
|
|
{/* Sensor Name */}
|
|
<View style={modalStyles.sensorBadge}>
|
|
<Ionicons name="water" size={14} color={AppColors.primary} />
|
|
<Text style={modalStyles.sensorName}>{sensor.deviceName}</Text>
|
|
</View>
|
|
|
|
{/* Error Description */}
|
|
<Text style={modalStyles.description}>{errorInfo.description}</Text>
|
|
|
|
{/* Hint */}
|
|
<View style={modalStyles.hintContainer}>
|
|
<Ionicons name="bulb-outline" size={16} color={AppColors.info} />
|
|
<Text style={modalStyles.hintText}>{errorInfo.hint}</Text>
|
|
</View>
|
|
|
|
{/* Action Buttons */}
|
|
<View style={modalStyles.actions}>
|
|
<TouchableOpacity style={modalStyles.retryButton} onPress={onRetry}>
|
|
<Ionicons name="refresh" size={18} color={AppColors.white} />
|
|
<Text style={modalStyles.retryText}>Retry</Text>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity style={modalStyles.skipButton} onPress={onSkip}>
|
|
<Ionicons name="arrow-forward" size={18} color={AppColors.textPrimary} />
|
|
<Text style={modalStyles.skipText}>Skip Sensor</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Cancel All */}
|
|
<TouchableOpacity style={modalStyles.cancelAllButton} onPress={onCancelAll}>
|
|
<Text style={modalStyles.cancelAllText}>Cancel All Setup</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const modalStyles = StyleSheet.create({
|
|
overlay: {
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: Spacing.lg,
|
|
},
|
|
container: {
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.xl,
|
|
padding: Spacing.xl,
|
|
width: '100%',
|
|
maxWidth: 340,
|
|
alignItems: 'center',
|
|
...Shadows.lg,
|
|
},
|
|
iconContainer: {
|
|
width: 80,
|
|
height: 80,
|
|
borderRadius: 40,
|
|
backgroundColor: AppColors.errorLight,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginBottom: Spacing.md,
|
|
},
|
|
title: {
|
|
fontSize: FontSizes.xl,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.textPrimary,
|
|
textAlign: 'center',
|
|
marginBottom: Spacing.sm,
|
|
},
|
|
sensorBadge: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: AppColors.primaryLighter,
|
|
paddingHorizontal: Spacing.sm,
|
|
paddingVertical: Spacing.xs,
|
|
borderRadius: BorderRadius.md,
|
|
gap: Spacing.xs,
|
|
marginBottom: Spacing.md,
|
|
},
|
|
sensorName: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: FontWeights.medium,
|
|
color: AppColors.primary,
|
|
},
|
|
description: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textSecondary,
|
|
textAlign: 'center',
|
|
marginBottom: Spacing.md,
|
|
lineHeight: 22,
|
|
},
|
|
hintContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
backgroundColor: AppColors.infoLight,
|
|
padding: Spacing.md,
|
|
borderRadius: BorderRadius.md,
|
|
marginBottom: Spacing.lg,
|
|
gap: Spacing.sm,
|
|
},
|
|
hintText: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.info,
|
|
flex: 1,
|
|
lineHeight: 20,
|
|
},
|
|
actions: {
|
|
flexDirection: 'row',
|
|
gap: Spacing.md,
|
|
marginBottom: Spacing.md,
|
|
},
|
|
retryButton: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: AppColors.primary,
|
|
paddingVertical: Spacing.md,
|
|
borderRadius: BorderRadius.lg,
|
|
gap: Spacing.xs,
|
|
...Shadows.sm,
|
|
},
|
|
retryText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.white,
|
|
},
|
|
skipButton: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: AppColors.surfaceSecondary,
|
|
paddingVertical: Spacing.md,
|
|
borderRadius: BorderRadius.lg,
|
|
gap: Spacing.xs,
|
|
borderWidth: 1,
|
|
borderColor: AppColors.border,
|
|
},
|
|
skipText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.textPrimary,
|
|
},
|
|
cancelAllButton: {
|
|
paddingVertical: Spacing.sm,
|
|
},
|
|
cancelAllText: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: FontWeights.medium,
|
|
color: AppColors.error,
|
|
},
|
|
});
|
|
|
|
// Format elapsed time as "Xs" or "Xm Xs"
|
|
function formatElapsedTime(startTime?: number, endTime?: number): string {
|
|
if (!startTime) return '';
|
|
const end = endTime || Date.now();
|
|
const elapsed = Math.floor((end - startTime) / 1000);
|
|
if (elapsed < 60) return `${elapsed}s`;
|
|
const minutes = Math.floor(elapsed / 60);
|
|
const seconds = elapsed % 60;
|
|
return `${minutes}m ${seconds}s`;
|
|
}
|
|
|
|
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,
|
|
index,
|
|
total,
|
|
onRetry,
|
|
onSkip,
|
|
}: {
|
|
sensor: SensorSetupState;
|
|
isActive: boolean;
|
|
index: number;
|
|
total: number;
|
|
onRetry?: () => void;
|
|
onSkip?: () => void;
|
|
}) {
|
|
const [elapsedTime, setElapsedTime] = useState('');
|
|
|
|
// Update elapsed time for active sensors
|
|
useEffect(() => {
|
|
if (isActive && sensor.startTime && !sensor.endTime) {
|
|
const interval = setInterval(() => {
|
|
setElapsedTime(formatElapsedTime(sensor.startTime));
|
|
}, 1000);
|
|
return () => clearInterval(interval);
|
|
} else if (sensor.endTime && sensor.startTime) {
|
|
setElapsedTime(formatElapsedTime(sensor.startTime, sensor.endTime));
|
|
}
|
|
}, [isActive, sensor.startTime, sensor.endTime]);
|
|
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;
|
|
const showProgress = sensor.status !== 'pending' && sensor.status !== 'skipped';
|
|
|
|
return (
|
|
<View style={[
|
|
styles.sensorCard,
|
|
isActive && styles.sensorCardActive,
|
|
sensor.status === 'success' && styles.sensorCardSuccess,
|
|
sensor.status === 'error' && styles.sensorCardError,
|
|
]}>
|
|
{/* Index Badge */}
|
|
<View style={[styles.indexBadge, { backgroundColor: getStatusColor() }]}>
|
|
<Text style={styles.indexBadgeText}>{index + 1}/{total}</Text>
|
|
</View>
|
|
|
|
<View style={styles.sensorHeader}>
|
|
<View style={[styles.sensorIcon, { backgroundColor: `${getStatusColor()}20` }]}>
|
|
<Ionicons name="water" size={20} color={getStatusColor()} />
|
|
</View>
|
|
<View style={styles.sensorInfo}>
|
|
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
|
|
<View style={styles.sensorMetaRow}>
|
|
{sensor.wellId && (
|
|
<Text style={styles.sensorMeta}>Well ID: {sensor.wellId}</Text>
|
|
)}
|
|
{elapsedTime && showProgress && (
|
|
<Text style={styles.elapsedTime}>{elapsedTime}</Text>
|
|
)}
|
|
</View>
|
|
</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 - enhanced display */}
|
|
{sensor.error && (
|
|
<View style={styles.errorContainer}>
|
|
<View style={styles.errorHeader}>
|
|
<Ionicons name="alert-circle" size={18} color={AppColors.error} />
|
|
<Text style={styles.errorTitle}>
|
|
{getErrorMessage(sensor.error).title}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.errorText}>
|
|
{getErrorMessage(sensor.error).description}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Action buttons for failed sensors - improved styling */}
|
|
{showActions && (
|
|
<View style={styles.actionButtons}>
|
|
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
|
|
<Ionicons name="refresh" size={16} color={AppColors.white} />
|
|
<Text style={styles.retryText}>Retry</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.skipButton} onPress={onSkip}>
|
|
<Ionicons name="arrow-forward" size={16} color={AppColors.textSecondary} />
|
|
<Text style={styles.skipText}>Skip</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export default function BatchSetupProgress({
|
|
sensors,
|
|
currentIndex,
|
|
ssid,
|
|
isPaused,
|
|
onRetry,
|
|
onSkip,
|
|
onCancelAll,
|
|
}: BatchSetupProgressProps) {
|
|
const scrollViewRef = useRef<ScrollView>(null);
|
|
const sensorCardRefs = useRef<{ [key: string]: View | null }>({});
|
|
const progressAnim = useRef(new Animated.Value(0)).current;
|
|
const [showErrorModal, setShowErrorModal] = useState(false);
|
|
|
|
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;
|
|
|
|
// Find the current failed sensor for the modal
|
|
const failedSensor = sensors.find(s => s.status === 'error');
|
|
|
|
// Show error modal when paused due to error
|
|
useEffect(() => {
|
|
if (isPaused && failedSensor) {
|
|
// Small delay for better UX - let the card error state render first
|
|
const timer = setTimeout(() => setShowErrorModal(true), 300);
|
|
return () => clearTimeout(timer);
|
|
} else {
|
|
setShowErrorModal(false);
|
|
}
|
|
}, [isPaused, failedSensor]);
|
|
|
|
const handleRetryFromModal = () => {
|
|
setShowErrorModal(false);
|
|
if (failedSensor && onRetry) {
|
|
onRetry(failedSensor.deviceId);
|
|
}
|
|
};
|
|
|
|
const handleSkipFromModal = () => {
|
|
setShowErrorModal(false);
|
|
if (failedSensor && onSkip) {
|
|
onSkip(failedSensor.deviceId);
|
|
}
|
|
};
|
|
|
|
const handleCancelAllFromModal = () => {
|
|
setShowErrorModal(false);
|
|
if (onCancelAll) {
|
|
onCancelAll();
|
|
}
|
|
};
|
|
|
|
// Animate progress bar
|
|
useEffect(() => {
|
|
Animated.timing(progressAnim, {
|
|
toValue: progress,
|
|
duration: 300,
|
|
useNativeDriver: false,
|
|
}).start();
|
|
}, [progress, progressAnim]);
|
|
|
|
// Auto-scroll to current sensor
|
|
useEffect(() => {
|
|
if (currentIndex >= 0 && scrollViewRef.current) {
|
|
// Calculate approximate scroll position (each card ~120px + spacing)
|
|
const cardHeight = 120;
|
|
const spacing = 12;
|
|
const scrollTo = currentIndex * (cardHeight + spacing);
|
|
|
|
setTimeout(() => {
|
|
scrollViewRef.current?.scrollTo({
|
|
y: Math.max(0, scrollTo - 20), // Small offset for visibility
|
|
animated: true,
|
|
});
|
|
}, 100);
|
|
}
|
|
}, [currentIndex]);
|
|
|
|
const animatedWidth = progressAnim.interpolate({
|
|
inputRange: [0, 100],
|
|
outputRange: ['0%', '100%'],
|
|
});
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* Progress Header */}
|
|
<View style={styles.progressHeader}>
|
|
<View style={styles.progressTitleRow}>
|
|
<Text style={styles.progressTitle}>
|
|
Connecting to "{ssid}"
|
|
</Text>
|
|
{!isPaused && currentIndex < sensors.length && (
|
|
<View style={styles.currentSensorBadge}>
|
|
<Text style={styles.currentSensorBadgeText}>
|
|
{currentIndex + 1}/{sensors.length}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<Text style={styles.progressSubtitle}>
|
|
{isPaused ? (
|
|
<Text style={styles.pausedText}>Paused - action required</Text>
|
|
) : totalProcessed === sensors.length ? (
|
|
'Setup complete!'
|
|
) : (
|
|
`Processing sensor ${currentIndex + 1} of ${sensors.length}...`
|
|
)}
|
|
</Text>
|
|
|
|
{/* Animated Progress bar */}
|
|
<View style={styles.progressBarContainer}>
|
|
<Animated.View style={[styles.progressBar, { width: animatedWidth }]} />
|
|
{/* Success/Error segments */}
|
|
<View style={styles.progressSegments}>
|
|
{sensors.map((sensor) => {
|
|
const segmentWidth: DimensionValue = `${100 / sensors.length}%`;
|
|
let backgroundColor = 'transparent';
|
|
if (sensor.status === 'success') backgroundColor = AppColors.success;
|
|
else if (sensor.status === 'error') backgroundColor = AppColors.error;
|
|
else if (sensor.status === 'skipped') backgroundColor = AppColors.warning;
|
|
return (
|
|
<View
|
|
key={sensor.deviceId}
|
|
style={[
|
|
styles.progressSegment,
|
|
{ width: segmentWidth, backgroundColor },
|
|
]}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Stats Row */}
|
|
<View style={styles.statsRow}>
|
|
{completedCount > 0 && (
|
|
<View style={styles.statItem}>
|
|
<Ionicons name="checkmark-circle" size={14} color={AppColors.success} />
|
|
<Text style={[styles.statText, { color: AppColors.success }]}>{completedCount}</Text>
|
|
</View>
|
|
)}
|
|
{failedCount > 0 && (
|
|
<View style={styles.statItem}>
|
|
<Ionicons name="close-circle" size={14} color={AppColors.error} />
|
|
<Text style={[styles.statText, { color: AppColors.error }]}>{failedCount}</Text>
|
|
</View>
|
|
)}
|
|
{skippedCount > 0 && (
|
|
<View style={styles.statItem}>
|
|
<Ionicons name="remove-circle" size={14} color={AppColors.warning} />
|
|
<Text style={[styles.statText, { color: AppColors.warning }]}>{skippedCount}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Sensors List */}
|
|
<ScrollView
|
|
ref={scrollViewRef}
|
|
style={styles.sensorsList}
|
|
contentContainerStyle={styles.sensorsListContent}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{sensors.map((sensor, index) => (
|
|
<SensorCard
|
|
key={sensor.deviceId}
|
|
sensor={sensor}
|
|
isActive={index === currentIndex && !isPaused}
|
|
index={index}
|
|
total={sensors.length}
|
|
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>
|
|
)}
|
|
|
|
{/* Error Action Modal */}
|
|
<ErrorActionModal
|
|
visible={showErrorModal}
|
|
sensor={failedSensor || null}
|
|
onRetry={handleRetryFromModal}
|
|
onSkip={handleSkipFromModal}
|
|
onCancelAll={handleCancelAllFromModal}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
progressHeader: {
|
|
marginBottom: Spacing.lg,
|
|
},
|
|
progressTitleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: Spacing.xs,
|
|
},
|
|
progressTitle: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.textPrimary,
|
|
flex: 1,
|
|
},
|
|
currentSensorBadge: {
|
|
backgroundColor: AppColors.primary,
|
|
paddingHorizontal: Spacing.sm,
|
|
paddingVertical: Spacing.xs,
|
|
borderRadius: BorderRadius.md,
|
|
},
|
|
currentSensorBadgeText: {
|
|
fontSize: FontSizes.xs,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.white,
|
|
},
|
|
progressSubtitle: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textSecondary,
|
|
marginBottom: Spacing.md,
|
|
},
|
|
pausedText: {
|
|
color: AppColors.warning,
|
|
fontWeight: FontWeights.medium,
|
|
},
|
|
progressBarContainer: {
|
|
height: 6,
|
|
backgroundColor: AppColors.border,
|
|
borderRadius: 3,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
},
|
|
progressBar: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
height: '100%',
|
|
backgroundColor: AppColors.primary,
|
|
borderRadius: 3,
|
|
opacity: 0.3,
|
|
},
|
|
progressSegments: {
|
|
flexDirection: 'row',
|
|
height: '100%',
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
},
|
|
progressSegment: {
|
|
height: '100%',
|
|
},
|
|
statsRow: {
|
|
flexDirection: 'row',
|
|
marginTop: Spacing.sm,
|
|
gap: Spacing.md,
|
|
},
|
|
statItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
},
|
|
statText: {
|
|
fontSize: FontSizes.xs,
|
|
fontWeight: FontWeights.medium,
|
|
},
|
|
sensorsList: {
|
|
flex: 1,
|
|
},
|
|
sensorsListContent: {
|
|
gap: Spacing.md,
|
|
paddingBottom: Spacing.lg,
|
|
},
|
|
sensorCard: {
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.lg,
|
|
padding: Spacing.md,
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
...Shadows.xs,
|
|
},
|
|
sensorCardActive: {
|
|
borderWidth: 2,
|
|
borderColor: AppColors.primary,
|
|
},
|
|
sensorCardSuccess: {
|
|
borderWidth: 1,
|
|
borderColor: AppColors.success,
|
|
},
|
|
sensorCardError: {
|
|
borderWidth: 1,
|
|
borderColor: AppColors.error,
|
|
},
|
|
indexBadge: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
right: 0,
|
|
paddingHorizontal: Spacing.sm,
|
|
paddingVertical: 2,
|
|
borderBottomLeftRadius: BorderRadius.md,
|
|
},
|
|
indexBadgeText: {
|
|
fontSize: FontSizes.xs,
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.white,
|
|
},
|
|
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,
|
|
},
|
|
sensorMetaRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: Spacing.sm,
|
|
},
|
|
sensorMeta: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textMuted,
|
|
},
|
|
elapsedTime: {
|
|
fontSize: FontSizes.xs,
|
|
fontWeight: FontWeights.medium,
|
|
color: AppColors.primary,
|
|
},
|
|
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: {
|
|
marginTop: Spacing.sm,
|
|
padding: Spacing.md,
|
|
backgroundColor: AppColors.errorLight,
|
|
borderRadius: BorderRadius.md,
|
|
borderLeftWidth: 3,
|
|
borderLeftColor: AppColors.error,
|
|
},
|
|
errorHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: Spacing.xs,
|
|
marginBottom: Spacing.xs,
|
|
},
|
|
errorTitle: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.error,
|
|
},
|
|
errorText: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textSecondary,
|
|
lineHeight: 18,
|
|
},
|
|
actionButtons: {
|
|
flexDirection: 'row',
|
|
marginTop: Spacing.md,
|
|
gap: Spacing.sm,
|
|
},
|
|
retryButton: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: Spacing.sm,
|
|
paddingHorizontal: Spacing.md,
|
|
backgroundColor: AppColors.primary,
|
|
borderRadius: BorderRadius.md,
|
|
gap: Spacing.xs,
|
|
...Shadows.xs,
|
|
},
|
|
retryText: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.white,
|
|
},
|
|
skipButton: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: Spacing.sm,
|
|
paddingHorizontal: Spacing.md,
|
|
backgroundColor: AppColors.surfaceSecondary,
|
|
borderRadius: BorderRadius.md,
|
|
borderWidth: 1,
|
|
borderColor: AppColors.border,
|
|
gap: Spacing.xs,
|
|
},
|
|
skipText: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: FontWeights.medium,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
cancelAllButton: {
|
|
alignItems: 'center',
|
|
paddingVertical: Spacing.sm,
|
|
marginTop: Spacing.md,
|
|
},
|
|
cancelAllText: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: FontWeights.medium,
|
|
color: AppColors.error,
|
|
},
|
|
});
|