Compare commits

..
2 Commits
15 changed files with 601 additions and 491 deletions
+5
View File
@@ -39,3 +39,8 @@ yarn-error.*
# generated native folders # generated native folders
/ios /ios
/android /android
# Gradle/Android build folders
**/build/
**/.gradle/
.vscode/
+7 -3
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { StyleSheet, View, SafeAreaView, StatusBar } from 'react-native'; import { StyleSheet, View, SafeAreaView, StatusBar } from 'react-native';
import HomeScreen from './src/screens/HomeScreen';
import CameraScreen from './src/screens/CameraScreen'; import CameraScreen from './src/screens/CameraScreen';
import PreviewScreen from './src/screens/PreviewScreen'; import PreviewScreen from './src/screens/PreviewScreen';
import AdminScreen from './src/screens/AdminScreen'; import AdminScreen from './src/screens/AdminScreen';
@@ -87,6 +86,7 @@ export default function App() {
countdownDuration={settings.countdownDuration} countdownDuration={settings.countdownDuration}
onPhotoCaptured={handlePhotoCaptured} onPhotoCaptured={handlePhotoCaptured}
onCancel={handleReset} onCancel={handleReset}
isIdle={false}
/> />
); );
case 'preview': case 'preview':
@@ -109,8 +109,12 @@ export default function App() {
); );
default: default:
return ( return (
<HomeScreen <CameraScreen
onStart={handleStartBooth} countdownDuration={settings.countdownDuration}
onPhotoCaptured={handlePhotoCaptured}
onCancel={handleReset}
isIdle={true}
onStartBooth={handleStartBooth}
onNavigateToAdmin={handleOpenAdmin} onNavigateToAdmin={handleOpenAdmin}
adminPassword={settings.adminPassword} adminPassword={settings.adminPassword}
/> />
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+4 -5
View File
@@ -4,7 +4,7 @@
"slug": "Schnappix", "slug": "Schnappix",
"version": "1.0.0", "version": "1.0.0",
"orientation": "landscape", "orientation": "landscape",
"icon": "./assets/icon.png", "icon": "./Icon.png",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
@@ -14,7 +14,7 @@
"package": "com.schnappix", "package": "com.schnappix",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#121214", "backgroundColor": "#121214",
"foregroundImage": "./assets/icon.png" "foregroundImage": "./Icon.png"
}, },
"permissions": [ "permissions": [
"android.permission.CAMERA", "android.permission.CAMERA",
@@ -48,15 +48,14 @@
{ {
"android": { "android": {
"extraMavenRepos": [ "extraMavenRepos": [
"https://jitpack.io", "https://jitpack.io"
"https://jcenter.bintray.com"
] ]
} }
} }
], ],
"./plugins/withKioskAdmin", "./plugins/withKioskAdmin",
"./plugins/withUsbCamera", "./plugins/withUsbCamera",
"./plugins/withSettingsRepositories" "./plugins/withLocalAars"
], ],
"extra": { "extra": {
"eas": { "eas": {
+3 -3
View File
@@ -31,7 +31,7 @@ dependencies {
exclude group: 'com.gyf.immersionbar', module: 'immersionbar' exclude group: 'com.gyf.immersionbar', module: 'immersionbar'
exclude group: 'com.zlc.glide', module: 'webpdecoder' exclude group: 'com.zlc.glide', module: 'webpdecoder'
} }
// Local AAR dependencies downloaded to avoid JCenter resolution issues // Local AAR dependencies compiled only; injected into app build.gradle by plugin
implementation files('libs/immersionbar-3.0.0.aar') compileOnly files('libs/immersionbar-3.0.0.aar')
implementation files('libs/webpdecoder-1.6.4.9.0.aar') compileOnly files('libs/webpdecoder-1.6.4.9.0.aar')
} }
+26
View File
@@ -0,0 +1,26 @@
const { withAppBuildGradle } = require('@expo/config-plugins');
const withLocalAars = (config) => {
return withAppBuildGradle(config, (config) => {
let contents = config.modResults.contents;
// Inject the local AAR files into dependencies block
const dependencyBlock = `
// Added by withLocalAars plugin
implementation files("../../modules/usb-camera/android/libs/immersionbar-3.0.0.aar")
implementation files("../../modules/usb-camera/android/libs/webpdecoder-1.6.4.9.0.aar")
`;
if (!contents.includes('immersionbar-3.0.0.aar')) {
contents = contents.replace(
/dependencies\s*\{/,
`dependencies {${dependencyBlock}`
);
}
config.modResults.contents = contents;
return config;
});
};
module.exports = withLocalAars;
-44
View File
@@ -1,44 +0,0 @@
const { withProjectBuildGradle, withSettingsGradle } = require('@expo/config-plugins');
const withSettingsRepositories = (config) => {
// 1. Modify settings.gradle (settings-level repositories)
config = withSettingsGradle(config, (config) => {
let contents = config.modResults.contents;
const reposBlock = `
// Added by withSettingsRepositories plugin
dependencyResolutionManagement {
repositories {
maven { url 'https://jitpack.io' }
maven { url 'https://jcenter.bintray.com' }
}
}
`;
if (!contents.includes('https://jcenter.bintray.com')) {
contents += reposBlock;
}
config.modResults.contents = contents;
return config;
});
// 2. Modify root build.gradle (project-level repositories)
config = withProjectBuildGradle(config, (config) => {
let contents = config.modResults.contents;
// Inject jcenter and jitpack into the allprojects.repositories block
if (!contents.includes('https://jcenter.bintray.com')) {
contents = contents.replace(
/maven\s*\{\s*url\s*['"]https:\/\/www\.jitpack\.io['"]\s*\}/g,
`maven { url 'https://www.jitpack.io' }
maven { url 'https://jcenter.bintray.com' }
maven { url 'https://jitpack.io' }`
);
}
config.modResults.contents = contents;
return config;
});
return config;
};
module.exports = withSettingsRepositories;
+43 -36
View File
@@ -9,6 +9,7 @@ import {
Switch, Switch,
Alert, Alert,
} from 'react-native'; } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { THEME } from '../styles/theme'; import { THEME } from '../styles/theme';
import KioskMode from '../../modules/kiosk-mode'; import KioskMode from '../../modules/kiosk-mode';
import { AppSettings, saveSettings } from '../services/settings'; import { AppSettings, saveSettings } from '../services/settings';
@@ -53,48 +54,54 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
if (success) { if (success) {
setKioskActive(true); setKioskActive(true);
Alert.alert( Alert.alert(
'Kiosk Mode Started', 'Kiosk-Modus gestartet',
isDeviceOwner isDeviceOwner
? 'True Kiosk Mode active. System navigation buttons are completely locked.' ? 'Echter Kiosk-Modus aktiv. Die System-Navigationsleiste ist vollständig gesperrt.'
: 'Screen Pinning initiated. Accept the OS prompt to lock the screen.' : 'Bildschirmheftung (Screen Pinning) gestartet. Bitte bestätige das Systemfenster, um den Bildschirm zu sperren.'
); );
} else { } else {
Alert.alert('Error', 'Failed to start Kiosk Mode.'); Alert.alert('Fehler', 'Kiosk-Modus konnte nicht gestartet werden.');
} }
} else { } else {
const success = KioskMode.stopKiosk(); const success = KioskMode.stopKiosk();
if (success) { if (success) {
setKioskActive(false); setKioskActive(false);
Alert.alert('Kiosk Mode Stopped', 'System navigation buttons are unlocked.'); Alert.alert('Kiosk-Modus beendet', 'Die Navigationstasten des Systems sind wieder freigegeben.');
} else { } else {
Alert.alert('Error', 'Failed to stop Kiosk Mode.'); Alert.alert('Fehler', 'Kiosk-Modus konnte nicht beendet werden.');
} }
} }
} catch (e: any) { } catch (e: any) {
Alert.alert('Native Error', e.message || 'Kiosk module error'); Alert.alert('Nativer Fehler', e.message || 'Fehler im Kiosk-Modul');
} }
}; };
const handleSave = async () => { const handleSave = async () => {
const duration = parseInt(countdown, 10); const duration = parseInt(countdown, 10);
if (isNaN(duration) || duration < 1 || duration > 30) { if (isNaN(duration) || duration < 1 || duration > 30) {
Alert.alert('Invalid Input', 'Countdown duration must be a number between 1 and 30 seconds.'); Alert.alert('Ungültige Eingabe', 'Die Countdown-Dauer muss eine Zahl zwischen 1 und 30 Sekunden sein.');
return; return;
} }
if (!printerIp.trim()) { const ipTrimmed = printerIp.trim();
Alert.alert('Invalid Input', 'Printer IP address cannot be empty.'); const ipRegex = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
if (!ipTrimmed) {
Alert.alert('Ungültige Eingabe', 'Die Drucker-IP-Adresse darf nicht leer sein.');
return;
}
if (!ipRegex.test(ipTrimmed)) {
Alert.alert('Ungültige Eingabe', 'Bitte gib eine gültige IP-Adresse ein (z.B. 192.168.1.100).');
return; return;
} }
if (!password.trim() || password.length < 4) { if (!password.trim() || password.length < 4) {
Alert.alert('Invalid Input', 'Admin password must be at least 4 digits.'); Alert.alert('Ungültige Eingabe', 'Das Admin-Passwort muss mindestens 4 Ziffern lang sein.');
return; return;
} }
const updated: AppSettings = { const updated: AppSettings = {
countdownDuration: duration, countdownDuration: duration,
printerIp: printerIp.trim(), printerIp: ipTrimmed,
adminPassword: password.trim(), adminPassword: password.trim(),
kioskModeEnabled: kioskActive, kioskModeEnabled: kioskActive,
}; };
@@ -102,33 +109,33 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
try { try {
await saveSettings(updated); await saveSettings(updated);
onSave(updated); onSave(updated);
Alert.alert('Success', 'Settings saved successfully!'); Alert.alert('Erfolg', 'Einstellungen erfolgreich gespeichert!');
} catch (e) { } catch (e) {
Alert.alert('Error', 'Failed to save settings to disk.'); Alert.alert('Fehler', 'Einstellungen konnten nicht gespeichert werden.');
} }
}; };
return ( return (
<View style={styles.container}> <Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}>
<View style={styles.headerRow}> <View style={styles.headerRow}>
<Text style={styles.title}>SCHNAPPIX SETTINGS</Text> <Text style={styles.title}>SCHNAPPIX EINSTELLUNGEN</Text>
<TouchableOpacity style={styles.closeBtn} onPress={onClose}> <TouchableOpacity style={styles.closeBtn} onPress={onClose}>
<Text style={styles.closeBtnText}>Back to Booth</Text> <Text style={styles.closeBtnText}>Zurück zur Fotobox</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView contentContainerStyle={styles.scrollContent}> <ScrollView contentContainerStyle={styles.scrollContent}>
{/* Printer Configurations */} {/* Printer Configurations */}
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardTitle}>Printer Configuration</Text> <Text style={styles.cardTitle}>Drucker-Konfiguration</Text>
<Text style={styles.cardDesc}> <Text style={styles.cardDesc}>
Configure the local IP address of your Wi-Fi connected Canon CP1300 printer. Konfiguriere die lokale IP-Adresse deines über WLAN verbundenen Canon CP1300 Druckers.
</Text> </Text>
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<Text style={styles.label}>Printer IP Address</Text> <Text style={styles.label}>Drucker IP-Adresse</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="e.g. 192.168.1.100" placeholder="z.B. 192.168.1.100"
placeholderTextColor={THEME.colors.textMuted} placeholderTextColor={THEME.colors.textMuted}
value={printerIp} value={printerIp}
onChangeText={setPrinterIp} onChangeText={setPrinterIp}
@@ -139,12 +146,12 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
{/* Booth Behavior */} {/* Booth Behavior */}
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardTitle}>Photo Booth Behavior</Text> <Text style={styles.cardTitle}>Fotobox-Verhalten</Text>
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<Text style={styles.label}>Countdown Duration (seconds)</Text> <Text style={styles.label}>Countdown-Dauer (Sekunden)</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Default: 3" placeholder="Standard: 3"
placeholderTextColor={THEME.colors.textMuted} placeholderTextColor={THEME.colors.textMuted}
value={countdown} value={countdown}
onChangeText={setCountdown} onChangeText={setCountdown}
@@ -152,10 +159,10 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
/> />
</View> </View>
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<Text style={styles.label}>Admin Panel Password</Text> <Text style={styles.label}>Admin-Passwort</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Default: 1234" placeholder="Standard: 1234"
placeholderTextColor={THEME.colors.textMuted} placeholderTextColor={THEME.colors.textMuted}
secureTextEntry secureTextEntry
value={password} value={password}
@@ -167,33 +174,33 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
{/* Security & Kiosk Mode */} {/* Security & Kiosk Mode */}
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardTitle}>Kiosk Mode & Security</Text> <Text style={styles.cardTitle}>Kiosk-Modus & Sicherheit</Text>
<Text style={styles.cardDesc}> <Text style={styles.cardDesc}>
Lock the screen to prevent guests from closing the app or opening system settings. Sperre den Bildschirm, um zu verhindern, dass Gäste die App schließen oder auf die Systemeinstellungen zugreifen.
</Text> </Text>
<View style={styles.statusRow}> <View style={styles.statusRow}>
<Text style={styles.statusLabel}>Kiosk Mode State:</Text> <Text style={styles.statusLabel}>Kiosk-Modus Status:</Text>
<Text style={[styles.statusValue, kioskActive ? styles.statusActive : styles.statusInactive]}> <Text style={[styles.statusValue, kioskActive ? styles.statusActive : styles.statusInactive]}>
{kioskActive ? 'LOCKED 🔒' : 'UNLOCKED 🔓'} {kioskActive ? 'GESPERRT 🔒' : 'ENTSPERRT 🔓'}
</Text> </Text>
</View> </View>
<View style={styles.statusRow}> <View style={styles.statusRow}>
<Text style={styles.statusLabel}>Device Owner Mode:</Text> <Text style={styles.statusLabel}>Device-Owner-Modus:</Text>
<Text style={[styles.statusValue, isDeviceOwner ? styles.statusActive : styles.statusWarning]}> <Text style={[styles.statusValue, isDeviceOwner ? styles.statusActive : styles.statusWarning]}>
{isDeviceOwner ? 'ACTIVE (True Lock) ✅' : 'INACTIVE (Screen Pinning Fallback) ⚠️'} {isDeviceOwner ? 'AKTIV (Echte Sperre) ✅' : 'INAKTIV (Screen Pinning Fallback) ⚠️'}
</Text> </Text>
</View> </View>
{!isDeviceOwner && ( {!isDeviceOwner && (
<Text style={styles.kioskWarningText}> <Text style={styles.kioskWarningText}>
Note: To enable True Lock task mode without prompt bypasses, make the app device owner using ADB. Hinweis: Um den echten Lock-Task-Modus ohne Bestätigungsaufforderung zu aktivieren, mache die App mittels ADB zum Device Owner.
</Text> </Text>
)} )}
<View style={styles.toggleRow}> <View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Lock Screen (Kiosk Mode)</Text> <Text style={styles.toggleLabel}>Bildschirm sperren (Kiosk-Modus)</Text>
<Switch <Switch
value={kioskActive} value={kioskActive}
onValueChange={handleKioskToggle} onValueChange={handleKioskToggle}
@@ -204,10 +211,10 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
</View> </View>
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}> <TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
<Text style={styles.saveBtnText}>SAVE SETTINGS</Text> <Text style={styles.saveBtnText}>EINSTELLUNGEN SPEICHERN</Text>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>
</View> </Animated.View>
); );
} }
+388 -56
View File
@@ -1,26 +1,70 @@
import React, { useState, useEffect, useRef } from 'react'; 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 { CameraView, useCameraPermissions } from 'expo-camera';
import * as FileSystem from 'expo-file-system'; import { File, Paths } from 'expo-file-system';
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, FadeIn, FadeOut } from 'react-native-reanimated';
import { THEME } from '../styles/theme'; import { THEME } from '../styles/theme';
import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera'; import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera';
import { saveToGallery } from '../services/storage';
interface CameraScreenProps { interface CameraScreenProps {
countdownDuration: number; countdownDuration: number;
onPhotoCaptured: (uri: string) => void; onPhotoCaptured: (uri: string) => void;
onCancel: () => 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 [permission, requestPermission] = useCameraPermissions();
const [isUsbConnected, setIsUsbConnected] = useState<boolean>(false); const [isUsbConnected, setIsUsbConnected] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number | string>(''); const [countdown, setCountdown] = useState<number | string>('');
const [isCapturing, setIsCapturing] = useState<boolean>(false); const [isCapturing, setIsCapturing] = useState<boolean>(false);
const [hasStarted, setHasStarted] = 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 usbCameraRef = useRef<UsbCameraRef>(null);
const expoCameraRef = useRef<any>(null); const expoCameraRef = useRef<any>(null);
// 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 }],
}));
// 1. Request built-in camera permission on mount (just in case we fallback) // 1. Request built-in camera permission on mount (just in case we fallback)
useEffect(() => { useEffect(() => {
if (!permission || !permission.granted) { if (!permission || !permission.granted) {
@@ -29,9 +73,8 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
}, [permission]); }, [permission]);
// 2. Check if a USB camera is connected. // 2. Check if a USB camera is connected.
// We can query the USB module or attempt to check every second in the background.
useEffect(() => { useEffect(() => {
let checkInterval: NodeJS.Timeout; let checkInterval: any;
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
const checkConnection = async () => { const checkConnection = async () => {
try { try {
@@ -40,12 +83,10 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
setIsUsbConnected(connected); setIsUsbConnected(connected);
} }
} catch (e) { } catch (e) {
// If native view manager is not loaded or errors, fallback to false
setIsUsbConnected(false); setIsUsbConnected(false);
} }
}; };
// Check initially and run an interval
checkConnection(); checkConnection();
checkInterval = setInterval(checkConnection, 2000); checkInterval = setInterval(checkConnection, 2000);
} }
@@ -55,13 +96,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(() => { useEffect(() => {
let timer: NodeJS.Timeout; if (isIdle) {
setHasStarted(false);
return;
}
let timer: any;
let count = countdownDuration; let count = countdownDuration;
setCountdown(count); setCountdown(count);
setHasStarted(true); setHasStarted(true);
setIsCapturing(false);
const runTimer = () => { const runTimer = () => {
if (count > 1) { if (count > 1) {
@@ -69,7 +116,7 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
setCountdown(count); setCountdown(count);
timer = setTimeout(runTimer, 1000); timer = setTimeout(runTimer, 1000);
} else if (count === 1) { } else if (count === 1) {
setCountdown('Cheese! 📸'); setCountdown('Bitte lächeln! 📸');
setIsCapturing(true); setIsCapturing(true);
timer = setTimeout(() => { timer = setTimeout(() => {
capture(); capture();
@@ -82,18 +129,28 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
return () => { return () => {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
}; };
}, [countdownDuration]); }, [countdownDuration, isIdle]);
// 4. Capture photo function // 4. Capture photo function
const capture = async () => { const capture = async () => {
const filename = `photo_${Date.now()}.jpg`; const filename = `photo_${Date.now()}.jpg`;
const tempUri = `${FileSystem.cacheDirectory}${filename}`; const tempFile = new File(Paths.cache, filename);
const tempUri = tempFile.uri;
try { try {
if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) { if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) {
// USB Camera Capture // USB Camera Capture
console.log('Capturing from USB Camera...'); console.log('Capturing from USB Camera...');
const path = await usbCameraRef.current.takePicture(tempUri); 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); onPhotoCaptured(path);
} else { } else {
// Fallback Camera Capture // Fallback Camera Capture
@@ -103,22 +160,47 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
quality: 0.95, quality: 0.95,
skipProcessing: false, 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); onPhotoCaptured(photo.uri);
} else { } else {
throw new Error('Camera ref is not available.'); throw new Error('Kamera-Referenz ist nicht verfügbar.');
} }
} }
} catch (error: any) { } catch (error: any) {
console.error('Capture failed:', error); console.error('Capture failed:', error);
alert('Failed to capture photo: ' + error.message); alert('Fehler beim Aufnehmen des Fotos: ' + error.message);
onCancel(); onCancel();
} }
}; };
const handleSettingsTap = () => {
setEnteredPassword('');
setErrorText('');
setPasswordModalVisible(true);
};
const handlePasswordSubmit = () => {
if (enteredPassword === adminPassword) {
setPasswordModalVisible(false);
onNavigateToAdmin?.();
} else {
setErrorText('Falsches Passwort');
setEnteredPassword('');
}
};
if (!permission) { if (!permission) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.infoText}>Loading permissions...</Text> <Text style={styles.infoText}>Berechtigungen werden geladen...</Text>
</View> </View>
); );
} }
@@ -126,20 +208,124 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
if (!permission.granted && !isUsbConnected) { if (!permission.granted && !isUsbConnected) {
return ( return (
<View style={styles.container}> <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}> <TouchableOpacity style={styles.btn} onPress={requestPermission}>
<Text style={styles.btnText}>Grant Permission</Text> <Text style={styles.btnText}>Erlaubnis erteilen</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.btn, styles.cancelBtn]} onPress={onCancel}> <TouchableOpacity style={[styles.btn, styles.cancelBtn]} onPress={onCancel}>
<Text style={styles.btnText}>Go Back</Text> <Text style={styles.btnText}>Zurück</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
} }
const renderContent = () => {
if (isIdle) {
// Home Overlay (frosty glass box on top of the camera stream)
return ( return (
<View style={styles.container}> <TouchableWithoutFeedback onPress={onStartBooth}>
{/* Camera Preview Area */} <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">
<Animated.View style={[styles.countdownBox, animatedStyle]}>
<Text style={styles.countdownText}>{countdown}</Text>
</Animated.View>
</View>
)}
{/* Cancel Button */}
{!isCapturing && (
<TouchableOpacity style={styles.backButton} onPress={onCancel}>
<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.previewContainer}>
{Platform.OS === 'android' && isUsbConnected ? ( {Platform.OS === 'android' && isUsbConnected ? (
<UsbCameraView ref={usbCameraRef} style={styles.cameraPreview} /> <UsbCameraView ref={usbCameraRef} style={styles.cameraPreview} />
@@ -152,29 +338,9 @@ export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCan
)} )}
</View> </View>
{/* Camera Info HUD */} {/* Render UI controls/overlays on top */}
<View style={styles.hudContainer}> {renderContent()}
<Text style={styles.cameraSourceIndicator}> </Animated.View>
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>
)}
</View>
); );
} }
@@ -182,32 +348,119 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#000', backgroundColor: '#000',
justifyContent: 'center',
alignItems: 'center',
}, },
previewContainer: { previewContainer: {
width: '100%', width: '100%',
height: '100%', height: '100%',
position: 'absolute', position: 'absolute',
top: 0,
left: 0,
}, },
cameraPreview: { cameraPreview: {
width: '100%', width: '100%',
height: '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: { hudContainer: {
position: 'absolute', position: 'absolute',
bottom: 20, bottom: 20,
backgroundColor: 'rgba(0, 0, 0, 0.6)', backgroundColor: 'rgba(0, 0, 0, 0.7)',
paddingVertical: THEME.spacing.xs, paddingVertical: THEME.spacing.xs,
paddingHorizontal: THEME.spacing.md, paddingHorizontal: THEME.spacing.md,
borderRadius: THEME.borderRadius.sm, borderRadius: THEME.borderRadius.sm,
borderWidth: 0.5,
borderColor: 'rgba(255, 255, 255, 0.1)',
}, },
cameraSourceIndicator: { cameraSourceIndicator: {
color: THEME.colors.text, color: THEME.colors.text,
fontSize: 14, fontSize: 14,
fontWeight: 'bold', fontWeight: 'bold',
}, },
overlay: { countdownOverlay: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
@@ -215,37 +468,39 @@ const styles = StyleSheet.create({
bottom: 0, bottom: 0,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
}, },
countdownBox: { countdownBox: {
backgroundColor: THEME.colors.glassBackground, backgroundColor: 'rgba(20, 20, 25, 0.85)',
paddingVertical: THEME.spacing.xl, paddingVertical: THEME.spacing.xl,
paddingHorizontal: THEME.spacing.xxl, paddingHorizontal: THEME.spacing.xxl,
borderRadius: THEME.borderRadius.lg, borderRadius: THEME.borderRadius.lg,
borderWidth: 1, borderWidth: 1,
borderColor: THEME.colors.border, borderColor: 'rgba(255, 255, 255, 0.15)',
minWidth: 180, minWidth: 180,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.4,
shadowRadius: 15,
elevation: 8,
}, },
countdownText: { countdownText: {
fontSize: 72, fontSize: 64,
fontWeight: '900', fontWeight: '900',
color: THEME.colors.text, color: THEME.colors.text,
textShadowColor: 'rgba(0, 0, 0, 0.75)', textAlign: 'center',
textShadowOffset: { width: -1, height: 1 },
textShadowRadius: 10,
}, },
backButton: { backButton: {
position: 'absolute', position: 'absolute',
top: 24, top: 24,
left: 24, left: 24,
backgroundColor: 'rgba(0, 0, 0, 0.6)', backgroundColor: 'rgba(30, 30, 35, 0.75)',
paddingVertical: THEME.spacing.sm, paddingVertical: THEME.spacing.sm,
paddingHorizontal: THEME.spacing.lg, paddingHorizontal: THEME.spacing.lg,
borderRadius: THEME.borderRadius.round, borderRadius: THEME.borderRadius.round,
borderWidth: 1, borderWidth: 1,
borderColor: THEME.colors.border, borderColor: 'rgba(255, 255, 255, 0.1)',
}, },
backButtonText: { backButtonText: {
color: THEME.colors.text, color: THEME.colors.text,
@@ -277,4 +532,81 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: 'bold', 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',
},
}); });
-261
View File
@@ -1,261 +0,0 @@
import React, { useState } from 'react';
import {
StyleSheet,
Text,
View,
TouchableOpacity,
Modal,
TextInput,
TouchableWithoutFeedback,
} from 'react-native';
import { THEME } from '../styles/theme';
interface HomeScreenProps {
onStart: () => void;
onNavigateToAdmin: () => void;
adminPassword?: string;
}
export default function HomeScreen({ onStart, onNavigateToAdmin, adminPassword = '1234' }: HomeScreenProps) {
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [enteredPassword, setEnteredPassword] = useState('');
const [errorText, setErrorText] = useState('');
const handleSettingsTap = () => {
setEnteredPassword('');
setErrorText('');
setPasswordModalVisible(true);
};
const handlePasswordSubmit = () => {
if (enteredPassword === adminPassword) {
setPasswordModalVisible(false);
onNavigateToAdmin();
} else {
setErrorText('Incorrect Password');
setEnteredPassword('');
}
};
return (
<TouchableWithoutFeedback onPress={onStart}>
<View style={styles.container}>
{/* 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.content}>
<Text style={styles.logo}>SCHNAPPIX</Text>
<Text style={styles.title}>Welcome to our celebration!</Text>
<View style={styles.divider} />
<View style={styles.startButton}>
<Text style={styles.startButtonText}>TAP ANYWHERE TO START</Text>
</View>
<Text style={styles.footer}>Take photos Create a collage Print instantly</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 Access</Text>
<Text style={styles.modalSub}>Enter password to open settings</Text>
<TextInput
style={styles.input}
secureTextEntry
placeholder="Enter password"
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}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.confirmButton]}
onPress={handlePasswordSubmit}
>
<Text style={styles.buttonText}>Open</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
</TouchableWithoutFeedback>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: THEME.colors.background,
justifyContent: 'center',
alignItems: 'center',
},
settingsButton: {
position: 'absolute',
top: 24,
right: 24,
width: 50,
height: 50,
borderRadius: THEME.borderRadius.round,
backgroundColor: THEME.colors.surfaceSecondary,
borderWidth: 1,
borderColor: THEME.colors.border,
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
},
settingsIcon: {
fontSize: 24,
color: THEME.colors.text,
},
content: {
alignItems: 'center',
padding: THEME.spacing.xl,
borderRadius: THEME.borderRadius.lg,
backgroundColor: THEME.colors.surface,
borderWidth: 1,
borderColor: THEME.colors.border,
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,
},
title: {
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,
},
footer: {
fontSize: 14,
color: THEME.colors.textMuted,
letterSpacing: 1,
},
modalOverlay: {
flex: 1,
backgroundColor: THEME.colors.overlay,
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
width: 320,
padding: THEME.spacing.lg,
borderRadius: THEME.borderRadius.md,
backgroundColor: THEME.colors.surfaceSecondary,
borderWidth: 1,
borderColor: THEME.colors.border,
alignItems: 'center',
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: THEME.colors.text,
marginBottom: THEME.spacing.xs,
},
modalSub: {
fontSize: 14,
color: THEME.colors.textMuted,
marginBottom: THEME.spacing.md,
},
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',
},
});
+54 -34
View File
@@ -9,6 +9,7 @@ import {
ActivityIndicator, ActivityIndicator,
} from 'react-native'; } from 'react-native';
import ViewShot from 'react-native-view-shot'; import ViewShot from 'react-native-view-shot';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { THEME } from '../styles/theme'; import { THEME } from '../styles/theme';
import { printImageLocal } from '../services/printer'; import { printImageLocal } from '../services/printer';
import { saveToGallery } from '../services/storage'; import { saveToGallery } from '../services/storage';
@@ -41,36 +42,36 @@ export default function PreviewScreen({
// Triggers the view capture and the print job // Triggers the view capture and the print job
const handlePrint = async () => { const handlePrint = async () => {
setIsProcessing(true); setIsProcessing(true);
setStatusMessage('Preparing your photo...'); setStatusMessage('Dein Foto wird vorbereitet...');
try { try {
let printUri = currentPhoto; let printUri = currentPhoto;
// If we are printing a collage layout, capture the ViewShot container // If we are printing a collage layout, capture the ViewShot container
if (layout !== 'single') { if (layout !== 'single') {
setStatusMessage('Generating collage...'); setStatusMessage('Collage wird generiert...');
const capturedUri = await viewShotRef.current.capture(); const capturedUri = await viewShotRef.current.capture();
printUri = capturedUri; printUri = capturedUri;
} }
setStatusMessage('Sending print job to printer...'); setStatusMessage('Druckauftrag wird an Drucker gesendet...');
// 1. Silent Print via IPP // 1. Silent Print via IPP
await printImageLocal(printUri, { await printImageLocal(printUri, {
ipAddress: printerIp, ipAddress: printerIp,
jobName: 'Schnappix Photo Booth', jobName: 'Schnappix Fotobox',
}); });
setStatusMessage('Saving to tablet library...'); setStatusMessage('Wird in Galerie gespeichert...');
// 2. Save permanently to DCIM/Schnappix // 2. Save permanently to DCIM/Schnappix
await saveToGallery(printUri); await saveToGallery(printUri);
setStatusMessage('Print successful! Enjoy!'); setStatusMessage('Druck erfolgreich! Viel Spaß! 🎉');
setTimeout(() => { setTimeout(() => {
setIsProcessing(false); setIsProcessing(false);
onReset(); // Go back to Home Screen onReset(); // Go back to Home Screen
}, 2000); }, 2000);
} catch (error: any) { } catch (error: any) {
console.error('Printing failed:', error); console.error('Printing failed:', error);
alert('Error: ' + error.message); alert('Fehler: ' + error.message);
setIsProcessing(false); setIsProcessing(false);
} }
}; };
@@ -78,7 +79,7 @@ export default function PreviewScreen({
// Saves to gallery without printing // Saves to gallery without printing
const handleSaveOnly = async () => { const handleSaveOnly = async () => {
setIsProcessing(true); setIsProcessing(true);
setStatusMessage('Generating image...'); setStatusMessage('Bild wird generiert...');
try { try {
let saveUri = currentPhoto; let saveUri = currentPhoto;
@@ -87,21 +88,46 @@ export default function PreviewScreen({
saveUri = capturedUri; saveUri = capturedUri;
} }
setStatusMessage('Saving to tablet library...'); setStatusMessage('Wird in Galerie gespeichert...');
await saveToGallery(saveUri); await saveToGallery(saveUri);
setStatusMessage('Saved successfully!'); setStatusMessage('Erfolgreich gespeichert!');
setTimeout(() => { setTimeout(() => {
setIsProcessing(false); setIsProcessing(false);
onReset(); onReset();
}, 1500); }, 1500);
} catch (error: any) { } catch (error: any) {
console.error('Saving failed:', error); console.error('Saving failed:', error);
alert('Error: ' + error.message); alert('Fehler: ' + error.message);
setIsProcessing(false); setIsProcessing(false);
} }
}; };
// Auto-save collage and exit
const handleExit = async () => {
// If layout is single, it has already been auto-saved to gallery right when captured
if (layout === 'single') {
onReset();
return;
}
setIsProcessing(true);
setStatusMessage('Collage wird gespeichert...');
try {
const capturedUri = await viewShotRef.current.capture();
await saveToGallery(capturedUri);
setStatusMessage('Erfolgreich gespeichert!');
setTimeout(() => {
setIsProcessing(false);
onReset();
}, 1000);
} catch (error) {
console.error('Auto-saving collage on exit failed:', error);
setIsProcessing(false);
onReset();
}
};
// Render the selected collage inside the capture container // Render the selected collage inside the capture container
const renderCollageView = () => { const renderCollageView = () => {
switch (layout) { switch (layout) {
@@ -114,8 +140,8 @@ export default function PreviewScreen({
))} ))}
</View> </View>
<View style={styles.collageFooter}> <View style={styles.collageFooter}>
<Text style={styles.collageFooterText}>SCHNAPPIX PHOTO BOOTH</Text> <Text style={styles.collageFooterText}>SCHNAPPIX FOTOBOX</Text>
<Text style={styles.collageFooterDate}>{new Date().toLocaleDateString()}</Text> <Text style={styles.collageFooterDate}>{new Date().toLocaleDateString('de-DE')}</Text>
</View> </View>
</View> </View>
); );
@@ -128,7 +154,7 @@ export default function PreviewScreen({
))} ))}
</View> </View>
<View style={styles.collageFooter}> <View style={styles.collageFooter}>
<Text style={styles.collageFooterText}>SCHNAPPIX PHOTO BOOTH</Text> <Text style={styles.collageFooterText}>SCHNAPPIX FOTOBOX</Text>
</View> </View>
</View> </View>
); );
@@ -141,7 +167,7 @@ export default function PreviewScreen({
))} ))}
</View> </View>
<View style={styles.collageFooter}> <View style={styles.collageFooter}>
<Text style={styles.collageFooterText}>SCHNAPPIX PHOTO BOOTH</Text> <Text style={styles.collageFooterText}>SCHNAPPIX FOTOBOX</Text>
</View> </View>
</View> </View>
); );
@@ -155,7 +181,7 @@ export default function PreviewScreen({
}; };
return ( return (
<View style={styles.container}> <Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}>
{/* Left panel: Preview Canvas */} {/* Left panel: Preview Canvas */}
<View style={styles.previewPanel}> <View style={styles.previewPanel}>
{layout === 'single' ? ( {layout === 'single' ? (
@@ -174,7 +200,7 @@ export default function PreviewScreen({
{/* Right panel: Controls */} {/* Right panel: Controls */}
<View style={styles.controlPanel}> <View style={styles.controlPanel}>
<Text style={styles.header}>CHOOSE YOUR STYLE</Text> <Text style={styles.header}>WÄHLE DEINEN STIL</Text>
{/* Layout Selection */} {/* Layout Selection */}
<View style={styles.layoutSelector}> <View style={styles.layoutSelector}>
@@ -182,7 +208,7 @@ export default function PreviewScreen({
style={[styles.layoutBtn, layout === 'single' && styles.layoutBtnActive]} style={[styles.layoutBtn, layout === 'single' && styles.layoutBtnActive]}
onPress={() => setLayout('single')} onPress={() => setLayout('single')}
> >
<Text style={styles.layoutBtnText}>Single</Text> <Text style={styles.layoutBtnText}>Einzelbild</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -206,7 +232,7 @@ export default function PreviewScreen({
disabled={photoUris.length < 3} disabled={photoUris.length < 3}
onPress={() => setLayout('strip')} onPress={() => setLayout('strip')}
> >
<Text style={styles.layoutBtnText}>Strip (3)</Text> <Text style={styles.layoutBtnText}>Streifen (3)</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -218,37 +244,37 @@ export default function PreviewScreen({
disabled={photoUris.length < 4} disabled={photoUris.length < 4}
onPress={() => setLayout('grid')} onPress={() => setLayout('grid')}
> >
<Text style={styles.layoutBtnText}>Grid (4)</Text> <Text style={styles.layoutBtnText}>Raster (4)</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={styles.photosCountText}> <Text style={styles.photosCountText}>
Photos Captured: {photoUris.length} / 4 Aufgenommene Fotos: {photoUris.length} / 4
</Text> </Text>
{/* Action Buttons */} {/* Action Buttons */}
<View style={styles.actions}> <View style={styles.actions}>
<TouchableOpacity style={[styles.actionBtn, styles.printBtn]} onPress={handlePrint}> <TouchableOpacity style={[styles.actionBtn, styles.printBtn]} onPress={handlePrint}>
<Text style={styles.actionBtnText}>PRINT NOW 🖨</Text> <Text style={styles.actionBtnText}>JETZT DRUCKEN 🖨</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.actionBtn, styles.saveBtn]} onPress={handleSaveOnly}> <TouchableOpacity style={[styles.actionBtn, styles.saveBtn]} onPress={handleSaveOnly}>
<Text style={styles.actionBtnText}>Save to Tablet Only 💾</Text> <Text style={styles.actionBtnText}>Nur auf Tablet speichern 💾</Text>
</TouchableOpacity> </TouchableOpacity>
{photoUris.length < 4 && ( {photoUris.length < 4 && (
<TouchableOpacity style={[styles.actionBtn, styles.addBtn]} onPress={onAddAnother}> <TouchableOpacity style={[styles.actionBtn, styles.addBtn]} onPress={onAddAnother}>
<Text style={styles.actionBtnText}>+ Add Another Photo</Text> <Text style={styles.actionBtnText}>+ Weiteres Foto aufnehmen</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
<View style={styles.rowActions}> <View style={styles.rowActions}>
<TouchableOpacity style={[styles.smallBtn, styles.retakeBtn]} onPress={onRetakeLast}> <TouchableOpacity style={[styles.smallBtn, styles.retakeBtn]} onPress={onRetakeLast}>
<Text style={styles.smallBtnText}>Retake Last</Text> <Text style={styles.smallBtnText}>Letztes wiederholen</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.smallBtn, styles.resetBtn]} onPress={onReset}> <TouchableOpacity style={[styles.smallBtn, styles.resetBtn]} onPress={handleExit}>
<Text style={styles.smallBtnText}>Exit</Text> <Text style={styles.smallBtnText}>Beenden</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -263,7 +289,7 @@ export default function PreviewScreen({
</View> </View>
</View> </View>
)} )}
</View> </Animated.View>
); );
} }
@@ -281,7 +307,6 @@ const styles = StyleSheet.create({
backgroundColor: '#050507', backgroundColor: '#050507',
}, },
viewShotContainer: { viewShotContainer: {
// 3:2 ratio aspect layout matching Canon Selphy postcard paper
width: 320, width: 320,
height: 480, height: 480,
justifyContent: 'center', justifyContent: 'center',
@@ -389,7 +414,6 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
}, },
// Collage Canvas Layouts (3:2 ratio aspect - width 320, height 480)
singleCanvas: { singleCanvas: {
width: 320, width: 320,
height: 480, height: 480,
@@ -433,7 +457,6 @@ const styles = StyleSheet.create({
fontSize: 8, fontSize: 8,
color: '#666666', color: '#666666',
}, },
// Strip (3 images vertical)
stripCanvas: {}, stripCanvas: {},
stripContent: { stripContent: {
flex: 1, flex: 1,
@@ -446,7 +469,6 @@ const styles = StyleSheet.create({
resizeMode: 'cover', resizeMode: 'cover',
borderRadius: 2, borderRadius: 2,
}, },
// Grid (4 images 2x2)
gridCanvas: {}, gridCanvas: {},
gridContainer: { gridContainer: {
flex: 1, flex: 1,
@@ -462,7 +484,6 @@ const styles = StyleSheet.create({
resizeMode: 'cover', resizeMode: 'cover',
borderRadius: 2, borderRadius: 2,
}, },
// Duo (2 images side by side or stacked)
duoCanvas: {}, duoCanvas: {},
duoContainer: { duoContainer: {
flex: 1, flex: 1,
@@ -475,7 +496,6 @@ const styles = StyleSheet.create({
resizeMode: 'cover', resizeMode: 'cover',
borderRadius: 2, borderRadius: 2,
}, },
// Loading overlay
loadingOverlay: { loadingOverlay: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
+26 -8
View File
@@ -1,6 +1,6 @@
import ipp from 'ipp-encoder'; import ipp from 'ipp-encoder';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import * as FileSystem from 'expo-file-system'; import { File } from 'expo-file-system';
export interface PrintOptions { export interface PrintOptions {
ipAddress: string; ipAddress: string;
@@ -19,13 +19,12 @@ export async function printImageLocal(imageUri: string, options: PrintOptions):
const { ipAddress, jobName = 'Schnappix Photo', username = 'Schnappix Kiosk' } = options; const { ipAddress, jobName = 'Schnappix Photo', username = 'Schnappix Kiosk' } = options;
if (!ipAddress) { if (!ipAddress) {
throw new Error('Printer IP address is required.'); throw new Error('Drucker-IP-Adresse ist erforderlich.');
} }
// 1. Read the image file from local storage as Base64 and convert to binary Buffer // 1. Read the image file from local storage as Base64 and convert to binary Buffer
const base64Data = await FileSystem.readAsStringAsync(imageUri, { const file = new File(imageUri);
encoding: FileSystem.EncodingType.Base64, const base64Data = await file.base64();
});
const imageBuffer = Buffer.from(base64Data, 'base64'); const imageBuffer = Buffer.from(base64Data, 'base64');
// 2. Build the IPP Print-Job request structure // 2. Build the IPP Print-Job request structure
@@ -57,7 +56,13 @@ export async function printImageLocal(imageUri: string, options: PrintOptions):
// 5. Send the POST request to the printer via standard HTTP on port 631 // 5. Send the POST request to the printer via standard HTTP on port 631
const printerUrl = `http://${ipAddress}:631/ipp/print`; const printerUrl = `http://${ipAddress}:631/ipp/print`;
console.log(`Sending silent print job to ${printerUrl}...`); const maxRetries = 3;
let delay = 1000;
let lastError: any = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Sending silent print job to ${printerUrl} (Attempt ${attempt}/${maxRetries})...`);
const response = await fetch(printerUrl, { const response = await fetch(printerUrl, {
method: 'POST', method: 'POST',
@@ -69,7 +74,7 @@ export async function printImageLocal(imageUri: string, options: PrintOptions):
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Printer responded with HTTP status ${response.status}: ${response.statusText}`); throw new Error(`Drucker antwortete mit HTTP-Status ${response.status}: ${response.statusText}`);
} }
// 6. Read and decode the response from the printer to verify success // 6. Read and decode the response from the printer to verify success
@@ -82,11 +87,24 @@ export async function printImageLocal(imageUri: string, options: PrintOptions):
// IPP success status is 0x0000 (successful-ok) // IPP success status is 0x0000 (successful-ok)
if (statusCode !== 0x0000) { if (statusCode !== 0x0000) {
throw new Error(`Printer returned IPP error code: 0x${statusCode.toString(16)}`); throw new Error(`Drucker lieferte IPP-Fehlercode: 0x${statusCode.toString(16)}`);
} }
console.log('Silent print job accepted successfully!'); console.log('Silent print job accepted successfully!');
} catch (e: any) { } catch (e: any) {
// If decoding fails, we still assume success since the network request completed successfully // If decoding fails, we still assume success since the network request completed successfully
console.warn('Failed to parse IPP response, but network request succeeded:', e.message); console.warn('Failed to parse IPP response, but network request succeeded:', e.message);
} }
return; // Success, exit retry loop
} catch (error: any) {
console.warn(`Print attempt ${attempt} failed: ${error.message}`);
lastError = error;
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delay));
delay *= 2; // exponential backoff
}
}
}
throw new Error(`Druckauftrag nach ${maxRetries} Versuchen fehlgeschlagen. Letzter Fehler: ${lastError?.message}`);
} }
+5 -6
View File
@@ -1,4 +1,4 @@
import * as FileSystem from 'expo-file-system'; import { File, Paths } from 'expo-file-system';
export interface AppSettings { export interface AppSettings {
countdownDuration: number; // in seconds countdownDuration: number; // in seconds
@@ -7,7 +7,7 @@ export interface AppSettings {
kioskModeEnabled: boolean; kioskModeEnabled: boolean;
} }
const SETTINGS_FILE = `${FileSystem.documentDirectory}settings.json`; const settingsFile = new File(Paths.document, 'settings.json');
const DEFAULT_SETTINGS: AppSettings = { const DEFAULT_SETTINGS: AppSettings = {
countdownDuration: 3, countdownDuration: 3,
@@ -21,9 +21,8 @@ const DEFAULT_SETTINGS: AppSettings = {
*/ */
export async function loadSettings(): Promise<AppSettings> { export async function loadSettings(): Promise<AppSettings> {
try { try {
const fileInfo = await FileSystem.getInfoAsync(SETTINGS_FILE); if (settingsFile.exists) {
if (fileInfo.exists) { const content = await settingsFile.text();
const content = await FileSystem.readAsStringAsync(SETTINGS_FILE);
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
return { ...DEFAULT_SETTINGS, ...parsed }; return { ...DEFAULT_SETTINGS, ...parsed };
} }
@@ -39,7 +38,7 @@ export async function loadSettings(): Promise<AppSettings> {
export async function saveSettings(settings: AppSettings): Promise<void> { export async function saveSettings(settings: AppSettings): Promise<void> {
try { try {
const content = JSON.stringify(settings, null, 2); const content = JSON.stringify(settings, null, 2);
await FileSystem.writeAsStringAsync(SETTINGS_FILE, content); settingsFile.write(content);
console.log('Settings saved successfully:', content); console.log('Settings saved successfully:', content);
} catch (e) { } catch (e) {
console.error('Failed to save settings:', e); console.error('Failed to save settings:', e);
+14 -10
View File
@@ -1,5 +1,4 @@
import * as MediaLibrary from 'expo-media-library'; import { Asset, Album, getPermissionsAsync, requestPermissionsAsync } from 'expo-media-library';
import { Platform } from 'react-native';
const ALBUM_NAME = 'Schnappix'; const ALBUM_NAME = 'Schnappix';
@@ -8,13 +7,13 @@ const ALBUM_NAME = 'Schnappix';
* @returns Promise<boolean> True if permissions are granted. * @returns Promise<boolean> True if permissions are granted.
*/ */
export async function requestStoragePermission(): Promise<boolean> { export async function requestStoragePermission(): Promise<boolean> {
const { status, canAskAgain } = await MediaLibrary.getPermissionsAsync(); const { status, canAskAgain } = await getPermissionsAsync();
if (status === 'granted') { if (status === 'granted') {
return true; return true;
} }
if (canAskAgain) { if (canAskAgain) {
const { status: newStatus } = await MediaLibrary.requestPermissionsAsync(); const { status: newStatus } = await requestPermissionsAsync();
return newStatus === 'granted'; return newStatus === 'granted';
} }
@@ -34,25 +33,30 @@ export async function saveToGallery(localUri: string): Promise<string> {
} }
// 1. Create a media asset from the local file // 1. Create a media asset from the local file
const asset = await MediaLibrary.createAssetAsync(localUri); const asset = await Asset.create(localUri);
try { try {
// 2. Check if the "Schnappix" album already exists // 2. Check if the "Schnappix" album already exists
let album = await MediaLibrary.getAlbumAsync(ALBUM_NAME); const album = await Album.get(ALBUM_NAME);
if (!album) { if (!album) {
// 3. If it doesn't exist, create it with our asset // 3. If it doesn't exist, create it with our asset
await MediaLibrary.createAlbumAsync(ALBUM_NAME, asset, false); await Album.create(ALBUM_NAME, [asset], false);
console.log(`Created new album "${ALBUM_NAME}" and saved photo.`); console.log(`Created new album "${ALBUM_NAME}" and saved photo.`);
} else { } else {
// 4. If it exists, add our asset to it // 4. If it exists, add our asset to it
await MediaLibrary.addAssetsToAlbumAsync([asset], album, false); await album.add(asset);
console.log(`Saved photo to existing album "${ALBUM_NAME}".`); console.log(`Saved photo to existing album "${ALBUM_NAME}".`);
} }
return asset.uri; const assetUri = await asset.getUri();
return assetUri;
} catch (e: any) { } catch (e: any) {
console.error('Error saving asset to album, returning fallback asset URI:', e); console.error('Error saving asset to album, returning fallback asset URI:', e);
return asset.uri; try {
return await asset.getUri();
} catch {
return asset.id;
}
} }
} }
+1
View File
@@ -0,0 +1 @@
declare module 'ipp-encoder';