Files
Schnappix/src/screens/CameraScreen.tsx
T

897 lines
26 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
StyleSheet,
Text,
View,
TouchableOpacity,
Platform,
Modal,
TextInput,
TouchableWithoutFeedback,
} from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as FileSystem from 'expo-file-system';
import { File, Paths } from 'expo-file-system/next';
import * as ImageManipulator from 'expo-image-manipulator';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
FadeIn,
FadeOut,
withSequence,
Easing,
runOnJS,
} from 'react-native-reanimated';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import {
faUserGear,
faCameraRetro,
faCamera,
faXmark,
} from '@fortawesome/free-solid-svg-icons';
import { THEME } from '../styles/theme';
import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera';
import { saveToGallery } from '../services/storage';
import FrameOverlay from '../components/FrameOverlay';
import { FRAMES } from '../data/frames';
import { logger } from '../services/logger';
interface CameraScreenProps {
countdownDuration: number;
onPhotoCaptured: (uri: string) => void;
onBurstComplete: (uris: string[]) => void;
onCancel: () => void;
isIdle?: boolean;
onStartBooth?: () => void;
onNavigateToAdmin?: () => void;
adminPassword?: string;
welcomeText?: string;
burstCount?: number;
burstIntervalSec?: number;
frameMode?: 'off' | 'always' | 'available';
selectedFrameId?: string | null;
}
export default function CameraScreen({
countdownDuration,
onPhotoCaptured,
onBurstComplete,
onCancel,
isIdle = false,
onStartBooth,
onNavigateToAdmin,
adminPassword = '1234',
welcomeText = 'Willkommen zu unserer Feier!',
burstCount = 1,
burstIntervalSec = 5,
frameMode = 'off',
selectedFrameId = null,
}: CameraScreenProps) {
const [permission, requestPermission] = useCameraPermissions();
const [isUsbConnected, setIsUsbConnected] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number | string>('');
const [isCapturing, setIsCapturing] = useState<boolean>(false);
const [hasStarted, setHasStarted] = useState<boolean>(false);
const [pictureSize, setPictureSize] = useState<string | undefined>(undefined);
// Burst mode state
const [burstIndex, setBurstIndex] = useState<number>(0);
const [burstUris, setBurstUris] = useState<string[]>([]);
const [showBurstIndicator, setShowBurstIndicator] = useState<string>('');
const [isBurstWaiting, setIsBurstWaiting] = useState<boolean>(false);
// Flash effect
const flashOpacity = useSharedValue(0);
// Admin Modal States
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [enteredPassword, setEnteredPassword] = useState('');
const [errorText, setErrorText] = useState('');
const usbCameraRef = useRef<UsbCameraRef>(null);
const expoCameraRef = useRef<any>(null);
const burstTimerRef = useRef<any>(null);
const countdownTimerRef = useRef<any>(null);
const isCancelledRef = useRef<boolean>(false);
// Reanimated countdown pulse animation
const scale = useSharedValue(1);
useEffect(() => {
if (countdown !== '') {
scale.value = 0.5;
scale.value = withSpring(1.25, { damping: 10, stiffness: 120 }, () => {
scale.value = withTiming(1.0, { duration: 150 });
});
}
}, [countdown]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const flashAnimatedStyle = useAnimatedStyle(() => ({
opacity: flashOpacity.value,
}));
// Trigger white flash effect
const triggerFlash = useCallback(() => {
flashOpacity.value = withSequence(
withTiming(1, { duration: 80 }),
withTiming(0, { duration: 400 })
);
}, []);
// 1. Request built-in camera permission on mount (just in case we fallback)
useEffect(() => {
if (!permission || !permission.granted) {
requestPermission();
}
}, [permission]);
// 2. Check if a USB camera is connected.
useEffect(() => {
let checkInterval: any;
if (Platform.OS === 'android') {
const checkConnection = async () => {
try {
if (usbCameraRef.current) {
const connected = await usbCameraRef.current.isCameraConnected();
setIsUsbConnected(connected);
}
} catch (e) {
setIsUsbConnected(false);
}
};
checkConnection();
checkInterval = setInterval(checkConnection, 2000);
}
return () => {
if (checkInterval) clearInterval(checkInterval);
};
}, []);
// Cleanup burst timer on unmount
useEffect(() => {
return () => {
if (burstTimerRef.current) clearTimeout(burstTimerRef.current);
if (countdownTimerRef.current) clearTimeout(countdownTimerRef.current);
};
}, []);
// Set safe picture size to avoid OOM
const handleCameraReady = useCallback(async () => {
if (expoCameraRef.current) {
try {
const sizes = await expoCameraRef.current.getAvailablePictureSizes();
if (sizes && sizes.length > 0) {
console.log('Available camera sizes:', sizes);
const parsedSizes = sizes.map((s: string) => {
const parts = s.split('x');
if (parts.length === 2) {
const w = parseInt(parts[0], 10);
const h = parseInt(parts[1], 10);
return { size: s, pixels: w * h, w, h };
}
return { size: s, pixels: 0, w: 0, h: 0 };
}).filter((s: { pixels: number }) => s.pixels > 0);
parsedSizes.sort((a, b) => a.pixels - b.pixels);
// Find the smallest size that has at least 1920 width or height (approx 2MP)
const optimal = parsedSizes.find((s: { w: number; h: number }) => Math.max(s.w, s.h) >= 1920);
if (optimal) {
console.log('Setting optimal pictureSize to prevent OOM:', optimal.size);
setPictureSize(optimal.size);
} else if (parsedSizes.length > 0) {
setPictureSize(parsedSizes[parsedSizes.length - 1].size);
} else {
setPictureSize('fallback');
}
} else {
setPictureSize('fallback');
}
} catch (e) {
console.warn('Failed to fetch picture sizes:', e);
setPictureSize('fallback');
}
}
}, []);
// Cancel capture process
const handleLocalCancel = async () => {
isCancelledRef.current = true;
if (burstTimerRef.current) clearTimeout(burstTimerRef.current);
if (countdownTimerRef.current) clearTimeout(countdownTimerRef.current);
// Clean up any temp files from a partially completed burst
for (const uri of burstUris) {
try {
await FileSystem.deleteAsync(uri, { idempotent: true });
} catch (e) {
console.warn('Failed to delete temp file on cancel:', e);
}
}
setCountdown(countdownDuration);
setIsCapturing(false);
setIsBurstWaiting(false);
setHasStarted(false);
setBurstIndex(0);
setBurstUris([]);
setShowBurstIndicator('');
onCancel();
};
// 3. Start the countdown on screen load (only when NOT idle)
useEffect(() => {
if (isIdle) {
setHasStarted(false);
setBurstIndex(0);
setBurstUris([]);
return;
}
// Delay start until safe picture size is negotiated (or fallback is set)
// Only applies to built-in camera, USB camera ignores pictureSize
if (pictureSize === undefined && !isUsbConnected) {
console.log('Waiting for pictureSize negotiation...');
return;
}
startCountdown();
return () => {};
}, [countdownDuration, isIdle, pictureSize, isUsbConnected]);
const startCountdown = () => {
isCancelledRef.current = false;
let count = countdownDuration;
setCountdown(count);
setHasStarted(true);
setIsCapturing(false);
setShowBurstIndicator('');
const runTimer = () => {
if (count > 1) {
count -= 1;
setCountdown(count);
countdownTimerRef.current = setTimeout(runTimer, 1000);
} else if (count === 1) {
setCountdown('smile');
setIsCapturing(true);
countdownTimerRef.current = setTimeout(() => {
capture();
}, 800);
}
};
countdownTimerRef.current = setTimeout(runTimer, 1000);
};
// 4. Capture photo function
const capture = async () => {
const filename = `photo_${Date.now()}.jpg`;
const tempFile = new File(Paths.cache, filename);
const tempUri = tempFile.uri;
try {
triggerFlash();
let capturedUri: string;
let rawWidth: number = 0;
let rawHeight: number = 0;
if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) {
// USB Camera Capture
console.log('Capturing from USB Camera...');
const path = await usbCameraRef.current.takePicture(tempUri);
capturedUri = path;
} else {
// Fallback Camera Capture
logger.log(`Capturing from built-in camera fallback. Size constraint: ${pictureSize || 'default'}`);
console.log('Capturing from built-in camera fallback...');
if (expoCameraRef.current) {
const photo = await expoCameraRef.current.takePictureAsync({
quality: 0.7, // Lower quality slightly to reduce file size
skipProcessing: Platform.OS === 'android', // Prevent memory crash on Android
});
capturedUri = photo.uri;
} else {
throw new Error('Kamera-Referenz ist nicht verfügbar.');
}
}
// If user cancelled while the camera promise was processing, abort before side-effects
if (isCancelledRef.current) {
console.log('Capture cancelled by user during processing.');
return;
}
// Instead of manipulating the image and risking an OOM or BitmapFactory crash,
// we directly use the captured photo and rely on React Native's Image component
// to safely downsample it during rendering via resizeMethod="resize".
console.log('Photo captured successfully, proceeding to PreviewScreen.');
// Auto-save individual photo to gallery
try {
await saveToGallery(capturedUri);
console.log('Auto-saved capture to gallery');
} catch (e) {
console.error('Failed to auto-save capture:', e);
}
// Handle burst mode
const effectiveBurst = burstCount || 1;
const newIndex = burstIndex + 1;
const newUris = [...burstUris, capturedUri];
if (effectiveBurst > 1 && newIndex < effectiveBurst) {
// More burst photos to take
setBurstIndex(newIndex);
setBurstUris(newUris);
setShowBurstIndicator(`Foto ${newIndex} von ${effectiveBurst} ✓`);
setIsCapturing(false);
setIsBurstWaiting(true);
// Wait burstIntervalSec then start next countdown
burstTimerRef.current = setTimeout(() => {
setIsBurstWaiting(false);
startCountdown();
}, burstIntervalSec * 1000);
} else if (effectiveBurst > 1) {
// Final burst photo
setBurstIndex(0);
setBurstUris([]);
// Short delay prevents Android expo-camera crash on immediate unmount
setTimeout(() => {
onBurstComplete(newUris);
}, 400);
} else {
// Single shot mode
setTimeout(() => {
onPhotoCaptured(capturedUri);
}, 400);
}
} catch (error: any) {
console.error('Capture failed:', error);
alert('Fehler beim Aufnehmen des Fotos: ' + error.message);
onCancel();
}
};
const handleSettingsTap = () => {
setEnteredPassword('');
setErrorText('');
setPasswordModalVisible(true);
};
const handlePasswordSubmit = () => {
if (enteredPassword === adminPassword) {
setPasswordModalVisible(false);
setTimeout(() => {
onNavigateToAdmin?.();
}, 350); // Delay navigation on iOS to avoid unmounting a view with an open modal
} else {
setErrorText('Falsches Passwort');
setEnteredPassword('');
}
};
if (!permission) {
return (
<View style={styles.container}>
<Text style={styles.infoText}>Berechtigungen werden geladen...</Text>
</View>
);
}
if (!permission.granted && !isUsbConnected) {
return (
<View style={styles.container}>
<Text style={styles.infoText}>Wir benötigen deine Erlaubnis, um die Kamera anzuzeigen</Text>
<TouchableOpacity style={styles.btn} onPress={requestPermission}>
<Text style={styles.btnText}>Erlaubnis erteilen</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, styles.cancelBtn]} onPress={handleLocalCancel}>
<Text style={styles.btnText}>Zurück</Text>
</TouchableOpacity>
</View>
);
}
const renderContent = () => {
if (isIdle) {
// Home Overlay (frosty glass box on top of the camera stream)
return (
<TouchableWithoutFeedback onPress={onStartBooth}>
<View style={styles.idleOverlayContainer}>
{/* Settings button in the top-right corner */}
<TouchableOpacity
style={styles.settingsButton}
onPress={handleSettingsTap}
activeOpacity={0.7}
>
<FontAwesomeIcon icon={faUserGear} size={22} color={THEME.colors.accent} />
</TouchableOpacity>
{/* Header Event Text */}
<Text style={styles.headerTitle}>{welcomeText}</Text>
{/* Footer Instructions */}
<Text style={styles.footerInstructions}>
ZUM STARTEN TIPPEN Fotos machen Collage Drucken
</Text>
{/* Password Prompt Modal */}
<Modal
visible={passwordModalVisible}
transparent={true}
animationType="fade"
onRequestClose={() => setPasswordModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Admin-Zugang</Text>
<Text style={styles.modalSub}>Passwort eingeben, um Einstellungen zu öffnen</Text>
<TextInput
style={styles.input}
secureTextEntry
placeholder="Passwort"
placeholderTextColor={THEME.colors.textMuted}
value={enteredPassword}
onChangeText={setEnteredPassword}
keyboardType="numeric"
autoFocus
/>
{errorText ? <Text style={styles.error}>{errorText}</Text> : null}
<View style={styles.modalButtons}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={() => setPasswordModalVisible(false)}
>
<Text style={styles.buttonText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.confirmButton]}
onPress={handlePasswordSubmit}
>
<LinearGradient
colors={THEME.gradient.primary}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={styles.confirmGradient}
>
<Text style={styles.buttonText}>Öffnen</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
</TouchableWithoutFeedback>
);
}
// Capture Mode overlays (Countdown, Burst indicator, and Cancel button)
const activeFrame = selectedFrameId && burstCount === 1
? FRAMES.find((f) => f.id === selectedFrameId)
: null;
return (
<View style={styles.captureOverlayContainer} pointerEvents="box-none">
{/* Frame Overlay */}
{frameMode === 'always' && activeFrame && (
<View style={styles.cameraWrapper} pointerEvents="none">
<FrameOverlay frameAsset={activeFrame.asset} />
</View>
)}
{/* Source indicator */}
<View style={styles.hudContainer}>
<Text style={styles.cameraSourceIndicator}>
Kamera: {isUsbConnected ? 'Externe USB-Kamera' : 'Tablet-Frontkamera (Fallback)'}
</Text>
</View>
{/* Burst progress indicator */}
{(burstCount || 1) > 1 && (
<View style={styles.burstBadge}>
<FontAwesomeIcon icon={faCameraRetro} size={12} color={THEME.colors.accent} />
<Text style={styles.burstBadgeText}>
Foto {burstIndex + 1} / {burstCount}
</Text>
</View>
)}
{/* Burst waiting indicator */}
{isBurstWaiting && showBurstIndicator !== '' && (
<View style={styles.countdownOverlay} pointerEvents="none">
<View style={styles.burstWaitingBox}>
<Text style={styles.burstWaitingText}>{showBurstIndicator}</Text>
<Text style={styles.burstWaitingSub}>Nächstes Foto in {burstIntervalSec}s</Text>
</View>
</View>
)}
{/* Countdown overlay */}
{hasStarted && !isBurstWaiting && (
<View style={styles.countdownOverlay} pointerEvents="none">
<Animated.View style={[styles.countdownBox, animatedStyle]}>
{countdown === 'smile' ? (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<Text style={styles.countdownText}>Bitte lächeln!</Text>
<FontAwesomeIcon icon={faCamera} size={48} color={THEME.colors.accent} />
</View>
) : (
<Text style={styles.countdownText}>{countdown}</Text>
)}
</Animated.View>
</View>
)}
{/* Cancel Button */}
{!isCapturing && !isBurstWaiting && (
<TouchableOpacity style={styles.backButton} onPress={handleLocalCancel}>
<FontAwesomeIcon icon={faXmark} size={16} color={THEME.colors.text} style={{ marginRight: 6 }} />
<Text style={styles.backButtonText}>ABBRECHEN</Text>
</TouchableOpacity>
)}
</View>
);
};
return (
<Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}>
{/* Camera Preview Background */}
<View style={styles.previewContainer}>
<View style={styles.cameraWrapper}>
{Platform.OS === 'android' && isUsbConnected ? (
<UsbCameraView ref={usbCameraRef} style={styles.cameraPreview} />
) : (
<CameraView
ref={expoCameraRef}
style={styles.cameraPreview}
facing="front"
pictureSize={pictureSize === 'fallback' ? undefined : pictureSize}
onCameraReady={handleCameraReady}
/>
)}
</View>
</View>
{/* Render UI controls/overlays on top */}
{renderContent()}
{/* White Flash Overlay */}
<Animated.View
style={[styles.flashOverlay, flashAnimatedStyle]}
pointerEvents="none"
/>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
previewContainer: {
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
},
cameraWrapper: {
height: '100%',
aspectRatio: 3 / 2,
},
cameraPreview: {
width: '100%',
height: '100%',
},
flashOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#FFFFFF',
zIndex: 9999,
},
idleOverlayContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'transparent',
},
captureOverlayContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
settingsButton: {
position: 'absolute',
top: 24,
right: 24,
width: 50,
height: 50,
borderRadius: THEME.borderRadius.round,
backgroundColor: 'rgba(13, 13, 26, 0.8)',
borderWidth: 1,
borderColor: THEME.colors.gradientBorder,
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
},
headerTitle: {
position: 'absolute',
top: 40,
width: '100%',
textAlign: 'center',
fontSize: 48,
fontWeight: '900',
color: THEME.colors.accent,
letterSpacing: 6,
textShadowColor: THEME.colors.cyanGlow,
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 25,
},
footerInstructions: {
position: 'absolute',
bottom: 40,
width: '100%',
textAlign: 'center',
fontSize: 20,
fontWeight: 'bold',
color: THEME.colors.text,
letterSpacing: 2,
textShadowColor: 'rgba(0,0,0,0.9)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 15,
},
hudContainer: {
position: 'absolute',
bottom: 20,
backgroundColor: 'rgba(5, 5, 16, 0.8)',
paddingVertical: THEME.spacing.xs,
paddingHorizontal: THEME.spacing.md,
borderRadius: THEME.borderRadius.sm,
borderWidth: 0.5,
borderColor: THEME.colors.gradientBorder,
},
cameraSourceIndicator: {
color: THEME.colors.text,
fontSize: 14,
fontWeight: 'bold',
},
burstBadge: {
position: 'absolute',
top: 24,
right: 24,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: 'rgba(5, 5, 16, 0.85)',
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: THEME.borderRadius.round,
borderWidth: 1,
borderColor: THEME.colors.accent,
},
burstBadgeText: {
color: THEME.colors.accent,
fontSize: 14,
fontWeight: 'bold',
},
burstWaitingBox: {
backgroundColor: 'rgba(5, 5, 16, 0.9)',
paddingVertical: THEME.spacing.xl,
paddingHorizontal: THEME.spacing.xxl,
borderRadius: THEME.borderRadius.lg,
borderWidth: 1,
borderColor: THEME.colors.accent,
alignItems: 'center',
shadowColor: THEME.colors.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 20,
elevation: 8,
},
burstWaitingText: {
fontSize: 28,
fontWeight: '900',
color: THEME.colors.accent,
textAlign: 'center',
},
burstWaitingSub: {
fontSize: 16,
color: THEME.colors.textMuted,
marginTop: THEME.spacing.sm,
},
countdownOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
},
countdownBox: {
backgroundColor: 'rgba(5, 5, 16, 0.88)',
paddingVertical: THEME.spacing.xl,
paddingHorizontal: THEME.spacing.xxl,
borderRadius: THEME.borderRadius.lg,
borderWidth: 1,
borderColor: THEME.colors.gradientBorder,
minWidth: 180,
alignItems: 'center',
justifyContent: 'center',
shadowColor: THEME.colors.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 20,
elevation: 8,
},
countdownText: {
fontSize: 64,
fontWeight: '900',
color: THEME.colors.text,
textAlign: 'center',
textShadowColor: THEME.colors.cyanGlow,
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 15,
},
backButton: {
position: 'absolute',
top: 24,
left: 24,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(13, 13, 26, 0.8)',
paddingVertical: THEME.spacing.sm,
paddingHorizontal: THEME.spacing.lg,
borderRadius: THEME.borderRadius.round,
borderWidth: 1,
borderColor: THEME.colors.gradientBorder,
},
backButtonText: {
color: THEME.colors.text,
fontSize: 16,
fontWeight: 'bold',
letterSpacing: 1,
},
infoText: {
color: THEME.colors.text,
fontSize: 18,
textAlign: 'center',
marginBottom: THEME.spacing.lg,
paddingHorizontal: THEME.spacing.xl,
},
btn: {
backgroundColor: THEME.colors.accent,
paddingVertical: THEME.spacing.md,
paddingHorizontal: THEME.spacing.xl,
borderRadius: THEME.borderRadius.round,
marginBottom: THEME.spacing.md,
shadowColor: THEME.colors.accent,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 10,
elevation: 6,
},
cancelBtn: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: THEME.colors.border,
},
btnText: {
color: THEME.colors.text,
fontSize: 16,
fontWeight: 'bold',
},
modalOverlay: {
flex: 1,
backgroundColor: THEME.colors.overlay,
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
width: 340,
padding: THEME.spacing.lg,
borderRadius: THEME.borderRadius.md,
backgroundColor: THEME.colors.surfaceSecondary,
borderWidth: 1,
borderColor: THEME.colors.border,
alignItems: 'center',
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 20,
elevation: 10,
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: THEME.colors.text,
marginBottom: THEME.spacing.xs,
},
modalSub: {
fontSize: 13,
color: THEME.colors.textMuted,
marginBottom: THEME.spacing.md,
textAlign: 'center',
},
input: {
width: '100%',
height: 50,
backgroundColor: THEME.colors.surface,
borderColor: THEME.colors.border,
borderWidth: 1,
borderRadius: THEME.borderRadius.sm,
color: THEME.colors.text,
paddingHorizontal: THEME.spacing.md,
fontSize: 16,
textAlign: 'center',
marginBottom: THEME.spacing.sm,
},
error: {
color: THEME.colors.error,
fontSize: 14,
marginBottom: THEME.spacing.sm,
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
marginTop: THEME.spacing.sm,
},
button: {
flex: 1,
height: 45,
borderRadius: THEME.borderRadius.sm,
justifyContent: 'center',
alignItems: 'center',
marginHorizontal: THEME.spacing.xs,
},
cancelButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: THEME.colors.border,
},
confirmButton: {
overflow: 'hidden',
borderRadius: THEME.borderRadius.sm,
},
confirmGradient: {
flex: 1,
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
buttonText: {
color: THEME.colors.text,
fontSize: 16,
fontWeight: '600',
},
});