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:
parent
be1c2eb7f5
commit
ca820b25fb
@ -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');
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user