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,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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user