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,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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);
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
#Mon May 25 16:29:05 CEST 2026
|
||||||
|
gradle.version=8.9
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Generated
+6565
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user