Add progress UI enhancements for batch sensor setup

- Add sensor index badge (1/5, 2/5...) on each card
- Add elapsed time display for processing sensors
- Add auto-scroll to current active sensor
- Add animated progress bar with success/error segments
- Add stats row showing success/error/skipped counts
- Improve visual feedback during batch WiFi setup

🤖 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:55:10 -08:00
parent be1c2eb7f5
commit ca820b25fb
2 changed files with 247 additions and 16 deletions

View File

@ -178,6 +178,13 @@ export default function SetupWiFiScreen() {
console.log(`[SetupWiFi] [${deviceName}] Starting setup...`); console.log(`[SetupWiFi] [${deviceName}] Starting setup...`);
// Set start time
setSensors(prev => prev.map(s =>
s.deviceId === deviceId
? { ...s, startTime: Date.now() }
: s
));
try { try {
// Step 1: Connect // Step 1: Connect
updateSensorStep(deviceId, 'connect', 'in_progress'); updateSensorStep(deviceId, 'connect', 'in_progress');

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useRef, useEffect, useState } from 'react';
import { import {
View, View,
Text, Text,
@ -6,6 +6,8 @@ import {
ScrollView, ScrollView,
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
Animated,
DimensionValue,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import type { SensorSetupState, SensorSetupStep } from '@/types'; import type { SensorSetupState, SensorSetupStep } from '@/types';
@ -28,6 +30,17 @@ interface BatchSetupProgressProps {
onCancelAll?: () => void; onCancelAll?: () => void;
} }
// 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> = { const STEP_LABELS: Record<SensorSetupStep['name'], string> = {
connect: 'Connecting', connect: 'Connecting',
unlock: 'Unlocking', unlock: 'Unlocking',
@ -79,14 +92,31 @@ function StepIndicator({ step }: { step: SensorSetupStep }) {
function SensorCard({ function SensorCard({
sensor, sensor,
isActive, isActive,
index,
total,
onRetry, onRetry,
onSkip, onSkip,
}: { }: {
sensor: SensorSetupState; sensor: SensorSetupState;
isActive: boolean; isActive: boolean;
index: number;
total: number;
onRetry?: () => void; onRetry?: () => void;
onSkip?: () => 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 = () => { const getStatusColor = () => {
switch (sensor.status) { switch (sensor.status) {
case 'success': case 'success':
@ -118,18 +148,34 @@ function SensorCard({
}; };
const showActions = sensor.status === 'error' && onRetry && onSkip; const showActions = sensor.status === 'error' && onRetry && onSkip;
const showProgress = sensor.status !== 'pending' && sensor.status !== 'skipped';
return ( return (
<View style={[styles.sensorCard, isActive && styles.sensorCardActive]}> <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.sensorHeader}>
<View style={styles.sensorIcon}> <View style={[styles.sensorIcon, { backgroundColor: `${getStatusColor()}20` }]}>
<Ionicons name="water" size={20} color={getStatusColor()} /> <Ionicons name="water" size={20} color={getStatusColor()} />
</View> </View>
<View style={styles.sensorInfo}> <View style={styles.sensorInfo}>
<Text style={styles.sensorName}>{sensor.deviceName}</Text> <Text style={styles.sensorName}>{sensor.deviceName}</Text>
<View style={styles.sensorMetaRow}>
{sensor.wellId && ( {sensor.wellId && (
<Text style={styles.sensorMeta}>Well ID: {sensor.wellId}</Text> <Text style={styles.sensorMeta}>Well ID: {sensor.wellId}</Text>
)} )}
{elapsedTime && showProgress && (
<Text style={styles.elapsedTime}>{elapsedTime}</Text>
)}
</View>
</View> </View>
<View style={styles.statusIcon}>{getStatusIcon()}</View> <View style={styles.statusIcon}>{getStatusIcon()}</View>
</View> </View>
@ -177,31 +223,123 @@ export default function BatchSetupProgress({
onSkip, onSkip,
onCancelAll, onCancelAll,
}: BatchSetupProgressProps) { }: BatchSetupProgressProps) {
const scrollViewRef = useRef<ScrollView>(null);
const sensorCardRefs = useRef<{ [key: string]: View | null }>({});
const progressAnim = useRef(new Animated.Value(0)).current;
const completedCount = sensors.filter(s => s.status === 'success').length; const completedCount = sensors.filter(s => s.status === 'success').length;
const failedCount = sensors.filter(s => s.status === 'error').length; const failedCount = sensors.filter(s => s.status === 'error').length;
const skippedCount = sensors.filter(s => s.status === 'skipped').length; const skippedCount = sensors.filter(s => s.status === 'skipped').length;
const totalProcessed = completedCount + failedCount + skippedCount; const totalProcessed = completedCount + failedCount + skippedCount;
const progress = (totalProcessed / sensors.length) * 100; const progress = (totalProcessed / sensors.length) * 100;
// 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 ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Progress Header */} {/* Progress Header */}
<View style={styles.progressHeader}> <View style={styles.progressHeader}>
<View style={styles.progressTitleRow}>
<Text style={styles.progressTitle}> <Text style={styles.progressTitle}>
Connecting sensors to "{ssid}"... Connecting to "{ssid}"
</Text> </Text>
{!isPaused && currentIndex < sensors.length && (
<View style={styles.currentSensorBadge}>
<Text style={styles.currentSensorBadgeText}>
{currentIndex + 1}/{sensors.length}
</Text>
</View>
)}
</View>
<Text style={styles.progressSubtitle}> <Text style={styles.progressSubtitle}>
{totalProcessed} of {sensors.length} complete {isPaused ? (
<Text style={styles.pausedText}>Paused - action required</Text>
) : totalProcessed === sensors.length ? (
'Setup complete!'
) : (
`Processing sensor ${currentIndex + 1} of ${sensors.length}...`
)}
</Text> </Text>
{/* Progress bar */} {/* Animated Progress bar */}
<View style={styles.progressBarContainer}> <View style={styles.progressBarContainer}>
<View style={[styles.progressBar, { width: `${progress}%` }]} /> <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>
</View> </View>
{/* Sensors List */} {/* Sensors List */}
<ScrollView <ScrollView
ref={scrollViewRef}
style={styles.sensorsList} style={styles.sensorsList}
contentContainerStyle={styles.sensorsListContent} contentContainerStyle={styles.sensorsListContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@ -211,6 +349,8 @@ export default function BatchSetupProgress({
key={sensor.deviceId} key={sensor.deviceId}
sensor={sensor} sensor={sensor}
isActive={index === currentIndex && !isPaused} isActive={index === currentIndex && !isPaused}
index={index}
total={sensors.length}
onRetry={onRetry ? () => onRetry(sensor.deviceId) : undefined} onRetry={onRetry ? () => onRetry(sensor.deviceId) : undefined}
onSkip={onSkip ? () => onSkip(sensor.deviceId) : undefined} onSkip={onSkip ? () => onSkip(sensor.deviceId) : undefined}
/> />
@ -234,27 +374,78 @@ const styles = StyleSheet.create({
progressHeader: { progressHeader: {
marginBottom: Spacing.lg, marginBottom: Spacing.lg,
}, },
progressTitleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: Spacing.xs,
},
progressTitle: { progressTitle: {
fontSize: FontSizes.lg, fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold, fontWeight: FontWeights.semibold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
marginBottom: Spacing.xs, 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: { progressSubtitle: {
fontSize: FontSizes.sm, fontSize: FontSizes.sm,
color: AppColors.textSecondary, color: AppColors.textSecondary,
marginBottom: Spacing.md, marginBottom: Spacing.md,
}, },
pausedText: {
color: AppColors.warning,
fontWeight: FontWeights.medium,
},
progressBarContainer: { progressBarContainer: {
height: 4, height: 6,
backgroundColor: AppColors.border, backgroundColor: AppColors.border,
borderRadius: 2, borderRadius: 3,
overflow: 'hidden', overflow: 'hidden',
position: 'relative',
}, },
progressBar: { progressBar: {
position: 'absolute',
top: 0,
left: 0,
height: '100%', height: '100%',
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
borderRadius: 2, 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: { sensorsList: {
flex: 1, flex: 1,
@ -267,12 +458,35 @@ const styles = StyleSheet.create({
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg, borderRadius: BorderRadius.lg,
padding: Spacing.md, padding: Spacing.md,
position: 'relative',
overflow: 'hidden',
...Shadows.xs, ...Shadows.xs,
}, },
sensorCardActive: { sensorCardActive: {
borderWidth: 2, borderWidth: 2,
borderColor: AppColors.primary, 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: { sensorHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -294,10 +508,20 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold, fontWeight: FontWeights.semibold,
color: AppColors.textPrimary, color: AppColors.textPrimary,
}, },
sensorMetaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
sensorMeta: { sensorMeta: {
fontSize: FontSizes.xs, fontSize: FontSizes.xs,
color: AppColors.textMuted, color: AppColors.textMuted,
}, },
elapsedTime: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
statusIcon: { statusIcon: {
marginLeft: Spacing.sm, marginLeft: Spacing.sm,
}, },