diff --git a/.gitignore b/.gitignore index d914c328..e7c45e0f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,8 @@ yarn-error.* # generated native folders /ios /android + +# Gradle/Android build folders +**/build/ +**/.gradle/ +.vscode/ diff --git a/App.tsx b/App.tsx index e2e5afd4..776186c8 100644 --- a/App.tsx +++ b/App.tsx @@ -1,6 +1,5 @@ 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'; diff --git a/Icon.png b/Icon.png new file mode 100644 index 00000000..1fc215a8 Binary files /dev/null and b/Icon.png differ diff --git a/app.json b/app.json index 0ba59627..398b2731 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "slug": "Schnappix", "version": "1.0.0", "orientation": "landscape", - "icon": "./assets/icon.png", + "icon": "./Icon.png", "userInterfaceStyle": "dark", "ios": { "supportsTablet": true, @@ -14,7 +14,7 @@ "package": "com.schnappix", "adaptiveIcon": { "backgroundColor": "#121214", - "foregroundImage": "./assets/icon.png" + "foregroundImage": "./Icon.png" }, "permissions": [ "android.permission.CAMERA", @@ -48,15 +48,14 @@ { "android": { "extraMavenRepos": [ - "https://jitpack.io", - "https://jcenter.bintray.com" + "https://jitpack.io" ] } } ], "./plugins/withKioskAdmin", "./plugins/withUsbCamera", - "./plugins/withSettingsRepositories" + "./plugins/withLocalAars" ], "extra": { "eas": { diff --git a/modules/usb-camera/android/build.gradle b/modules/usb-camera/android/build.gradle index 7c15d9e1..3a9129c7 100644 --- a/modules/usb-camera/android/build.gradle +++ b/modules/usb-camera/android/build.gradle @@ -31,7 +31,7 @@ dependencies { exclude group: 'com.gyf.immersionbar', module: 'immersionbar' exclude group: 'com.zlc.glide', module: 'webpdecoder' } - // Local AAR dependencies downloaded to avoid JCenter resolution issues - implementation files('libs/immersionbar-3.0.0.aar') - implementation files('libs/webpdecoder-1.6.4.9.0.aar') + // Local AAR dependencies compiled only; injected into app build.gradle by plugin + compileOnly files('libs/immersionbar-3.0.0.aar') + compileOnly files('libs/webpdecoder-1.6.4.9.0.aar') } diff --git a/plugins/withLocalAars.js b/plugins/withLocalAars.js new file mode 100644 index 00000000..92cfa0ed --- /dev/null +++ b/plugins/withLocalAars.js @@ -0,0 +1,26 @@ +const { withAppBuildGradle } = require('@expo/config-plugins'); + +const withLocalAars = (config) => { + return withAppBuildGradle(config, (config) => { + let contents = config.modResults.contents; + + // Inject the local AAR files into dependencies block + const dependencyBlock = ` + // Added by withLocalAars plugin + implementation files("../../modules/usb-camera/android/libs/immersionbar-3.0.0.aar") + implementation files("../../modules/usb-camera/android/libs/webpdecoder-1.6.4.9.0.aar") +`; + + if (!contents.includes('immersionbar-3.0.0.aar')) { + contents = contents.replace( + /dependencies\s*\{/, + `dependencies {${dependencyBlock}` + ); + } + + config.modResults.contents = contents; + return config; + }); +}; + +module.exports = withLocalAars; diff --git a/plugins/withSettingsRepositories.js b/plugins/withSettingsRepositories.js deleted file mode 100644 index 1cc01847..00000000 --- a/plugins/withSettingsRepositories.js +++ /dev/null @@ -1,44 +0,0 @@ -const { withProjectBuildGradle, withSettingsGradle } = require('@expo/config-plugins'); - -const withSettingsRepositories = (config) => { - // 1. Modify settings.gradle (settings-level repositories) - config = withSettingsGradle(config, (config) => { - let contents = config.modResults.contents; - const reposBlock = ` -// Added by withSettingsRepositories plugin -dependencyResolutionManagement { - repositories { - maven { url 'https://jitpack.io' } - maven { url 'https://jcenter.bintray.com' } - } -} -`; - if (!contents.includes('https://jcenter.bintray.com')) { - contents += reposBlock; - } - config.modResults.contents = contents; - return config; - }); - - // 2. Modify root build.gradle (project-level repositories) - config = withProjectBuildGradle(config, (config) => { - let contents = config.modResults.contents; - - // Inject jcenter and jitpack into the allprojects.repositories block - if (!contents.includes('https://jcenter.bintray.com')) { - contents = contents.replace( - /maven\s*\{\s*url\s*['"]https:\/\/www\.jitpack\.io['"]\s*\}/g, - `maven { url 'https://www.jitpack.io' } - maven { url 'https://jcenter.bintray.com' } - maven { url 'https://jitpack.io' }` - ); - } - - config.modResults.contents = contents; - return config; - }); - - return config; -}; - -module.exports = withSettingsRepositories; diff --git a/src/screens/AdminScreen.tsx b/src/screens/AdminScreen.tsx index e032cd18..2d4b680c 100644 --- a/src/screens/AdminScreen.tsx +++ b/src/screens/AdminScreen.tsx @@ -9,6 +9,7 @@ import { Switch, Alert, } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { THEME } from '../styles/theme'; import KioskMode from '../../modules/kiosk-mode'; import { AppSettings, saveSettings } from '../services/settings'; @@ -82,10 +83,16 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS return; } - if (!printerIp.trim()) { + const ipTrimmed = printerIp.trim(); + const ipRegex = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/; + if (!ipTrimmed) { Alert.alert('Ungültige Eingabe', 'Die Drucker-IP-Adresse darf nicht leer sein.'); return; } + if (!ipRegex.test(ipTrimmed)) { + Alert.alert('Ungültige Eingabe', 'Bitte gib eine gültige IP-Adresse ein (z.B. 192.168.1.100).'); + return; + } if (!password.trim() || password.length < 4) { Alert.alert('Ungültige Eingabe', 'Das Admin-Passwort muss mindestens 4 Ziffern lang sein.'); @@ -94,7 +101,7 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS const updated: AppSettings = { countdownDuration: duration, - printerIp: printerIp.trim(), + printerIp: ipTrimmed, adminPassword: password.trim(), kioskModeEnabled: kioskActive, }; @@ -109,7 +116,7 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS }; return ( - + SCHNAPPIX EINSTELLUNGEN @@ -207,7 +214,7 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS EINSTELLUNGEN SPEICHERN - + ); } diff --git a/src/screens/CameraScreen.tsx b/src/screens/CameraScreen.tsx index 04ee3ae4..ca4458dd 100644 --- a/src/screens/CameraScreen.tsx +++ b/src/screens/CameraScreen.tsx @@ -10,7 +10,8 @@ import { TouchableWithoutFeedback, } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; -import * as FileSystem from 'expo-file-system/legacy'; +import { File, Paths } from 'expo-file-system'; +import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, FadeIn, FadeOut } from 'react-native-reanimated'; import { THEME } from '../styles/theme'; import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera'; import { saveToGallery } from '../services/storage'; @@ -48,6 +49,22 @@ export default function CameraScreen({ const usbCameraRef = useRef(null); const expoCameraRef = useRef(null); + // Reanimated countdown pulse animation + const scale = useSharedValue(1); + + useEffect(() => { + if (countdown !== '') { + scale.value = 0.5; + scale.value = withSpring(1.25, { damping: 10, stiffness: 120 }, () => { + scale.value = withTiming(1.0, { duration: 150 }); + }); + } + }, [countdown]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + // 1. Request built-in camera permission on mount (just in case we fallback) useEffect(() => { if (!permission || !permission.granted) { @@ -117,7 +134,8 @@ export default function CameraScreen({ // 4. Capture photo function const capture = async () => { const filename = `photo_${Date.now()}.jpg`; - const tempUri = `${FileSystem.cacheDirectory}${filename}`; + const tempFile = new File(Paths.cache, filename); + const tempUri = tempFile.uri; try { if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) { @@ -289,9 +307,9 @@ export default function CameraScreen({ {/* Countdown overlay */} {hasStarted && ( - + {countdown} - + )} @@ -306,7 +324,7 @@ export default function CameraScreen({ }; return ( - + {/* Camera Preview Background */} {Platform.OS === 'android' && isUsbConnected ? ( @@ -322,7 +340,7 @@ export default function CameraScreen({ {/* Render UI controls/overlays on top */} {renderContent()} - + ); } diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx deleted file mode 100644 index 1ea25cd1..00000000 --- a/src/screens/HomeScreen.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import React, { useState } from 'react'; -import { - StyleSheet, - Text, - View, - TouchableOpacity, - Modal, - TextInput, - TouchableWithoutFeedback, -} from 'react-native'; -import { THEME } from '../styles/theme'; - -interface HomeScreenProps { - onStart: () => void; - onNavigateToAdmin: () => void; - adminPassword?: string; -} - -export default function HomeScreen({ onStart, onNavigateToAdmin, adminPassword = '1234' }: HomeScreenProps) { - const [passwordModalVisible, setPasswordModalVisible] = useState(false); - const [enteredPassword, setEnteredPassword] = useState(''); - const [errorText, setErrorText] = useState(''); - - const handleSettingsTap = () => { - setEnteredPassword(''); - setErrorText(''); - setPasswordModalVisible(true); - }; - - const handlePasswordSubmit = () => { - if (enteredPassword === adminPassword) { - setPasswordModalVisible(false); - onNavigateToAdmin(); - } else { - setErrorText('Falsches Passwort'); - setEnteredPassword(''); - } - }; - - return ( - - - {/* Settings button in the top-right corner */} - - ⚙️ - - - - SCHNAPPIX - Willkommen zu unserer Feier! - - - - - ZUM STARTEN ÜBERALL TIPPEN - - - Fotos machen • Collage erstellen • Sofort drucken - - - {/* Password Prompt Modal */} - setPasswordModalVisible(false)} - > - - - Admin-Zugang - Passwort eingeben, um Einstellungen zu öffnen - - - - {errorText ? {errorText} : null} - - - setPasswordModalVisible(false)} - > - Abbrechen - - - Öffnen - - - - - - - - ); -} - -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', - }, -}); diff --git a/src/screens/PreviewScreen.tsx b/src/screens/PreviewScreen.tsx index 7081076c..98ae8f5e 100644 --- a/src/screens/PreviewScreen.tsx +++ b/src/screens/PreviewScreen.tsx @@ -9,6 +9,7 @@ import { ActivityIndicator, } from 'react-native'; import ViewShot from 'react-native-view-shot'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { THEME } from '../styles/theme'; import { printImageLocal } from '../services/printer'; import { saveToGallery } from '../services/storage'; @@ -180,7 +181,7 @@ export default function PreviewScreen({ }; return ( - + {/* Left panel: Preview Canvas */} {layout === 'single' ? ( @@ -288,7 +289,7 @@ export default function PreviewScreen({ )} - + ); } diff --git a/src/services/printer.ts b/src/services/printer.ts index 15a1dfcd..62cad4b3 100644 --- a/src/services/printer.ts +++ b/src/services/printer.ts @@ -1,6 +1,6 @@ import ipp from 'ipp-encoder'; import { Buffer } from 'buffer'; -import * as FileSystem from 'expo-file-system/legacy'; +import { File } from 'expo-file-system'; export interface PrintOptions { ipAddress: string; @@ -23,9 +23,8 @@ export async function printImageLocal(imageUri: string, options: PrintOptions): } // 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 file = new File(imageUri); + const base64Data = await file.base64(); const imageBuffer = Buffer.from(base64Data, 'base64'); // 2. Build the IPP Print-Job request structure @@ -57,36 +56,55 @@ export async function printImageLocal(imageUri: string, options: PrintOptions): // 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), - }); + const maxRetries = 3; + let delay = 1000; + let lastError: any = null; - if (!response.ok) { - throw new Error(`Drucker antwortete mit HTTP-Status ${response.status}: ${response.statusText}`); - } + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + console.log(`Sending silent print job to ${printerUrl} (Attempt ${attempt}/${maxRetries})...`); + + 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), + }); - // 6. Read and decode the response from the printer to verify success - const responseArrayBuffer = await response.arrayBuffer(); - const responseBuffer = Buffer.from(responseArrayBuffer); + if (!response.ok) { + throw new Error(`Drucker antwortete mit HTTP-Status ${response.status}: ${response.statusText}`); + } - try { - const decodedResponse = ipp.response.decode(responseBuffer); - const statusCode = decodedResponse.statusCode; - - // IPP success status is 0x0000 (successful-ok) - if (statusCode !== 0x0000) { - throw new Error(`Drucker lieferte IPP-Fehlercode: 0x${statusCode.toString(16)}`); + // 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(`Drucker lieferte IPP-Fehlercode: 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); + } + + return; // Success, exit retry loop + } catch (error: any) { + console.warn(`Print attempt ${attempt} failed: ${error.message}`); + lastError = error; + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, delay)); + delay *= 2; // exponential backoff + } } - 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); } + + throw new Error(`Druckauftrag nach ${maxRetries} Versuchen fehlgeschlagen. Letzter Fehler: ${lastError?.message}`); } diff --git a/src/services/settings.ts b/src/services/settings.ts index 969752e7..cb0b0485 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -1,4 +1,4 @@ -import * as FileSystem from 'expo-file-system/legacy'; +import { File, Paths } from 'expo-file-system'; export interface AppSettings { countdownDuration: number; // in seconds @@ -7,7 +7,7 @@ export interface AppSettings { kioskModeEnabled: boolean; } -const SETTINGS_FILE = `${FileSystem.documentDirectory}settings.json`; +const settingsFile = new File(Paths.document, 'settings.json'); const DEFAULT_SETTINGS: AppSettings = { countdownDuration: 3, @@ -21,9 +21,8 @@ const DEFAULT_SETTINGS: AppSettings = { */ export async function loadSettings(): Promise { try { - const fileInfo = await FileSystem.getInfoAsync(SETTINGS_FILE); - if (fileInfo.exists) { - const content = await FileSystem.readAsStringAsync(SETTINGS_FILE); + if (settingsFile.exists) { + const content = await settingsFile.text(); const parsed = JSON.parse(content); return { ...DEFAULT_SETTINGS, ...parsed }; } @@ -39,7 +38,7 @@ export async function loadSettings(): Promise { export async function saveSettings(settings: AppSettings): Promise { try { const content = JSON.stringify(settings, null, 2); - await FileSystem.writeAsStringAsync(SETTINGS_FILE, content); + settingsFile.write(content); console.log('Settings saved successfully:', content); } catch (e) { console.error('Failed to save settings:', e); diff --git a/src/services/storage.ts b/src/services/storage.ts index 9098c3e0..faa38daf 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,5 +1,4 @@ -import * as MediaLibrary from 'expo-media-library/legacy'; -import { Platform } from 'react-native'; +import { Asset, Album, getPermissionsAsync, requestPermissionsAsync } from 'expo-media-library'; const ALBUM_NAME = 'Schnappix'; @@ -8,13 +7,13 @@ const ALBUM_NAME = 'Schnappix'; * @returns Promise True if permissions are granted. */ export async function requestStoragePermission(): Promise { - const { status, canAskAgain } = await MediaLibrary.getPermissionsAsync(); + const { status, canAskAgain } = await getPermissionsAsync(); if (status === 'granted') { return true; } if (canAskAgain) { - const { status: newStatus } = await MediaLibrary.requestPermissionsAsync(); + const { status: newStatus } = await requestPermissionsAsync(); return newStatus === 'granted'; } @@ -34,25 +33,30 @@ export async function saveToGallery(localUri: string): Promise { } // 1. Create a media asset from the local file - const asset = await MediaLibrary.createAssetAsync(localUri); + const asset = await Asset.create(localUri); try { // 2. Check if the "Schnappix" album already exists - let album = await MediaLibrary.getAlbumAsync(ALBUM_NAME); + const album = await Album.get(ALBUM_NAME); if (!album) { // 3. If it doesn't exist, create it with our asset - await MediaLibrary.createAlbumAsync(ALBUM_NAME, asset, false); + await Album.create(ALBUM_NAME, [asset], false); console.log(`Created new album "${ALBUM_NAME}" and saved photo.`); } else { // 4. If it exists, add our asset to it - await MediaLibrary.addAssetsToAlbumAsync([asset], album, false); + await album.add(asset); console.log(`Saved photo to existing album "${ALBUM_NAME}".`); } - return asset.uri; + const assetUri = await asset.getUri(); + return assetUri; } catch (e: any) { console.error('Error saving asset to album, returning fallback asset URI:', e); - return asset.uri; + try { + return await asset.getUri(); + } catch { + return asset.id; + } } }