Initialize Schnappix Photo Booth application with custom UVC camera, silent Wi-Fi IPP printing, local gallery storage, and kiosk screen pinning support
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Switch,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { THEME } from '../styles/theme';
|
||||
import KioskMode from '../../modules/kiosk-mode';
|
||||
import { AppSettings, saveSettings } from '../services/settings';
|
||||
|
||||
interface AdminScreenProps {
|
||||
currentSettings: AppSettings;
|
||||
onSave: (settings: AppSettings) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AdminScreen({ currentSettings, onSave, onClose }: AdminScreenProps) {
|
||||
const [countdown, setCountdown] = useState<string>(String(currentSettings.countdownDuration));
|
||||
const [printerIp, setPrinterIp] = useState<string>(currentSettings.printerIp);
|
||||
const [password, setPassword] = useState<string>(currentSettings.adminPassword);
|
||||
|
||||
const [kioskActive, setKioskActive] = useState<boolean>(false);
|
||||
const [isDeviceOwner, setIsDeviceOwner] = useState<boolean>(false);
|
||||
|
||||
// Load Kiosk and Device Owner status on mount
|
||||
useEffect(() => {
|
||||
const checkKioskStatus = () => {
|
||||
try {
|
||||
const active = KioskMode.isKioskActive();
|
||||
const owner = KioskMode.isDeviceOwner();
|
||||
setKioskActive(active);
|
||||
setIsDeviceOwner(owner);
|
||||
} catch (e) {
|
||||
console.warn('Failed to query native KioskMode module:', e);
|
||||
}
|
||||
};
|
||||
|
||||
checkKioskStatus();
|
||||
// Poll status every second
|
||||
const interval = setInterval(checkKioskStatus, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleKioskToggle = (enable: boolean) => {
|
||||
try {
|
||||
if (enable) {
|
||||
const success = KioskMode.startKiosk();
|
||||
if (success) {
|
||||
setKioskActive(true);
|
||||
Alert.alert(
|
||||
'Kiosk Mode Started',
|
||||
isDeviceOwner
|
||||
? 'True Kiosk Mode active. System navigation buttons are completely locked.'
|
||||
: 'Screen Pinning initiated. Accept the OS prompt to lock the screen.'
|
||||
);
|
||||
} else {
|
||||
Alert.alert('Error', 'Failed to start Kiosk Mode.');
|
||||
}
|
||||
} else {
|
||||
const success = KioskMode.stopKiosk();
|
||||
if (success) {
|
||||
setKioskActive(false);
|
||||
Alert.alert('Kiosk Mode Stopped', 'System navigation buttons are unlocked.');
|
||||
} else {
|
||||
Alert.alert('Error', 'Failed to stop Kiosk Mode.');
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
Alert.alert('Native Error', e.message || 'Kiosk module error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const duration = parseInt(countdown, 10);
|
||||
if (isNaN(duration) || duration < 1 || duration > 30) {
|
||||
Alert.alert('Invalid Input', 'Countdown duration must be a number between 1 and 30 seconds.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!printerIp.trim()) {
|
||||
Alert.alert('Invalid Input', 'Printer IP address cannot be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.trim() || password.length < 4) {
|
||||
Alert.alert('Invalid Input', 'Admin password must be at least 4 digits.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated: AppSettings = {
|
||||
countdownDuration: duration,
|
||||
printerIp: printerIp.trim(),
|
||||
adminPassword: password.trim(),
|
||||
kioskModeEnabled: kioskActive,
|
||||
};
|
||||
|
||||
try {
|
||||
await saveSettings(updated);
|
||||
onSave(updated);
|
||||
Alert.alert('Success', 'Settings saved successfully!');
|
||||
} catch (e) {
|
||||
Alert.alert('Error', 'Failed to save settings to disk.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.title}>SCHNAPPIX SETTINGS</Text>
|
||||
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
|
||||
<Text style={styles.closeBtnText}>Back to Booth</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{/* Printer Configurations */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Printer Configuration</Text>
|
||||
<Text style={styles.cardDesc}>
|
||||
Configure the local IP address of your Wi-Fi connected Canon CP1300 printer.
|
||||
</Text>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Printer IP Address</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
placeholderTextColor={THEME.colors.textMuted}
|
||||
value={printerIp}
|
||||
onChangeText={setPrinterIp}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Booth Behavior */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Photo Booth Behavior</Text>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Countdown Duration (seconds)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Default: 3"
|
||||
placeholderTextColor={THEME.colors.textMuted}
|
||||
value={countdown}
|
||||
onChangeText={setCountdown}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Admin Panel Password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Default: 1234"
|
||||
placeholderTextColor={THEME.colors.textMuted}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Security & Kiosk Mode */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Kiosk Mode & Security</Text>
|
||||
<Text style={styles.cardDesc}>
|
||||
Lock the screen to prevent guests from closing the app or opening system settings.
|
||||
</Text>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusLabel}>Kiosk Mode State:</Text>
|
||||
<Text style={[styles.statusValue, kioskActive ? styles.statusActive : styles.statusInactive]}>
|
||||
{kioskActive ? 'LOCKED 🔒' : 'UNLOCKED 🔓'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusLabel}>Device Owner Mode:</Text>
|
||||
<Text style={[styles.statusValue, isDeviceOwner ? styles.statusActive : styles.statusWarning]}>
|
||||
{isDeviceOwner ? 'ACTIVE (True Lock) ✅' : 'INACTIVE (Screen Pinning Fallback) ⚠️'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!isDeviceOwner && (
|
||||
<Text style={styles.kioskWarningText}>
|
||||
Note: To enable True Lock task mode without prompt bypasses, make the app device owner using ADB.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.toggleRow}>
|
||||
<Text style={styles.toggleLabel}>Lock Screen (Kiosk Mode)</Text>
|
||||
<Switch
|
||||
value={kioskActive}
|
||||
onValueChange={handleKioskToggle}
|
||||
trackColor={{ false: THEME.colors.surfaceSecondary, true: THEME.colors.primary }}
|
||||
thumbColor={THEME.colors.text}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
||||
<Text style={styles.saveBtnText}>SAVE SETTINGS</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: THEME.colors.background,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: THEME.spacing.lg,
|
||||
paddingHorizontal: THEME.spacing.xl,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
backgroundColor: THEME.colors.surface,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: '900',
|
||||
color: THEME.colors.text,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
closeBtn: {
|
||||
backgroundColor: THEME.colors.surfaceSecondary,
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
paddingVertical: THEME.spacing.sm,
|
||||
paddingHorizontal: THEME.spacing.lg,
|
||||
borderRadius: THEME.borderRadius.round,
|
||||
},
|
||||
closeBtnText: {
|
||||
color: THEME.colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
scrollContent: {
|
||||
padding: THEME.spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
maxWidth: 680,
|
||||
backgroundColor: THEME.colors.surface,
|
||||
borderRadius: THEME.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
padding: THEME.spacing.lg,
|
||||
marginBottom: THEME.spacing.lg,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: THEME.colors.text,
|
||||
marginBottom: THEME.spacing.xs,
|
||||
},
|
||||
cardDesc: {
|
||||
fontSize: 14,
|
||||
color: THEME.colors.textMuted,
|
||||
marginBottom: THEME.spacing.md,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: THEME.spacing.md,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
color: THEME.colors.text,
|
||||
marginBottom: THEME.spacing.xs,
|
||||
},
|
||||
input: {
|
||||
height: 48,
|
||||
backgroundColor: THEME.colors.surfaceSecondary,
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
borderRadius: THEME.borderRadius.sm,
|
||||
color: THEME.colors.text,
|
||||
paddingHorizontal: THEME.spacing.md,
|
||||
fontSize: 16,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: THEME.spacing.xs,
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: 14,
|
||||
color: THEME.colors.textMuted,
|
||||
marginRight: THEME.spacing.sm,
|
||||
},
|
||||
statusValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
statusActive: {
|
||||
color: THEME.colors.success,
|
||||
},
|
||||
statusInactive: {
|
||||
color: THEME.colors.textMuted,
|
||||
},
|
||||
statusWarning: {
|
||||
color: THEME.colors.accent,
|
||||
},
|
||||
kioskWarningText: {
|
||||
fontSize: 12,
|
||||
color: THEME.colors.textMuted,
|
||||
marginTop: THEME.spacing.xs,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
toggleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: THEME.spacing.lg,
|
||||
paddingTop: THEME.spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: THEME.colors.border,
|
||||
},
|
||||
toggleLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: THEME.colors.text,
|
||||
},
|
||||
saveBtn: {
|
||||
width: '100%',
|
||||
maxWidth: 680,
|
||||
height: 52,
|
||||
backgroundColor: THEME.colors.primary,
|
||||
borderRadius: THEME.borderRadius.md,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: THEME.spacing.sm,
|
||||
marginBottom: THEME.spacing.xl,
|
||||
},
|
||||
saveBtnText: {
|
||||
color: THEME.colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { StyleSheet, Text, View, TouchableOpacity, Platform } from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { THEME } from '../styles/theme';
|
||||
import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera';
|
||||
|
||||
interface CameraScreenProps {
|
||||
countdownDuration: number;
|
||||
onPhotoCaptured: (uri: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function CameraScreen({ countdownDuration, onPhotoCaptured, onCancel }: CameraScreenProps) {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isUsbConnected, setIsUsbConnected] = useState<boolean>(false);
|
||||
const [countdown, setCountdown] = useState<number | string>('');
|
||||
const [isCapturing, setIsCapturing] = useState<boolean>(false);
|
||||
const [hasStarted, setHasStarted] = useState<boolean>(false);
|
||||
|
||||
const usbCameraRef = useRef<UsbCameraRef>(null);
|
||||
const expoCameraRef = useRef<any>(null);
|
||||
|
||||
// 1. Request built-in camera permission on mount (just in case we fallback)
|
||||
useEffect(() => {
|
||||
if (!permission || !permission.granted) {
|
||||
requestPermission();
|
||||
}
|
||||
}, [permission]);
|
||||
|
||||
// 2. Check if a USB camera is connected.
|
||||
// We can query the USB module or attempt to check every second in the background.
|
||||
useEffect(() => {
|
||||
let checkInterval: NodeJS.Timeout;
|
||||
if (Platform.OS === 'android') {
|
||||
const checkConnection = async () => {
|
||||
try {
|
||||
if (usbCameraRef.current) {
|
||||
const connected = await usbCameraRef.current.isCameraConnected();
|
||||
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);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (checkInterval) clearInterval(checkInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 3. Start the countdown on screen load
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
let count = countdownDuration;
|
||||
|
||||
setCountdown(count);
|
||||
setHasStarted(true);
|
||||
|
||||
const runTimer = () => {
|
||||
if (count > 1) {
|
||||
count -= 1;
|
||||
setCountdown(count);
|
||||
timer = setTimeout(runTimer, 1000);
|
||||
} else if (count === 1) {
|
||||
setCountdown('Cheese! 📸');
|
||||
setIsCapturing(true);
|
||||
timer = setTimeout(() => {
|
||||
capture();
|
||||
}, 800); // give the user a split second to smile
|
||||
}
|
||||
};
|
||||
|
||||
timer = setTimeout(runTimer, 1000);
|
||||
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [countdownDuration]);
|
||||
|
||||
// 4. Capture photo function
|
||||
const capture = async () => {
|
||||
const filename = `photo_${Date.now()}.jpg`;
|
||||
const tempUri = `${FileSystem.cacheDirectory}${filename}`;
|
||||
|
||||
try {
|
||||
if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) {
|
||||
// USB Camera Capture
|
||||
console.log('Capturing from USB Camera...');
|
||||
const path = await usbCameraRef.current.takePicture(tempUri);
|
||||
onPhotoCaptured(path);
|
||||
} else {
|
||||
// Fallback Camera Capture
|
||||
console.log('Capturing from built-in camera fallback...');
|
||||
if (expoCameraRef.current) {
|
||||
const photo = await expoCameraRef.current.takePictureAsync({
|
||||
quality: 0.95,
|
||||
skipProcessing: false,
|
||||
});
|
||||
onPhotoCaptured(photo.uri);
|
||||
} else {
|
||||
throw new Error('Camera ref is not available.');
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Capture failed:', error);
|
||||
alert('Failed to capture photo: ' + error.message);
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (!permission) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.infoText}>Loading permissions...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!permission.granted && !isUsbConnected) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.infoText}>We need your permission to show the camera</Text>
|
||||
<TouchableOpacity style={styles.btn} onPress={requestPermission}>
|
||||
<Text style={styles.btnText}>Grant Permission</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.btn, styles.cancelBtn]} onPress={onCancel}>
|
||||
<Text style={styles.btnText}>Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Camera Preview Area */}
|
||||
<View style={styles.previewContainer}>
|
||||
{Platform.OS === 'android' && isUsbConnected ? (
|
||||
<UsbCameraView ref={usbCameraRef} style={styles.cameraPreview} />
|
||||
) : (
|
||||
<CameraView
|
||||
ref={expoCameraRef}
|
||||
style={styles.cameraPreview}
|
||||
facing="front"
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
previewContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
},
|
||||
cameraPreview: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
hudContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
paddingVertical: THEME.spacing.xs,
|
||||
paddingHorizontal: THEME.spacing.md,
|
||||
borderRadius: THEME.borderRadius.sm,
|
||||
},
|
||||
cameraSourceIndicator: {
|
||||
color: THEME.colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
countdownBox: {
|
||||
backgroundColor: THEME.colors.glassBackground,
|
||||
paddingVertical: THEME.spacing.xl,
|
||||
paddingHorizontal: THEME.spacing.xxl,
|
||||
borderRadius: THEME.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
minWidth: 180,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
countdownText: {
|
||||
fontSize: 72,
|
||||
fontWeight: '900',
|
||||
color: THEME.colors.text,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.75)',
|
||||
textShadowOffset: { width: -1, height: 1 },
|
||||
textShadowRadius: 10,
|
||||
},
|
||||
backButton: {
|
||||
position: 'absolute',
|
||||
top: 24,
|
||||
left: 24,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
paddingVertical: THEME.spacing.sm,
|
||||
paddingHorizontal: THEME.spacing.lg,
|
||||
borderRadius: THEME.borderRadius.round,
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
},
|
||||
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.primary,
|
||||
paddingVertical: THEME.spacing.md,
|
||||
paddingHorizontal: THEME.spacing.xl,
|
||||
borderRadius: THEME.borderRadius.round,
|
||||
marginBottom: THEME.spacing.md,
|
||||
},
|
||||
cancelBtn: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
},
|
||||
btnText: {
|
||||
color: THEME.colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import ViewShot from 'react-native-view-shot';
|
||||
import { THEME } from '../styles/theme';
|
||||
import { printImageLocal } from '../services/printer';
|
||||
import { saveToGallery } from '../services/storage';
|
||||
|
||||
interface PreviewScreenProps {
|
||||
photoUris: string[];
|
||||
printerIp: string;
|
||||
onRetakeLast: () => void;
|
||||
onAddAnother: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
type CollageLayout = 'single' | 'strip' | 'grid' | 'duo';
|
||||
|
||||
export default function PreviewScreen({
|
||||
photoUris,
|
||||
printerIp,
|
||||
onRetakeLast,
|
||||
onAddAnother,
|
||||
onReset,
|
||||
}: PreviewScreenProps) {
|
||||
const [layout, setLayout] = useState<CollageLayout>('single');
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string>('');
|
||||
|
||||
const viewShotRef = useRef<any>(null);
|
||||
|
||||
const currentPhoto = photoUris[photoUris.length - 1];
|
||||
|
||||
// Triggers the view capture and the print job
|
||||
const handlePrint = async () => {
|
||||
setIsProcessing(true);
|
||||
setStatusMessage('Preparing your photo...');
|
||||
try {
|
||||
let printUri = currentPhoto;
|
||||
|
||||
// If we are printing a collage layout, capture the ViewShot container
|
||||
if (layout !== 'single') {
|
||||
setStatusMessage('Generating collage...');
|
||||
const capturedUri = await viewShotRef.current.capture();
|
||||
printUri = capturedUri;
|
||||
}
|
||||
|
||||
setStatusMessage('Sending print job to printer...');
|
||||
// 1. Silent Print via IPP
|
||||
await printImageLocal(printUri, {
|
||||
ipAddress: printerIp,
|
||||
jobName: 'Schnappix Photo Booth',
|
||||
});
|
||||
|
||||
setStatusMessage('Saving to tablet library...');
|
||||
// 2. Save permanently to DCIM/Schnappix
|
||||
await saveToGallery(printUri);
|
||||
|
||||
setStatusMessage('Print successful! Enjoy!');
|
||||
setTimeout(() => {
|
||||
setIsProcessing(false);
|
||||
onReset(); // Go back to Home Screen
|
||||
}, 2000);
|
||||
} catch (error: any) {
|
||||
console.error('Printing failed:', error);
|
||||
alert('Error: ' + error.message);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Saves to gallery without printing
|
||||
const handleSaveOnly = async () => {
|
||||
setIsProcessing(true);
|
||||
setStatusMessage('Generating image...');
|
||||
try {
|
||||
let saveUri = currentPhoto;
|
||||
|
||||
if (layout !== 'single') {
|
||||
const capturedUri = await viewShotRef.current.capture();
|
||||
saveUri = capturedUri;
|
||||
}
|
||||
|
||||
setStatusMessage('Saving to tablet library...');
|
||||
await saveToGallery(saveUri);
|
||||
|
||||
setStatusMessage('Saved successfully!');
|
||||
setTimeout(() => {
|
||||
setIsProcessing(false);
|
||||
onReset();
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Saving failed:', error);
|
||||
alert('Error: ' + error.message);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render the selected collage inside the capture container
|
||||
const renderCollageView = () => {
|
||||
switch (layout) {
|
||||
case 'strip':
|
||||
return (
|
||||
<View style={[styles.collageCanvas, styles.stripCanvas]}>
|
||||
<View style={styles.stripContent}>
|
||||
{photoUris.slice(-3).map((uri, idx) => (
|
||||
<Image key={idx} source={{ uri }} style={styles.stripImage} />
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.collageFooter}>
|
||||
<Text style={styles.collageFooterText}>SCHNAPPIX PHOTO BOOTH</Text>
|
||||
<Text style={styles.collageFooterDate}>{new Date().toLocaleDateString()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
case 'grid':
|
||||
return (
|
||||
<View style={[styles.collageCanvas, styles.gridCanvas]}>
|
||||
<View style={styles.gridContainer}>
|
||||
{photoUris.slice(-4).map((uri, idx) => (
|
||||
<Image key={idx} source={{ uri }} style={styles.gridImage} />
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.collageFooter}>
|
||||
<Text style={styles.collageFooterText}>SCHNAPPIX PHOTO BOOTH</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
case 'duo':
|
||||
return (
|
||||
<View style={[styles.collageCanvas, styles.duoCanvas]}>
|
||||
<View style={styles.duoContainer}>
|
||||
{photoUris.slice(-2).map((uri, idx) => (
|
||||
<Image key={idx} source={{ uri }} style={styles.duoImage} />
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.collageFooter}>
|
||||
<Text style={styles.collageFooterText}>SCHNAPPIX PHOTO BOOTH</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<View style={styles.singleCanvas}>
|
||||
<Image source={{ uri: currentPhoto }} style={styles.singleImage} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Left panel: Preview Canvas */}
|
||||
<View style={styles.previewPanel}>
|
||||
{layout === 'single' ? (
|
||||
renderCollageView()
|
||||
) : (
|
||||
// ViewShot wraps the collage to capture it at exactly 3:2 ratio (1200x1800 px) for the printer
|
||||
<ViewShot
|
||||
ref={viewShotRef}
|
||||
options={{ format: 'jpg', quality: 0.95 }}
|
||||
style={styles.viewShotContainer}
|
||||
>
|
||||
{renderCollageView()}
|
||||
</ViewShot>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Right panel: Controls */}
|
||||
<View style={styles.controlPanel}>
|
||||
<Text style={styles.header}>CHOOSE YOUR STYLE</Text>
|
||||
|
||||
{/* Layout Selection */}
|
||||
<View style={styles.layoutSelector}>
|
||||
<TouchableOpacity
|
||||
style={[styles.layoutBtn, layout === 'single' && styles.layoutBtnActive]}
|
||||
onPress={() => setLayout('single')}
|
||||
>
|
||||
<Text style={styles.layoutBtnText}>Single</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.layoutBtn,
|
||||
photoUris.length < 2 && styles.layoutBtnDisabled,
|
||||
layout === 'duo' && styles.layoutBtnActive,
|
||||
]}
|
||||
disabled={photoUris.length < 2}
|
||||
onPress={() => setLayout('duo')}
|
||||
>
|
||||
<Text style={styles.layoutBtnText}>Duo (2)</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.layoutBtn,
|
||||
photoUris.length < 3 && styles.layoutBtnDisabled,
|
||||
layout === 'strip' && styles.layoutBtnActive,
|
||||
]}
|
||||
disabled={photoUris.length < 3}
|
||||
onPress={() => setLayout('strip')}
|
||||
>
|
||||
<Text style={styles.layoutBtnText}>Strip (3)</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.layoutBtn,
|
||||
photoUris.length < 4 && styles.layoutBtnDisabled,
|
||||
layout === 'grid' && styles.layoutBtnActive,
|
||||
]}
|
||||
disabled={photoUris.length < 4}
|
||||
onPress={() => setLayout('grid')}
|
||||
>
|
||||
<Text style={styles.layoutBtnText}>Grid (4)</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={styles.photosCountText}>
|
||||
Photos Captured: {photoUris.length} / 4
|
||||
</Text>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={[styles.actionBtn, styles.printBtn]} onPress={handlePrint}>
|
||||
<Text style={styles.actionBtnText}>PRINT NOW 🖨️</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.actionBtn, styles.saveBtn]} onPress={handleSaveOnly}>
|
||||
<Text style={styles.actionBtnText}>Save to Tablet Only 💾</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{photoUris.length < 4 && (
|
||||
<TouchableOpacity style={[styles.actionBtn, styles.addBtn]} onPress={onAddAnother}>
|
||||
<Text style={styles.actionBtnText}>+ Add Another Photo</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<View style={styles.rowActions}>
|
||||
<TouchableOpacity style={[styles.smallBtn, styles.retakeBtn]} onPress={onRetakeLast}>
|
||||
<Text style={styles.smallBtnText}>Retake Last</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.smallBtn, styles.resetBtn]} onPress={onReset}>
|
||||
<Text style={styles.smallBtnText}>Exit</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isProcessing && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<View style={styles.loadingBox}>
|
||||
<ActivityIndicator size="large" color={THEME.colors.primary} />
|
||||
<Text style={styles.loadingText}>{statusMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: THEME.colors.background,
|
||||
},
|
||||
previewPanel: {
|
||||
flex: 1.2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: THEME.spacing.md,
|
||||
backgroundColor: '#050507',
|
||||
},
|
||||
viewShotContainer: {
|
||||
// 3:2 ratio aspect layout matching Canon Selphy postcard paper
|
||||
width: 320,
|
||||
height: 480,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlPanel: {
|
||||
flex: 0.8,
|
||||
backgroundColor: THEME.colors.surface,
|
||||
padding: THEME.spacing.xl,
|
||||
justifyContent: 'center',
|
||||
borderLeftWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
},
|
||||
header: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
color: THEME.colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: THEME.spacing.md,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
layoutSelector: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: THEME.spacing.md,
|
||||
},
|
||||
layoutBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: THEME.spacing.sm,
|
||||
backgroundColor: THEME.colors.surfaceSecondary,
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
borderRadius: THEME.borderRadius.sm,
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 3,
|
||||
},
|
||||
layoutBtnActive: {
|
||||
backgroundColor: THEME.colors.primary,
|
||||
borderColor: THEME.colors.primary,
|
||||
},
|
||||
layoutBtnDisabled: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
layoutBtnText: {
|
||||
color: THEME.colors.text,
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
photosCountText: {
|
||||
fontSize: 14,
|
||||
color: THEME.colors.textMuted,
|
||||
textAlign: 'center',
|
||||
marginBottom: THEME.spacing.lg,
|
||||
},
|
||||
actions: {
|
||||
width: '100%',
|
||||
},
|
||||
actionBtn: {
|
||||
width: '100%',
|
||||
paddingVertical: THEME.spacing.md,
|
||||
borderRadius: THEME.borderRadius.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: THEME.spacing.md,
|
||||
},
|
||||
printBtn: {
|
||||
backgroundColor: THEME.colors.primary,
|
||||
},
|
||||
saveBtn: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
},
|
||||
addBtn: {
|
||||
backgroundColor: THEME.colors.accent,
|
||||
},
|
||||
actionBtnText: {
|
||||
color: THEME.colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
rowActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: THEME.spacing.sm,
|
||||
},
|
||||
smallBtn: {
|
||||
flex: 0.48,
|
||||
paddingVertical: THEME.spacing.sm,
|
||||
borderRadius: THEME.borderRadius.sm,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
retakeBtn: {
|
||||
borderColor: THEME.colors.error,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
resetBtn: {
|
||||
borderColor: THEME.colors.border,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
smallBtnText: {
|
||||
color: THEME.colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
// Collage Canvas Layouts (3:2 ratio aspect - width 320, height 480)
|
||||
singleCanvas: {
|
||||
width: 320,
|
||||
height: 480,
|
||||
backgroundColor: '#fff',
|
||||
padding: 10,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
},
|
||||
singleImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
collageCanvas: {
|
||||
width: 320,
|
||||
height: 480,
|
||||
backgroundColor: '#ffffff',
|
||||
padding: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
collageFooter: {
|
||||
height: 35,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: '#e0e0e0',
|
||||
marginTop: 6,
|
||||
},
|
||||
collageFooterText: {
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
color: '#333333',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
collageFooterDate: {
|
||||
fontSize: 8,
|
||||
color: '#666666',
|
||||
},
|
||||
// Strip (3 images vertical)
|
||||
stripCanvas: {},
|
||||
stripContent: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
stripImage: {
|
||||
width: '100%',
|
||||
height: '31%',
|
||||
resizeMode: 'cover',
|
||||
borderRadius: 2,
|
||||
},
|
||||
// Grid (4 images 2x2)
|
||||
gridCanvas: {},
|
||||
gridContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
gridImage: {
|
||||
width: '49%',
|
||||
height: '49%',
|
||||
resizeMode: 'cover',
|
||||
borderRadius: 2,
|
||||
},
|
||||
// Duo (2 images side by side or stacked)
|
||||
duoCanvas: {},
|
||||
duoContainer: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
duoImage: {
|
||||
width: '100%',
|
||||
height: '49%',
|
||||
resizeMode: 'cover',
|
||||
borderRadius: 2,
|
||||
},
|
||||
// Loading overlay
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: THEME.colors.overlay,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 999,
|
||||
},
|
||||
loadingBox: {
|
||||
backgroundColor: THEME.colors.surfaceSecondary,
|
||||
padding: THEME.spacing.xl,
|
||||
borderRadius: THEME.borderRadius.md,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
},
|
||||
loadingText: {
|
||||
color: THEME.colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginTop: THEME.spacing.md,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import ipp from 'ipp-encoder';
|
||||
import { Buffer } from 'buffer';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
export interface PrintOptions {
|
||||
ipAddress: string;
|
||||
jobName?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a print job directly and silently to the printer via IPP (Internet Printing Protocol).
|
||||
* Bypasses the OS-level print dialog.
|
||||
*
|
||||
* @param imageUri Local file URI of the JPEG photo to print.
|
||||
* @param options Printer connection settings (IP address, job name).
|
||||
*/
|
||||
export async function printImageLocal(imageUri: string, options: PrintOptions): Promise<void> {
|
||||
const { ipAddress, jobName = 'Schnappix Photo', username = 'Schnappix Kiosk' } = options;
|
||||
|
||||
if (!ipAddress) {
|
||||
throw new Error('Printer IP address is required.');
|
||||
}
|
||||
|
||||
// 1. Read the image file from local storage as Base64 and convert to binary Buffer
|
||||
const base64Data = await FileSystem.readAsStringAsync(imageUri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
const imageBuffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// 2. Build the IPP Print-Job request structure
|
||||
const ippRequestObj = {
|
||||
version: { major: 1, minor: 1 },
|
||||
operationId: 0x0002, // Print-Job operation
|
||||
requestId: 1,
|
||||
groups: [
|
||||
{
|
||||
tag: 0x01, // OPERATION_ATTRIBUTES_TAG
|
||||
attributes: [
|
||||
{ tag: 0x47, name: 'attributes-charset', value: 'utf-8' },
|
||||
{ tag: 0x48, name: 'attributes-natural-language', value: 'en-us' },
|
||||
{ tag: 0x45, name: 'printer-uri', value: `ipp://${ipAddress}:631/ipp/print` },
|
||||
{ tag: 0x42, name: 'requesting-user-name', value: username },
|
||||
{ tag: 0x42, name: 'job-name', value: jobName },
|
||||
{ tag: 0x49, name: 'document-format', value: 'image/jpeg' },
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 3. Serialize the IPP metadata structure into a binary buffer
|
||||
const ippHeaderBuffer = ipp.request.encode(ippRequestObj);
|
||||
|
||||
// 4. Concatenate the IPP request buffer with the raw JPEG image data
|
||||
const finalPayload = Buffer.concat([ippHeaderBuffer, imageBuffer]);
|
||||
|
||||
// 5. Send the POST request to the printer via standard HTTP on port 631
|
||||
const printerUrl = `http://${ipAddress}:631/ipp/print`;
|
||||
|
||||
console.log(`Sending silent print job to ${printerUrl}...`);
|
||||
|
||||
const response = await fetch(printerUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/ipp',
|
||||
},
|
||||
// Pass as Uint8Array to ensure React Native fetch treats it as raw binary
|
||||
body: new Uint8Array(finalPayload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Printer responded with HTTP status ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 6. Read and decode the response from the printer to verify success
|
||||
const responseArrayBuffer = await response.arrayBuffer();
|
||||
const responseBuffer = Buffer.from(responseArrayBuffer);
|
||||
|
||||
try {
|
||||
const decodedResponse = ipp.response.decode(responseBuffer);
|
||||
const statusCode = decodedResponse.statusCode;
|
||||
|
||||
// IPP success status is 0x0000 (successful-ok)
|
||||
if (statusCode !== 0x0000) {
|
||||
throw new Error(`Printer returned IPP error code: 0x${statusCode.toString(16)}`);
|
||||
}
|
||||
console.log('Silent print job accepted successfully!');
|
||||
} catch (e: any) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
export interface AppSettings {
|
||||
countdownDuration: number; // in seconds
|
||||
printerIp: string;
|
||||
adminPassword: string;
|
||||
kioskModeEnabled: boolean;
|
||||
}
|
||||
|
||||
const SETTINGS_FILE = `${FileSystem.documentDirectory}settings.json`;
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
countdownDuration: 3,
|
||||
printerIp: '192.168.1.100',
|
||||
adminPassword: '1234',
|
||||
kioskModeEnabled: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Load settings from persistent storage.
|
||||
*/
|
||||
export async function loadSettings(): Promise<AppSettings> {
|
||||
try {
|
||||
const fileInfo = await FileSystem.getInfoAsync(SETTINGS_FILE);
|
||||
if (fileInfo.exists) {
|
||||
const content = await FileSystem.readAsStringAsync(SETTINGS_FILE);
|
||||
const parsed = JSON.parse(content);
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings, using defaults:', e);
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to persistent storage.
|
||||
*/
|
||||
export async function saveSettings(settings: AppSettings): Promise<void> {
|
||||
try {
|
||||
const content = JSON.stringify(settings, null, 2);
|
||||
await FileSystem.writeAsStringAsync(SETTINGS_FILE, content);
|
||||
console.log('Settings saved successfully:', content);
|
||||
} catch (e) {
|
||||
console.error('Failed to save settings:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const ALBUM_NAME = 'Schnappix';
|
||||
|
||||
/**
|
||||
* Request necessary media library permissions.
|
||||
* @returns Promise<boolean> True if permissions are granted.
|
||||
*/
|
||||
export async function requestStoragePermission(): Promise<boolean> {
|
||||
const { status, canAskAgain } = await MediaLibrary.getPermissionsAsync();
|
||||
if (status === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (canAskAgain) {
|
||||
const { status: newStatus } = await MediaLibrary.requestPermissionsAsync();
|
||||
return newStatus === 'granted';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a local image file directly to the public DCIM gallery inside the "Schnappix" album.
|
||||
*
|
||||
* @param localUri The local file URI of the image (temp cached file).
|
||||
* @returns Promise<string> The permanent URI of the saved asset in the gallery.
|
||||
*/
|
||||
export async function saveToGallery(localUri: string): Promise<string> {
|
||||
const hasPermission = await requestStoragePermission();
|
||||
if (!hasPermission) {
|
||||
throw new Error('Storage permission not granted. Cannot save photo to gallery.');
|
||||
}
|
||||
|
||||
// 1. Create a media asset from the local file
|
||||
const asset = await MediaLibrary.createAssetAsync(localUri);
|
||||
|
||||
try {
|
||||
// 2. Check if the "Schnappix" album already exists
|
||||
let album = await MediaLibrary.getAlbumAsync(ALBUM_NAME);
|
||||
|
||||
if (!album) {
|
||||
// 3. If it doesn't exist, create it with our asset
|
||||
await MediaLibrary.createAlbumAsync(ALBUM_NAME, asset, false);
|
||||
console.log(`Created new album "${ALBUM_NAME}" and saved photo.`);
|
||||
} else {
|
||||
// 4. If it exists, add our asset to it
|
||||
await MediaLibrary.addAssetsToAlbumAsync([asset], album, false);
|
||||
console.log(`Saved photo to existing album "${ALBUM_NAME}".`);
|
||||
}
|
||||
|
||||
return asset.uri;
|
||||
} catch (e: any) {
|
||||
console.error('Error saving asset to album, returning fallback asset URI:', e);
|
||||
return asset.uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export const THEME = {
|
||||
colors: {
|
||||
background: '#0B0B0E',
|
||||
surface: '#121217',
|
||||
surfaceSecondary: '#1C1C24',
|
||||
primary: '#6C5DD3',
|
||||
primaryDark: '#564AB1',
|
||||
accent: '#FF7A9A',
|
||||
text: '#FFFFFF',
|
||||
textMuted: '#8F90A6',
|
||||
border: 'rgba(255, 255, 255, 0.08)',
|
||||
glassBackground: 'rgba(18, 18, 23, 0.75)',
|
||||
overlay: 'rgba(0, 0, 0, 0.85)',
|
||||
success: '#3F8CFF',
|
||||
error: '#FF6A55',
|
||||
},
|
||||
fonts: {
|
||||
// Falls back to system sans-serif which is clean and modern on Android/iOS
|
||||
regular: 'System',
|
||||
bold: 'System',
|
||||
},
|
||||
spacing: {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
},
|
||||
borderRadius: {
|
||||
sm: 6,
|
||||
md: 12,
|
||||
lg: 20,
|
||||
xl: 30,
|
||||
round: 9999,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user