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 ( - - + + + + ); } 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