Fix iOS crash, storage, and printing issues; setup apk build
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { StyleSheet, View, SafeAreaView, StatusBar } from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import CameraScreen from './src/screens/CameraScreen';
|
||||
import PreviewScreen from './src/screens/PreviewScreen';
|
||||
import AdminScreen from './src/screens/AdminScreen';
|
||||
@@ -46,6 +47,12 @@ export default function App() {
|
||||
setCurrentScreen('preview');
|
||||
};
|
||||
|
||||
// Called by CameraScreen when burst mode completes all photos
|
||||
const handleBurstComplete = (uris: string[]) => {
|
||||
setPhotoUris((prev) => [...prev, ...uris]);
|
||||
setCurrentScreen('preview');
|
||||
};
|
||||
|
||||
const handleRetakeLast = () => {
|
||||
// Remove last captured photo
|
||||
const updatedUris = [...photoUris];
|
||||
@@ -85,8 +92,11 @@ export default function App() {
|
||||
<CameraScreen
|
||||
countdownDuration={settings.countdownDuration}
|
||||
onPhotoCaptured={handlePhotoCaptured}
|
||||
onBurstComplete={handleBurstComplete}
|
||||
onCancel={handleReset}
|
||||
isIdle={false}
|
||||
burstCount={settings.burstCount}
|
||||
burstIntervalSec={settings.burstIntervalSec}
|
||||
/>
|
||||
);
|
||||
case 'preview':
|
||||
@@ -97,6 +107,11 @@ export default function App() {
|
||||
onRetakeLast={handleRetakeLast}
|
||||
onAddAnother={handleAddAnother}
|
||||
onReset={handleReset}
|
||||
frameMode={settings.frameMode}
|
||||
initialFrameId={settings.selectedFrameId}
|
||||
dateOverlay={settings.dateOverlay}
|
||||
dateOverlayPosition={settings.dateOverlayPosition}
|
||||
eventText={settings.eventText}
|
||||
/>
|
||||
);
|
||||
case 'admin':
|
||||
@@ -112,32 +127,38 @@ export default function App() {
|
||||
<CameraScreen
|
||||
countdownDuration={settings.countdownDuration}
|
||||
onPhotoCaptured={handlePhotoCaptured}
|
||||
onBurstComplete={handleBurstComplete}
|
||||
onCancel={handleReset}
|
||||
isIdle={true}
|
||||
onStartBooth={handleStartBooth}
|
||||
onNavigateToAdmin={handleOpenAdmin}
|
||||
adminPassword={settings.adminPassword}
|
||||
welcomeText={settings.welcomeText}
|
||||
burstCount={settings.burstCount}
|
||||
burstIntervalSec={settings.burstIntervalSec}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar hidden={true} />
|
||||
{renderScreen()}
|
||||
</SafeAreaView>
|
||||
<GestureHandlerRootView style={styles.container}>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar hidden={true} />
|
||||
{renderScreen()}
|
||||
</SafeAreaView>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000000',
|
||||
backgroundColor: '#050510',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#050507',
|
||||
backgroundColor: '#050510',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"android": {
|
||||
"package": "com.schnappix",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#121214",
|
||||
"backgroundColor": "#050510",
|
||||
"foregroundImage": "./Icon.png"
|
||||
},
|
||||
"permissions": [
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -9,7 +9,10 @@
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
global.Buffer = global.Buffer || Buffer;
|
||||
|
||||
import App from './App';
|
||||
|
||||
|
||||
Generated
+279
@@ -8,6 +8,9 @@
|
||||
"name": "schnappix",
|
||||
"version": "1.0.0",
|
||||
"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",
|
||||
"expo": "~54.0.0",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
@@ -18,7 +21,9 @@
|
||||
"ipp-encoder": "^5.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-svg": "~15.15.0",
|
||||
"react-native-view-shot": "4.0.3",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
@@ -1615,6 +1620,18 @@
|
||||
"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": {
|
||||
"version": "54.0.25",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz",
|
||||
@@ -2359,6 +2376,54 @@
|
||||
"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": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -2965,6 +3030,12 @@
|
||||
"@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": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -3571,6 +3642,12 @@
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
||||
@@ -4058,6 +4135,56 @@
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -4188,6 +4315,61 @@
|
||||
"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": {
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
@@ -4252,6 +4434,18 @@
|
||||
"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": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
|
||||
@@ -4896,6 +5090,21 @@
|
||||
"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": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
|
||||
@@ -4990,6 +5199,12 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -5914,6 +6129,12 @@
|
||||
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
|
||||
"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": {
|
||||
"version": "5.2.1",
|
||||
"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_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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
||||
@@ -7013,6 +7246,23 @@
|
||||
"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": {
|
||||
"version": "3.0.4",
|
||||
"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": {
|
||||
"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",
|
||||
@@ -7215,6 +7480,20 @@
|
||||
"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": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"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",
|
||||
"expo": "~54.0.0",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
@@ -13,7 +16,9 @@
|
||||
"ipp-encoder": "^5.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-svg": "~15.15.0",
|
||||
"react-native-view-shot": "4.0.3",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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%',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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') },
|
||||
];
|
||||
+310
-9
@@ -10,9 +10,23 @@ import {
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
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 KioskMode from '../../modules/kiosk-mode';
|
||||
import { AppSettings, saveSettings } from '../services/settings';
|
||||
import { FRAMES } from '../data/frames';
|
||||
|
||||
interface AdminScreenProps {
|
||||
currentSettings: AppSettings;
|
||||
@@ -20,6 +34,30 @@ interface AdminScreenProps {
|
||||
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) {
|
||||
const [countdown, setCountdown] = useState<string>(String(currentSettings.countdownDuration));
|
||||
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 [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
|
||||
useEffect(() => {
|
||||
const checkKioskStatus = () => {
|
||||
@@ -99,11 +147,36 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
|
||||
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 = {
|
||||
countdownDuration: duration,
|
||||
printerIp: ipTrimmed,
|
||||
adminPassword: password.trim(),
|
||||
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 {
|
||||
@@ -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 (
|
||||
<Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}>
|
||||
<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}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} size={14} color={THEME.colors.text} />
|
||||
<Text style={styles.closeBtnText}>Zurück zur Fotobox</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{/* Printer Configurations */}
|
||||
{/* ─── Printer Configuration ─── */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Drucker-Konfiguration</Text>
|
||||
<View style={styles.cardHeader}>
|
||||
<FontAwesomeIcon icon={faPrint} size={16} color={THEME.colors.accent} />
|
||||
<Text style={styles.cardTitle}>Drucker-Konfiguration</Text>
|
||||
</View>
|
||||
<Text style={styles.cardDesc}>
|
||||
Konfiguriere die lokale IP-Adresse deines über WLAN verbundenen Canon CP1300 Druckers.
|
||||
</Text>
|
||||
@@ -144,9 +245,12 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Booth Behavior */}
|
||||
{/* ─── Booth Behavior ─── */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Fotobox-Verhalten</Text>
|
||||
<View style={styles.cardHeader}>
|
||||
<FontAwesomeIcon icon={faCameraRetro} size={16} color={THEME.colors.accent} />
|
||||
<Text style={styles.cardTitle}>Fotobox-Verhalten</Text>
|
||||
</View>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Countdown-Dauer (Sekunden)</Text>
|
||||
<TextInput
|
||||
@@ -170,11 +274,129 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Security & Kiosk Mode */}
|
||||
{/* ─── Continuous Shooting ─── */}
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Kiosk-Modus & Sicherheit</Text>
|
||||
<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 (1–5)</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, 3–30)</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>
|
||||
</View>
|
||||
<Text style={styles.cardDesc}>
|
||||
Sperre den Bildschirm, um zu verhindern, dass Gäste die App schließen oder auf die Systemeinstellungen zugreifen.
|
||||
</Text>
|
||||
@@ -210,7 +432,9 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ─── Save Button ─── */}
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
@@ -233,6 +457,11 @@ const styles = StyleSheet.create({
|
||||
borderColor: THEME.colors.border,
|
||||
backgroundColor: THEME.colors.surface,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: '900',
|
||||
@@ -240,6 +469,9 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 2,
|
||||
},
|
||||
closeBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: THEME.colors.surfaceSecondary,
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
@@ -266,11 +498,16 @@ const styles = StyleSheet.create({
|
||||
padding: THEME.spacing.lg,
|
||||
marginBottom: THEME.spacing.lg,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginBottom: THEME.spacing.xs,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: THEME.colors.text,
|
||||
marginBottom: THEME.spacing.xs,
|
||||
},
|
||||
cardDesc: {
|
||||
fontSize: 14,
|
||||
@@ -295,6 +532,64 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: THEME.spacing.md,
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: THEME.spacing.xs,
|
||||
@@ -315,7 +610,7 @@ const styles = StyleSheet.create({
|
||||
color: THEME.colors.textMuted,
|
||||
},
|
||||
statusWarning: {
|
||||
color: THEME.colors.accent,
|
||||
color: THEME.colors.error,
|
||||
},
|
||||
kioskWarningText: {
|
||||
fontSize: 12,
|
||||
@@ -341,12 +636,18 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
maxWidth: 680,
|
||||
height: 52,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: THEME.colors.primary,
|
||||
borderRadius: THEME.borderRadius.md,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: THEME.spacing.sm,
|
||||
marginBottom: THEME.spacing.xl,
|
||||
shadowColor: THEME.colors.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
saveBtnText: {
|
||||
color: THEME.colors.text,
|
||||
|
||||
+239
-61
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -11,7 +11,23 @@ import {
|
||||
} from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
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 { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera';
|
||||
import { saveToGallery } from '../services/storage';
|
||||
@@ -19,21 +35,29 @@ import { saveToGallery } from '../services/storage';
|
||||
interface CameraScreenProps {
|
||||
countdownDuration: number;
|
||||
onPhotoCaptured: (uri: string) => void;
|
||||
onBurstComplete: (uris: string[]) => void;
|
||||
onCancel: () => void;
|
||||
isIdle?: boolean;
|
||||
onStartBooth?: () => void;
|
||||
onNavigateToAdmin?: () => void;
|
||||
adminPassword?: string;
|
||||
welcomeText?: string;
|
||||
burstCount?: number;
|
||||
burstIntervalSec?: number;
|
||||
}
|
||||
|
||||
export default function CameraScreen({
|
||||
countdownDuration,
|
||||
onPhotoCaptured,
|
||||
onBurstComplete,
|
||||
onCancel,
|
||||
isIdle = false,
|
||||
onStartBooth,
|
||||
onNavigateToAdmin,
|
||||
adminPassword = '1234',
|
||||
welcomeText = 'Willkommen zu unserer Feier!',
|
||||
burstCount = 1,
|
||||
burstIntervalSec = 5,
|
||||
}: CameraScreenProps) {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isUsbConnected, setIsUsbConnected] = useState<boolean>(false);
|
||||
@@ -41,6 +65,15 @@ export default function CameraScreen({
|
||||
const [isCapturing, setIsCapturing] = 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
|
||||
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
|
||||
const [enteredPassword, setEnteredPassword] = useState('');
|
||||
@@ -48,6 +81,7 @@ export default function CameraScreen({
|
||||
|
||||
const usbCameraRef = useRef<UsbCameraRef>(null);
|
||||
const expoCameraRef = useRef<any>(null);
|
||||
const burstTimerRef = useRef<any>(null);
|
||||
|
||||
// Reanimated countdown pulse animation
|
||||
const scale = useSharedValue(1);
|
||||
@@ -65,6 +99,18 @@ export default function CameraScreen({
|
||||
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)
|
||||
useEffect(() => {
|
||||
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)
|
||||
useEffect(() => {
|
||||
if (isIdle) {
|
||||
setHasStarted(false);
|
||||
setBurstIndex(0);
|
||||
setBurstUris([]);
|
||||
return;
|
||||
}
|
||||
|
||||
startCountdown();
|
||||
|
||||
return () => {};
|
||||
}, [countdownDuration, isIdle]);
|
||||
|
||||
const startCountdown = () => {
|
||||
let timer: any;
|
||||
let count = countdownDuration;
|
||||
|
||||
setCountdown(count);
|
||||
setHasStarted(true);
|
||||
setIsCapturing(false);
|
||||
setShowBurstIndicator('');
|
||||
|
||||
const runTimer = () => {
|
||||
if (count > 1) {
|
||||
@@ -120,16 +182,15 @@ export default function CameraScreen({
|
||||
setIsCapturing(true);
|
||||
timer = setTimeout(() => {
|
||||
capture();
|
||||
}, 800); // give the user a split second to smile
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
timer = setTimeout(runTimer, 1000);
|
||||
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [countdownDuration, isIdle]);
|
||||
// Store timer for cleanup
|
||||
burstTimerRef.current = timer;
|
||||
};
|
||||
|
||||
// 4. Capture photo function
|
||||
const capture = async () => {
|
||||
@@ -138,20 +199,15 @@ export default function CameraScreen({
|
||||
const tempUri = tempFile.uri;
|
||||
|
||||
try {
|
||||
triggerFlash();
|
||||
|
||||
let capturedUri: string;
|
||||
|
||||
if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) {
|
||||
// USB Camera Capture
|
||||
console.log('Capturing from USB Camera...');
|
||||
const path = await usbCameraRef.current.takePicture(tempUri);
|
||||
|
||||
// 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);
|
||||
capturedUri = path;
|
||||
} else {
|
||||
// Fallback Camera Capture
|
||||
console.log('Capturing from built-in camera fallback...');
|
||||
@@ -160,20 +216,47 @@ export default function CameraScreen({
|
||||
quality: 0.95,
|
||||
skipProcessing: false,
|
||||
});
|
||||
|
||||
// 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);
|
||||
capturedUri = photo.uri;
|
||||
} else {
|
||||
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) {
|
||||
console.error('Capture failed:', error);
|
||||
alert('Fehler beim Aufnehmen des Fotos: ' + error.message);
|
||||
@@ -190,7 +273,9 @@ export default function CameraScreen({
|
||||
const handlePasswordSubmit = () => {
|
||||
if (enteredPassword === adminPassword) {
|
||||
setPasswordModalVisible(false);
|
||||
onNavigateToAdmin?.();
|
||||
setTimeout(() => {
|
||||
onNavigateToAdmin?.();
|
||||
}, 350); // Delay navigation on iOS to avoid unmounting a view with an open modal
|
||||
} else {
|
||||
setErrorText('Falsches Passwort');
|
||||
setEnteredPassword('');
|
||||
@@ -231,16 +316,17 @@ export default function CameraScreen({
|
||||
onPress={handleSettingsTap}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.settingsIcon}>⚙️</Text>
|
||||
<FontAwesomeIcon icon={faUserGear} size={22} color={THEME.colors.accent} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.welcomeBox}>
|
||||
<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.startButton}>
|
||||
<FontAwesomeIcon icon={faCameraRetro} size={20} color={THEME.colors.text} style={{ marginRight: 10 }} />
|
||||
<Text style={styles.startButtonText}>ZUM STARTEN ÜBERALL TIPPEN</Text>
|
||||
</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 (
|
||||
<View style={styles.captureOverlayContainer} pointerEvents="box-none">
|
||||
{/* Source indicator */}
|
||||
@@ -304,8 +390,28 @@ export default function CameraScreen({
|
||||
</Text>
|
||||
</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 */}
|
||||
{hasStarted && (
|
||||
{hasStarted && !isBurstWaiting && (
|
||||
<View style={styles.countdownOverlay} pointerEvents="none">
|
||||
<Animated.View style={[styles.countdownBox, animatedStyle]}>
|
||||
<Text style={styles.countdownText}>{countdown}</Text>
|
||||
@@ -314,8 +420,9 @@ export default function CameraScreen({
|
||||
)}
|
||||
|
||||
{/* Cancel Button */}
|
||||
{!isCapturing && (
|
||||
{!isCapturing && !isBurstWaiting && (
|
||||
<TouchableOpacity style={styles.backButton} onPress={onCancel}>
|
||||
<FontAwesomeIcon icon={faXmark} size={16} color={THEME.colors.text} style={{ marginRight: 6 }} />
|
||||
<Text style={styles.backButtonText}>ABBRECHEN</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -340,6 +447,12 @@ export default function CameraScreen({
|
||||
|
||||
{/* Render UI controls/overlays on top */}
|
||||
{renderContent()}
|
||||
|
||||
{/* White Flash Overlay */}
|
||||
<Animated.View
|
||||
style={[styles.flashOverlay, flashAnimatedStyle]}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -360,11 +473,20 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
flashOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#FFFFFF',
|
||||
zIndex: 9999,
|
||||
},
|
||||
idleOverlayContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
||||
backgroundColor: 'rgba(5, 5, 16, 0.25)',
|
||||
},
|
||||
captureOverlayContainer: {
|
||||
flex: 1,
|
||||
@@ -378,29 +500,25 @@ const styles = StyleSheet.create({
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: THEME.borderRadius.round,
|
||||
backgroundColor: 'rgba(30, 30, 35, 0.75)',
|
||||
backgroundColor: 'rgba(13, 13, 26, 0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderColor: 'rgba(255, 43, 214, 0.25)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 999,
|
||||
},
|
||||
settingsIcon: {
|
||||
fontSize: 24,
|
||||
color: THEME.colors.text,
|
||||
},
|
||||
welcomeBox: {
|
||||
alignItems: 'center',
|
||||
padding: THEME.spacing.xl,
|
||||
borderRadius: THEME.borderRadius.lg,
|
||||
backgroundColor: 'rgba(18, 18, 20, 0.8)',
|
||||
backgroundColor: 'rgba(5, 5, 16, 0.85)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
borderColor: 'rgba(255, 43, 214, 0.2)',
|
||||
width: '60%',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowColor: THEME.colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 20,
|
||||
shadowRadius: 30,
|
||||
elevation: 10,
|
||||
},
|
||||
logo: {
|
||||
@@ -409,9 +527,9 @@ const styles = StyleSheet.create({
|
||||
color: THEME.colors.primary,
|
||||
letterSpacing: 8,
|
||||
marginBottom: THEME.spacing.sm,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
textShadowColor: THEME.colors.neonGlow,
|
||||
textShadowOffset: { width: 0, height: 0 },
|
||||
textShadowRadius: 20,
|
||||
},
|
||||
welcomeTitle: {
|
||||
fontSize: 22,
|
||||
@@ -426,13 +544,24 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: THEME.colors.accent,
|
||||
marginBottom: THEME.spacing.xl,
|
||||
borderRadius: THEME.borderRadius.sm,
|
||||
shadowColor: THEME.colors.accent,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
startButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: THEME.spacing.md,
|
||||
paddingHorizontal: THEME.spacing.xl,
|
||||
backgroundColor: THEME.colors.primary,
|
||||
borderRadius: THEME.borderRadius.round,
|
||||
marginBottom: THEME.spacing.xl,
|
||||
shadowColor: THEME.colors.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
startButtonText: {
|
||||
color: THEME.colors.text,
|
||||
@@ -448,18 +577,62 @@ const styles = StyleSheet.create({
|
||||
hudContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
backgroundColor: 'rgba(5, 5, 16, 0.8)',
|
||||
paddingVertical: THEME.spacing.xs,
|
||||
paddingHorizontal: THEME.spacing.md,
|
||||
borderRadius: THEME.borderRadius.sm,
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderColor: 'rgba(255, 43, 214, 0.15)',
|
||||
},
|
||||
cameraSourceIndicator: {
|
||||
color: THEME.colors.text,
|
||||
fontSize: 14,
|
||||
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: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
@@ -470,19 +643,19 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
countdownBox: {
|
||||
backgroundColor: 'rgba(20, 20, 25, 0.85)',
|
||||
backgroundColor: 'rgba(5, 5, 16, 0.88)',
|
||||
paddingVertical: THEME.spacing.xl,
|
||||
paddingHorizontal: THEME.spacing.xxl,
|
||||
borderRadius: THEME.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
borderColor: 'rgba(255, 43, 214, 0.25)',
|
||||
minWidth: 180,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowColor: THEME.colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 15,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
countdownText: {
|
||||
@@ -490,17 +663,22 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '900',
|
||||
color: THEME.colors.text,
|
||||
textAlign: 'center',
|
||||
textShadowColor: THEME.colors.neonGlow,
|
||||
textShadowOffset: { width: 0, height: 0 },
|
||||
textShadowRadius: 15,
|
||||
},
|
||||
backButton: {
|
||||
position: 'absolute',
|
||||
top: 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,
|
||||
paddingHorizontal: THEME.spacing.lg,
|
||||
borderRadius: THEME.borderRadius.round,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderColor: 'rgba(255, 43, 214, 0.2)',
|
||||
},
|
||||
backButtonText: {
|
||||
color: THEME.colors.text,
|
||||
@@ -546,10 +724,10 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 15,
|
||||
shadowColor: THEME.colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
modalTitle: {
|
||||
|
||||
+324
-51
@@ -1,18 +1,32 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import ViewShot from 'react-native-view-shot';
|
||||
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 { printImageLocal } from '../services/printer';
|
||||
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 {
|
||||
photoUris: string[];
|
||||
@@ -20,6 +34,14 @@ interface PreviewScreenProps {
|
||||
onRetakeLast: () => void;
|
||||
onAddAnother: () => 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';
|
||||
@@ -30,44 +52,147 @@ export default function PreviewScreen({
|
||||
onRetakeLast,
|
||||
onAddAnother,
|
||||
onReset,
|
||||
frameMode,
|
||||
initialFrameId,
|
||||
dateOverlay,
|
||||
dateOverlayPosition,
|
||||
eventText,
|
||||
}: PreviewScreenProps) {
|
||||
const [layout, setLayout] = useState<CollageLayout>('single');
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
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 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
|
||||
const handlePrint = async () => {
|
||||
setIsProcessing(true);
|
||||
setStatusMessage('Dein Foto wird vorbereitet...');
|
||||
try {
|
||||
let printUri = currentPhoto;
|
||||
|
||||
// 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('Bild wird generiert...');
|
||||
const printUri = await captureComposite();
|
||||
|
||||
setStatusMessage('Druckauftrag wird an Drucker gesendet...');
|
||||
// 1. Silent Print via IPP
|
||||
await printImageLocal(printUri, {
|
||||
ipAddress: printerIp,
|
||||
jobName: 'Schnappix Fotobox',
|
||||
});
|
||||
|
||||
setStatusMessage('Wird in Galerie gespeichert...');
|
||||
// 2. Save permanently to DCIM/Schnappix
|
||||
await saveToGallery(printUri);
|
||||
|
||||
setStatusMessage('Druck erfolgreich! Viel Spaß! 🎉');
|
||||
setTimeout(() => {
|
||||
setIsProcessing(false);
|
||||
onReset(); // Go back to Home Screen
|
||||
onReset();
|
||||
}, 2000);
|
||||
} catch (error: any) {
|
||||
console.error('Printing failed:', error);
|
||||
@@ -81,12 +206,7 @@ export default function PreviewScreen({
|
||||
setIsProcessing(true);
|
||||
setStatusMessage('Bild wird generiert...');
|
||||
try {
|
||||
let saveUri = currentPhoto;
|
||||
|
||||
if (layout !== 'single') {
|
||||
const capturedUri = await viewShotRef.current.capture();
|
||||
saveUri = capturedUri;
|
||||
}
|
||||
const saveUri = await captureComposite();
|
||||
|
||||
setStatusMessage('Wird in Galerie gespeichert...');
|
||||
await saveToGallery(saveUri);
|
||||
@@ -103,18 +223,17 @@ export default function PreviewScreen({
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-save collage and exit
|
||||
// Auto-save and exit
|
||||
const handleExit = async () => {
|
||||
// If layout is single, it has already been auto-saved to gallery right when captured
|
||||
if (layout === 'single') {
|
||||
if (stickers.length === 0 && layout === 'single' && !activeFrame && dateOverlay === 'off') {
|
||||
onReset();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatusMessage('Collage wird gespeichert...');
|
||||
setStatusMessage('Wird gespeichert...');
|
||||
try {
|
||||
const capturedUri = await viewShotRef.current.capture();
|
||||
const capturedUri = await captureComposite();
|
||||
await saveToGallery(capturedUri);
|
||||
setStatusMessage('Erfolgreich gespeichert!');
|
||||
setTimeout(() => {
|
||||
@@ -122,13 +241,13 @@ export default function PreviewScreen({
|
||||
onReset();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Auto-saving collage on exit failed:', error);
|
||||
console.error('Auto-saving on exit failed:', error);
|
||||
setIsProcessing(false);
|
||||
onReset();
|
||||
}
|
||||
};
|
||||
|
||||
// Render the selected collage inside the capture container
|
||||
// ── Render the collage ──
|
||||
const renderCollageView = () => {
|
||||
switch (layout) {
|
||||
case 'strip':
|
||||
@@ -183,20 +302,54 @@ export default function PreviewScreen({
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}>
|
||||
{/* Left panel: Preview Canvas */}
|
||||
<View style={styles.previewPanel}>
|
||||
{layout === 'single' ? (
|
||||
renderCollageView()
|
||||
) : (
|
||||
// ViewShot wraps the collage to capture it at exactly 3:2 ratio (1200x1800 px) for the printer
|
||||
<ViewShot
|
||||
ref={viewShotRef}
|
||||
options={{ format: 'jpg', quality: 0.95 }}
|
||||
style={styles.viewShotContainer}
|
||||
>
|
||||
{renderCollageView()}
|
||||
</ViewShot>
|
||||
<TouchableOpacity
|
||||
style={styles.previewPanel}
|
||||
activeOpacity={1}
|
||||
onPress={handleCanvasPress}
|
||||
>
|
||||
<ViewShot
|
||||
ref={viewShotRef}
|
||||
options={{ format: 'jpg', quality: 0.95 }}
|
||||
style={styles.viewShotContainer}
|
||||
>
|
||||
{renderCollageView()}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Right panel: Controls */}
|
||||
<View style={styles.controlPanel}>
|
||||
@@ -252,28 +405,79 @@ export default function PreviewScreen({
|
||||
Aufgenommene Fotos: {photoUris.length} / 4
|
||||
</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 */}
|
||||
<View style={styles.actions}>
|
||||
<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 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>
|
||||
|
||||
{photoUris.length < 4 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<View style={styles.rowActions}>
|
||||
<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 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>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -304,7 +508,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: THEME.spacing.md,
|
||||
backgroundColor: '#050507',
|
||||
backgroundColor: '#030308',
|
||||
},
|
||||
viewShotContainer: {
|
||||
width: 320,
|
||||
@@ -327,6 +531,9 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
marginBottom: THEME.spacing.md,
|
||||
letterSpacing: 2,
|
||||
textShadowColor: THEME.colors.neonGlow,
|
||||
textShadowOffset: { width: 0, height: 0 },
|
||||
textShadowRadius: 10,
|
||||
},
|
||||
layoutSelector: {
|
||||
flexDirection: 'row',
|
||||
@@ -346,6 +553,11 @@ const styles = StyleSheet.create({
|
||||
layoutBtnActive: {
|
||||
backgroundColor: THEME.colors.primary,
|
||||
borderColor: THEME.colors.primary,
|
||||
shadowColor: THEME.colors.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
layoutBtnDisabled: {
|
||||
opacity: 0.3,
|
||||
@@ -359,13 +571,43 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
color: THEME.colors.textMuted,
|
||||
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: {
|
||||
width: '100%',
|
||||
},
|
||||
actionBtn: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
paddingVertical: THEME.spacing.md,
|
||||
borderRadius: THEME.borderRadius.md,
|
||||
alignItems: 'center',
|
||||
@@ -374,6 +616,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
printBtn: {
|
||||
backgroundColor: THEME.colors.primary,
|
||||
shadowColor: THEME.colors.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
saveBtn: {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -381,7 +628,7 @@ const styles = StyleSheet.create({
|
||||
borderColor: THEME.colors.border,
|
||||
},
|
||||
addBtn: {
|
||||
backgroundColor: THEME.colors.accent,
|
||||
backgroundColor: THEME.colors.accentDark,
|
||||
},
|
||||
actionBtnText: {
|
||||
color: THEME.colors.text,
|
||||
@@ -396,9 +643,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
smallBtn: {
|
||||
flex: 0.48,
|
||||
flexDirection: 'row',
|
||||
paddingVertical: THEME.spacing.sm,
|
||||
borderRadius: THEME.borderRadius.sm,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
retakeBtn: {
|
||||
@@ -410,19 +659,20 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
smallBtnText: {
|
||||
color: THEME.colors.text,
|
||||
color: THEME.colors.textMuted,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
// ── Photo Canvases ──
|
||||
singleCanvas: {
|
||||
width: 320,
|
||||
height: 480,
|
||||
backgroundColor: '#fff',
|
||||
padding: 10,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowColor: THEME.colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 10,
|
||||
shadowRadius: 15,
|
||||
elevation: 5,
|
||||
},
|
||||
singleImage: {
|
||||
@@ -496,6 +746,24 @@ const styles = StyleSheet.create({
|
||||
resizeMode: 'cover',
|
||||
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: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
@@ -514,6 +782,11 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: THEME.colors.border,
|
||||
shadowColor: THEME.colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
loadingText: {
|
||||
color: THEME.colors.text,
|
||||
|
||||
@@ -5,6 +5,19 @@ export interface AppSettings {
|
||||
printerIp: string;
|
||||
adminPassword: string;
|
||||
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, 2–5 = burst
|
||||
burstIntervalSec: number; // seconds between burst shots
|
||||
// Welcome screen text
|
||||
welcomeText: string;
|
||||
}
|
||||
|
||||
const settingsFile = new File(Paths.document, 'settings.json');
|
||||
@@ -14,6 +27,14 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
printerIp: '192.168.1.100',
|
||||
adminPassword: '1234',
|
||||
kioskModeEnabled: false,
|
||||
frameMode: 'off',
|
||||
selectedFrameId: null,
|
||||
dateOverlay: 'off',
|
||||
dateOverlayPosition: 'bottom-right',
|
||||
eventText: '',
|
||||
burstCount: 1,
|
||||
burstIntervalSec: 5,
|
||||
welcomeText: 'Willkommen zu unserer Feier!',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -33,28 +33,27 @@ export async function saveToGallery(localUri: string): Promise<string> {
|
||||
}
|
||||
|
||||
// 1. Create a media asset from the local file
|
||||
const asset = await Asset.create(localUri);
|
||||
const asset = await createAssetAsync(localUri);
|
||||
|
||||
try {
|
||||
// 2. Check if the "Schnappix" album already exists
|
||||
const album = await Album.get(ALBUM_NAME);
|
||||
const album = await getAlbumAsync(ALBUM_NAME);
|
||||
|
||||
if (!album) {
|
||||
// 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.`);
|
||||
} else {
|
||||
// 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}".`);
|
||||
}
|
||||
|
||||
const assetUri = await asset.getUri();
|
||||
return assetUri;
|
||||
return asset.uri;
|
||||
} catch (e: any) {
|
||||
console.error('Error saving asset to album, returning fallback asset URI:', e);
|
||||
try {
|
||||
return await asset.getUri();
|
||||
return asset.uri;
|
||||
} catch {
|
||||
return asset.id;
|
||||
}
|
||||
|
||||
+15
-12
@@ -1,18 +1,21 @@
|
||||
export const THEME = {
|
||||
colors: {
|
||||
background: '#0B0B0E',
|
||||
surface: '#121217',
|
||||
surfaceSecondary: '#1C1C24',
|
||||
primary: '#6C5DD3',
|
||||
primaryDark: '#564AB1',
|
||||
accent: '#FF7A9A',
|
||||
background: '#050510',
|
||||
surface: '#0D0D1A',
|
||||
surfaceSecondary: '#151528',
|
||||
primary: '#ff2bd6',
|
||||
primaryDark: '#cc22ab',
|
||||
accent: '#28dfff',
|
||||
accentDark: '#1fb3cc',
|
||||
text: '#FFFFFF',
|
||||
textMuted: '#8F90A6',
|
||||
border: 'rgba(255, 255, 255, 0.08)',
|
||||
glassBackground: 'rgba(18, 18, 23, 0.75)',
|
||||
overlay: 'rgba(0, 0, 0, 0.85)',
|
||||
success: '#3F8CFF',
|
||||
error: '#FF6A55',
|
||||
textMuted: '#7a7a9e',
|
||||
border: 'rgba(255, 43, 214, 0.15)',
|
||||
glassBackground: 'rgba(13, 13, 26, 0.85)',
|
||||
overlay: 'rgba(5, 5, 16, 0.92)',
|
||||
success: '#28dfff',
|
||||
error: '#ff4466',
|
||||
neonGlow: 'rgba(255, 43, 214, 0.4)',
|
||||
cyanGlow: 'rgba(40, 223, 255, 0.4)',
|
||||
},
|
||||
fonts: {
|
||||
// Falls back to system sans-serif which is clean and modern on Android/iOS
|
||||
|
||||
Reference in New Issue
Block a user