Files
Schnappix/src/screens/CameraScreen.tsx
T

1028 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
StyleSheet,
Text,
View,
TouchableOpacity,
Platform,
Modal,
TextInput,
TouchableWithoutFeedback,
PermissionsAndroid,
} from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as FileSystem from 'expo-file-system/legacy';
import { File, Paths } from 'expo-file-system/next';
import * as ImageManipulator from 'expo-image-manipulator';
import { LinearGradient } from 'expo-linear-gradient';
import MaskedView from '@react-native-masked-view/masked-view';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
FadeIn,
FadeOut,
withSequence,
runOnJS,
} from 'react-native-reanimated';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import {
faUserGear,
faCameraRetro,
faCamera,
faXmark,
faHandPointer,
faFaceSmile,
} 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;
footerText: string;
useFrontCamera: boolean;
isObscured: boolean;
eventText: string;
}
export default function CameraScreen({
countdownDuration,
onPhotoCaptured,
onBurstComplete,
onCancel,
isIdle = false,
onStartBooth,
onNavigateToAdmin,
adminPassword = '1234',
welcomeText = 'Zeit für ein Erinnerungsfoto!',
burstCount = 1,
burstIntervalSec,
frameMode,
selectedFrameId,
footerText,
useFrontCamera,
isObscured,
eventText,
}: CameraScreenProps) {
const [permission, requestPermission] = useCameraPermissions();
const [isUsbConnected, setIsUsbConnected] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number | 'smile'>(countdownDuration);
const [isCapturing, setIsCapturing] = useState<boolean>(false);
const [hasStarted, setHasStarted] = useState<boolean>(false);
const [pictureSize, setPictureSize] = useState<string | undefined>('fallback');
// Burst mode state and refs
const [burstIndex, setBurstIndex] = useState<number>(0);
const burstIndexRef = useRef<number>(0);
const burstUrisRef = useRef<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 completionTimerRef = useRef<any>(null);
const isCancelledRef = useRef<boolean>(false);
// Reanimated countdown pulse animation
const scale = useSharedValue(1);
useEffect(() => {
if (countdown !== null) {
scale.value = 1;
scale.value = withSequence(
withTiming(1.5, { duration: 100 }),
withSpring(1, { damping: 10, stiffness: 100 })
);
}
}, [countdown]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const flashAnimatedStyle = useAnimatedStyle(() => ({
opacity: flashOpacity.value,
}));
// Turn on white flash effect
const turnFlashOn = useCallback(() => {
flashOpacity.value = withTiming(1, { duration: 50 });
}, []);
// Turn off white flash effect
const turnFlashOff = useCallback(() => {
flashOpacity.value = withTiming(0, { duration: 800 });
}, []);
// 1. Request built-in camera permission on mount (just in case we fallback)
useEffect(() => {
if (permission && !permission.granted && permission.canAskAgain) {
requestPermission();
}
// Specifically for ausbc native library which needs these at runtime
if (Platform.OS === 'android') {
const requestAndroidPerms = async () => {
try {
const granted = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.CAMERA,
PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
]);
logger.log('Android Permissions: ' + JSON.stringify(granted));
} catch (err) {
console.warn('Failed to request Android permissions', err);
}
};
requestAndroidPerms();
}
}, [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);
if (completionTimerRef.current) clearTimeout(completionTimerRef.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: any, b: any) => a.pixels - b.pixels);
// Find the LARGEST size available to satisfy user request for original size
const optimal = parsedSizes[parsedSizes.length - 1];
if (optimal) {
console.log('Setting max pictureSize for high-res original:', optimal.size);
setPictureSize(optimal.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 () => {
logger.log('Action: Cancelled camera burst capture');
isCancelledRef.current = true;
if (burstTimerRef.current) clearTimeout(burstTimerRef.current);
if (countdownTimerRef.current) clearTimeout(countdownTimerRef.current);
if (completionTimerRef.current) clearTimeout(completionTimerRef.current);
const urisToDelete = [...burstUrisRef.current];
setCountdown(countdownDuration);
setIsCapturing(false);
setIsBurstWaiting(false);
setHasStarted(false);
setBurstIndex(0);
burstIndexRef.current = 0;
burstUrisRef.current = [];
setShowBurstIndicator('');
onCancel();
// Clean up any temp files from a partially completed burst in the background
Promise.all(
urisToDelete.map(uri =>
FileSystem.deleteAsync(uri, { idempotent: true }).catch(e => {
console.warn('Failed to delete temp file on cancel:', e);
})
)
);
};
// 3. Start the countdown on screen load (only when NOT idle)
useEffect(() => {
if (isIdle) {
setHasStarted(false);
setBurstIndex(0);
burstIndexRef.current = 0;
burstUrisRef.current = [];
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 () => {
if (countdownTimerRef.current) clearTimeout(countdownTimerRef.current);
};
}, [countdownDuration, isIdle, pictureSize, isUsbConnected, burstCount, burstIntervalSec]);
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 {
turnFlashOn();
// Wait for UI to render the flash before blocking main thread with capture
await new Promise(resolve => setTimeout(resolve, 150));
let capturedUri: string;
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. Bitte stelle sicher, dass die externe Kamera verbunden ist, oder aktiviere den Front-Kamera-Fallback in den Admin-Einstellungen.');
}
}
turnFlashOff();
// 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;
}
// Auto-save the high-res original raw photo to the gallery
try {
await saveToGallery(capturedUri, 'Raw', eventText);
logger.log('Auto-saved high-res raw capture to gallery');
} catch (e: any) {
logger.error(`Failed to auto-save raw capture: ${e?.message || e}`);
}
// Downscale for the UI to prevent OutOfMemoryError in React Native Image
try {
console.log('Downscaling photo for PreviewScreen UI...');
const manipResult = await ImageManipulator.manipulateAsync(
capturedUri,
[{ resize: { width: 1920 } }],
{ compress: 0.8, format: ImageManipulator.SaveFormat.JPEG }
);
capturedUri = manipResult.uri;
console.log('Successfully downscaled UI photo.');
} catch (e) {
console.error('Failed to downscale photo, UI might crash:', e);
}
console.log('Photo captured successfully, proceeding to PreviewScreen.');
// Handle burst mode
const effectiveBurst = burstCount || 1;
const newIndex = burstIndexRef.current + 1;
const newUris = [...burstUrisRef.current, capturedUri];
if (effectiveBurst > 1 && newIndex < effectiveBurst) {
// More burst photos to take
burstIndexRef.current = newIndex;
burstUrisRef.current = newUris;
setBurstIndex(newIndex);
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
burstIndexRef.current = 0;
burstUrisRef.current = [];
setBurstIndex(0);
// Short delay prevents Android expo-camera crash on immediate unmount
completionTimerRef.current = setTimeout(() => {
onBurstComplete(newUris);
}, 400);
} else {
// Single shot mode
completionTimerRef.current = setTimeout(() => {
onPhotoCaptured(capturedUri);
}, 400);
}
} catch (error: any) {
turnFlashOff();
console.error('Capture failed:', error);
alert('Fehler beim Aufnehmen des Fotos: ' + error.message);
onCancel();
}
};
const handleSettingsTap = () => {
logger.log('Action: Tapped settings button');
setEnteredPassword('');
setErrorText('');
setPasswordModalVisible(true);
};
const handlePasswordSubmit = () => {
logger.log(`Action: Submitted admin password (correct: ${enteredPassword === adminPassword})`);
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('');
}
};
// Show permission block if built-in camera has no permission AND USB is not connected
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 Tablet-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}
testID="settings-button"
>
<FontAwesomeIcon icon={faUserGear} size={22} color={THEME.colors.accent} />
</TouchableOpacity>
<View style={styles.idleContentArea} pointerEvents="none">
<View style={styles.headerTitleContainer}>
{/* Glow/Shadow Layer */}
<Text
style={[styles.headerTitleBase, { position: 'absolute', color: THEME.colors.accent, textShadowColor: THEME.colors.cyanGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 25 }]}
adjustsFontSizeToFit
numberOfLines={1}
>
{welcomeText}
</Text>
{/* White Outline Layers */}
{[
{ t: -2, l: -2 }, { t: -2, l: 0 }, { t: -2, l: 2 },
{ t: 0, l: -2 }, { t: 0, l: 2 },
{ t: 2, l: -2 }, { t: 2, l: 0 }, { t: 2, l: 2 }
].map((pos, index) => (
<Text
key={`outline-${index}`}
style={[styles.headerTitleBase, { position: 'absolute', color: 'white', top: pos.t, left: pos.l }]}
adjustsFontSizeToFit
numberOfLines={1}
>
{welcomeText}
</Text>
))}
{/* Gradient Text Layer over the shadow layer */}
<MaskedView
style={StyleSheet.absoluteFill}
maskElement={
<Text
style={[styles.headerTitleBase, { position: 'absolute', color: 'black' }]}
adjustsFontSizeToFit
numberOfLines={1}
>
{welcomeText}
</Text>
}
>
<LinearGradient
colors={THEME.gradient.primary}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={{ flex: 1 }}
/>
</MaskedView>
</View>
{/* Footer Instructions */}
<Text
style={styles.footerInstructions}
adjustsFontSizeToFit
numberOfLines={1}
>
{footerText === 'Display tippen! • Lächeln! • Foto schnappen!' ? (
<Text>
<FontAwesomeIcon icon={faHandPointer} color={THEME.colors.text} size={32} />Display tippen!ㅤ•ㅤ
<FontAwesomeIcon icon={faFaceSmile} color={THEME.colors.text} size={32} />Lächeln!ㅤ•ㅤ
<FontAwesomeIcon icon={faCamera} color={THEME.colors.text} size={32} />Foto schnappen!
</Text>
) : (
footerText
)}
</Text>
</View>
{/* 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
testID="admin-password-input"
/>
{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 removed per request */}
{/* 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 (
<View style={styles.container}>
{/* Camera Preview Background */}
<View style={styles.previewContainer}>
<View style={styles.cameraWrapper}>
{Platform.OS === 'android' && !useFrontCamera ? (
<UsbCameraView
ref={usbCameraRef}
style={StyleSheet.absoluteFill}
/>
) : (
<CameraView
ref={expoCameraRef}
style={[styles.cameraPreview, { position: 'absolute', opacity: 1, zIndex: 10 }]}
facing="front"
pictureSize={pictureSize === 'fallback' ? undefined : pictureSize}
onCameraReady={handleCameraReady}
/>
)}
</View>
</View>
{/* Render UI controls/overlays on top */}
{renderContent()}
{/* Flash overlay */}
<Animated.View style={[styles.flashOverlay, flashAnimatedStyle]} pointerEvents="none" />
</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',
justifyContent: 'center',
alignItems: 'center',
zIndex: 100,
},
idleContentArea: {
height: '100%',
aspectRatio: 3 / 2,
position: 'relative',
},
captureOverlayContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 100,
},
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,
},
headerTitleContainer: {
position: 'absolute',
top: 40,
left: '2%',
width: '96%',
height: 100,
},
headerTitleBase: {
width: '100%',
textAlign: 'center',
fontSize: 80,
fontWeight: '900',
letterSpacing: 6,
},
footerInstructions: {
position: 'absolute',
bottom: 40,
left: '2%',
width: '96%',
textAlign: 'center',
fontSize: 40,
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: 'left',
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',
},
});