- 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>
530 lines
16 KiB
TypeScript
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,
|
|
},
|
|
});
|