Fix iOS crash, storage, and printing issues; setup apk build

This commit is contained in:
2026-05-30 23:09:36 +02:00
parent eee684a3c9
commit 54666b077a
19 changed files with 1821 additions and 149 deletions
+23 -2
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { StyleSheet, View, SafeAreaView, StatusBar } from 'react-native'; import { StyleSheet, View, SafeAreaView, StatusBar } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import CameraScreen from './src/screens/CameraScreen'; import CameraScreen from './src/screens/CameraScreen';
import PreviewScreen from './src/screens/PreviewScreen'; import PreviewScreen from './src/screens/PreviewScreen';
import AdminScreen from './src/screens/AdminScreen'; import AdminScreen from './src/screens/AdminScreen';
@@ -46,6 +47,12 @@ export default function App() {
setCurrentScreen('preview'); setCurrentScreen('preview');
}; };
// Called by CameraScreen when burst mode completes all photos
const handleBurstComplete = (uris: string[]) => {
setPhotoUris((prev) => [...prev, ...uris]);
setCurrentScreen('preview');
};
const handleRetakeLast = () => { const handleRetakeLast = () => {
// Remove last captured photo // Remove last captured photo
const updatedUris = [...photoUris]; const updatedUris = [...photoUris];
@@ -85,8 +92,11 @@ export default function App() {
<CameraScreen <CameraScreen
countdownDuration={settings.countdownDuration} countdownDuration={settings.countdownDuration}
onPhotoCaptured={handlePhotoCaptured} onPhotoCaptured={handlePhotoCaptured}
onBurstComplete={handleBurstComplete}
onCancel={handleReset} onCancel={handleReset}
isIdle={false} isIdle={false}
burstCount={settings.burstCount}
burstIntervalSec={settings.burstIntervalSec}
/> />
); );
case 'preview': case 'preview':
@@ -97,6 +107,11 @@ export default function App() {
onRetakeLast={handleRetakeLast} onRetakeLast={handleRetakeLast}
onAddAnother={handleAddAnother} onAddAnother={handleAddAnother}
onReset={handleReset} onReset={handleReset}
frameMode={settings.frameMode}
initialFrameId={settings.selectedFrameId}
dateOverlay={settings.dateOverlay}
dateOverlayPosition={settings.dateOverlayPosition}
eventText={settings.eventText}
/> />
); );
case 'admin': case 'admin':
@@ -112,32 +127,38 @@ export default function App() {
<CameraScreen <CameraScreen
countdownDuration={settings.countdownDuration} countdownDuration={settings.countdownDuration}
onPhotoCaptured={handlePhotoCaptured} onPhotoCaptured={handlePhotoCaptured}
onBurstComplete={handleBurstComplete}
onCancel={handleReset} onCancel={handleReset}
isIdle={true} isIdle={true}
onStartBooth={handleStartBooth} onStartBooth={handleStartBooth}
onNavigateToAdmin={handleOpenAdmin} onNavigateToAdmin={handleOpenAdmin}
adminPassword={settings.adminPassword} adminPassword={settings.adminPassword}
welcomeText={settings.welcomeText}
burstCount={settings.burstCount}
burstIntervalSec={settings.burstIntervalSec}
/> />
); );
} }
}; };
return ( return (
<GestureHandlerRootView style={styles.container}>
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar hidden={true} /> <StatusBar hidden={true} />
{renderScreen()} {renderScreen()}
</SafeAreaView> </SafeAreaView>
</GestureHandlerRootView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#000000', backgroundColor: '#050510',
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
backgroundColor: '#050507', backgroundColor: '#050510',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
+1 -1
View File
@@ -13,7 +13,7 @@
"android": { "android": {
"package": "com.schnappix", "package": "com.schnappix",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#121214", "backgroundColor": "#050510",
"foregroundImage": "./Icon.png" "foregroundImage": "./Icon.png"
}, },
"permissions": [ "permissions": [
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+4 -1
View File
@@ -9,7 +9,10 @@
"distribution": "internal" "distribution": "internal"
}, },
"preview": { "preview": {
"distribution": "internal" "distribution": "internal",
"android": {
"buildType": "apk"
}
}, },
"production": { "production": {
"autoIncrement": true "autoIncrement": true
+3
View File
@@ -1,4 +1,7 @@
import { registerRootComponent } from 'expo'; import { registerRootComponent } from 'expo';
import { Buffer } from 'buffer';
global.Buffer = global.Buffer || Buffer;
import App from './App'; import App from './App';
+279
View File
@@ -8,6 +8,9 @@
"name": "schnappix", "name": "schnappix",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"expo": "~54.0.0", "expo": "~54.0.0",
"expo-build-properties": "~1.0.10", "expo-build-properties": "~1.0.10",
@@ -18,7 +21,9 @@
"ipp-encoder": "^5.0.0", "ipp-encoder": "^5.0.0",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.24.0",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-svg": "~15.15.0",
"react-native-view-shot": "4.0.3", "react-native-view-shot": "4.0.3",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1"
}, },
@@ -1615,6 +1620,18 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@egjs/hammerjs": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
"license": "MIT",
"dependencies": {
"@types/hammerjs": "^2.0.36"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@expo/cli": { "node_modules/@expo/cli": {
"version": "54.0.25", "version": "54.0.25",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz",
@@ -2359,6 +2376,54 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz",
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz",
"integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
"integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-native-fontawesome": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@fortawesome/react-native-fontawesome/-/react-native-fontawesome-0.3.2.tgz",
"integrity": "sha512-CiWfJWSZHRg12VXlaeFnaa5yJVPOrjsSFEvF6ntz3cnjg4oN3cvauL+JATacMCl0v9xzib32qC1WZAvvGkfB4w==",
"license": "MIT",
"dependencies": {
"humps": "^2.0.1",
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react-native": ">= 0.67",
"react-native-svg": ">= 11.x"
}
},
"node_modules/@isaacs/fs-minipass": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -2965,6 +3030,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/hammerjs": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"license": "MIT"
},
"node_modules/@types/http-cache-semantics": { "node_modules/@types/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -3571,6 +3642,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": { "node_modules/bplist-creator": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -4058,6 +4135,56 @@
"utrie": "^1.0.2" "utrie": "^1.0.2"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4188,6 +4315,61 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.4.7", "version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -4252,6 +4434,18 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-editor": { "node_modules/env-editor": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -4896,6 +5090,21 @@
"hermes-estree": "0.29.1" "hermes-estree": "0.29.1"
} }
}, },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/hosted-git-info": { "node_modules/hosted-git-info": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
@@ -4990,6 +5199,12 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/humps": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz",
"integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==",
"license": "MIT"
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5914,6 +6129,12 @@
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -6535,6 +6756,18 @@
"node": "^16.14.0 || >=18.0.0" "node": "^16.14.0 || >=18.0.0"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": { "node_modules/nullthrows": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -7013,6 +7246,23 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -7190,6 +7440,21 @@
} }
} }
}, },
"node_modules/react-native-gesture-handler": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.24.0.tgz",
"integrity": "sha512-ZdWyOd1C8axKJHIfYxjJKCcxjWEpUtUWgTOVY2wynbiveSQDm8X/PDyAKXSer/GOtIpjudUbACOndZXCN3vHsw==",
"license": "MIT",
"dependencies": {
"@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-is-edge-to-edge": { "node_modules/react-native-is-edge-to-edge": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
@@ -7215,6 +7480,20 @@
"react-native-worklets": "0.5 - 0.8" "react-native-worklets": "0.5 - 0.8"
} }
}, },
"node_modules/react-native-svg": {
"version": "15.15.5",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz",
"integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-view-shot": { "node_modules/react-native-view-shot": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz", "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz",
+5
View File
@@ -3,6 +3,9 @@
"version": "1.0.0", "version": "1.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"expo": "~54.0.0", "expo": "~54.0.0",
"expo-build-properties": "~1.0.10", "expo-build-properties": "~1.0.10",
@@ -13,7 +16,9 @@
"ipp-encoder": "^5.0.0", "ipp-encoder": "^5.0.0",
"react": "19.1.0", "react": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.24.0",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-svg": "~15.15.0",
"react-native-view-shot": "4.0.3", "react-native-view-shot": "4.0.3",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1"
}, },
+196
View File
@@ -0,0 +1,196 @@
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
runOnJS,
} from 'react-native-reanimated';
import { THEME } from '../styles/theme';
export interface StickerData {
id: string;
type: 'emoji' | 'text';
content: string;
x: number;
y: number;
scale: number;
rotation: number;
}
interface DraggableStickerProps {
sticker: StickerData;
isSelected: boolean;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onUpdate: (id: string, updates: Partial<StickerData>) => void;
}
export default function DraggableSticker({
sticker,
isSelected,
onSelect,
onDelete,
onUpdate,
}: DraggableStickerProps) {
const translateX = useSharedValue(sticker.x);
const translateY = useSharedValue(sticker.y);
const scale = useSharedValue(sticker.scale);
const rotation = useSharedValue(sticker.rotation);
const savedTranslateX = useSharedValue(sticker.x);
const savedTranslateY = useSharedValue(sticker.y);
const savedScale = useSharedValue(sticker.scale);
const savedRotation = useSharedValue(sticker.rotation);
const selectSticker = () => {
onSelect(sticker.id);
};
const updateSticker = (x: number, y: number, s: number, r: number) => {
onUpdate(sticker.id, { x, y, scale: s, rotation: r });
};
const panGesture = Gesture.Pan()
.onStart(() => {
savedTranslateX.value = translateX.value;
savedTranslateY.value = translateY.value;
runOnJS(selectSticker)();
})
.onUpdate((event) => {
translateX.value = savedTranslateX.value + event.translationX;
translateY.value = savedTranslateY.value + event.translationY;
})
.onEnd(() => {
runOnJS(updateSticker)(
translateX.value,
translateY.value,
scale.value,
rotation.value
);
})
.minDistance(5);
const pinchGesture = Gesture.Pinch()
.onStart(() => {
savedScale.value = scale.value;
})
.onUpdate((event) => {
const newScale = savedScale.value * event.scale;
scale.value = Math.max(0.3, Math.min(4, newScale));
})
.onEnd(() => {
runOnJS(updateSticker)(
translateX.value,
translateY.value,
scale.value,
rotation.value
);
});
const rotationGesture = Gesture.Rotation()
.onStart(() => {
savedRotation.value = rotation.value;
})
.onUpdate((event) => {
rotation.value = savedRotation.value + event.rotation;
})
.onEnd(() => {
runOnJS(updateSticker)(
translateX.value,
translateY.value,
scale.value,
rotation.value
);
});
const tapGesture = Gesture.Tap()
.onEnd(() => {
runOnJS(selectSticker)();
});
const composedDrag = Gesture.Simultaneous(panGesture, pinchGesture, rotationGesture);
const gesture = Gesture.Exclusive(composedDrag, tapGesture);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
{ rotate: `${rotation.value}rad` },
],
}));
const isText = sticker.type === 'text';
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.stickerWrapper, animatedStyle]}>
{isText ? (
<View style={styles.textStickerBox}>
<Text style={styles.textStickerContent}>{sticker.content}</Text>
</View>
) : (
<Text style={styles.emojiText}>{sticker.content}</Text>
)}
{isSelected && (
<TouchableOpacity
style={styles.deleteBtn}
onPress={() => onDelete(sticker.id)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Text style={styles.deleteBtnText}>×</Text>
</TouchableOpacity>
)}
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
stickerWrapper: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100,
},
emojiText: {
fontSize: 48,
textAlign: 'center',
},
textStickerBox: {
backgroundColor: 'rgba(5, 5, 16, 0.7)',
paddingVertical: 4,
paddingHorizontal: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: THEME.colors.accent,
},
textStickerContent: {
fontSize: 14,
fontWeight: 'bold',
color: THEME.colors.text,
textAlign: 'center',
textShadowColor: THEME.colors.cyanGlow,
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 6,
},
deleteBtn: {
position: 'absolute',
top: -10,
right: -10,
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: THEME.colors.error,
alignItems: 'center',
justifyContent: 'center',
zIndex: 200,
},
deleteBtnText: {
color: '#fff',
fontSize: 14,
fontWeight: 'bold',
lineHeight: 16,
},
});
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
import { StyleSheet, Image, View } from 'react-native';
interface FrameOverlayProps {
frameAsset: any; // require() asset
}
/**
* Renders a frame PNG overlay on top of the photo.
* The frame PNG must have a transparent center (alpha=0) so the photo shows through.
* Positioned absolutely to cover the entire photo canvas.
*/
export default function FrameOverlay({ frameAsset }: FrameOverlayProps) {
if (!frameAsset) return null;
return (
<View style={styles.container} pointerEvents="none">
<Image
source={frameAsset}
style={styles.frameImage}
resizeMode="cover"
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
},
frameImage: {
width: '100%',
height: '100%',
},
});
+137
View File
@@ -0,0 +1,137 @@
import React from 'react';
import {
StyleSheet,
Text,
View,
TouchableOpacity,
ScrollView,
Image,
} from 'react-native';
import { FRAMES, FrameDefinition } from '../data/frames';
import { THEME } from '../styles/theme';
interface FramePickerProps {
selectedFrameId: string | null;
onSelectFrame: (frameId: string | null) => void;
}
/**
* Horizontal scrollable frame picker.
* Shows "Kein Rahmen" (no frame) + all available frames from the registry.
*/
export default function FramePicker({ selectedFrameId, onSelectFrame }: FramePickerProps) {
if (FRAMES.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>Keine Rahmen verfügbar</Text>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Rahmen wählen</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{/* No frame option */}
<TouchableOpacity
style={[
styles.frameThumb,
selectedFrameId === null && styles.frameThumbActive,
]}
onPress={() => onSelectFrame(null)}
>
<View style={styles.noFrameBox}>
<Text style={styles.noFrameText}></Text>
</View>
<Text style={styles.frameName}>Kein Rahmen</Text>
</TouchableOpacity>
{/* Frame options */}
{FRAMES.map((frame: FrameDefinition) => (
<TouchableOpacity
key={frame.id}
style={[
styles.frameThumb,
selectedFrameId === frame.id && styles.frameThumbActive,
]}
onPress={() => onSelectFrame(frame.id)}
>
<Image
source={frame.asset}
style={styles.thumbImage}
resizeMode="contain"
/>
<Text style={styles.frameName}>{frame.name}</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: THEME.spacing.md,
},
emptyContainer: {
padding: THEME.spacing.md,
alignItems: 'center',
},
emptyText: {
color: THEME.colors.textMuted,
fontSize: 13,
fontStyle: 'italic',
},
title: {
fontSize: 13,
fontWeight: 'bold',
color: THEME.colors.textMuted,
letterSpacing: 1,
textTransform: 'uppercase',
marginBottom: THEME.spacing.sm,
},
scrollContent: {
gap: 8,
},
frameThumb: {
width: 64,
alignItems: 'center',
padding: 4,
borderRadius: THEME.borderRadius.sm,
borderWidth: 1,
borderColor: 'transparent',
},
frameThumbActive: {
borderColor: THEME.colors.primary,
backgroundColor: 'rgba(255, 43, 214, 0.1)',
},
noFrameBox: {
width: 48,
height: 64,
backgroundColor: THEME.colors.surfaceSecondary,
borderRadius: 4,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: THEME.colors.border,
},
noFrameText: {
fontSize: 20,
color: THEME.colors.textMuted,
},
thumbImage: {
width: 48,
height: 64,
borderRadius: 4,
},
frameName: {
fontSize: 10,
color: THEME.colors.textMuted,
marginTop: 3,
textAlign: 'center',
},
});
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
import { THEME } from '../styles/theme';
interface IconProps {
icon: IconDefinition;
size?: number;
color?: string;
style?: any;
}
/**
* Schnappix Icon wrapper around FontAwesome.
* Defaults to the neon accent cyan color.
*/
export default function Icon({ icon, size = 24, color, style }: IconProps) {
return (
<FontAwesomeIcon
icon={icon}
size={size}
color={color || THEME.colors.accent}
style={style}
/>
);
}
+160
View File
@@ -0,0 +1,160 @@
import React from 'react';
import {
StyleSheet,
Text,
View,
TouchableOpacity,
ScrollView,
} from 'react-native';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import { faCalendarDay, faClock, faChampagneGlasses, faXmark } from '@fortawesome/free-solid-svg-icons';
import { THEME } from '../styles/theme';
// Curated party emoji set (Mix of A + D per user request)
const PARTY_EMOJIS = [
'🎉', '🥳', '🎈', '🍾', '🥂', '💃', '🕺', '👑',
'🌟', '💖', '🔥', '🎶', '🍻', '🎭', '🤩', '😎',
'🤪', '😍', '🤗', '✨', '💫', '⭐', '🎊', '🎀',
'🦄', '🌈', '🎵', '🎤', '💎', '🎁',
];
interface StickerTrayProps {
onAddEmoji: (emoji: string) => void;
onAddDateSticker: (format: 'date' | 'datetime') => void;
onAddEventSticker: () => void;
onClose: () => void;
eventText: string;
}
export default function StickerTray({
onAddEmoji,
onAddDateSticker,
onAddEventSticker,
onClose,
eventText,
}: StickerTrayProps) {
return (
<View style={styles.container}>
{/* Header row */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Sticker hinzufügen</Text>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<FontAwesomeIcon icon={faXmark} size={18} color={THEME.colors.text} />
</TouchableOpacity>
</View>
{/* Date/Time quick-add buttons */}
<View style={styles.dateRow}>
<TouchableOpacity
style={styles.dateBtn}
onPress={() => onAddDateSticker('date')}
>
<FontAwesomeIcon icon={faCalendarDay} size={14} color={THEME.colors.accent} />
<Text style={styles.dateBtnText}>Datum</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.dateBtn}
onPress={() => onAddDateSticker('datetime')}
>
<FontAwesomeIcon icon={faClock} size={14} color={THEME.colors.accent} />
<Text style={styles.dateBtnText}>Datum + Uhrzeit</Text>
</TouchableOpacity>
{eventText ? (
<TouchableOpacity style={styles.dateBtn} onPress={onAddEventSticker}>
<FontAwesomeIcon icon={faChampagneGlasses} size={14} color={THEME.colors.primary} />
<Text style={[styles.dateBtnText, { color: THEME.colors.primary }]}>
{eventText.length > 15 ? eventText.substring(0, 15) + '…' : eventText}
</Text>
</TouchableOpacity>
) : null}
</View>
{/* Emoji grid */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.emojiScroll}
>
{PARTY_EMOJIS.map((emoji, idx) => (
<TouchableOpacity
key={idx}
style={styles.emojiBtn}
onPress={() => onAddEmoji(emoji)}
activeOpacity={0.6}
>
<Text style={styles.emojiText}>{emoji}</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(13, 13, 26, 0.95)',
borderTopWidth: 1,
borderTopColor: THEME.colors.border,
paddingBottom: 8,
zIndex: 500,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
},
headerTitle: {
fontSize: 13,
fontWeight: 'bold',
color: THEME.colors.textMuted,
letterSpacing: 1,
textTransform: 'uppercase',
},
closeBtn: {
padding: 4,
},
dateRow: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingBottom: 6,
gap: 8,
},
dateBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: THEME.colors.surfaceSecondary,
paddingVertical: 5,
paddingHorizontal: 10,
borderRadius: THEME.borderRadius.sm,
borderWidth: 1,
borderColor: THEME.colors.border,
},
dateBtnText: {
fontSize: 12,
color: THEME.colors.accent,
fontWeight: '600',
},
emojiScroll: {
paddingHorizontal: 8,
paddingVertical: 4,
},
emojiBtn: {
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'center',
marginHorizontal: 2,
borderRadius: THEME.borderRadius.sm,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
emojiText: {
fontSize: 28,
},
});
+27
View File
@@ -0,0 +1,27 @@
/**
* Frame registry for Schnappix photo frames.
*
* To add a new frame:
* 1. Place a transparent PNG (1200×1800px, sRGB, alpha channel) into assets/frames/
* 2. Add an entry below with require() pointing to the asset
*
* Frame PNG requirements:
* - Format: PNG with transparency (alpha channel)
* - Resolution: 1200 × 1800 px (3:2 portrait)
* - The photo area must be fully transparent
* - Keep each file under 500 KB
*/
export interface FrameDefinition {
id: string;
name: string;
asset: any; // require() return type
}
// Placeholder frame entries — replace the assets once you have real frame PNGs.
// Uncomment entries below when you add the actual PNG files to assets/frames/.
export const FRAMES: FrameDefinition[] = [
// { id: 'balloons', name: 'Luftballons', asset: require('../../assets/frames/frame_balloons.png') },
// { id: 'party_name', name: 'Party Name', asset: require('../../assets/frames/frame_party_name.png') },
// { id: 'neon', name: 'Neon Glow', asset: require('../../assets/frames/frame_neon.png') },
];
+307 -6
View File
@@ -10,9 +10,23 @@ import {
Alert, Alert,
} from 'react-native'; } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import {
faUserGear,
faArrowLeft,
faPrint,
faCameraRetro,
faLock,
faBorderAll,
faCalendarDay,
faImages,
faFloppyDisk,
faChampagneGlasses,
} from '@fortawesome/free-solid-svg-icons';
import { THEME } from '../styles/theme'; import { THEME } from '../styles/theme';
import KioskMode from '../../modules/kiosk-mode'; import KioskMode from '../../modules/kiosk-mode';
import { AppSettings, saveSettings } from '../services/settings'; import { AppSettings, saveSettings } from '../services/settings';
import { FRAMES } from '../data/frames';
interface AdminScreenProps { interface AdminScreenProps {
currentSettings: AppSettings; currentSettings: AppSettings;
@@ -20,6 +34,30 @@ interface AdminScreenProps {
onClose: () => void; onClose: () => void;
} }
type FrameMode = 'off' | 'always' | 'available';
type DateOverlayMode = 'off' | 'date' | 'datetime';
type DatePosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-center';
const FRAME_MODE_OPTIONS: { value: FrameMode; label: string }[] = [
{ value: 'off', label: 'Aus' },
{ value: 'always', label: 'Immer an' },
{ value: 'available', label: 'Verfügbar' },
];
const DATE_OVERLAY_OPTIONS: { value: DateOverlayMode; label: string }[] = [
{ value: 'off', label: 'Aus' },
{ value: 'date', label: 'Nur Datum' },
{ value: 'datetime', label: 'Datum + Uhrzeit' },
];
const DATE_POSITION_OPTIONS: { value: DatePosition; label: string }[] = [
{ value: 'bottom-right', label: 'Unten rechts' },
{ value: 'bottom-left', label: 'Unten links' },
{ value: 'bottom-center', label: 'Unten Mitte' },
{ value: 'top-right', label: 'Oben rechts' },
{ value: 'top-left', label: 'Oben links' },
];
export default function AdminScreen({ currentSettings, onSave, onClose }: AdminScreenProps) { export default function AdminScreen({ currentSettings, onSave, onClose }: AdminScreenProps) {
const [countdown, setCountdown] = useState<string>(String(currentSettings.countdownDuration)); const [countdown, setCountdown] = useState<string>(String(currentSettings.countdownDuration));
const [printerIp, setPrinterIp] = useState<string>(currentSettings.printerIp); const [printerIp, setPrinterIp] = useState<string>(currentSettings.printerIp);
@@ -28,6 +66,16 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
const [kioskActive, setKioskActive] = useState<boolean>(false); const [kioskActive, setKioskActive] = useState<boolean>(false);
const [isDeviceOwner, setIsDeviceOwner] = useState<boolean>(false); const [isDeviceOwner, setIsDeviceOwner] = useState<boolean>(false);
// New settings state
const [frameMode, setFrameMode] = useState<FrameMode>(currentSettings.frameMode);
const [selectedFrameId, setSelectedFrameId] = useState<string | null>(currentSettings.selectedFrameId);
const [dateOverlay, setDateOverlay] = useState<DateOverlayMode>(currentSettings.dateOverlay);
const [dateOverlayPosition, setDateOverlayPosition] = useState<DatePosition>(currentSettings.dateOverlayPosition);
const [eventText, setEventText] = useState<string>(currentSettings.eventText);
const [burstCount, setBurstCount] = useState<string>(String(currentSettings.burstCount));
const [burstInterval, setBurstInterval] = useState<string>(String(currentSettings.burstIntervalSec));
const [welcomeText, setWelcomeText] = useState<string>(currentSettings.welcomeText);
// Load Kiosk and Device Owner status on mount // Load Kiosk and Device Owner status on mount
useEffect(() => { useEffect(() => {
const checkKioskStatus = () => { const checkKioskStatus = () => {
@@ -99,11 +147,36 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
return; return;
} }
const burst = parseInt(burstCount, 10);
if (isNaN(burst) || burst < 1 || burst > 5) {
Alert.alert('Ungültige Eingabe', 'Die Serienbildanzahl muss zwischen 1 und 5 liegen.');
return;
}
const interval = parseInt(burstInterval, 10);
if (isNaN(interval) || interval < 3 || interval > 30) {
Alert.alert('Ungültige Eingabe', 'Der Abstand zwischen Serienbildern muss zwischen 3 und 30 Sekunden liegen.');
return;
}
if (frameMode === 'always' && selectedFrameId === null && FRAMES.length > 0) {
Alert.alert('Hinweis', 'Bitte wähle einen Rahmen aus, wenn der Modus "Immer an" aktiviert ist.');
return;
}
const updated: AppSettings = { const updated: AppSettings = {
countdownDuration: duration, countdownDuration: duration,
printerIp: ipTrimmed, printerIp: ipTrimmed,
adminPassword: password.trim(), adminPassword: password.trim(),
kioskModeEnabled: kioskActive, kioskModeEnabled: kioskActive,
frameMode,
selectedFrameId: frameMode === 'always' ? selectedFrameId : null,
dateOverlay,
dateOverlayPosition,
eventText: eventText.trim(),
burstCount: burst,
burstIntervalSec: interval,
welcomeText: welcomeText.trim() || 'Willkommen zu unserer Feier!',
}; };
try { try {
@@ -115,19 +188,47 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
} }
}; };
// Renders a 3-way toggle segment control
const renderSegment = <T extends string>(
options: { value: T; label: string }[],
current: T,
onChange: (v: T) => void,
) => (
<View style={styles.segmentRow}>
{options.map((opt) => (
<TouchableOpacity
key={opt.value}
style={[styles.segmentBtn, current === opt.value && styles.segmentBtnActive]}
onPress={() => onChange(opt.value)}
>
<Text style={[styles.segmentText, current === opt.value && styles.segmentTextActive]}>
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
);
return ( return (
<Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}> <Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}>
<View style={styles.headerRow}> <View style={styles.headerRow}>
<Text style={styles.title}>SCHNAPPIX EINSTELLUNGEN</Text> <View style={styles.headerLeft}>
<FontAwesomeIcon icon={faUserGear} size={22} color={THEME.colors.primary} />
<Text style={styles.title}>EINSTELLUNGEN</Text>
</View>
<TouchableOpacity style={styles.closeBtn} onPress={onClose}> <TouchableOpacity style={styles.closeBtn} onPress={onClose}>
<FontAwesomeIcon icon={faArrowLeft} size={14} color={THEME.colors.text} />
<Text style={styles.closeBtnText}>Zurück zur Fotobox</Text> <Text style={styles.closeBtnText}>Zurück zur Fotobox</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<ScrollView contentContainerStyle={styles.scrollContent}> <ScrollView contentContainerStyle={styles.scrollContent}>
{/* Printer Configurations */} {/* ─── Printer Configuration ─── */}
<View style={styles.card}> <View style={styles.card}>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faPrint} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Drucker-Konfiguration</Text> <Text style={styles.cardTitle}>Drucker-Konfiguration</Text>
</View>
<Text style={styles.cardDesc}> <Text style={styles.cardDesc}>
Konfiguriere die lokale IP-Adresse deines über WLAN verbundenen Canon CP1300 Druckers. Konfiguriere die lokale IP-Adresse deines über WLAN verbundenen Canon CP1300 Druckers.
</Text> </Text>
@@ -144,9 +245,12 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
</View> </View>
</View> </View>
{/* Booth Behavior */} {/* ─── Booth Behavior ─── */}
<View style={styles.card}> <View style={styles.card}>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faCameraRetro} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Fotobox-Verhalten</Text> <Text style={styles.cardTitle}>Fotobox-Verhalten</Text>
</View>
<View style={styles.inputGroup}> <View style={styles.inputGroup}>
<Text style={styles.label}>Countdown-Dauer (Sekunden)</Text> <Text style={styles.label}>Countdown-Dauer (Sekunden)</Text>
<TextInput <TextInput
@@ -170,11 +274,129 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
keyboardType="number-pad" keyboardType="number-pad"
/> />
</View> </View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Begrüßungstext</Text>
<TextInput
style={styles.input}
placeholder="Willkommen zu unserer Feier!"
placeholderTextColor={THEME.colors.textMuted}
value={welcomeText}
onChangeText={setWelcomeText}
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Event-Name (für Datum-Sticker)</Text>
<TextInput
style={styles.input}
placeholder="z.B. Jannik's Party"
placeholderTextColor={THEME.colors.textMuted}
value={eventText}
onChangeText={setEventText}
/>
</View>
</View> </View>
{/* Security & Kiosk Mode */} {/* ─── Continuous Shooting ─── */}
<View style={styles.card}> <View style={styles.card}>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faImages} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Serienbild / Burst-Modus</Text>
</View>
<Text style={styles.cardDesc}>
Mehrere Fotos automatisch hintereinander aufnehmen. Bei Anzahl = 1 wird nur ein einzelnes Foto aufgenommen.
</Text>
<View style={styles.inputGroup}>
<Text style={styles.label}>Anzahl Fotos pro Durchgang (15)</Text>
<TextInput
style={styles.input}
placeholder="Standard: 1"
placeholderTextColor={THEME.colors.textMuted}
value={burstCount}
onChangeText={setBurstCount}
keyboardType="number-pad"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Abstand zwischen Fotos (Sekunden, 330)</Text>
<TextInput
style={styles.input}
placeholder="Standard: 5"
placeholderTextColor={THEME.colors.textMuted}
value={burstInterval}
onChangeText={setBurstInterval}
keyboardType="number-pad"
/>
</View>
</View>
{/* ─── Photo Frames ─── */}
<View style={styles.card}>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faBorderAll} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Fotorahmen</Text>
</View>
<Text style={styles.cardDesc}>
Rahmen über die Fotos legen. "Aus" deaktiviert Rahmen, "Immer an" erzwingt einen gewählten Rahmen, "Verfügbar" lässt Gäste einen Rahmen auswählen.
</Text>
{renderSegment(FRAME_MODE_OPTIONS, frameMode, setFrameMode)}
{frameMode === 'always' && FRAMES.length > 0 && (
<View style={styles.inputGroup}>
<Text style={styles.label}>Ausgewählter Rahmen</Text>
<View style={styles.frameChipRow}>
{FRAMES.map((frame) => (
<TouchableOpacity
key={frame.id}
style={[
styles.frameChip,
selectedFrameId === frame.id && styles.frameChipActive,
]}
onPress={() => setSelectedFrameId(frame.id)}
>
<Text style={[
styles.frameChipText,
selectedFrameId === frame.id && styles.frameChipTextActive,
]}>
{frame.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
{frameMode === 'always' && FRAMES.length === 0 && (
<Text style={styles.warningText}>
Noch keine Rahmen hinterlegt. Lege PNG-Dateien in assets/frames/ ab.
</Text>
)}
</View>
{/* ─── Date/Time Overlay ─── */}
<View style={styles.card}>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faCalendarDay} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Datum/Uhrzeit-Einblendung</Text>
</View>
<Text style={styles.cardDesc}>
Datum automatisch auf jedes Foto einblenden. Unabhängig davon ist der Datum-Sticker in der Bearbeitung immer verfügbar.
</Text>
{renderSegment(DATE_OVERLAY_OPTIONS, dateOverlay, setDateOverlay)}
{dateOverlay !== 'off' && (
<View style={styles.inputGroup}>
<Text style={styles.label}>Position der Einblendung</Text>
{renderSegment(DATE_POSITION_OPTIONS, dateOverlayPosition, setDateOverlayPosition)}
</View>
)}
</View>
{/* ─── Kiosk Mode ─── */}
<View style={styles.card}>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faLock} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Kiosk-Modus & Sicherheit</Text> <Text style={styles.cardTitle}>Kiosk-Modus & Sicherheit</Text>
</View>
<Text style={styles.cardDesc}> <Text style={styles.cardDesc}>
Sperre den Bildschirm, um zu verhindern, dass Gäste die App schließen oder auf die Systemeinstellungen zugreifen. Sperre den Bildschirm, um zu verhindern, dass Gäste die App schließen oder auf die Systemeinstellungen zugreifen.
</Text> </Text>
@@ -210,7 +432,9 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
</View> </View>
</View> </View>
{/* ─── Save Button ─── */}
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}> <TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
<FontAwesomeIcon icon={faFloppyDisk} size={18} color={THEME.colors.text} style={{ marginRight: 10 }} />
<Text style={styles.saveBtnText}>EINSTELLUNGEN SPEICHERN</Text> <Text style={styles.saveBtnText}>EINSTELLUNGEN SPEICHERN</Text>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>
@@ -233,6 +457,11 @@ const styles = StyleSheet.create({
borderColor: THEME.colors.border, borderColor: THEME.colors.border,
backgroundColor: THEME.colors.surface, backgroundColor: THEME.colors.surface,
}, },
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
title: { title: {
fontSize: 22, fontSize: 22,
fontWeight: '900', fontWeight: '900',
@@ -240,6 +469,9 @@ const styles = StyleSheet.create({
letterSpacing: 2, letterSpacing: 2,
}, },
closeBtn: { closeBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: THEME.colors.surfaceSecondary, backgroundColor: THEME.colors.surfaceSecondary,
borderWidth: 1, borderWidth: 1,
borderColor: THEME.colors.border, borderColor: THEME.colors.border,
@@ -266,11 +498,16 @@ const styles = StyleSheet.create({
padding: THEME.spacing.lg, padding: THEME.spacing.lg,
marginBottom: THEME.spacing.lg, marginBottom: THEME.spacing.lg,
}, },
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginBottom: THEME.spacing.xs,
},
cardTitle: { cardTitle: {
fontSize: 18, fontSize: 18,
fontWeight: 'bold', fontWeight: 'bold',
color: THEME.colors.text, color: THEME.colors.text,
marginBottom: THEME.spacing.xs,
}, },
cardDesc: { cardDesc: {
fontSize: 14, fontSize: 14,
@@ -295,6 +532,64 @@ const styles = StyleSheet.create({
paddingHorizontal: THEME.spacing.md, paddingHorizontal: THEME.spacing.md,
fontSize: 16, fontSize: 16,
}, },
segmentRow: {
flexDirection: 'row',
backgroundColor: THEME.colors.surfaceSecondary,
borderRadius: THEME.borderRadius.sm,
borderWidth: 1,
borderColor: THEME.colors.border,
marginBottom: THEME.spacing.md,
overflow: 'hidden',
},
segmentBtn: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
},
segmentBtnActive: {
backgroundColor: THEME.colors.primary,
},
segmentText: {
fontSize: 13,
fontWeight: '600',
color: THEME.colors.textMuted,
},
segmentTextActive: {
color: THEME.colors.text,
},
frameChipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: THEME.spacing.xs,
},
frameChip: {
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: THEME.borderRadius.round,
borderWidth: 1,
borderColor: THEME.colors.border,
backgroundColor: THEME.colors.surfaceSecondary,
},
frameChipActive: {
borderColor: THEME.colors.primary,
backgroundColor: 'rgba(255, 43, 214, 0.15)',
},
frameChipText: {
fontSize: 13,
color: THEME.colors.textMuted,
fontWeight: '600',
},
frameChipTextActive: {
color: THEME.colors.primary,
},
warningText: {
fontSize: 12,
color: THEME.colors.error,
fontStyle: 'italic',
marginTop: THEME.spacing.xs,
},
statusRow: { statusRow: {
flexDirection: 'row', flexDirection: 'row',
marginBottom: THEME.spacing.xs, marginBottom: THEME.spacing.xs,
@@ -315,7 +610,7 @@ const styles = StyleSheet.create({
color: THEME.colors.textMuted, color: THEME.colors.textMuted,
}, },
statusWarning: { statusWarning: {
color: THEME.colors.accent, color: THEME.colors.error,
}, },
kioskWarningText: { kioskWarningText: {
fontSize: 12, fontSize: 12,
@@ -341,12 +636,18 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
maxWidth: 680, maxWidth: 680,
height: 52, height: 52,
flexDirection: 'row',
backgroundColor: THEME.colors.primary, backgroundColor: THEME.colors.primary,
borderRadius: THEME.borderRadius.md, borderRadius: THEME.borderRadius.md,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginTop: THEME.spacing.sm, marginTop: THEME.spacing.sm,
marginBottom: THEME.spacing.xl, marginBottom: THEME.spacing.xl,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 8,
}, },
saveBtnText: { saveBtnText: {
color: THEME.colors.text, color: THEME.colors.text,
+237 -59
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { import {
StyleSheet, StyleSheet,
Text, Text,
@@ -11,7 +11,23 @@ import {
} from 'react-native'; } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera'; import { CameraView, useCameraPermissions } from 'expo-camera';
import { File, Paths } from 'expo-file-system'; import { File, Paths } from 'expo-file-system';
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, FadeIn, FadeOut } from 'react-native-reanimated'; import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
FadeIn,
FadeOut,
withSequence,
Easing,
runOnJS,
} from 'react-native-reanimated';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import {
faUserGear,
faCameraRetro,
faXmark,
} from '@fortawesome/free-solid-svg-icons';
import { THEME } from '../styles/theme'; import { THEME } from '../styles/theme';
import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera'; import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera';
import { saveToGallery } from '../services/storage'; import { saveToGallery } from '../services/storage';
@@ -19,21 +35,29 @@ import { saveToGallery } from '../services/storage';
interface CameraScreenProps { interface CameraScreenProps {
countdownDuration: number; countdownDuration: number;
onPhotoCaptured: (uri: string) => void; onPhotoCaptured: (uri: string) => void;
onBurstComplete: (uris: string[]) => void;
onCancel: () => void; onCancel: () => void;
isIdle?: boolean; isIdle?: boolean;
onStartBooth?: () => void; onStartBooth?: () => void;
onNavigateToAdmin?: () => void; onNavigateToAdmin?: () => void;
adminPassword?: string; adminPassword?: string;
welcomeText?: string;
burstCount?: number;
burstIntervalSec?: number;
} }
export default function CameraScreen({ export default function CameraScreen({
countdownDuration, countdownDuration,
onPhotoCaptured, onPhotoCaptured,
onBurstComplete,
onCancel, onCancel,
isIdle = false, isIdle = false,
onStartBooth, onStartBooth,
onNavigateToAdmin, onNavigateToAdmin,
adminPassword = '1234', adminPassword = '1234',
welcomeText = 'Willkommen zu unserer Feier!',
burstCount = 1,
burstIntervalSec = 5,
}: CameraScreenProps) { }: CameraScreenProps) {
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [isUsbConnected, setIsUsbConnected] = useState<boolean>(false); const [isUsbConnected, setIsUsbConnected] = useState<boolean>(false);
@@ -41,6 +65,15 @@ export default function CameraScreen({
const [isCapturing, setIsCapturing] = useState<boolean>(false); const [isCapturing, setIsCapturing] = useState<boolean>(false);
const [hasStarted, setHasStarted] = useState<boolean>(false); const [hasStarted, setHasStarted] = useState<boolean>(false);
// Burst mode state
const [burstIndex, setBurstIndex] = useState<number>(0);
const [burstUris, setBurstUris] = useState<string[]>([]);
const [showBurstIndicator, setShowBurstIndicator] = useState<string>('');
const [isBurstWaiting, setIsBurstWaiting] = useState<boolean>(false);
// Flash effect
const flashOpacity = useSharedValue(0);
// Admin Modal States // Admin Modal States
const [passwordModalVisible, setPasswordModalVisible] = useState(false); const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [enteredPassword, setEnteredPassword] = useState(''); const [enteredPassword, setEnteredPassword] = useState('');
@@ -48,6 +81,7 @@ export default function CameraScreen({
const usbCameraRef = useRef<UsbCameraRef>(null); const usbCameraRef = useRef<UsbCameraRef>(null);
const expoCameraRef = useRef<any>(null); const expoCameraRef = useRef<any>(null);
const burstTimerRef = useRef<any>(null);
// Reanimated countdown pulse animation // Reanimated countdown pulse animation
const scale = useSharedValue(1); const scale = useSharedValue(1);
@@ -65,6 +99,18 @@ export default function CameraScreen({
transform: [{ scale: scale.value }], transform: [{ scale: scale.value }],
})); }));
const flashAnimatedStyle = useAnimatedStyle(() => ({
opacity: flashOpacity.value,
}));
// Trigger white flash effect
const triggerFlash = useCallback(() => {
flashOpacity.value = withSequence(
withTiming(1, { duration: 80, easing: Easing.out(Easing.quad) }),
withTiming(0, { duration: 400, easing: Easing.in(Easing.quad) })
);
}, []);
// 1. Request built-in camera permission on mount (just in case we fallback) // 1. Request built-in camera permission on mount (just in case we fallback)
useEffect(() => { useEffect(() => {
if (!permission || !permission.granted) { if (!permission || !permission.granted) {
@@ -96,19 +142,35 @@ export default function CameraScreen({
}; };
}, []); }, []);
// Cleanup burst timer on unmount
useEffect(() => {
return () => {
if (burstTimerRef.current) clearTimeout(burstTimerRef.current);
};
}, []);
// 3. Start the countdown on screen load (only when NOT idle) // 3. Start the countdown on screen load (only when NOT idle)
useEffect(() => { useEffect(() => {
if (isIdle) { if (isIdle) {
setHasStarted(false); setHasStarted(false);
setBurstIndex(0);
setBurstUris([]);
return; return;
} }
startCountdown();
return () => {};
}, [countdownDuration, isIdle]);
const startCountdown = () => {
let timer: any; let timer: any;
let count = countdownDuration; let count = countdownDuration;
setCountdown(count); setCountdown(count);
setHasStarted(true); setHasStarted(true);
setIsCapturing(false); setIsCapturing(false);
setShowBurstIndicator('');
const runTimer = () => { const runTimer = () => {
if (count > 1) { if (count > 1) {
@@ -120,16 +182,15 @@ export default function CameraScreen({
setIsCapturing(true); setIsCapturing(true);
timer = setTimeout(() => { timer = setTimeout(() => {
capture(); capture();
}, 800); // give the user a split second to smile }, 800);
} }
}; };
timer = setTimeout(runTimer, 1000); timer = setTimeout(runTimer, 1000);
return () => { // Store timer for cleanup
if (timer) clearTimeout(timer); burstTimerRef.current = timer;
}; };
}, [countdownDuration, isIdle]);
// 4. Capture photo function // 4. Capture photo function
const capture = async () => { const capture = async () => {
@@ -138,20 +199,15 @@ export default function CameraScreen({
const tempUri = tempFile.uri; const tempUri = tempFile.uri;
try { try {
triggerFlash();
let capturedUri: string;
if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) { if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) {
// USB Camera Capture // USB Camera Capture
console.log('Capturing from USB Camera...'); console.log('Capturing from USB Camera...');
const path = await usbCameraRef.current.takePicture(tempUri); const path = await usbCameraRef.current.takePicture(tempUri);
capturedUri = path;
// Auto-save individual photo directly to gallery
try {
await saveToGallery(path);
console.log('Auto-saved USB capture to gallery');
} catch (e) {
console.error('Failed to auto-save USB capture:', e);
}
onPhotoCaptured(path);
} else { } else {
// Fallback Camera Capture // Fallback Camera Capture
console.log('Capturing from built-in camera fallback...'); console.log('Capturing from built-in camera fallback...');
@@ -160,20 +216,47 @@ export default function CameraScreen({
quality: 0.95, quality: 0.95,
skipProcessing: false, skipProcessing: false,
}); });
capturedUri = photo.uri;
// Auto-save individual photo directly to gallery
try {
await saveToGallery(photo.uri);
console.log('Auto-saved fallback capture to gallery');
} catch (e) {
console.error('Failed to auto-save fallback capture:', e);
}
onPhotoCaptured(photo.uri);
} else { } else {
throw new Error('Kamera-Referenz ist nicht verfügbar.'); throw new Error('Kamera-Referenz ist nicht verfügbar.');
} }
} }
// Auto-save individual photo to gallery
try {
await saveToGallery(capturedUri);
console.log('Auto-saved capture to gallery');
} catch (e) {
console.error('Failed to auto-save capture:', e);
}
// Handle burst mode
const effectiveBurst = burstCount || 1;
const newIndex = burstIndex + 1;
const newUris = [...burstUris, capturedUri];
if (effectiveBurst > 1 && newIndex < effectiveBurst) {
// More burst photos to take
setBurstIndex(newIndex);
setBurstUris(newUris);
setShowBurstIndicator(`Foto ${newIndex} von ${effectiveBurst}`);
setIsCapturing(false);
setIsBurstWaiting(true);
// Wait burstIntervalSec then start next countdown
burstTimerRef.current = setTimeout(() => {
setIsBurstWaiting(false);
startCountdown();
}, burstIntervalSec * 1000);
} else if (effectiveBurst > 1) {
// Final burst photo
setBurstIndex(0);
setBurstUris([]);
onBurstComplete(newUris);
} else {
// Single shot mode
onPhotoCaptured(capturedUri);
}
} catch (error: any) { } catch (error: any) {
console.error('Capture failed:', error); console.error('Capture failed:', error);
alert('Fehler beim Aufnehmen des Fotos: ' + error.message); alert('Fehler beim Aufnehmen des Fotos: ' + error.message);
@@ -190,7 +273,9 @@ export default function CameraScreen({
const handlePasswordSubmit = () => { const handlePasswordSubmit = () => {
if (enteredPassword === adminPassword) { if (enteredPassword === adminPassword) {
setPasswordModalVisible(false); setPasswordModalVisible(false);
setTimeout(() => {
onNavigateToAdmin?.(); onNavigateToAdmin?.();
}, 350); // Delay navigation on iOS to avoid unmounting a view with an open modal
} else { } else {
setErrorText('Falsches Passwort'); setErrorText('Falsches Passwort');
setEnteredPassword(''); setEnteredPassword('');
@@ -231,16 +316,17 @@ export default function CameraScreen({
onPress={handleSettingsTap} onPress={handleSettingsTap}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={styles.settingsIcon}></Text> <FontAwesomeIcon icon={faUserGear} size={22} color={THEME.colors.accent} />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.welcomeBox}> <View style={styles.welcomeBox}>
<Text style={styles.logo}>SCHNAPPIX</Text> <Text style={styles.logo}>SCHNAPPIX</Text>
<Text style={styles.welcomeTitle}>Willkommen zu unserer Feier!</Text> <Text style={styles.welcomeTitle}>{welcomeText}</Text>
<View style={styles.divider} /> <View style={styles.divider} />
<View style={styles.startButton}> <View style={styles.startButton}>
<FontAwesomeIcon icon={faCameraRetro} size={20} color={THEME.colors.text} style={{ marginRight: 10 }} />
<Text style={styles.startButtonText}>ZUM STARTEN ÜBERALL TIPPEN</Text> <Text style={styles.startButtonText}>ZUM STARTEN ÜBERALL TIPPEN</Text>
</View> </View>
@@ -294,7 +380,7 @@ export default function CameraScreen({
); );
} }
// Capture Mode overlays (Countdown and Cancel button) // Capture Mode overlays (Countdown, Burst indicator, and Cancel button)
return ( return (
<View style={styles.captureOverlayContainer} pointerEvents="box-none"> <View style={styles.captureOverlayContainer} pointerEvents="box-none">
{/* Source indicator */} {/* Source indicator */}
@@ -304,8 +390,28 @@ export default function CameraScreen({
</Text> </Text>
</View> </View>
{/* Burst progress indicator */}
{(burstCount || 1) > 1 && (
<View style={styles.burstBadge}>
<FontAwesomeIcon icon={faCameraRetro} size={12} color={THEME.colors.accent} />
<Text style={styles.burstBadgeText}>
Foto {burstIndex + 1} / {burstCount}
</Text>
</View>
)}
{/* Burst waiting indicator */}
{isBurstWaiting && showBurstIndicator !== '' && (
<View style={styles.countdownOverlay} pointerEvents="none">
<View style={styles.burstWaitingBox}>
<Text style={styles.burstWaitingText}>{showBurstIndicator}</Text>
<Text style={styles.burstWaitingSub}>Nächstes Foto in {burstIntervalSec}s</Text>
</View>
</View>
)}
{/* Countdown overlay */} {/* Countdown overlay */}
{hasStarted && ( {hasStarted && !isBurstWaiting && (
<View style={styles.countdownOverlay} pointerEvents="none"> <View style={styles.countdownOverlay} pointerEvents="none">
<Animated.View style={[styles.countdownBox, animatedStyle]}> <Animated.View style={[styles.countdownBox, animatedStyle]}>
<Text style={styles.countdownText}>{countdown}</Text> <Text style={styles.countdownText}>{countdown}</Text>
@@ -314,8 +420,9 @@ export default function CameraScreen({
)} )}
{/* Cancel Button */} {/* Cancel Button */}
{!isCapturing && ( {!isCapturing && !isBurstWaiting && (
<TouchableOpacity style={styles.backButton} onPress={onCancel}> <TouchableOpacity style={styles.backButton} onPress={onCancel}>
<FontAwesomeIcon icon={faXmark} size={16} color={THEME.colors.text} style={{ marginRight: 6 }} />
<Text style={styles.backButtonText}>ABBRECHEN</Text> <Text style={styles.backButtonText}>ABBRECHEN</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -340,6 +447,12 @@ export default function CameraScreen({
{/* Render UI controls/overlays on top */} {/* Render UI controls/overlays on top */}
{renderContent()} {renderContent()}
{/* White Flash Overlay */}
<Animated.View
style={[styles.flashOverlay, flashAnimatedStyle]}
pointerEvents="none"
/>
</Animated.View> </Animated.View>
); );
} }
@@ -360,11 +473,20 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
flashOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#FFFFFF',
zIndex: 9999,
},
idleOverlayContainer: { idleOverlayContainer: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.15)', backgroundColor: 'rgba(5, 5, 16, 0.25)',
}, },
captureOverlayContainer: { captureOverlayContainer: {
flex: 1, flex: 1,
@@ -378,29 +500,25 @@ const styles = StyleSheet.create({
width: 50, width: 50,
height: 50, height: 50,
borderRadius: THEME.borderRadius.round, borderRadius: THEME.borderRadius.round,
backgroundColor: 'rgba(30, 30, 35, 0.75)', backgroundColor: 'rgba(13, 13, 26, 0.8)',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)', borderColor: 'rgba(255, 43, 214, 0.25)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
zIndex: 999, zIndex: 999,
}, },
settingsIcon: {
fontSize: 24,
color: THEME.colors.text,
},
welcomeBox: { welcomeBox: {
alignItems: 'center', alignItems: 'center',
padding: THEME.spacing.xl, padding: THEME.spacing.xl,
borderRadius: THEME.borderRadius.lg, borderRadius: THEME.borderRadius.lg,
backgroundColor: 'rgba(18, 18, 20, 0.8)', backgroundColor: 'rgba(5, 5, 16, 0.85)',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.15)', borderColor: 'rgba(255, 43, 214, 0.2)',
width: '60%', width: '60%',
shadowColor: '#000', shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 10 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 20, shadowRadius: 30,
elevation: 10, elevation: 10,
}, },
logo: { logo: {
@@ -409,9 +527,9 @@ const styles = StyleSheet.create({
color: THEME.colors.primary, color: THEME.colors.primary,
letterSpacing: 8, letterSpacing: 8,
marginBottom: THEME.spacing.sm, marginBottom: THEME.spacing.sm,
textShadowColor: 'rgba(0, 0, 0, 0.5)', textShadowColor: THEME.colors.neonGlow,
textShadowOffset: { width: 0, height: 2 }, textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 4, textShadowRadius: 20,
}, },
welcomeTitle: { welcomeTitle: {
fontSize: 22, fontSize: 22,
@@ -426,13 +544,24 @@ const styles = StyleSheet.create({
backgroundColor: THEME.colors.accent, backgroundColor: THEME.colors.accent,
marginBottom: THEME.spacing.xl, marginBottom: THEME.spacing.xl,
borderRadius: THEME.borderRadius.sm, borderRadius: THEME.borderRadius.sm,
shadowColor: THEME.colors.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 8,
}, },
startButton: { startButton: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: THEME.spacing.md, paddingVertical: THEME.spacing.md,
paddingHorizontal: THEME.spacing.xl, paddingHorizontal: THEME.spacing.xl,
backgroundColor: THEME.colors.primary, backgroundColor: THEME.colors.primary,
borderRadius: THEME.borderRadius.round, borderRadius: THEME.borderRadius.round,
marginBottom: THEME.spacing.xl, marginBottom: THEME.spacing.xl,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 12,
elevation: 8,
}, },
startButtonText: { startButtonText: {
color: THEME.colors.text, color: THEME.colors.text,
@@ -448,18 +577,62 @@ const styles = StyleSheet.create({
hudContainer: { hudContainer: {
position: 'absolute', position: 'absolute',
bottom: 20, bottom: 20,
backgroundColor: 'rgba(0, 0, 0, 0.7)', backgroundColor: 'rgba(5, 5, 16, 0.8)',
paddingVertical: THEME.spacing.xs, paddingVertical: THEME.spacing.xs,
paddingHorizontal: THEME.spacing.md, paddingHorizontal: THEME.spacing.md,
borderRadius: THEME.borderRadius.sm, borderRadius: THEME.borderRadius.sm,
borderWidth: 0.5, borderWidth: 0.5,
borderColor: 'rgba(255, 255, 255, 0.1)', borderColor: 'rgba(255, 43, 214, 0.15)',
}, },
cameraSourceIndicator: { cameraSourceIndicator: {
color: THEME.colors.text, color: THEME.colors.text,
fontSize: 14, fontSize: 14,
fontWeight: 'bold', fontWeight: 'bold',
}, },
burstBadge: {
position: 'absolute',
top: 24,
right: 24,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: 'rgba(5, 5, 16, 0.85)',
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: THEME.borderRadius.round,
borderWidth: 1,
borderColor: THEME.colors.accent,
},
burstBadgeText: {
color: THEME.colors.accent,
fontSize: 14,
fontWeight: 'bold',
},
burstWaitingBox: {
backgroundColor: 'rgba(5, 5, 16, 0.9)',
paddingVertical: THEME.spacing.xl,
paddingHorizontal: THEME.spacing.xxl,
borderRadius: THEME.borderRadius.lg,
borderWidth: 1,
borderColor: THEME.colors.accent,
alignItems: 'center',
shadowColor: THEME.colors.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 20,
elevation: 8,
},
burstWaitingText: {
fontSize: 28,
fontWeight: '900',
color: THEME.colors.accent,
textAlign: 'center',
},
burstWaitingSub: {
fontSize: 16,
color: THEME.colors.textMuted,
marginTop: THEME.spacing.sm,
},
countdownOverlay: { countdownOverlay: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@@ -470,19 +643,19 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
countdownBox: { countdownBox: {
backgroundColor: 'rgba(20, 20, 25, 0.85)', backgroundColor: 'rgba(5, 5, 16, 0.88)',
paddingVertical: THEME.spacing.xl, paddingVertical: THEME.spacing.xl,
paddingHorizontal: THEME.spacing.xxl, paddingHorizontal: THEME.spacing.xxl,
borderRadius: THEME.borderRadius.lg, borderRadius: THEME.borderRadius.lg,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.15)', borderColor: 'rgba(255, 43, 214, 0.25)',
minWidth: 180, minWidth: 180,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
shadowColor: '#000', shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 10 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4, shadowOpacity: 0.4,
shadowRadius: 15, shadowRadius: 20,
elevation: 8, elevation: 8,
}, },
countdownText: { countdownText: {
@@ -490,17 +663,22 @@ const styles = StyleSheet.create({
fontWeight: '900', fontWeight: '900',
color: THEME.colors.text, color: THEME.colors.text,
textAlign: 'center', textAlign: 'center',
textShadowColor: THEME.colors.neonGlow,
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 15,
}, },
backButton: { backButton: {
position: 'absolute', position: 'absolute',
top: 24, top: 24,
left: 24, left: 24,
backgroundColor: 'rgba(30, 30, 35, 0.75)', flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(13, 13, 26, 0.8)',
paddingVertical: THEME.spacing.sm, paddingVertical: THEME.spacing.sm,
paddingHorizontal: THEME.spacing.lg, paddingHorizontal: THEME.spacing.lg,
borderRadius: THEME.borderRadius.round, borderRadius: THEME.borderRadius.round,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)', borderColor: 'rgba(255, 43, 214, 0.2)',
}, },
backButtonText: { backButtonText: {
color: THEME.colors.text, color: THEME.colors.text,
@@ -546,10 +724,10 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: THEME.colors.border, borderColor: THEME.colors.border,
alignItems: 'center', alignItems: 'center',
shadowColor: '#000', shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 10 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4, shadowOpacity: 0.3,
shadowRadius: 15, shadowRadius: 20,
elevation: 10, elevation: 10,
}, },
modalTitle: { modalTitle: {
+317 -44
View File
@@ -1,18 +1,32 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useCallback } from 'react';
import { import {
StyleSheet, StyleSheet,
Text, Text,
View, View,
Image, Image,
TouchableOpacity, TouchableOpacity,
ScrollView,
ActivityIndicator, ActivityIndicator,
} from 'react-native'; } from 'react-native';
import ViewShot from 'react-native-view-shot'; import ViewShot from 'react-native-view-shot';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import {
faPrint,
faDownload,
faPlus,
faRotateLeft,
faRightFromBracket,
faFaceGrinStars,
faBorderAll,
} from '@fortawesome/free-solid-svg-icons';
import { THEME } from '../styles/theme'; import { THEME } from '../styles/theme';
import { printImageLocal } from '../services/printer'; import { printImageLocal } from '../services/printer';
import { saveToGallery } from '../services/storage'; import { saveToGallery } from '../services/storage';
import DraggableSticker, { StickerData } from '../components/DraggableSticker';
import StickerTray from '../components/StickerTray';
import FrameOverlay from '../components/FrameOverlay';
import FramePicker from '../components/FramePicker';
import { FRAMES } from '../data/frames';
interface PreviewScreenProps { interface PreviewScreenProps {
photoUris: string[]; photoUris: string[];
@@ -20,6 +34,14 @@ interface PreviewScreenProps {
onRetakeLast: () => void; onRetakeLast: () => void;
onAddAnother: () => void; onAddAnother: () => void;
onReset: () => void; onReset: () => void;
// Frame settings
frameMode: 'off' | 'always' | 'available';
initialFrameId: string | null;
// Date overlay settings
dateOverlay: 'off' | 'date' | 'datetime';
dateOverlayPosition: string;
// Event text
eventText: string;
} }
type CollageLayout = 'single' | 'strip' | 'grid' | 'duo'; type CollageLayout = 'single' | 'strip' | 'grid' | 'duo';
@@ -30,44 +52,147 @@ export default function PreviewScreen({
onRetakeLast, onRetakeLast,
onAddAnother, onAddAnother,
onReset, onReset,
frameMode,
initialFrameId,
dateOverlay,
dateOverlayPosition,
eventText,
}: PreviewScreenProps) { }: PreviewScreenProps) {
const [layout, setLayout] = useState<CollageLayout>('single'); const [layout, setLayout] = useState<CollageLayout>('single');
const [isProcessing, setIsProcessing] = useState<boolean>(false); const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [statusMessage, setStatusMessage] = useState<string>(''); const [statusMessage, setStatusMessage] = useState<string>('');
// Sticker state
const [stickers, setStickers] = useState<StickerData[]>([]);
const [selectedStickerId, setSelectedStickerId] = useState<string | null>(null);
const [showStickerTray, setShowStickerTray] = useState<boolean>(false);
// Frame state
const [selectedFrameId, setSelectedFrameId] = useState<string | null>(
frameMode === 'always' ? initialFrameId : null
);
const viewShotRef = useRef<any>(null); const viewShotRef = useRef<any>(null);
const currentPhoto = photoUris[photoUris.length - 1]; const currentPhoto = photoUris[photoUris.length - 1];
// Get the active frame asset
const activeFrame = selectedFrameId
? FRAMES.find((f) => f.id === selectedFrameId)
: null;
// Format date/time for overlays and stickers
const formatDate = useCallback((mode: 'date' | 'datetime'): string => {
const now = new Date();
const date = now.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
if (mode === 'datetime') {
const time = now.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
return `${date} ${time}`;
}
return date;
}, []);
// Get date overlay position styles
const getDatePositionStyle = () => {
switch (dateOverlayPosition) {
case 'bottom-left':
return { bottom: 12, left: 12 };
case 'top-right':
return { top: 12, right: 12 };
case 'top-left':
return { top: 12, left: 12 };
case 'bottom-center':
return { bottom: 12, left: 0, right: 0, alignItems: 'center' as const };
case 'bottom-right':
default:
return { bottom: 12, right: 12 };
}
};
// ── Sticker handlers ──
const addSticker = useCallback((type: 'emoji' | 'text', content: string) => {
const newSticker: StickerData = {
id: `sticker_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
type,
content,
x: 100 + Math.random() * 40 - 20,
y: 100 + Math.random() * 40 - 20,
scale: 1,
rotation: 0,
};
setStickers((prev) => [...prev, newSticker]);
setSelectedStickerId(newSticker.id);
}, []);
const handleAddEmoji = useCallback((emoji: string) => {
addSticker('emoji', emoji);
}, [addSticker]);
const handleAddDateSticker = useCallback((format: 'date' | 'datetime') => {
addSticker('text', formatDate(format));
}, [addSticker, formatDate]);
const handleAddEventSticker = useCallback(() => {
const date = formatDate('date');
addSticker('text', `${eventText}${date}`);
}, [addSticker, formatDate, eventText]);
const handleStickerSelect = useCallback((id: string) => {
setSelectedStickerId((prev) => (prev === id ? null : id));
}, []);
const handleStickerDelete = useCallback((id: string) => {
setStickers((prev) => prev.filter((s) => s.id !== id));
setSelectedStickerId(null);
}, []);
const handleStickerUpdate = useCallback((id: string, updates: Partial<StickerData>) => {
setStickers((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...updates } : s))
);
}, []);
// Deselect stickers when tapping the photo area
const handleCanvasPress = useCallback(() => {
setSelectedStickerId(null);
}, []);
// ── Capture the final image ──
const captureComposite = async (): Promise<string> => {
if (viewShotRef.current) {
return await viewShotRef.current.capture();
}
return currentPhoto;
};
// Triggers the view capture and the print job // Triggers the view capture and the print job
const handlePrint = async () => { const handlePrint = async () => {
setIsProcessing(true); setIsProcessing(true);
setStatusMessage('Dein Foto wird vorbereitet...'); setStatusMessage('Dein Foto wird vorbereitet...');
try { try {
let printUri = currentPhoto; setStatusMessage('Bild wird generiert...');
const printUri = await captureComposite();
// If we are printing a collage layout, capture the ViewShot container
if (layout !== 'single') {
setStatusMessage('Collage wird generiert...');
const capturedUri = await viewShotRef.current.capture();
printUri = capturedUri;
}
setStatusMessage('Druckauftrag wird an Drucker gesendet...'); setStatusMessage('Druckauftrag wird an Drucker gesendet...');
// 1. Silent Print via IPP
await printImageLocal(printUri, { await printImageLocal(printUri, {
ipAddress: printerIp, ipAddress: printerIp,
jobName: 'Schnappix Fotobox', jobName: 'Schnappix Fotobox',
}); });
setStatusMessage('Wird in Galerie gespeichert...'); setStatusMessage('Wird in Galerie gespeichert...');
// 2. Save permanently to DCIM/Schnappix
await saveToGallery(printUri); await saveToGallery(printUri);
setStatusMessage('Druck erfolgreich! Viel Spaß! 🎉'); setStatusMessage('Druck erfolgreich! Viel Spaß! 🎉');
setTimeout(() => { setTimeout(() => {
setIsProcessing(false); setIsProcessing(false);
onReset(); // Go back to Home Screen onReset();
}, 2000); }, 2000);
} catch (error: any) { } catch (error: any) {
console.error('Printing failed:', error); console.error('Printing failed:', error);
@@ -81,12 +206,7 @@ export default function PreviewScreen({
setIsProcessing(true); setIsProcessing(true);
setStatusMessage('Bild wird generiert...'); setStatusMessage('Bild wird generiert...');
try { try {
let saveUri = currentPhoto; const saveUri = await captureComposite();
if (layout !== 'single') {
const capturedUri = await viewShotRef.current.capture();
saveUri = capturedUri;
}
setStatusMessage('Wird in Galerie gespeichert...'); setStatusMessage('Wird in Galerie gespeichert...');
await saveToGallery(saveUri); await saveToGallery(saveUri);
@@ -103,18 +223,17 @@ export default function PreviewScreen({
} }
}; };
// Auto-save collage and exit // Auto-save and exit
const handleExit = async () => { const handleExit = async () => {
// If layout is single, it has already been auto-saved to gallery right when captured if (stickers.length === 0 && layout === 'single' && !activeFrame && dateOverlay === 'off') {
if (layout === 'single') {
onReset(); onReset();
return; return;
} }
setIsProcessing(true); setIsProcessing(true);
setStatusMessage('Collage wird gespeichert...'); setStatusMessage('Wird gespeichert...');
try { try {
const capturedUri = await viewShotRef.current.capture(); const capturedUri = await captureComposite();
await saveToGallery(capturedUri); await saveToGallery(capturedUri);
setStatusMessage('Erfolgreich gespeichert!'); setStatusMessage('Erfolgreich gespeichert!');
setTimeout(() => { setTimeout(() => {
@@ -122,13 +241,13 @@ export default function PreviewScreen({
onReset(); onReset();
}, 1000); }, 1000);
} catch (error) { } catch (error) {
console.error('Auto-saving collage on exit failed:', error); console.error('Auto-saving on exit failed:', error);
setIsProcessing(false); setIsProcessing(false);
onReset(); onReset();
} }
}; };
// Render the selected collage inside the capture container // ── Render the collage ──
const renderCollageView = () => { const renderCollageView = () => {
switch (layout) { switch (layout) {
case 'strip': case 'strip':
@@ -183,20 +302,54 @@ export default function PreviewScreen({
return ( return (
<Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}> <Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}>
{/* Left panel: Preview Canvas */} {/* Left panel: Preview Canvas */}
<View style={styles.previewPanel}> <TouchableOpacity
{layout === 'single' ? ( style={styles.previewPanel}
renderCollageView() activeOpacity={1}
) : ( onPress={handleCanvasPress}
// ViewShot wraps the collage to capture it at exactly 3:2 ratio (1200x1800 px) for the printer >
<ViewShot <ViewShot
ref={viewShotRef} ref={viewShotRef}
options={{ format: 'jpg', quality: 0.95 }} options={{ format: 'jpg', quality: 0.95 }}
style={styles.viewShotContainer} style={styles.viewShotContainer}
> >
{renderCollageView()} {renderCollageView()}
</ViewShot>
)} {/* Frame Overlay */}
{activeFrame && <FrameOverlay frameAsset={activeFrame.asset} />}
{/* Date/Time Overlay */}
{dateOverlay !== 'off' && (
<View style={[styles.dateOverlay, getDatePositionStyle()]} pointerEvents="none">
<Text style={styles.dateOverlayText}>
{formatDate(dateOverlay)}
</Text>
</View> </View>
)}
{/* Stickers */}
{stickers.map((sticker) => (
<DraggableSticker
key={sticker.id}
sticker={sticker}
isSelected={selectedStickerId === sticker.id}
onSelect={handleStickerSelect}
onDelete={handleStickerDelete}
onUpdate={handleStickerUpdate}
/>
))}
</ViewShot>
{/* Sticker Tray (outside ViewShot — not captured) */}
{showStickerTray && (
<StickerTray
onAddEmoji={handleAddEmoji}
onAddDateSticker={handleAddDateSticker}
onAddEventSticker={handleAddEventSticker}
onClose={() => setShowStickerTray(false)}
eventText={eventText}
/>
)}
</TouchableOpacity>
{/* Right panel: Controls */} {/* Right panel: Controls */}
<View style={styles.controlPanel}> <View style={styles.controlPanel}>
@@ -252,28 +405,79 @@ export default function PreviewScreen({
Aufgenommene Fotos: {photoUris.length} / 4 Aufgenommene Fotos: {photoUris.length} / 4
</Text> </Text>
{/* Editing Tools Row */}
<View style={styles.editToolsRow}>
<TouchableOpacity
style={[styles.editToolBtn, showStickerTray && styles.editToolBtnActive]}
onPress={() => setShowStickerTray(!showStickerTray)}
>
<FontAwesomeIcon
icon={faFaceGrinStars}
size={16}
color={showStickerTray ? THEME.colors.text : THEME.colors.accent}
/>
<Text style={[styles.editToolText, showStickerTray && styles.editToolTextActive]}>
Sticker
</Text>
</TouchableOpacity>
{frameMode === 'available' && (
<TouchableOpacity
style={[styles.editToolBtn, selectedFrameId !== null && styles.editToolBtnActive]}
onPress={() => {
if (selectedFrameId !== null) {
setSelectedFrameId(null);
}
// FramePicker is shown below
}}
>
<FontAwesomeIcon
icon={faBorderAll}
size={16}
color={selectedFrameId !== null ? THEME.colors.text : THEME.colors.accent}
/>
<Text style={[styles.editToolText, selectedFrameId !== null && styles.editToolTextActive]}>
Rahmen
</Text>
</TouchableOpacity>
)}
</View>
{/* Frame Picker (when mode = available) */}
{frameMode === 'available' && (
<FramePicker
selectedFrameId={selectedFrameId}
onSelectFrame={setSelectedFrameId}
/>
)}
{/* Action Buttons */} {/* Action Buttons */}
<View style={styles.actions}> <View style={styles.actions}>
<TouchableOpacity style={[styles.actionBtn, styles.printBtn]} onPress={handlePrint}> <TouchableOpacity style={[styles.actionBtn, styles.printBtn]} onPress={handlePrint}>
<Text style={styles.actionBtnText}>JETZT DRUCKEN 🖨</Text> <FontAwesomeIcon icon={faPrint} size={18} color={THEME.colors.text} style={{ marginRight: 8 }} />
<Text style={styles.actionBtnText}>JETZT DRUCKEN</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.actionBtn, styles.saveBtn]} onPress={handleSaveOnly}> <TouchableOpacity style={[styles.actionBtn, styles.saveBtn]} onPress={handleSaveOnly}>
<Text style={styles.actionBtnText}>Nur auf Tablet speichern 💾</Text> <FontAwesomeIcon icon={faDownload} size={16} color={THEME.colors.accent} style={{ marginRight: 8 }} />
<Text style={[styles.actionBtnText, { color: THEME.colors.accent }]}>Nur auf Tablet speichern</Text>
</TouchableOpacity> </TouchableOpacity>
{photoUris.length < 4 && ( {photoUris.length < 4 && (
<TouchableOpacity style={[styles.actionBtn, styles.addBtn]} onPress={onAddAnother}> <TouchableOpacity style={[styles.actionBtn, styles.addBtn]} onPress={onAddAnother}>
<Text style={styles.actionBtnText}>+ Weiteres Foto aufnehmen</Text> <FontAwesomeIcon icon={faPlus} size={16} color={THEME.colors.text} style={{ marginRight: 8 }} />
<Text style={styles.actionBtnText}>Weiteres Foto aufnehmen</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
<View style={styles.rowActions}> <View style={styles.rowActions}>
<TouchableOpacity style={[styles.smallBtn, styles.retakeBtn]} onPress={onRetakeLast}> <TouchableOpacity style={[styles.smallBtn, styles.retakeBtn]} onPress={onRetakeLast}>
<Text style={styles.smallBtnText}>Letztes wiederholen</Text> <FontAwesomeIcon icon={faRotateLeft} size={14} color={THEME.colors.error} style={{ marginRight: 6 }} />
<Text style={[styles.smallBtnText, { color: THEME.colors.error }]}>Wiederholen</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.smallBtn, styles.resetBtn]} onPress={handleExit}> <TouchableOpacity style={[styles.smallBtn, styles.resetBtn]} onPress={handleExit}>
<FontAwesomeIcon icon={faRightFromBracket} size={14} color={THEME.colors.textMuted} style={{ marginRight: 6 }} />
<Text style={styles.smallBtnText}>Beenden</Text> <Text style={styles.smallBtnText}>Beenden</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -304,7 +508,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
padding: THEME.spacing.md, padding: THEME.spacing.md,
backgroundColor: '#050507', backgroundColor: '#030308',
}, },
viewShotContainer: { viewShotContainer: {
width: 320, width: 320,
@@ -327,6 +531,9 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
marginBottom: THEME.spacing.md, marginBottom: THEME.spacing.md,
letterSpacing: 2, letterSpacing: 2,
textShadowColor: THEME.colors.neonGlow,
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
}, },
layoutSelector: { layoutSelector: {
flexDirection: 'row', flexDirection: 'row',
@@ -346,6 +553,11 @@ const styles = StyleSheet.create({
layoutBtnActive: { layoutBtnActive: {
backgroundColor: THEME.colors.primary, backgroundColor: THEME.colors.primary,
borderColor: THEME.colors.primary, borderColor: THEME.colors.primary,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 4,
}, },
layoutBtnDisabled: { layoutBtnDisabled: {
opacity: 0.3, opacity: 0.3,
@@ -359,13 +571,43 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
color: THEME.colors.textMuted, color: THEME.colors.textMuted,
textAlign: 'center', textAlign: 'center',
marginBottom: THEME.spacing.lg, marginBottom: THEME.spacing.sm,
},
editToolsRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 10,
marginBottom: THEME.spacing.md,
},
editToolBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: THEME.borderRadius.round,
borderWidth: 1,
borderColor: THEME.colors.border,
backgroundColor: THEME.colors.surfaceSecondary,
},
editToolBtnActive: {
backgroundColor: THEME.colors.primary,
borderColor: THEME.colors.primary,
},
editToolText: {
fontSize: 13,
fontWeight: '600',
color: THEME.colors.accent,
},
editToolTextActive: {
color: THEME.colors.text,
}, },
actions: { actions: {
width: '100%', width: '100%',
}, },
actionBtn: { actionBtn: {
width: '100%', width: '100%',
flexDirection: 'row',
paddingVertical: THEME.spacing.md, paddingVertical: THEME.spacing.md,
borderRadius: THEME.borderRadius.md, borderRadius: THEME.borderRadius.md,
alignItems: 'center', alignItems: 'center',
@@ -374,6 +616,11 @@ const styles = StyleSheet.create({
}, },
printBtn: { printBtn: {
backgroundColor: THEME.colors.primary, backgroundColor: THEME.colors.primary,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 8,
}, },
saveBtn: { saveBtn: {
backgroundColor: 'transparent', backgroundColor: 'transparent',
@@ -381,7 +628,7 @@ const styles = StyleSheet.create({
borderColor: THEME.colors.border, borderColor: THEME.colors.border,
}, },
addBtn: { addBtn: {
backgroundColor: THEME.colors.accent, backgroundColor: THEME.colors.accentDark,
}, },
actionBtnText: { actionBtnText: {
color: THEME.colors.text, color: THEME.colors.text,
@@ -396,9 +643,11 @@ const styles = StyleSheet.create({
}, },
smallBtn: { smallBtn: {
flex: 0.48, flex: 0.48,
flexDirection: 'row',
paddingVertical: THEME.spacing.sm, paddingVertical: THEME.spacing.sm,
borderRadius: THEME.borderRadius.sm, borderRadius: THEME.borderRadius.sm,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center',
borderWidth: 1, borderWidth: 1,
}, },
retakeBtn: { retakeBtn: {
@@ -410,19 +659,20 @@ const styles = StyleSheet.create({
backgroundColor: 'transparent', backgroundColor: 'transparent',
}, },
smallBtnText: { smallBtnText: {
color: THEME.colors.text, color: THEME.colors.textMuted,
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
}, },
// ── Photo Canvases ──
singleCanvas: { singleCanvas: {
width: 320, width: 320,
height: 480, height: 480,
backgroundColor: '#fff', backgroundColor: '#fff',
padding: 10, padding: 10,
shadowColor: '#000', shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 10, shadowRadius: 15,
elevation: 5, elevation: 5,
}, },
singleImage: { singleImage: {
@@ -496,6 +746,24 @@ const styles = StyleSheet.create({
resizeMode: 'cover', resizeMode: 'cover',
borderRadius: 2, borderRadius: 2,
}, },
// ── Date Overlay ──
dateOverlay: {
position: 'absolute',
zIndex: 60,
},
dateOverlayText: {
fontSize: 11,
fontWeight: 'bold',
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.8)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 3,
backgroundColor: 'rgba(0, 0, 0, 0.35)',
paddingVertical: 2,
paddingHorizontal: 6,
borderRadius: 3,
},
// ── Loading ──
loadingOverlay: { loadingOverlay: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@@ -514,6 +782,11 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
borderWidth: 1, borderWidth: 1,
borderColor: THEME.colors.border, borderColor: THEME.colors.border,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 20,
elevation: 10,
}, },
loadingText: { loadingText: {
color: THEME.colors.text, color: THEME.colors.text,
+21
View File
@@ -5,6 +5,19 @@ export interface AppSettings {
printerIp: string; printerIp: string;
adminPassword: string; adminPassword: string;
kioskModeEnabled: boolean; kioskModeEnabled: boolean;
// Photo Frames
frameMode: 'off' | 'always' | 'available';
selectedFrameId: string | null;
// Date/Time overlay
dateOverlay: 'off' | 'date' | 'datetime';
dateOverlayPosition: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-center';
// Event text (shown in date sticker & welcome)
eventText: string;
// Continuous shooting / burst
burstCount: number; // 1 = single shot, 25 = burst
burstIntervalSec: number; // seconds between burst shots
// Welcome screen text
welcomeText: string;
} }
const settingsFile = new File(Paths.document, 'settings.json'); const settingsFile = new File(Paths.document, 'settings.json');
@@ -14,6 +27,14 @@ const DEFAULT_SETTINGS: AppSettings = {
printerIp: '192.168.1.100', printerIp: '192.168.1.100',
adminPassword: '1234', adminPassword: '1234',
kioskModeEnabled: false, kioskModeEnabled: false,
frameMode: 'off',
selectedFrameId: null,
dateOverlay: 'off',
dateOverlayPosition: 'bottom-right',
eventText: '',
burstCount: 1,
burstIntervalSec: 5,
welcomeText: 'Willkommen zu unserer Feier!',
}; };
/** /**
+7 -8
View File
@@ -1,4 +1,4 @@
import { Asset, Album, getPermissionsAsync, requestPermissionsAsync } from 'expo-media-library'; import { getPermissionsAsync, requestPermissionsAsync, createAssetAsync, getAlbumAsync, createAlbumAsync, addAssetsToAlbumAsync } from 'expo-media-library';
const ALBUM_NAME = 'Schnappix'; const ALBUM_NAME = 'Schnappix';
@@ -33,28 +33,27 @@ export async function saveToGallery(localUri: string): Promise<string> {
} }
// 1. Create a media asset from the local file // 1. Create a media asset from the local file
const asset = await Asset.create(localUri); const asset = await createAssetAsync(localUri);
try { try {
// 2. Check if the "Schnappix" album already exists // 2. Check if the "Schnappix" album already exists
const album = await Album.get(ALBUM_NAME); const album = await getAlbumAsync(ALBUM_NAME);
if (!album) { if (!album) {
// 3. If it doesn't exist, create it with our asset // 3. If it doesn't exist, create it with our asset
await Album.create(ALBUM_NAME, [asset], false); await createAlbumAsync(ALBUM_NAME, asset, false);
console.log(`Created new album "${ALBUM_NAME}" and saved photo.`); console.log(`Created new album "${ALBUM_NAME}" and saved photo.`);
} else { } else {
// 4. If it exists, add our asset to it // 4. If it exists, add our asset to it
await album.add(asset); await addAssetsToAlbumAsync([asset], album, false);
console.log(`Saved photo to existing album "${ALBUM_NAME}".`); console.log(`Saved photo to existing album "${ALBUM_NAME}".`);
} }
const assetUri = await asset.getUri(); return asset.uri;
return assetUri;
} catch (e: any) { } catch (e: any) {
console.error('Error saving asset to album, returning fallback asset URI:', e); console.error('Error saving asset to album, returning fallback asset URI:', e);
try { try {
return await asset.getUri(); return asset.uri;
} catch { } catch {
return asset.id; return asset.id;
} }
+15 -12
View File
@@ -1,18 +1,21 @@
export const THEME = { export const THEME = {
colors: { colors: {
background: '#0B0B0E', background: '#050510',
surface: '#121217', surface: '#0D0D1A',
surfaceSecondary: '#1C1C24', surfaceSecondary: '#151528',
primary: '#6C5DD3', primary: '#ff2bd6',
primaryDark: '#564AB1', primaryDark: '#cc22ab',
accent: '#FF7A9A', accent: '#28dfff',
accentDark: '#1fb3cc',
text: '#FFFFFF', text: '#FFFFFF',
textMuted: '#8F90A6', textMuted: '#7a7a9e',
border: 'rgba(255, 255, 255, 0.08)', border: 'rgba(255, 43, 214, 0.15)',
glassBackground: 'rgba(18, 18, 23, 0.75)', glassBackground: 'rgba(13, 13, 26, 0.85)',
overlay: 'rgba(0, 0, 0, 0.85)', overlay: 'rgba(5, 5, 16, 0.92)',
success: '#3F8CFF', success: '#28dfff',
error: '#FF6A55', error: '#ff4466',
neonGlow: 'rgba(255, 43, 214, 0.4)',
cyanGlow: 'rgba(40, 223, 255, 0.4)',
}, },
fonts: { fonts: {
// Falls back to system sans-serif which is clean and modern on Android/iOS // Falls back to system sans-serif which is clean and modern on Android/iOS