Localize app to German, implement auto-save to gallery, always-on camera stream, fix expo-media-library and expo-file-system deprecation/compile errors

This commit is contained in:
2026-05-25 21:39:15 +02:00
parent 99634bb480
commit d8e397eac1
9 changed files with 474 additions and 135 deletions
+368 -54
View File
@@ -1,23 +1,50 @@
import React, { useState, useEffect, useRef } from 'react';
import { StyleSheet, Text, View, TouchableOpacity, Platform } from 'react-native';
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 * as FileSystem from 'expo-file-system/legacy';
import { THEME } from '../styles/theme';
import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera';
import { saveToGallery } from '../services/storage';
interface CameraScreenProps {
countdownDuration: number;
onPhotoCaptured: (uri: string) => void;
onCancel: () => void;
isIdle?: boolean;
onStartBooth?: () => void;
onNavigateToAdmin?: () => void;
adminPassword?: string;
}
export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCancel }: CameraScreenProps) {
export default function CameraScreen({
countdownDuration,
onPhotoCaptured,
onCancel,
isIdle = false,
onStartBooth,
onNavigateToAdmin,
adminPassword = '1234',
}: 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);
// 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);
@@ -28,10 +55,9 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
}
}, [permission]);
// 2. Check if a USB camera is connected.
// We can query the USB module or attempt to check every second in the background.
// 2. Check if a USB camera is connected.
useEffect(() => {
let checkInterval: NodeJS.Timeout;
let checkInterval: any;
if (Platform.OS === 'android') {
const checkConnection = async () => {
try {
@@ -40,12 +66,10 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
setIsUsbConnected(connected);
}
} catch (e) {
// If native view manager is not loaded or errors, fallback to false
setIsUsbConnected(false);
}
};
// Check initially and run an interval
checkConnection();
checkInterval = setInterval(checkConnection, 2000);
}
@@ -55,13 +79,19 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
};
}, []);
// 3. Start the countdown on screen load
// 3. Start the countdown on screen load (only when NOT idle)
useEffect(() => {
let timer: NodeJS.Timeout;
if (isIdle) {
setHasStarted(false);
return;
}
let timer: any;
let count = countdownDuration;
setCountdown(count);
setHasStarted(true);
setIsCapturing(false);
const runTimer = () => {
if (count > 1) {
@@ -69,7 +99,7 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
setCountdown(count);
timer = setTimeout(runTimer, 1000);
} else if (count === 1) {
setCountdown('Cheese! 📸');
setCountdown('Bitte lächeln! 📸');
setIsCapturing(true);
timer = setTimeout(() => {
capture();
@@ -82,7 +112,7 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
return () => {
if (timer) clearTimeout(timer);
};
}, [countdownDuration]);
}, [countdownDuration, isIdle]);
// 4. Capture photo function
const capture = async () => {
@@ -94,6 +124,15 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
// USB Camera Capture
console.log('Capturing from USB Camera...');
const path = await usbCameraRef.current.takePicture(tempUri);
// Auto-save individual photo directly to gallery
try {
await saveToGallery(path);
console.log('Auto-saved USB capture to gallery');
} catch (e) {
console.error('Failed to auto-save USB capture:', e);
}
onPhotoCaptured(path);
} else {
// Fallback Camera Capture
@@ -103,22 +142,47 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
quality: 0.95,
skipProcessing: false,
});
// Auto-save individual photo directly to gallery
try {
await saveToGallery(photo.uri);
console.log('Auto-saved fallback capture to gallery');
} catch (e) {
console.error('Failed to auto-save fallback capture:', e);
}
onPhotoCaptured(photo.uri);
} else {
throw new Error('Camera ref is not available.');
throw new Error('Kamera-Referenz ist nicht verfügbar.');
}
}
} catch (error: any) {
console.error('Capture failed:', error);
alert('Failed to capture photo: ' + error.message);
alert('Fehler beim Aufnehmen des Fotos: ' + error.message);
onCancel();
}
};
const handleSettingsTap = () => {
setEnteredPassword('');
setErrorText('');
setPasswordModalVisible(true);
};
const handlePasswordSubmit = () => {
if (enteredPassword === adminPassword) {
setPasswordModalVisible(false);
onNavigateToAdmin?.();
} else {
setErrorText('Falsches Passwort');
setEnteredPassword('');
}
};
if (!permission) {
return (
<View style={styles.container}>
<Text style={styles.infoText}>Loading permissions...</Text>
<Text style={styles.infoText}>Berechtigungen werden geladen...</Text>
</View>
);
}
@@ -126,20 +190,124 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
if (!permission.granted && !isUsbConnected) {
return (
<View style={styles.container}>
<Text style={styles.infoText}>We need your permission to show the camera</Text>
<Text style={styles.infoText}>Wir benötigen deine Erlaubnis, um die Kamera anzuzeigen</Text>
<TouchableOpacity style={styles.btn} onPress={requestPermission}>
<Text style={styles.btnText}>Grant Permission</Text>
<Text style={styles.btnText}>Erlaubnis erteilen</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, styles.cancelBtn]} onPress={onCancel}>
<Text style={styles.btnText}>Go Back</Text>
<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}
>
<Text style={styles.settingsIcon}></Text>
</TouchableOpacity>
<View style={styles.welcomeBox}>
<Text style={styles.logo}>SCHNAPPIX</Text>
<Text style={styles.welcomeTitle}>Willkommen zu unserer Feier!</Text>
<View style={styles.divider} />
<View style={styles.startButton}>
<Text style={styles.startButtonText}>ZUM STARTEN ÜBERALL TIPPEN</Text>
</View>
<Text style={styles.welcomeFooter}>Fotos machen Collage erstellen Sofort drucken</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
/>
{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}
>
<Text style={styles.buttonText}>Öffnen</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
</TouchableWithoutFeedback>
);
}
// Capture Mode overlays (Countdown and Cancel button)
return (
<View style={styles.captureOverlayContainer} pointerEvents="box-none">
{/* Source indicator */}
<View style={styles.hudContainer}>
<Text style={styles.cameraSourceIndicator}>
Kamera: {isUsbConnected ? 'Externe USB-Kamera' : 'Tablet-Frontkamera (Fallback)'}
</Text>
</View>
{/* Countdown overlay */}
{hasStarted && (
<View style={styles.countdownOverlay} pointerEvents="none">
<View style={styles.countdownBox}>
<Text style={styles.countdownText}>{countdown}</Text>
</View>
</View>
)}
{/* Cancel Button */}
{!isCapturing && (
<TouchableOpacity style={styles.backButton} onPress={onCancel}>
<Text style={styles.backButtonText}>ABBRECHEN</Text>
</TouchableOpacity>
)}
</View>
);
};
return (
<View style={styles.container}>
{/* Camera Preview Area */}
{/* Camera Preview Background */}
<View style={styles.previewContainer}>
{Platform.OS === 'android' && isUsbConnected ? (
<UsbCameraView ref={usbCameraRef} style={styles.cameraPreview} />
@@ -152,28 +320,8 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
)}
</View>
{/* Camera Info HUD */}
<View style={styles.hudContainer}>
<Text style={styles.cameraSourceIndicator}>
Source: {isUsbConnected ? 'External USB Camera' : 'Front Tablet Camera (Fallback)'}
</Text>
</View>
{/* Countdown overlay */}
{hasStarted && (
<View style={styles.overlay}>
<View style={styles.countdownBox}>
<Text style={styles.countdownText}>{countdown}</Text>
</View>
</View>
)}
{/* Cancel Button */}
{!isCapturing && (
<TouchableOpacity style={styles.backButton} onPress={onCancel}>
<Text style={styles.backButtonText}>CANCEL</Text>
</TouchableOpacity>
)}
{/* Render UI controls/overlays on top */}
{renderContent()}
</View>
);
}
@@ -182,32 +330,119 @@ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
justifyContent: 'center',
alignItems: 'center',
},
previewContainer: {
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
},
cameraPreview: {
width: '100%',
height: '100%',
},
idleOverlayContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.15)',
},
captureOverlayContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
settingsButton: {
position: 'absolute',
top: 24,
right: 24,
width: 50,
height: 50,
borderRadius: THEME.borderRadius.round,
backgroundColor: 'rgba(30, 30, 35, 0.75)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
},
settingsIcon: {
fontSize: 24,
color: THEME.colors.text,
},
welcomeBox: {
alignItems: 'center',
padding: THEME.spacing.xl,
borderRadius: THEME.borderRadius.lg,
backgroundColor: 'rgba(18, 18, 20, 0.8)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.15)',
width: '60%',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.3,
shadowRadius: 20,
elevation: 10,
},
logo: {
fontSize: 54,
fontWeight: '900',
color: THEME.colors.primary,
letterSpacing: 8,
marginBottom: THEME.spacing.sm,
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
},
welcomeTitle: {
fontSize: 22,
color: THEME.colors.text,
textAlign: 'center',
marginBottom: THEME.spacing.lg,
letterSpacing: 1,
},
divider: {
width: 60,
height: 3,
backgroundColor: THEME.colors.accent,
marginBottom: THEME.spacing.xl,
borderRadius: THEME.borderRadius.sm,
},
startButton: {
paddingVertical: THEME.spacing.md,
paddingHorizontal: THEME.spacing.xl,
backgroundColor: THEME.colors.primary,
borderRadius: THEME.borderRadius.round,
marginBottom: THEME.spacing.xl,
},
startButtonText: {
color: THEME.colors.text,
fontSize: 18,
fontWeight: 'bold',
letterSpacing: 2,
},
welcomeFooter: {
fontSize: 14,
color: THEME.colors.textMuted,
letterSpacing: 1,
},
hudContainer: {
position: 'absolute',
bottom: 20,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
paddingVertical: THEME.spacing.xs,
paddingHorizontal: THEME.spacing.md,
borderRadius: THEME.borderRadius.sm,
borderWidth: 0.5,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
cameraSourceIndicator: {
color: THEME.colors.text,
fontSize: 14,
fontWeight: 'bold',
},
overlay: {
countdownOverlay: {
position: 'absolute',
top: 0,
left: 0,
@@ -215,37 +450,39 @@ const styles = StyleSheet.create({
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
},
countdownBox: {
backgroundColor: THEME.colors.glassBackground,
backgroundColor: 'rgba(20, 20, 25, 0.85)',
paddingVertical: THEME.spacing.xl,
paddingHorizontal: THEME.spacing.xxl,
borderRadius: THEME.borderRadius.lg,
borderWidth: 1,
borderColor: THEME.colors.border,
borderColor: 'rgba(255, 255, 255, 0.15)',
minWidth: 180,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.4,
shadowRadius: 15,
elevation: 8,
},
countdownText: {
fontSize: 72,
fontSize: 64,
fontWeight: '900',
color: THEME.colors.text,
textShadowColor: 'rgba(0, 0, 0, 0.75)',
textShadowOffset: { width: -1, height: 1 },
textShadowRadius: 10,
textAlign: 'center',
},
backButton: {
position: 'absolute',
top: 24,
left: 24,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
backgroundColor: 'rgba(30, 30, 35, 0.75)',
paddingVertical: THEME.spacing.sm,
paddingHorizontal: THEME.spacing.lg,
borderRadius: THEME.borderRadius.round,
borderWidth: 1,
borderColor: THEME.colors.border,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
backButtonText: {
color: THEME.colors.text,
@@ -277,4 +514,81 @@ const styles = StyleSheet.create({
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: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.4,
shadowRadius: 15,
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: {
backgroundColor: THEME.colors.primary,
},
buttonText: {
color: THEME.colors.text,
fontSize: 16,
fontWeight: '600',
},
});