WellNuo/components/BatchSetupProgress.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

947 lines
26 KiB
TypeScript

import React, { useRef, useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
Animated,
DimensionValue,
Modal,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { SensorSetupState, SensorSetupStep } from '@/types';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
// User-friendly error messages based on error type
function getErrorMessage(error: string | undefined): { title: string; description: string; hint: string } {
if (!error) {
return {
title: 'Unknown Error',
description: 'Something went wrong.',
hint: 'Try again or skip this sensor.',
};
}
const lowerError = error.toLowerCase();
if (lowerError.includes('connect') || lowerError.includes('connection')) {
return {
title: 'Connection Failed',
description: 'Could not connect to the sensor via Bluetooth.',
hint: 'Move closer to the sensor and ensure it\'s powered on.',
};
}
if (lowerError.includes('unlock') || lowerError.includes('pin')) {
return {
title: 'Unlock Failed',
description: 'Could not unlock the sensor for configuration.',
hint: 'The sensor may need to be reset. Try again.',
};
}
if (lowerError.includes('wifi') || lowerError.includes('network')) {
return {
title: 'WiFi Configuration Failed',
description: 'Could not set up the WiFi connection on the sensor.',
hint: 'Check that the WiFi password is correct.',
};
}
if (lowerError.includes('register') || lowerError.includes('attach') || lowerError.includes('api')) {
return {
title: 'Registration Failed',
description: 'Could not register the sensor with your account.',
hint: 'Check your internet connection and try again.',
};
}
if (lowerError.includes('timeout') || lowerError.includes('respond')) {
return {
title: 'Sensor Not Responding',
description: 'The sensor stopped responding during setup.',
hint: 'Move closer or check if the sensor is still powered on.',
};
}
if (lowerError.includes('reboot')) {
return {
title: 'Reboot Failed',
description: 'Could not restart the sensor.',
hint: 'The sensor may still work. Try checking it in Equipment.',
};
}
return {
title: 'Setup Failed',
description: error,
hint: 'Try again or skip this sensor to continue with others.',
};
}
interface BatchSetupProgressProps {
sensors: SensorSetupState[];
currentIndex: number;
ssid: string;
isPaused: boolean;
onRetry?: (deviceId: string) => void;
onSkip?: (deviceId: string) => void;
onCancelAll?: () => void;
}
// Error Action Modal Component
function ErrorActionModal({
visible,
sensor,
onRetry,
onSkip,
onCancelAll,
}: {
visible: boolean;
sensor: SensorSetupState | null;
onRetry: () => void;
onSkip: () => void;
onCancelAll: () => void;
}) {
if (!sensor) return null;
const errorInfo = getErrorMessage(sensor.error);
return (
<Modal
visible={visible}
transparent
animationType="fade"
statusBarTranslucent
>
<View style={modalStyles.overlay}>
<View style={modalStyles.container}>
{/* Error Icon */}
<View style={modalStyles.iconContainer}>
<Ionicons name="alert-circle" size={48} color={AppColors.error} />
</View>
{/* Error Title */}
<Text style={modalStyles.title}>{errorInfo.title}</Text>
{/* Sensor Name */}
<View style={modalStyles.sensorBadge}>
<Ionicons name="water" size={14} color={AppColors.primary} />
<Text style={modalStyles.sensorName}>{sensor.deviceName}</Text>
</View>
{/* Error Description */}
<Text style={modalStyles.description}>{errorInfo.description}</Text>
{/* Hint */}
<View style={modalStyles.hintContainer}>
<Ionicons name="bulb-outline" size={16} color={AppColors.info} />
<Text style={modalStyles.hintText}>{errorInfo.hint}</Text>
</View>
{/* Action Buttons */}
<View style={modalStyles.actions}>
<TouchableOpacity style={modalStyles.retryButton} onPress={onRetry}>
<Ionicons name="refresh" size={18} color={AppColors.white} />
<Text style={modalStyles.retryText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity style={modalStyles.skipButton} onPress={onSkip}>
<Ionicons name="arrow-forward" size={18} color={AppColors.textPrimary} />
<Text style={modalStyles.skipText}>Skip Sensor</Text>
</TouchableOpacity>
</View>
{/* Cancel All */}
<TouchableOpacity style={modalStyles.cancelAllButton} onPress={onCancelAll}>
<Text style={modalStyles.cancelAllText}>Cancel All Setup</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const modalStyles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: Spacing.lg,
},
container: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
width: '100%',
maxWidth: 340,
alignItems: 'center',
...Shadows.lg,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: AppColors.errorLight,
justifyContent: 'center',
alignItems: 'center',
marginBottom: Spacing.md,
},
title: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
textAlign: 'center',
marginBottom: Spacing.sm,
},
sensorBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primaryLighter,
paddingHorizontal: Spacing.sm,
paddingVertical: Spacing.xs,
borderRadius: BorderRadius.md,
gap: Spacing.xs,
marginBottom: Spacing.md,
},
sensorName: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.primary,
},
description: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginBottom: Spacing.md,
lineHeight: 22,
},
hintContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: AppColors.infoLight,
padding: Spacing.md,
borderRadius: BorderRadius.md,
marginBottom: Spacing.lg,
gap: Spacing.sm,
},
hintText: {
fontSize: FontSizes.sm,
color: AppColors.info,
flex: 1,
lineHeight: 20,
},
actions: {
flexDirection: 'row',
gap: Spacing.md,
marginBottom: Spacing.md,
},
retryButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
...Shadows.sm,
},
retryText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
skipButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.surfaceSecondary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
borderWidth: 1,
borderColor: AppColors.border,
},
skipText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
cancelAllButton: {
paddingVertical: Spacing.sm,
},
cancelAllText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.error,
},
});
// 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> = {
connect: 'Connecting',
unlock: 'Unlocking',
wifi: 'Setting WiFi',
attach: 'Registering',
reboot: 'Rebooting',
};
function StepIndicator({ step }: { step: SensorSetupStep }) {
const getIcon = () => {
switch (step.status) {
case 'completed':
return <Ionicons name="checkmark" size={12} color={AppColors.success} />;
case 'in_progress':
return <ActivityIndicator size={10} color={AppColors.primary} />;
case 'failed':
return <Ionicons name="close" size={12} color={AppColors.error} />;
default:
return <View style={styles.pendingDot} />;
}
};
const getTextColor = () => {
switch (step.status) {
case 'completed':
return AppColors.success;
case 'in_progress':
return AppColors.primary;
case 'failed':
return AppColors.error;
default:
return AppColors.textMuted;
}
};
return (
<View style={styles.stepRow}>
<View style={styles.stepIcon}>{getIcon()}</View>
<Text style={[styles.stepLabel, { color: getTextColor() }]}>
{STEP_LABELS[step.name]}
</Text>
{step.error && (
<Text style={styles.stepError}>{step.error}</Text>
)}
</View>
);
}
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':
return AppColors.success;
case 'error':
return AppColors.error;
case 'skipped':
return AppColors.warning;
case 'pending':
return AppColors.textMuted;
default:
return AppColors.primary;
}
};
const getStatusIcon = () => {
switch (sensor.status) {
case 'success':
return <Ionicons name="checkmark-circle" size={24} color={AppColors.success} />;
case 'error':
return <Ionicons name="close-circle" size={24} color={AppColors.error} />;
case 'skipped':
return <Ionicons name="remove-circle" size={24} color={AppColors.warning} />;
case 'pending':
return <Ionicons name="ellipse-outline" size={24} color={AppColors.textMuted} />;
default:
return <ActivityIndicator size={20} color={AppColors.primary} />;
}
};
const showActions = sensor.status === 'error' && onRetry && onSkip;
const showProgress = sensor.status !== 'pending' && sensor.status !== 'skipped';
return (
<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.sensorIcon, { backgroundColor: `${getStatusColor()}20` }]}>
<Ionicons name="water" size={20} color={getStatusColor()} />
</View>
<View style={styles.sensorInfo}>
<Text style={styles.sensorName}>{sensor.deviceName}</Text>
<View style={styles.sensorMetaRow}>
{sensor.wellId && (
<Text style={styles.sensorMeta}>Well ID: {sensor.wellId}</Text>
)}
{elapsedTime && showProgress && (
<Text style={styles.elapsedTime}>{elapsedTime}</Text>
)}
</View>
</View>
<View style={styles.statusIcon}>{getStatusIcon()}</View>
</View>
{/* Show steps for active or completed sensors */}
{(isActive || sensor.status === 'success' || sensor.status === 'error') && (
<View style={styles.stepsContainer}>
{sensor.steps.map((step, index) => (
<StepIndicator key={step.name} step={step} />
))}
</View>
)}
{/* Error message - enhanced display */}
{sensor.error && (
<View style={styles.errorContainer}>
<View style={styles.errorHeader}>
<Ionicons name="alert-circle" size={18} color={AppColors.error} />
<Text style={styles.errorTitle}>
{getErrorMessage(sensor.error).title}
</Text>
</View>
<Text style={styles.errorText}>
{getErrorMessage(sensor.error).description}
</Text>
</View>
)}
{/* Action buttons for failed sensors - improved styling */}
{showActions && (
<View style={styles.actionButtons}>
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
<Ionicons name="refresh" size={16} color={AppColors.white} />
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={onSkip}>
<Ionicons name="arrow-forward" size={16} color={AppColors.textSecondary} />
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
export default function BatchSetupProgress({
sensors,
currentIndex,
ssid,
isPaused,
onRetry,
onSkip,
onCancelAll,
}: BatchSetupProgressProps) {
const scrollViewRef = useRef<ScrollView>(null);
const sensorCardRefs = useRef<{ [key: string]: View | null }>({});
const progressAnim = useRef(new Animated.Value(0)).current;
const [showErrorModal, setShowErrorModal] = useState(false);
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;
// Find the current failed sensor for the modal
const failedSensor = sensors.find(s => s.status === 'error');
// Show error modal when paused due to error
useEffect(() => {
if (isPaused && failedSensor) {
// Small delay for better UX - let the card error state render first
const timer = setTimeout(() => setShowErrorModal(true), 300);
return () => clearTimeout(timer);
} else {
setShowErrorModal(false);
}
}, [isPaused, failedSensor]);
const handleRetryFromModal = () => {
setShowErrorModal(false);
if (failedSensor && onRetry) {
onRetry(failedSensor.deviceId);
}
};
const handleSkipFromModal = () => {
setShowErrorModal(false);
if (failedSensor && onSkip) {
onSkip(failedSensor.deviceId);
}
};
const handleCancelAllFromModal = () => {
setShowErrorModal(false);
if (onCancelAll) {
onCancelAll();
}
};
// 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 (
<View style={styles.container}>
{/* Progress Header */}
<View style={styles.progressHeader}>
<View style={styles.progressTitleRow}>
<Text style={styles.progressTitle}>
Connecting to "{ssid}"
</Text>
{!isPaused && currentIndex < sensors.length && (
<View style={styles.currentSensorBadge}>
<Text style={styles.currentSensorBadgeText}>
{currentIndex + 1}/{sensors.length}
</Text>
</View>
)}
</View>
<Text style={styles.progressSubtitle}>
{isPaused ? (
<Text style={styles.pausedText}>Paused - action required</Text>
) : totalProcessed === sensors.length ? (
'Setup complete!'
) : (
`Processing sensor ${currentIndex + 1} of ${sensors.length}...`
)}
</Text>
{/* Animated Progress bar */}
<View style={styles.progressBarContainer}>
<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>
{/* Sensors List */}
<ScrollView
ref={scrollViewRef}
style={styles.sensorsList}
contentContainerStyle={styles.sensorsListContent}
showsVerticalScrollIndicator={false}
>
{sensors.map((sensor, index) => (
<SensorCard
key={sensor.deviceId}
sensor={sensor}
isActive={index === currentIndex && !isPaused}
index={index}
total={sensors.length}
onRetry={onRetry ? () => onRetry(sensor.deviceId) : undefined}
onSkip={onSkip ? () => onSkip(sensor.deviceId) : undefined}
/>
))}
</ScrollView>
{/* Cancel button */}
{onCancelAll && (
<TouchableOpacity style={styles.cancelAllButton} onPress={onCancelAll}>
<Text style={styles.cancelAllText}>Cancel Setup</Text>
</TouchableOpacity>
)}
{/* Error Action Modal */}
<ErrorActionModal
visible={showErrorModal}
sensor={failedSensor || null}
onRetry={handleRetryFromModal}
onSkip={handleSkipFromModal}
onCancelAll={handleCancelAllFromModal}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
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,
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: 6,
backgroundColor: AppColors.border,
borderRadius: 3,
overflow: 'hidden',
position: 'relative',
},
progressBar: {
position: 'absolute',
top: 0,
left: 0,
height: '100%',
backgroundColor: AppColors.primary,
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,
},
sensorsListContent: {
gap: Spacing.md,
paddingBottom: Spacing.lg,
},
sensorCard: {
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',
},
sensorIcon: {
width: 40,
height: 40,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.sm,
},
sensorInfo: {
flex: 1,
},
sensorName: {
fontSize: FontSizes.base,
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,
},
stepsContainer: {
marginTop: Spacing.md,
paddingTop: Spacing.sm,
borderTopWidth: 1,
borderTopColor: AppColors.border,
gap: Spacing.xs,
},
stepRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
stepIcon: {
width: 16,
height: 16,
justifyContent: 'center',
alignItems: 'center',
},
pendingDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: AppColors.textMuted,
},
stepLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
},
stepError: {
fontSize: FontSizes.xs,
color: AppColors.error,
flex: 1,
},
errorContainer: {
marginTop: Spacing.sm,
padding: Spacing.md,
backgroundColor: AppColors.errorLight,
borderRadius: BorderRadius.md,
borderLeftWidth: 3,
borderLeftColor: AppColors.error,
},
errorHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
marginBottom: Spacing.xs,
},
errorTitle: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.error,
},
errorText: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
lineHeight: 18,
},
actionButtons: {
flexDirection: 'row',
marginTop: Spacing.md,
gap: Spacing.sm,
},
retryButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.primary,
borderRadius: BorderRadius.md,
gap: Spacing.xs,
...Shadows.xs,
},
retryText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
skipButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.surfaceSecondary,
borderRadius: BorderRadius.md,
borderWidth: 1,
borderColor: AppColors.border,
gap: Spacing.xs,
},
skipText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textSecondary,
},
cancelAllButton: {
alignItems: 'center',
paddingVertical: Spacing.sm,
marginTop: Spacing.md,
},
cancelAllText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.error,
},
});