From ca820b25fb12ce7a37bbaec6f8b909084cc8dcf0 Mon Sep 17 00:00:00 2001 From: Sergei Date: Mon, 19 Jan 2026 22:55:10 -0800 Subject: [PATCH] Add progress UI enhancements for batch sensor setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/(tabs)/beneficiaries/[id]/setup-wifi.tsx | 7 + components/BatchSetupProgress.tsx | 256 +++++++++++++++++-- 2 files changed, 247 insertions(+), 16 deletions(-) diff --git a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx index 21507a1..61f6e7a 100644 --- a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx +++ b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx @@ -178,6 +178,13 @@ export default function SetupWiFiScreen() { console.log(`[SetupWiFi] [${deviceName}] Starting setup...`); + // 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'); diff --git a/components/BatchSetupProgress.tsx b/components/BatchSetupProgress.tsx index cb0e5eb..5c99b07 100644 --- a/components/BatchSetupProgress.tsx +++ b/components/BatchSetupProgress.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef, useEffect, useState } from 'react'; import { View, Text, @@ -6,6 +6,8 @@ import { ScrollView, TouchableOpacity, ActivityIndicator, + Animated, + DimensionValue, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import type { SensorSetupState, SensorSetupStep } from '@/types'; @@ -28,6 +30,17 @@ interface BatchSetupProgressProps { 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 = { connect: 'Connecting', unlock: 'Unlocking', @@ -79,14 +92,31 @@ function StepIndicator({ step }: { step: SensorSetupStep }) { 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': @@ -118,18 +148,34 @@ function SensorCard({ }; const showActions = sensor.status === 'error' && onRetry && onSkip; + const showProgress = sensor.status !== 'pending' && sensor.status !== 'skipped'; return ( - + + {/* Index Badge */} + + {index + 1}/{total} + + - + {sensor.deviceName} - {sensor.wellId && ( - Well ID: {sensor.wellId} - )} + + {sensor.wellId && ( + Well ID: {sensor.wellId} + )} + {elapsedTime && showProgress && ( + {elapsedTime} + )} + {getStatusIcon()} @@ -177,31 +223,123 @@ export default function BatchSetupProgress({ onSkip, onCancelAll, }: BatchSetupProgressProps) { + const scrollViewRef = useRef(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 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; + // 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 ( {/* Progress Header */} - - Connecting sensors to "{ssid}"... - + + + Connecting to "{ssid}" + + {!isPaused && currentIndex < sensors.length && ( + + + {currentIndex + 1}/{sensors.length} + + + )} + - {totalProcessed} of {sensors.length} complete + {isPaused ? ( + Paused - action required + ) : totalProcessed === sensors.length ? ( + 'Setup complete!' + ) : ( + `Processing sensor ${currentIndex + 1} of ${sensors.length}...` + )} - {/* Progress bar */} + {/* Animated Progress bar */} - + + {/* Success/Error segments */} + + {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 ( + + ); + })} + + + + {/* Stats Row */} + + {completedCount > 0 && ( + + + {completedCount} + + )} + {failedCount > 0 && ( + + + {failedCount} + + )} + {skippedCount > 0 && ( + + + {skippedCount} + + )} {/* Sensors List */} onRetry(sensor.deviceId) : undefined} onSkip={onSkip ? () => onSkip(sensor.deviceId) : undefined} /> @@ -234,27 +374,78 @@ const styles = StyleSheet.create({ 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, - 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: { fontSize: FontSizes.sm, color: AppColors.textSecondary, marginBottom: Spacing.md, }, + pausedText: { + color: AppColors.warning, + fontWeight: FontWeights.medium, + }, progressBarContainer: { - height: 4, + height: 6, backgroundColor: AppColors.border, - borderRadius: 2, + borderRadius: 3, overflow: 'hidden', + position: 'relative', }, progressBar: { + position: 'absolute', + top: 0, + left: 0, height: '100%', 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: { flex: 1, @@ -267,12 +458,35 @@ const styles = StyleSheet.create({ 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', @@ -294,10 +508,20 @@ const styles = StyleSheet.create({ 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, },