WellNuo/components/SetupResultsScreen.tsx
Sergei 9f9124fdab feat(sensors): Batch sensor setup with progress UI and error handling
- Add updateDeviceMetadata and attachDeviceToDeployment API methods
- Device Settings: editable location/description fields with save
- Equipment screen: location placeholder and quick navigation to settings
- Add Sensor: multi-select with checkboxes, select all/deselect all
- Setup WiFi: batch processing of multiple sensors sequentially
- BatchSetupProgress: animated progress bar, step indicators, auto-scroll
- SetupResultsScreen: success/failed/skipped summary with retry options
- Error handling: modal with Retry/Skip/Cancel All buttons
- Documentation: SENSORS_SYSTEM.md with full BLE protocol and flows

Implemented via Ralphy CLI autonomous agent in ~43 minutes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-19 23:24:22 -08:00

530 lines
16 KiB
TypeScript

import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { SensorSetupState } from '@/types';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
interface SetupResultsScreenProps {
sensors: SensorSetupState[];
onRetry: (deviceId: string) => void;
onDone: () => void;
}
// Format elapsed time as readable string
function formatElapsedTime(startTime?: number, endTime?: number): string {
if (!startTime || !endTime) return '';
const elapsed = Math.floor((endTime - startTime) / 1000);
if (elapsed < 60) return `${elapsed}s`;
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
return `${minutes}m ${seconds}s`;
}
// Get user-friendly error message
function getErrorMessage(error: string | undefined): string {
if (!error) return 'Unknown error';
const lowerError = error.toLowerCase();
if (lowerError.includes('connect') || lowerError.includes('connection')) {
return 'Could not connect via Bluetooth';
}
if (lowerError.includes('wifi') || lowerError.includes('network')) {
return 'WiFi configuration failed';
}
if (lowerError.includes('register') || lowerError.includes('attach') || lowerError.includes('api')) {
return 'Could not register sensor';
}
if (lowerError.includes('timeout') || lowerError.includes('respond')) {
return 'Sensor not responding';
}
if (lowerError.includes('unlock') || lowerError.includes('pin')) {
return 'Could not unlock sensor';
}
return error;
}
export default function SetupResultsScreen({
sensors,
onRetry,
onDone,
}: SetupResultsScreenProps) {
const successSensors = sensors.filter(s => s.status === 'success');
const failedSensors = sensors.filter(s => s.status === 'error');
const skippedSensors = sensors.filter(s => s.status === 'skipped');
const allSuccess = successSensors.length === sensors.length;
const allFailed = failedSensors.length + skippedSensors.length === sensors.length;
const partialSuccess = !allSuccess && !allFailed && successSensors.length > 0;
// Calculate total setup time
const totalTime = sensors.reduce((acc, sensor) => {
if (sensor.startTime && sensor.endTime) {
return acc + (sensor.endTime - sensor.startTime);
}
return acc;
}, 0);
const totalTimeStr = totalTime > 0 ? formatElapsedTime(0, totalTime) : null;
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<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}
showsVerticalScrollIndicator={false}
>
{/* Summary Card */}
<View style={styles.summaryCard}>
<View style={[
styles.summaryIcon,
{ backgroundColor: allSuccess ? AppColors.successLight :
allFailed ? AppColors.errorLight : AppColors.warningLight }
]}>
<Ionicons
name={allSuccess ? 'checkmark-circle' :
allFailed ? 'close-circle' : 'alert-circle'}
size={56}
color={allSuccess ? AppColors.success :
allFailed ? AppColors.error : AppColors.warning}
/>
</View>
<Text style={styles.summaryTitle}>
{allSuccess ? 'All Sensors Connected!' :
allFailed ? 'Setup Failed' :
'Partial Success'}
</Text>
<Text style={styles.summarySubtitle}>
{successSensors.length} of {sensors.length} sensors configured successfully
</Text>
{/* Stats Row */}
<View style={styles.statsRow}>
{successSensors.length > 0 && (
<View style={styles.statItem}>
<View style={[styles.statDot, { backgroundColor: AppColors.success }]} />
<Text style={styles.statText}>{successSensors.length} Success</Text>
</View>
)}
{failedSensors.length > 0 && (
<View style={styles.statItem}>
<View style={[styles.statDot, { backgroundColor: AppColors.error }]} />
<Text style={styles.statText}>{failedSensors.length} Failed</Text>
</View>
)}
{skippedSensors.length > 0 && (
<View style={styles.statItem}>
<View style={[styles.statDot, { backgroundColor: AppColors.warning }]} />
<Text style={styles.statText}>{skippedSensors.length} Skipped</Text>
</View>
)}
</View>
{totalTimeStr && (
<Text style={styles.totalTime}>Total time: {totalTimeStr}</Text>
)}
</View>
{/* Success List */}
{successSensors.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="checkmark-circle" size={18} color={AppColors.success} />
<Text style={styles.sectionTitle}>Successfully Connected</Text>
</View>
{successSensors.map(sensor => (
<View key={sensor.deviceId} style={styles.sensorItem}>
<View style={styles.sensorInfo}>
<View style={[styles.sensorIcon, { backgroundColor: AppColors.successLight }]}>
<Ionicons name="water" size={18} color={AppColors.success} />
</View>
<View style={styles.sensorDetails}>
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
<View style={styles.sensorMeta}>
{sensor.wellId && (
<Text style={styles.sensorMetaText}>Well ID: {sensor.wellId}</Text>
)}
{sensor.startTime && sensor.endTime && (
<Text style={styles.sensorMetaText}>
{formatElapsedTime(sensor.startTime, sensor.endTime)}
</Text>
)}
</View>
</View>
</View>
<Ionicons name="checkmark" size={20} color={AppColors.success} />
</View>
))}
</View>
)}
{/* Failed List */}
{failedSensors.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="close-circle" size={18} color={AppColors.error} />
<Text style={styles.sectionTitle}>Failed</Text>
</View>
{failedSensors.map(sensor => (
<View key={sensor.deviceId} style={styles.sensorItemWithAction}>
<View style={styles.sensorInfo}>
<View style={[styles.sensorIcon, { backgroundColor: AppColors.errorLight }]}>
<Ionicons name="water" size={18} color={AppColors.error} />
</View>
<View style={styles.sensorDetails}>
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
<Text style={styles.errorText}>{getErrorMessage(sensor.error)}</Text>
</View>
</View>
<TouchableOpacity
style={styles.retryButton}
onPress={() => onRetry(sensor.deviceId)}
>
<Ionicons name="refresh" size={16} color={AppColors.primary} />
<Text style={styles.retryButtonText}>Retry</Text>
</TouchableOpacity>
</View>
))}
</View>
)}
{/* Skipped List */}
{skippedSensors.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Ionicons name="remove-circle" size={18} color={AppColors.warning} />
<Text style={styles.sectionTitle}>Skipped</Text>
</View>
{skippedSensors.map(sensor => (
<View key={sensor.deviceId} style={styles.sensorItemWithAction}>
<View style={styles.sensorInfo}>
<View style={[styles.sensorIcon, { backgroundColor: AppColors.warningLight }]}>
<Ionicons name="water" size={18} color={AppColors.warning} />
</View>
<View style={styles.sensorDetails}>
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
<Text style={styles.skippedText}>Skipped by user</Text>
</View>
</View>
<TouchableOpacity
style={styles.retryButton}
onPress={() => onRetry(sensor.deviceId)}
>
<Ionicons name="refresh" size={16} color={AppColors.primary} />
<Text style={styles.retryButtonText}>Retry</Text>
</TouchableOpacity>
</View>
))}
</View>
)}
{/* Help 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 Equipment\n' +
' It may take up to 1 minute for sensors to come online\n' +
' You can configure sensor locations in Device Settings'
) : (
' Return to Equipment and try adding sensors again\n' +
' Make sure sensors are powered on and nearby\n' +
' Check that WiFi password is correct'
)}
</Text>
</View>
</ScrollView>
{/* Bottom Actions */}
<View style={styles.bottomActions}>
{(failedSensors.length > 0 || skippedSensors.length > 0) && (
<TouchableOpacity
style={styles.retryAllButton}
onPress={() => {
[...failedSensors, ...skippedSensors].forEach(s => onRetry(s.deviceId));
}}
>
<Ionicons name="refresh" size={18} color={AppColors.primary} />
<Text style={styles.retryAllButtonText}>
Retry All Failed ({failedSensors.length + skippedSensors.length})
</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.doneButton} onPress={onDone}>
<Text style={styles.doneButtonText}>Done</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
backgroundColor: AppColors.surface,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
placeholder: {
width: 32,
},
content: {
flex: 1,
},
scrollContent: {
padding: Spacing.lg,
paddingBottom: Spacing.xxl,
},
// Summary Card
summaryCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
alignItems: 'center',
marginBottom: Spacing.lg,
...Shadows.sm,
},
summaryIcon: {
width: 100,
height: 100,
borderRadius: 50,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
summaryTitle: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
textAlign: 'center',
},
summarySubtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
marginBottom: Spacing.md,
textAlign: 'center',
},
statsRow: {
flexDirection: 'row',
gap: Spacing.lg,
marginBottom: Spacing.sm,
},
statItem: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
},
statDot: {
width: 8,
height: 8,
borderRadius: 4,
},
statText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
fontWeight: FontWeights.medium,
},
totalTime: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginTop: Spacing.xs,
},
// Sections
section: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.md,
...Shadows.xs,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: Spacing.md,
paddingBottom: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
sectionTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// Sensor Items
sensorItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.borderLight,
},
sensorItemWithAction: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.borderLight,
},
sensorInfo: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
sensorIcon: {
width: 36,
height: 36,
borderRadius: BorderRadius.md,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.sm,
},
sensorDetails: {
flex: 1,
},
sensorName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
marginBottom: 2,
},
sensorMeta: {
flexDirection: 'row',
gap: Spacing.sm,
},
sensorMetaText: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
errorText: {
fontSize: FontSizes.xs,
color: AppColors.error,
},
skippedText: {
fontSize: FontSizes.xs,
color: AppColors.warning,
},
// Buttons
retryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
paddingVertical: Spacing.xs,
paddingHorizontal: Spacing.sm,
backgroundColor: AppColors.primaryLighter,
borderRadius: BorderRadius.md,
},
retryButtonText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.primary,
},
// Help Card
helpCard: {
backgroundColor: AppColors.infoLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginTop: Spacing.sm,
},
helpHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
marginBottom: Spacing.xs,
},
helpTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.info,
},
helpText: {
fontSize: FontSizes.sm,
color: AppColors.info,
lineHeight: 20,
},
// Bottom Actions
bottomActions: {
padding: Spacing.lg,
borderTopWidth: 1,
borderTopColor: AppColors.border,
backgroundColor: AppColors.surface,
gap: Spacing.sm,
},
retryAllButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
borderWidth: 1,
borderColor: AppColors.primary,
backgroundColor: AppColors.primaryLighter,
},
retryAllButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.primary,
},
doneButton: {
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
alignItems: 'center',
...Shadows.md,
},
doneButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
});