diff --git a/App.tsx b/App.tsx
index 776186c8..eb53a5a3 100644
--- a/App.tsx
+++ b/App.tsx
@@ -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() {
);
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() {
);
}
};
return (
-
-
- {renderScreen()}
-
+
+
+
+ {renderScreen()}
+
+
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
- backgroundColor: '#000000',
+ backgroundColor: '#050510',
},
loadingContainer: {
flex: 1,
- backgroundColor: '#050507',
+ backgroundColor: '#050510',
justifyContent: 'center',
alignItems: 'center',
},
diff --git a/app.json b/app.json
index 398b2731..965962ce 100644
--- a/app.json
+++ b/app.json
@@ -13,7 +13,7 @@
"android": {
"package": "com.schnappix",
"adaptiveIcon": {
- "backgroundColor": "#121214",
+ "backgroundColor": "#050510",
"foregroundImage": "./Icon.png"
},
"permissions": [
diff --git a/assets/frames/frame_test.png b/assets/frames/frame_test.png
new file mode 100644
index 00000000..73f3e380
Binary files /dev/null and b/assets/frames/frame_test.png differ
diff --git a/eas.json b/eas.json
index 2502ff61..c96583fe 100644
--- a/eas.json
+++ b/eas.json
@@ -9,7 +9,10 @@
"distribution": "internal"
},
"preview": {
- "distribution": "internal"
+ "distribution": "internal",
+ "android": {
+ "buildType": "apk"
+ }
},
"production": {
"autoIncrement": true
diff --git a/index.ts b/index.ts
index 1d6e981e..e49705d5 100644
--- a/index.ts
+++ b/index.ts
@@ -1,4 +1,7 @@
import { registerRootComponent } from 'expo';
+import { Buffer } from 'buffer';
+
+global.Buffer = global.Buffer || Buffer;
import App from './App';
diff --git a/package-lock.json b/package-lock.json
index e87a15ea..0d3f8d44 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 9efcccb2..a6bcdde9 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/src/components/DraggableSticker.tsx b/src/components/DraggableSticker.tsx
new file mode 100644
index 00000000..2d638ccd
--- /dev/null
+++ b/src/components/DraggableSticker.tsx
@@ -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) => 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 (
+
+
+ {isText ? (
+
+ {sticker.content}
+
+ ) : (
+ {sticker.content}
+ )}
+ {isSelected && (
+ onDelete(sticker.id)}
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ >
+ ×
+
+ )}
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/src/components/FrameOverlay.tsx b/src/components/FrameOverlay.tsx
new file mode 100644
index 00000000..eaca3c66
--- /dev/null
+++ b/src/components/FrameOverlay.tsx
@@ -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 (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ zIndex: 50,
+ },
+ frameImage: {
+ width: '100%',
+ height: '100%',
+ },
+});
diff --git a/src/components/FramePicker.tsx b/src/components/FramePicker.tsx
new file mode 100644
index 00000000..f6842f35
--- /dev/null
+++ b/src/components/FramePicker.tsx
@@ -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 (
+
+ Keine Rahmen verfügbar
+
+ );
+ }
+
+ return (
+
+ Rahmen wählen
+
+ {/* No frame option */}
+ onSelectFrame(null)}
+ >
+
+ ✕
+
+ Kein Rahmen
+
+
+ {/* Frame options */}
+ {FRAMES.map((frame: FrameDefinition) => (
+ onSelectFrame(frame.id)}
+ >
+
+ {frame.name}
+
+ ))}
+
+
+ );
+}
+
+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',
+ },
+});
diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx
new file mode 100644
index 00000000..31be920d
--- /dev/null
+++ b/src/components/Icon.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/StickerTray.tsx b/src/components/StickerTray.tsx
new file mode 100644
index 00000000..a7e20266
--- /dev/null
+++ b/src/components/StickerTray.tsx
@@ -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 (
+
+ {/* Header row */}
+
+ Sticker hinzufügen
+
+
+
+
+
+ {/* Date/Time quick-add buttons */}
+
+ onAddDateSticker('date')}
+ >
+
+ Datum
+
+ onAddDateSticker('datetime')}
+ >
+
+ Datum + Uhrzeit
+
+ {eventText ? (
+
+
+
+ {eventText.length > 15 ? eventText.substring(0, 15) + '…' : eventText}
+
+
+ ) : null}
+
+
+ {/* Emoji grid */}
+
+ {PARTY_EMOJIS.map((emoji, idx) => (
+ onAddEmoji(emoji)}
+ activeOpacity={0.6}
+ >
+ {emoji}
+
+ ))}
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/src/data/frames.ts b/src/data/frames.ts
new file mode 100644
index 00000000..47c60c98
--- /dev/null
+++ b/src/data/frames.ts
@@ -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') },
+];
diff --git a/src/screens/AdminScreen.tsx b/src/screens/AdminScreen.tsx
index 2d4b680c..c9162140 100644
--- a/src/screens/AdminScreen.tsx
+++ b/src/screens/AdminScreen.tsx
@@ -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(currentSettings.countdownDuration));
const [printerIp, setPrinterIp] = useState(currentSettings.printerIp);
@@ -28,6 +66,16 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
const [kioskActive, setKioskActive] = useState(false);
const [isDeviceOwner, setIsDeviceOwner] = useState(false);
+ // New settings state
+ const [frameMode, setFrameMode] = useState(currentSettings.frameMode);
+ const [selectedFrameId, setSelectedFrameId] = useState(currentSettings.selectedFrameId);
+ const [dateOverlay, setDateOverlay] = useState(currentSettings.dateOverlay);
+ const [dateOverlayPosition, setDateOverlayPosition] = useState(currentSettings.dateOverlayPosition);
+ const [eventText, setEventText] = useState(currentSettings.eventText);
+ const [burstCount, setBurstCount] = useState(String(currentSettings.burstCount));
+ const [burstInterval, setBurstInterval] = useState(String(currentSettings.burstIntervalSec));
+ const [welcomeText, setWelcomeText] = useState(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 = (
+ options: { value: T; label: string }[],
+ current: T,
+ onChange: (v: T) => void,
+ ) => (
+
+ {options.map((opt) => (
+ onChange(opt.value)}
+ >
+
+ {opt.label}
+
+
+ ))}
+
+ );
+
return (
- SCHNAPPIX EINSTELLUNGEN
+
+
+ EINSTELLUNGEN
+
+
Zurück zur Fotobox
- {/* Printer Configurations */}
+ {/* ─── Printer Configuration ─── */}
- Drucker-Konfiguration
+
+
+ Drucker-Konfiguration
+
Konfiguriere die lokale IP-Adresse deines über WLAN verbundenen Canon CP1300 Druckers.
@@ -144,9 +245,12 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
- {/* Booth Behavior */}
+ {/* ─── Booth Behavior ─── */}
- Fotobox-Verhalten
+
+
+ Fotobox-Verhalten
+
Countdown-Dauer (Sekunden)
+
+ Begrüßungstext
+
+
+
+ Event-Name (für Datum-Sticker)
+
+
- {/* Security & Kiosk Mode */}
+ {/* ─── Continuous Shooting ─── */}
- Kiosk-Modus & Sicherheit
+
+
+ Serienbild / Burst-Modus
+
+
+ Mehrere Fotos automatisch hintereinander aufnehmen. Bei Anzahl = 1 wird nur ein einzelnes Foto aufgenommen.
+
+
+ Anzahl Fotos pro Durchgang (1–5)
+
+
+
+ Abstand zwischen Fotos (Sekunden, 3–30)
+
+
+
+
+ {/* ─── Photo Frames ─── */}
+
+
+
+ Fotorahmen
+
+
+ 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.
+
+ {renderSegment(FRAME_MODE_OPTIONS, frameMode, setFrameMode)}
+
+ {frameMode === 'always' && FRAMES.length > 0 && (
+
+ Ausgewählter Rahmen
+
+ {FRAMES.map((frame) => (
+ setSelectedFrameId(frame.id)}
+ >
+
+ {frame.name}
+
+
+ ))}
+
+
+ )}
+
+ {frameMode === 'always' && FRAMES.length === 0 && (
+
+ Noch keine Rahmen hinterlegt. Lege PNG-Dateien in assets/frames/ ab.
+
+ )}
+
+
+ {/* ─── Date/Time Overlay ─── */}
+
+
+
+ Datum/Uhrzeit-Einblendung
+
+
+ Datum automatisch auf jedes Foto einblenden. Unabhängig davon ist der Datum-Sticker in der Bearbeitung immer verfügbar.
+
+ {renderSegment(DATE_OVERLAY_OPTIONS, dateOverlay, setDateOverlay)}
+
+ {dateOverlay !== 'off' && (
+
+ Position der Einblendung
+ {renderSegment(DATE_POSITION_OPTIONS, dateOverlayPosition, setDateOverlayPosition)}
+
+ )}
+
+
+ {/* ─── Kiosk Mode ─── */}
+
+
+
+ Kiosk-Modus & Sicherheit
+
Sperre den Bildschirm, um zu verhindern, dass Gäste die App schließen oder auf die Systemeinstellungen zugreifen.
@@ -210,7 +432,9 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
+ {/* ─── Save Button ─── */}
+
EINSTELLUNGEN SPEICHERN
@@ -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,
diff --git a/src/screens/CameraScreen.tsx b/src/screens/CameraScreen.tsx
index ca4458dd..00da0274 100644
--- a/src/screens/CameraScreen.tsx
+++ b/src/screens/CameraScreen.tsx
@@ -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(false);
@@ -41,6 +65,15 @@ export default function CameraScreen({
const [isCapturing, setIsCapturing] = useState(false);
const [hasStarted, setHasStarted] = useState(false);
+ // Burst mode state
+ const [burstIndex, setBurstIndex] = useState(0);
+ const [burstUris, setBurstUris] = useState([]);
+ const [showBurstIndicator, setShowBurstIndicator] = useState('');
+ const [isBurstWaiting, setIsBurstWaiting] = useState(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(null);
const expoCameraRef = useRef(null);
+ const burstTimerRef = useRef(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}
>
- ⚙️
+
SCHNAPPIX
- Willkommen zu unserer Feier!
+ {welcomeText}
+
ZUM STARTEN ÜBERALL TIPPEN
@@ -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 (
{/* Source indicator */}
@@ -304,8 +390,28 @@ export default function CameraScreen({
+ {/* Burst progress indicator */}
+ {(burstCount || 1) > 1 && (
+
+
+
+ Foto {burstIndex + 1} / {burstCount}
+
+
+ )}
+
+ {/* Burst waiting indicator */}
+ {isBurstWaiting && showBurstIndicator !== '' && (
+
+
+ {showBurstIndicator}
+ Nächstes Foto in {burstIntervalSec}s…
+
+
+ )}
+
{/* Countdown overlay */}
- {hasStarted && (
+ {hasStarted && !isBurstWaiting && (
{countdown}
@@ -314,8 +420,9 @@ export default function CameraScreen({
)}
{/* Cancel Button */}
- {!isCapturing && (
+ {!isCapturing && !isBurstWaiting && (
+
ABBRECHEN
)}
@@ -340,6 +447,12 @@ export default function CameraScreen({
{/* Render UI controls/overlays on top */}
{renderContent()}
+
+ {/* White Flash Overlay */}
+
);
}
@@ -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: {
diff --git a/src/screens/PreviewScreen.tsx b/src/screens/PreviewScreen.tsx
index 98ae8f5e..cd1f9fce 100644
--- a/src/screens/PreviewScreen.tsx
+++ b/src/screens/PreviewScreen.tsx
@@ -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('single');
const [isProcessing, setIsProcessing] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
-
+
+ // Sticker state
+ const [stickers, setStickers] = useState([]);
+ const [selectedStickerId, setSelectedStickerId] = useState(null);
+ const [showStickerTray, setShowStickerTray] = useState(false);
+
+ // Frame state
+ const [selectedFrameId, setSelectedFrameId] = useState(
+ frameMode === 'always' ? initialFrameId : null
+ );
+
const viewShotRef = useRef(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) => {
+ 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 => {
+ 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 (
{/* Left panel: Preview Canvas */}
-
- {layout === 'single' ? (
- renderCollageView()
- ) : (
- // ViewShot wraps the collage to capture it at exactly 3:2 ratio (1200x1800 px) for the printer
-
- {renderCollageView()}
-
+
+
+ {renderCollageView()}
+
+ {/* Frame Overlay */}
+ {activeFrame && }
+
+ {/* Date/Time Overlay */}
+ {dateOverlay !== 'off' && (
+
+
+ {formatDate(dateOverlay)}
+
+
+ )}
+
+ {/* Stickers */}
+ {stickers.map((sticker) => (
+
+ ))}
+
+
+ {/* Sticker Tray (outside ViewShot — not captured) */}
+ {showStickerTray && (
+ setShowStickerTray(false)}
+ eventText={eventText}
+ />
)}
-
+
{/* Right panel: Controls */}
@@ -252,28 +405,79 @@ export default function PreviewScreen({
Aufgenommene Fotos: {photoUris.length} / 4
+ {/* Editing Tools Row */}
+
+ setShowStickerTray(!showStickerTray)}
+ >
+
+
+ Sticker
+
+
+
+ {frameMode === 'available' && (
+ {
+ if (selectedFrameId !== null) {
+ setSelectedFrameId(null);
+ }
+ // FramePicker is shown below
+ }}
+ >
+
+
+ Rahmen
+
+
+ )}
+
+
+ {/* Frame Picker (when mode = available) */}
+ {frameMode === 'available' && (
+
+ )}
+
{/* Action Buttons */}
- JETZT DRUCKEN 🖨️
+
+ JETZT DRUCKEN
- Nur auf Tablet speichern 💾
+
+ Nur auf Tablet speichern
{photoUris.length < 4 && (
- + Weiteres Foto aufnehmen
+
+ Weiteres Foto aufnehmen
)}
- Letztes wiederholen
+
+ Wiederholen
+
Beenden
@@ -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,
diff --git a/src/services/settings.ts b/src/services/settings.ts
index cb0b0485..ea48c60f 100644
--- a/src/services/settings.ts
+++ b/src/services/settings.ts
@@ -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!',
};
/**
diff --git a/src/services/storage.ts b/src/services/storage.ts
index faa38daf..81a75cef 100644
--- a/src/services/storage.ts
+++ b/src/services/storage.ts
@@ -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 {
}
// 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;
}
diff --git a/src/styles/theme.ts b/src/styles/theme.ts
index 48e4133d..b719b98b 100644
--- a/src/styles/theme.ts
+++ b/src/styles/theme.ts
@@ -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