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:
2026-05-25 16:44:15 +02:00
parent 5958319772
commit 872e1ffc86
51 changed files with 8931 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
+140
View File
@@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react';
import { StyleSheet, View, SafeAreaView, StatusBar } from 'react-native';
import HomeScreen from './src/screens/HomeScreen';
import CameraScreen from './src/screens/CameraScreen';
import PreviewScreen from './src/screens/PreviewScreen';
import AdminScreen from './src/screens/AdminScreen';
import { loadSettings, AppSettings } from './src/services/settings';
import KioskMode from './modules/kiosk-mode';
type ScreenState = 'home' | 'camera' | 'preview' | 'admin';
export default function App() {
const [currentScreen, setCurrentScreen] = useState<ScreenState>('home');
const [photoUris, setPhotoUris] = useState<string[]>([]);
const [settings, setSettings] = useState<AppSettings | null>(null);
// 1. Load configuration settings on app start
useEffect(() => {
async function initApp() {
const savedSettings = await loadSettings();
setSettings(savedSettings);
// Auto-start Kiosk mode if it was configured as enabled
if (savedSettings.kioskModeEnabled) {
try {
KioskMode.startKiosk();
} catch (e) {
console.warn('Failed to auto-start Kiosk mode:', e);
}
}
}
initApp();
}, []);
if (!settings) {
return <View style={styles.loadingContainer} />;
}
// 2. Navigation Actions
const handleStartBooth = () => {
setPhotoUris([]);
setCurrentScreen('camera');
};
const handlePhotoCaptured = (uri: string) => {
setPhotoUris((prev) => [...prev, uri]);
setCurrentScreen('preview');
};
const handleRetakeLast = () => {
// Remove last captured photo
const updatedUris = [...photoUris];
updatedUris.pop();
setPhotoUris(updatedUris);
// If we have other photos, let user retake from current collage length
if (updatedUris.length > 0) {
setCurrentScreen('camera');
} else {
setCurrentScreen('home');
}
};
const handleAddAnother = () => {
setCurrentScreen('camera');
};
const handleReset = () => {
setPhotoUris([]);
setCurrentScreen('home');
};
const handleSaveSettings = (updated: AppSettings) => {
setSettings(updated);
};
const handleOpenAdmin = () => {
setCurrentScreen('admin');
};
// 3. Conditional Screen Rendering
const renderScreen = () => {
switch (currentScreen) {
case 'camera':
return (
<CameraScreen
countdownDuration={settings.countdownDuration}
onPhotoCaptured={handlePhotoCaptured}
onCancel={handleReset}
/>
);
case 'preview':
return (
<PreviewScreen
photoUris={photoUris}
printerIp={settings.printerIp}
onRetakeLast={handleRetakeLast}
onAddAnother={handleAddAnother}
onReset={handleReset}
/>
);
case 'admin':
return (
<AdminScreen
currentSettings={settings}
onSave={handleSaveSettings}
onClose={() => setCurrentScreen('home')}
/>
);
default:
return (
<HomeScreen
onStart={handleStartBooth}
onNavigateToAdmin={handleOpenAdmin}
adminPassword={settings.adminPassword}
/>
);
}
};
return (
<SafeAreaView style={styles.container}>
<StatusBar hidden={true} />
{renderScreen()}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000000',
},
loadingContainer: {
flex: 1,
backgroundColor: '#050507',
justifyContent: 'center',
alignItems: 'center',
},
});
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1 +1 @@
ECHO ist eingeschaltet (ON).
Schnappix
+54
View File
@@ -0,0 +1,54 @@
{
"expo": {
"name": "Schnappix",
"slug": "Schnappix",
"version": "1.0.0",
"orientation": "landscape",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.schnappix"
},
"android": {
"package": "com.schnappix",
"adaptiveIcon": {
"backgroundColor": "#121214",
"foregroundImage": "./assets/icon.png"
},
"permissions": [
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO",
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE"
]
},
"plugins": [
[
"expo-media-library",
{
"photosPermission": "Allow Schnappix to save captured photos to your gallery.",
"savePhotosPermission": "Allow Schnappix to save captured photos to your gallery."
}
],
[
"expo-camera",
{
"cameraPermission": "Allow Schnappix to access the tablet's front-facing camera."
}
],
[
"expo-build-properties",
{
"android": {
"extraMavenRepos": [
"https://jitpack.io"
]
}
}
],
"./plugins/withKioskAdmin",
"./plugins/withUsbCamera"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+8
View File
@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
@@ -0,0 +1,2 @@
#Mon May 25 16:29:05 CEST 2026
gradle.version=8.9
+21
View File
@@ -0,0 +1,21 @@
apply plugin: 'com.android.library'
apply plugin: 'expo-module-gradle-plugin'
apply plugin: 'org.jetbrains.kotlin.android'
android {
namespace "expo.modules.kioskmode"
compileSdkVersion findProperty("expo.compileSdkVersion") ?: 34
defaultConfig {
minSdkVersion findProperty("expo.minSdkVersion") ?: 23
targetSdkVersion findProperty("expo.targetSdkVersion") ?: 34
}
buildFeatures {
buildConfig true
}
}
dependencies {
implementation 'com.facebook.react:react-android'
}
@@ -0,0 +1,25 @@
package expo.modules.kioskmode
import android.app.admin.DeviceAdminReceiver
import android.content.Context
import android.content.Intent
import android.widget.Toast
import android.content.ComponentName
class AdminReceiver : DeviceAdminReceiver() {
override fun onEnabled(context: Context, intent: Intent) {
super.onEnabled(context, intent)
Toast.makeText(context, "Schnappix Admin Enabled", Toast.LENGTH_SHORT).show()
}
override fun onDisabled(context: Context, intent: Intent) {
super.onDisabled(context, intent)
Toast.makeText(context, "Schnappix Admin Disabled", Toast.LENGTH_SHORT).show()
}
companion object {
fun getComponentName(context: Context): ComponentName {
return ComponentName(context.applicationContext, AdminReceiver::class.java)
}
}
}
@@ -0,0 +1,63 @@
package expo.modules.kioskmode
import android.app.Activity
import android.app.ActivityManager
import android.app.admin.DevicePolicyManager
import android.content.Context
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class KioskModeModule : Module() {
override fun definition() = ModuleDefinition {
Name("KioskMode")
Function("startKiosk") {
val activity = appContext.currentActivity ?: return@Function false
val dpm = activity.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
val packageName = activity.packageName
val adminComponent = AdminReceiver.getComponentName(activity)
activity.runOnUiThread {
try {
if (dpm.isDeviceOwnerApp(packageName)) {
// Whitelist package to enable true Kiosk Mode
dpm.setLockTaskPackages(adminComponent, arrayOf(packageName))
}
activity.startLockTask()
} catch (e: Exception) {
e.printStackTrace()
}
}
return@Function true
}
Function("stopKiosk") {
val activity = appContext.currentActivity ?: return@Function false
activity.runOnUiThread {
try {
activity.stopLockTask()
} catch (e: Exception) {
e.printStackTrace()
}
}
return@Function true
}
Function("isKioskActive") {
val activity = appContext.currentActivity ?: return@Function false
val am = activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return@Function if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
am.lockTaskModeState != ActivityManager.LOCK_TASK_MODE_NONE
} else {
@Suppress("DEPRECATION")
am.isInLockTaskMode
}
}
Function("isDeviceOwner") {
val activity = appContext.currentActivity ?: return@Function false
val dpm = activity.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
return@Function dpm.isDeviceOwnerApp(activity.packageName)
}
}
}
@@ -0,0 +1,8 @@
{
"platforms": ["android"],
"android": {
"modules": [
"expo.modules.kioskmode.KioskModeModule"
]
}
}
+35
View File
@@ -0,0 +1,35 @@
import { requireNativeModule } from 'expo-modules-core';
import { Platform } from 'react-native';
let KioskMode: any = null;
if (Platform.OS === 'android') {
try {
KioskMode = requireNativeModule('KioskMode');
} catch (e) {
console.warn('KioskMode native module not found');
}
}
export interface KioskModeInterface {
startKiosk(): boolean;
stopKiosk(): boolean;
isKioskActive(): boolean;
isDeviceOwner(): boolean;
}
const KioskModeProxy: KioskModeInterface = {
startKiosk() {
return KioskMode ? KioskMode.startKiosk() : false;
},
stopKiosk() {
return KioskMode ? KioskMode.stopKiosk() : false;
},
isKioskActive() {
return KioskMode ? KioskMode.isKioskActive() : false;
},
isDeviceOwner() {
return KioskMode ? KioskMode.isDeviceOwner() : false;
}
};
export default KioskModeProxy;
+23
View File
@@ -0,0 +1,23 @@
apply plugin: 'com.android.library'
apply plugin: 'expo-module-gradle-plugin'
apply plugin: 'org.jetbrains.kotlin.android'
android {
namespace "expo.modules.usbcamera"
compileSdkVersion findProperty("expo.compileSdkVersion") ?: 34
defaultConfig {
minSdkVersion 23 // libausbc requires min SDK 23
targetSdkVersion findProperty("expo.targetSdkVersion") ?: 34
}
buildFeatures {
buildConfig true
}
}
dependencies {
implementation 'com.facebook.react:react-android'
// AndroidUSBCamera library for USB/UVC Webcams
implementation 'com.github.jiangdongguo.AndroidUSBCamera:libausbc:3.3.3'
}
@@ -0,0 +1,27 @@
package expo.modules.usbcamera
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.Promise
class UsbCameraModule : Module() {
override fun definition() = ModuleDefinition {
Name("UsbCamera")
View(UsbCameraView::class) {
Events("onCameraReady")
AsyncFunction("takePicture") { view: UsbCameraView, savePath: String, promise: Promise ->
view.takePicture(savePath, promise)
}
AsyncFunction("isCameraConnected") { view: UsbCameraView, promise: Promise ->
promise.resolve(view.isCameraConnected())
}
OnViewDestroys { view ->
view.onDestroy()
}
}
}
}
@@ -0,0 +1,87 @@
package expo.modules.usbcamera
import android.content.Context
import android.widget.FrameLayout
import android.util.AttributeSet
import com.jiangdg.ausbc.CameraClient
import com.jiangdg.ausbc.widget.AspectRatioTextureView
import com.jiangdg.ausbc.camera.bean.CameraRequest
import com.jiangdg.ausbc.camera.CameraUvcStrategy
import com.jiangdg.ausbc.callback.ICaptureCallBack
import expo.modules.kotlin.views.ExpoView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.Promise
class UsbCameraView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
private val textureView: AspectRatioTextureView
private var cameraClient: CameraClient? = null
init {
textureView = AspectRatioTextureView(context)
addView(textureView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
cameraClient = CameraClient.newBuilder(context)
.setCameraStrategy(CameraUvcStrategy(context))
.setEnableGLES(true)
.build()
post {
startPreview()
}
}
private fun startPreview() {
try {
val cameraRequest = CameraRequest.CameraRequestBuilder()
.setPreviewWidth(1280)
.setPreviewHeight(720)
.create()
cameraClient?.openCamera(textureView, cameraRequest)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun takePicture(savePath: String, promise: Promise) {
val client = cameraClient
if (client == null) {
promise.reject("ERR_CAMERA_NOT_READY", "Camera client is not initialized", null)
return
}
try {
client.captureImage(object : ICaptureCallBack {
override fun onBegin() {
// Do nothing
}
override fun onError(error: String?) {
promise.reject("ERR_CAPTURE_FAILED", error ?: "Unknown error", null)
}
override fun onComplete(path: String?) {
if (path != null) {
promise.resolve(path)
} else {
promise.reject("ERR_CAPTURE_FAILED", "File path was null", null)
}
}
}, savePath)
} catch (e: Exception) {
promise.reject("ERR_CAPTURE_EXCEPTION", e.message ?: "Capture threw an exception", e)
}
}
fun isCameraConnected(): Boolean {
return cameraClient?.isCameraOpened ?: false
}
fun onDestroy() {
try {
cameraClient?.closeCamera()
} catch (e: Exception) {
e.printStackTrace()
}
cameraClient = null
}
}
@@ -0,0 +1,8 @@
{
"platforms": ["android"],
"android": {
"modules": [
"expo.modules.usbcamera.UsbCameraModule"
]
}
}
+20
View File
@@ -0,0 +1,20 @@
import { requireNativeViewManager } from 'expo-modules-core';
import * as React from 'react';
import { ViewProps, Platform } from 'react-native';
const viewName = 'UsbCamera';
export interface UsbCameraViewProps extends ViewProps {
// Add props here if we decide to pass them from JS
}
export interface UsbCameraRef {
takePicture(savePath: string): Promise<string>;
isCameraConnected(): Promise<boolean>;
}
// Export the native view manager component
export const UsbCameraView: React.ComponentType<UsbCameraViewProps & { ref?: React.RefObject<any> }> =
Platform.OS === 'android'
? requireNativeViewManager(viewName)
: (() => null) as any;
+6565
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "schnappix",
"version": "1.0.0",
"main": "index.ts",
"dependencies": {
"buffer": "^6.0.3",
"expo": "~56.0.4",
"expo-build-properties": "~56.0.14",
"expo-camera": "~56.0.7",
"expo-file-system": "~56.0.7",
"expo-media-library": "~56.0.6",
"expo-status-bar": "~56.0.4",
"ipp-encoder": "^5.0.0",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-reanimated": "4.3.1",
"react-native-view-shot": "^5.1.0"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"private": true
}
+78
View File
@@ -0,0 +1,78 @@
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const withKioskAdmin = (config) => {
// 1. Modify AndroidManifest.xml
config = withAndroidManifest(config, async (config) => {
const androidManifest = config.modResults;
const mainApplication = androidManifest.manifest.application[0];
if (!mainApplication.receiver) {
mainApplication.receiver = [];
}
// Check if receiver is already added to avoid duplication
const receiverExists = mainApplication.receiver.some(
(r) => r.$['android:name'] === 'expo.modules.kioskmode.AdminReceiver'
);
if (!receiverExists) {
mainApplication.receiver.push({
$: {
'android:name': 'expo.modules.kioskmode.AdminReceiver',
'android:label': 'Schnappix Kiosk Admin',
'android:permission': 'android.permission.BIND_DEVICE_ADMIN',
'android:exported': 'true',
},
'meta-data': [
{
$: {
'android:name': 'android.app.device_admin',
'android:resource': '@xml/device_admin_receiver',
},
},
],
'intent-filter': [
{
action: [
{ $: { 'android:name': 'android.app.action.DEVICE_ADMIN_ENABLED' } },
{ $: { 'android:name': 'android.app.action.DEVICE_ADMIN_DISABLED' } },
],
},
],
});
}
return config;
});
// 2. Create the device_admin_receiver.xml resource file in android/app/src/main/res/xml/
config = withDangerousMod(config, [
'android',
async (config) => {
const { projectRoot } = config.modRequest;
const resXmlDir = path.join(projectRoot, 'android/app/src/main/res/xml');
// Ensure res/xml directory exists
if (!fs.existsSync(resXmlDir)) {
fs.mkdirSync(resXmlDir, { recursive: true });
}
const xmlFilePath = path.join(resXmlDir, 'device_admin_receiver.xml');
const xmlContent = `<?xml version="1.0" encoding="utf-8"?>
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
<uses-policies>
<force-lock />
</uses-policies>
</device-admin>`;
fs.writeFileSync(xmlFilePath, xmlContent, 'utf8');
return config;
},
]);
return config;
};
module.exports = withKioskAdmin;
+29
View File
@@ -0,0 +1,29 @@
const { withAndroidManifest } = require('@expo/config-plugins');
const withUsbCamera = (config) => {
return withAndroidManifest(config, async (config) => {
const androidManifest = config.modResults;
if (!androidManifest.manifest['uses-feature']) {
androidManifest.manifest['uses-feature'] = [];
}
// Check if usb.host feature is already added
const featureExists = androidManifest.manifest['uses-feature'].some(
(f) => f.$['android:name'] === 'android.hardware.usb.host'
);
if (!featureExists) {
androidManifest.manifest['uses-feature'].push({
$: {
'android:name': 'android.hardware.usb.host',
'android:required': 'false',
},
});
}
return config;
});
};
module.exports = withUsbCamera;
+350
View File
@@ -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,
},
});
+280
View File
@@ -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',
},
});
+261
View File
@@ -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',
},
});
+504
View File
@@ -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,
},
});
+92
View File
@@ -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);
}
}
+48
View File
@@ -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;
}
}
+58
View File
@@ -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;
}
}
+37
View File
@@ -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,
},
};
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}