diff --git a/App.js b/App.js index 9352f2c..b5221fa 100644 --- a/App.js +++ b/App.js @@ -1,20 +1,304 @@ +import { useEffect, useMemo, useState } from 'react'; +import { SafeAreaView, StyleSheet, Text, View } from 'react-native'; import { StatusBar } from 'expo-status-bar'; -import { SafeAreaView, StyleSheet } from 'react-native'; +import BottomTabBar from './src/components/layout/BottomTabBar'; +import AlternativesScreen from './src/screens/AlternativesScreen'; import HomeScreen from './src/screens/HomeScreen'; +import ProductDetailScreen from './src/screens/ProductDetailScreen'; +import ProfileScreen from './src/screens/ProfileScreen'; +import ScanScreen from './src/screens/ScanScreen'; +import ShoppingListScreen from './src/screens/ShoppingListScreen'; +import { lookupProductByBarcode } from './src/services/productApi'; +import { storageService } from './src/services/storageService'; +import { colors, defaultPreferences } from './src/utils/constants'; + +const initialShoppingList = { + id: 'active-list', + title: 'Aktueller Einkauf', + items: [], + estimatedTotal: 0, + currency: 'CHF', + status: 'active', + createdAt: new Date().toISOString() +}; export default function App() { + const [activeTab, setActiveTab] = useState('home'); + const [route, setRoute] = useState({ name: 'tabs' }); + const [preferences, setPreferences] = useState(defaultPreferences); + const [shoppingList, setShoppingList] = useState(initialShoppingList); + const [productCache, setProductCache] = useState({}); + const [recentProducts, setRecentProducts] = useState([]); + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + let isMounted = true; + + async function hydrate() { + const [ + storedPreferences, + storedShoppingList, + storedProductCache, + storedRecentProducts + ] = await Promise.all([ + storageService.loadPreferences(defaultPreferences), + storageService.loadShoppingList(initialShoppingList), + storageService.loadProductCache(), + storageService.loadRecentProducts() + ]); + + if (isMounted) { + setPreferences(storedPreferences); + setShoppingList(storedShoppingList); + setProductCache(storedProductCache); + setRecentProducts(storedRecentProducts); + setIsHydrated(true); + } + } + + hydrate(); + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + if (isHydrated) storageService.savePreferences(preferences); + }, [isHydrated, preferences]); + + useEffect(() => { + if (isHydrated) storageService.saveShoppingList(shoppingList); + }, [isHydrated, shoppingList]); + + useEffect(() => { + if (isHydrated) storageService.saveProductCache(productCache); + }, [isHydrated, productCache]); + + useEffect(() => { + if (isHydrated) storageService.saveRecentProducts(recentProducts); + }, [isHydrated, recentProducts]); + + const routeProduct = route.product; + + const tabScreen = useMemo(() => { + if (activeTab === 'scan') { + return ( + + ); + } + + if (activeTab === 'list') { + return ( + setActiveTab('scan')} + onToggleItem={toggleListItem} + shoppingList={shoppingList} + /> + ); + } + + if (activeTab === 'profile') { + return ( + + ); + } + + return ( + setActiveTab('scan')} + preferences={preferences} + recentProducts={recentProducts} + /> + ); + }, [activeTab, preferences, productCache, recentProducts, shoppingList]); + + async function scanProductByBarcode(barcode) { + const result = await lookupProductByBarcode( + barcode, + productCache, + preferences.offlineModeEnabled + ); + + if (result.status === 'found') { + openProduct(result.product); + addProductToCache(result.product); + addRecentProduct(result.product); + } + + return result; + } + + function openProduct(product) { + addProductToCache(product); + addRecentProduct(product); + setRoute({ name: 'productDetail', product }); + } + + function showAlternatives(product) { + setRoute({ name: 'alternatives', product }); + } + + function addProductToCache(product) { + setProductCache((current) => ({ + ...current, + [product.barcode]: product + })); + } + + function addRecentProduct(product) { + setRecentProducts((current) => { + const withoutDuplicate = current.filter((item) => item.id !== product.id); + return [product, ...withoutDuplicate].slice(0, 5); + }); + } + + function addProductToShoppingList(product) { + setShoppingList((current) => { + const existingItem = current.items.find((item) => item.productId === product.id); + const items = existingItem + ? current.items.map((item) => + item.productId === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ) + : [ + ...current.items, + { + id: `item-${Date.now()}`, + productId: product.id, + productName: product.name, + quantity: 1, + estimatedPrice: product.price, + checked: false, + addedAt: new Date().toISOString() + } + ]; + + return { + ...current, + currency: product.currency || current.currency, + estimatedTotal: calculateEstimatedTotal(items), + items + }; + }); + } + + function toggleListItem(itemId) { + setShoppingList((current) => ({ + ...current, + items: current.items.map((item) => + item.id === itemId ? { ...item, checked: !item.checked } : item + ) + })); + } + + function deleteListItem(itemId) { + setShoppingList((current) => { + const items = current.items.filter((item) => item.id !== itemId); + return { + ...current, + estimatedTotal: calculateEstimatedTotal(items), + items + }; + }); + } + + function completeShoppingList() { + setShoppingList({ + ...initialShoppingList, + createdAt: new Date().toISOString() + }); + } + + function goBackToTabs() { + setRoute({ name: 'tabs' }); + } + + function renderRoute() { + if (!isHydrated) { + return ( + + Shoppy wird geladen... + + ); + } + + if (route.name === 'productDetail' && routeProduct) { + return ( + + ); + } + + if (route.name === 'alternatives' && routeProduct) { + return ( + setRoute({ name: 'productDetail', product: routeProduct })} + onChoose={(product) => { + addProductToShoppingList(product); + setActiveTab('list'); + setRoute({ name: 'tabs' }); + }} + onOpenProduct={openProduct} + preferences={preferences} + product={routeProduct} + productCache={productCache} + /> + ); + } + + return tabScreen; + } + return ( - + {renderRoute()} + {route.name === 'tabs' && isHydrated ? ( + + ) : null} ); } +function calculateEstimatedTotal(items) { + return items.reduce((sum, item) => { + const price = typeof item.estimatedPrice === 'number' ? item.estimatedPrice : 0; + return sum + price * item.quantity; + }, 0); +} + const styles = StyleSheet.create({ container: { + backgroundColor: colors.background, + flex: 1 + }, + loading: { + alignItems: 'center', flex: 1, - backgroundColor: '#f7f3ea' + justifyContent: 'center', + padding: 24 + }, + loadingText: { + color: colors.muted, + fontSize: 16, + fontWeight: '700' } }); diff --git a/README.md b/README.md index a626940..7dc5d50 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ Die App richtet sich an Privatpersonen, die im Supermarkt schnell bessere Kaufen - Persönliche Empfehlung anzeigen: kaufen, prüfen oder meiden - Nutzerprofil mit Allergien, Ernährungszielen, Budget und Nachhaltigkeitspräferenzen - Kurze Begründung zur Bewertung und mögliche Alternativhinweise +- Manuelle Produktsuche, falls der Barcode nicht erkannt wird +- Lokale Einkaufsliste mit Menge, Preis, Abhaken und Löschen +- Lokaler Produktcache und Offline-Modus für gespeicherte Produkte - Später geplant: erweiterte Profile, Budgettracking, Nachhaltigkeitsfilter und Rezeptvorschläge ## One-Pager @@ -60,7 +63,23 @@ Shoppy/ ## Beispiel-Screen -Die App enthält aktuell einen einfachen Startbildschirm mit Titel, kurzem App-Versprechen, Button und Status-Text. Der Screen dient als Grundlage für die spätere Scanner- und Bewertungsfunktion. +Die App enthält jetzt einen MVP-Flow mit Bottom-Tabs: + +- Home: Startseite und letzte Scans +- Scan: Kamera-Scanner, Beispiel-Barcodes und manuelle Suche +- Produktdetail: Ampelbewertung nach Allergien, Budget, Ernährung und Nachhaltigkeit +- Alternativen: bessere Produktvorschläge aus dem lokalen Beispieldatensatz +- Einkaufsliste: Produkte hinzufügen, abhaken, löschen und Gesamtsumme sehen +- Profil: Präferenzen, Allergien, Budget, Bio-Vorliebe und Offline-Modus verwalten + +Beispiel-Barcodes für Tests ohne echte Produkte: + +```text +7610200336707 - Bio Haferdrink +7612345001112 - Erdnuss Schoko-Riegel +7612345002225 - Protein Joghurt Beeren +7612345003338 - Vegane Linsen-Bowl +``` ## Runbook @@ -84,6 +103,8 @@ npx expo start Danach kann die App mit Expo Go auf einem Smartphone oder in einem Emulator gestartet werden. +Für den Barcode-Scan muss die Kamera-Berechtigung erlaubt werden. Auf Web oder ohne Kamera kann der Scanner über die Beispiel-Barcodes und die manuelle Suche getestet werden. + ## Repository teilen Repository-Link für den Kursleiter: diff --git a/app.json b/app.json index 60d62d8..e16b3c3 100644 --- a/app.json +++ b/app.json @@ -12,9 +12,15 @@ "**/*" ], "ios": { - "supportsTablet": true + "supportsTablet": true, + "infoPlist": { + "NSCameraUsageDescription": "Shoppy nutzt die Kamera, um Produkt-Barcodes im Supermarkt zu scannen." + } }, "android": { + "permissions": [ + "CAMERA" + ], "adaptiveIcon": { "backgroundColor": "#f7f3ea" } diff --git a/package-lock.json b/package-lock.json index b5c185f..7e0789e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,15 @@ "name": "shoppy", "version": "1.0.0", "dependencies": { + "@expo/metro-runtime": "~3.2.3", + "@react-native-async-storage/async-storage": "1.23.1", "expo": "~51.0.0", + "expo-camera": "~15.0.16", + "expo-status-bar": "~1.12.1", "react": "18.2.0", - "react-native": "0.74.5" + "react-dom": "18.2.0", + "react-native": "0.74.5", + "react-native-web": "~0.19.10" }, "devDependencies": { "@babel/core": "^7.24.0" @@ -2636,6 +2642,15 @@ "node": ">= 10.0.0" } }, + "node_modules/@expo/metro-runtime": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-3.2.3.tgz", + "integrity": "sha512-v5ji+fAGi7B9YavrxvekuF8gXEV/5fz0+PhaED5AaFDnbGB4IJIbpaiqK9nqZV1axjGZNQSw6Q8TsnFetCR3bQ==", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/@expo/osascript": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.0.tgz", @@ -3330,6 +3345,18 @@ "node": ">=14" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz", + "integrity": "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.60 <1.0" + } + }, "node_modules/@react-native-community/cli": { "version": "13.6.9", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-13.6.9.tgz", @@ -6306,6 +6333,15 @@ "node": ">=8" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, "node_modules/dag-map": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/dag-map/-/dag-map-1.0.2.tgz", @@ -7053,6 +7089,18 @@ "expo": "*" } }, + "node_modules/expo-camera": { + "version": "15.0.16", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-15.0.16.tgz", + "integrity": "sha512-FLE02DMqkjwsb7IugKAqQvBe6s+TCQeb5LupO1+r//wAhBwmHncOrc6zV95ZEC2f9PTPK34nFH/s8CDGiVzIAA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-constants": { "version": "16.0.2", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-16.0.2.tgz", @@ -7161,6 +7209,12 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-status-bar": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-1.12.1.tgz", + "integrity": "sha512-/t3xdbS8KB0prj5KG5w7z+wZPFlPtkgs95BsmrP/E7Q0xHXTcDcQ6Cu2FkFuRM+PKTb17cJDnLkawyS5vDLxMA==", + "license": "MIT" + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -7183,6 +7237,12 @@ "node": ">=8.6.0" } }, + "node_modules/fast-loops": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.4.tgz", + "integrity": "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg==", + "license": "MIT" + }, "node_modules/fast-xml-parser": { "version": "4.5.6", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz", @@ -7941,6 +8001,12 @@ "node": ">=10.17.0" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8048,6 +8114,16 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/inline-style-prefixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.4.tgz", + "integrity": "sha512-FwXmZC2zbeeS7NzGjJ6pAiqRhXR0ugUShSNb6GApMl6da0/XGc4MOJsoWAywia52EEWbXNSy0pzkwz/+Y+swSg==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0", + "fast-loops": "^1.1.3" + } + }, "node_modules/internal-ip": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz", @@ -8453,6 +8529,15 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -9848,6 +9933,18 @@ "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==", "license": "BSD-2-Clause" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -11297,6 +11394,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -11531,6 +11634,28 @@ } } }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -11598,6 +11723,32 @@ } } }, + "node_modules/react-native-web": { + "version": "0.19.13", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz", + "integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^6.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/react-native/node_modules/@jest/types": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", @@ -13001,6 +13152,12 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", diff --git a/package.json b/package.json index 412f94b..a6f1229 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,15 @@ "web": "expo start --web" }, "dependencies": { + "@expo/metro-runtime": "~3.2.3", + "@react-native-async-storage/async-storage": "1.23.1", "expo": "~51.0.0", + "expo-camera": "~15.0.16", + "expo-status-bar": "~1.12.1", "react": "18.2.0", - "react-native": "0.74.5" + "react-dom": "18.2.0", + "react-native": "0.74.5", + "react-native-web": "~0.19.10" }, "devDependencies": { "@babel/core": "^7.24.0" diff --git a/src/components/PrimaryButton.js b/src/components/PrimaryButton.js index e3c5d88..3ce398e 100644 --- a/src/components/PrimaryButton.js +++ b/src/components/PrimaryButton.js @@ -1,16 +1,20 @@ import { Pressable, StyleSheet, Text } from 'react-native'; -export default function PrimaryButton({ label, onPress }) { +import { colors } from '../utils/constants'; + +export default function PrimaryButton({ label, onPress, disabled = false }) { return ( [ styles.button, - pressed ? styles.buttonPressed : null + pressed ? styles.buttonPressed : null, + disabled ? styles.disabled : null ]} > - {label} + {label} ); } @@ -18,7 +22,7 @@ export default function PrimaryButton({ label, onPress }) { const styles = StyleSheet.create({ button: { alignItems: 'center', - backgroundColor: '#256f5b', + backgroundColor: colors.primary, borderRadius: 8, paddingHorizontal: 20, paddingVertical: 14 @@ -27,8 +31,14 @@ const styles = StyleSheet.create({ opacity: 0.82 }, label: { - color: '#ffffff', + color: colors.surface, fontSize: 16, fontWeight: '700' + }, + disabled: { + backgroundColor: colors.border + }, + disabledLabel: { + color: colors.muted } }); diff --git a/src/components/SecondaryButton.js b/src/components/SecondaryButton.js new file mode 100644 index 0000000..0d0b473 --- /dev/null +++ b/src/components/SecondaryButton.js @@ -0,0 +1,45 @@ +import { Pressable, StyleSheet, Text } from 'react-native'; + +import { colors } from '../utils/constants'; + +export default function SecondaryButton({ label, onPress, disabled = false }) { + return ( + [ + styles.button, + pressed ? styles.buttonPressed : null, + disabled ? styles.disabled : null + ]} + > + {label} + + ); +} + +const styles = StyleSheet.create({ + button: { + alignItems: 'center', + borderColor: colors.primary, + borderRadius: 8, + borderWidth: 1, + paddingHorizontal: 18, + paddingVertical: 12 + }, + buttonPressed: { + opacity: 0.75 + }, + disabled: { + borderColor: colors.border + }, + label: { + color: colors.primary, + fontSize: 15, + fontWeight: '700' + }, + disabledLabel: { + color: colors.muted + } +}); diff --git a/src/components/form/BudgetInput.js b/src/components/form/BudgetInput.js new file mode 100644 index 0000000..379e062 --- /dev/null +++ b/src/components/form/BudgetInput.js @@ -0,0 +1,56 @@ +import { StyleSheet, Text, TextInput, View } from 'react-native'; + +import { colors } from '../../utils/constants'; + +export default function BudgetInput({ value, currency, onChangeValue }) { + return ( + + Maximales Einkaufsbudget + + {currency} + onChangeValue(Number(text.replace(',', '.')) || 0)} + style={styles.input} + value={String(value ?? '')} + /> + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + gap: 8, + padding: 14 + }, + label: { + color: colors.text, + fontSize: 15, + fontWeight: '800' + }, + row: { + alignItems: 'center', + flexDirection: 'row', + gap: 10 + }, + currency: { + color: colors.muted, + fontSize: 16, + fontWeight: '800' + }, + input: { + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + color: colors.text, + flex: 1, + fontSize: 16, + paddingHorizontal: 12, + paddingVertical: 10 + } +}); diff --git a/src/components/form/ManualSearchInput.js b/src/components/form/ManualSearchInput.js new file mode 100644 index 0000000..48d7e68 --- /dev/null +++ b/src/components/form/ManualSearchInput.js @@ -0,0 +1,38 @@ +import { StyleSheet, TextInput, View } from 'react-native'; + +import PrimaryButton from '../PrimaryButton'; +import { colors } from '../../utils/constants'; + +export default function ManualSearchInput({ value, onChangeText, onSubmit, placeholder = 'Produktname oder Barcode' }) { + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + row: { + gap: 10 + }, + input: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + color: colors.text, + fontSize: 16, + paddingHorizontal: 14, + paddingVertical: 12 + } +}); diff --git a/src/components/form/SettingsSwitchRow.js b/src/components/form/SettingsSwitchRow.js new file mode 100644 index 0000000..cab0b60 --- /dev/null +++ b/src/components/form/SettingsSwitchRow.js @@ -0,0 +1,47 @@ +import { StyleSheet, Switch, Text, View } from 'react-native'; + +import { colors } from '../../utils/constants'; + +export default function SettingsSwitchRow({ label, description, value, onValueChange }) { + return ( + + + {label} + {description} + + + + ); +} + +const styles = StyleSheet.create({ + row: { + alignItems: 'center', + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + flexDirection: 'row', + gap: 12, + padding: 14 + }, + copy: { + flex: 1 + }, + label: { + color: colors.text, + fontSize: 15, + fontWeight: '800' + }, + description: { + color: colors.muted, + fontSize: 13, + lineHeight: 18, + marginTop: 3 + } +}); diff --git a/src/components/layout/AppHeader.js b/src/components/layout/AppHeader.js new file mode 100644 index 0000000..072a753 --- /dev/null +++ b/src/components/layout/AppHeader.js @@ -0,0 +1,64 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../../utils/constants'; + +export default function AppHeader({ title, subtitle, onBack, rightAction }) { + return ( + + + {onBack ? ( + + ‹ + + ) : null} + + {title} + {subtitle ? {subtitle} : null} + + {rightAction ? {rightAction} : null} + + + ); +} + +const styles = StyleSheet.create({ + header: { + paddingTop: 6 + }, + row: { + alignItems: 'center', + flexDirection: 'row', + gap: 12 + }, + backButton: { + alignItems: 'center', + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + height: 40, + justifyContent: 'center', + width: 40 + }, + backText: { + color: colors.primary, + fontSize: 30, + lineHeight: 34 + }, + titleBlock: { + flex: 1 + }, + title: { + color: colors.text, + fontSize: 28, + fontWeight: '800' + }, + subtitle: { + color: colors.muted, + fontSize: 15, + lineHeight: 21, + marginTop: 4 + }, + rightAction: { + alignItems: 'flex-end' + } +}); diff --git a/src/components/layout/BottomTabBar.js b/src/components/layout/BottomTabBar.js new file mode 100644 index 0000000..f2629a0 --- /dev/null +++ b/src/components/layout/BottomTabBar.js @@ -0,0 +1,69 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../../utils/constants'; + +const tabs = [ + { id: 'home', label: 'Home', icon: '⌂' }, + { id: 'scan', label: 'Scan', icon: '□' }, + { id: 'list', label: 'Liste', icon: '✓' }, + { id: 'profile', label: 'Profil', icon: '◦' } +]; + +export default function BottomTabBar({ activeTab, onChange }) { + return ( + + {tabs.map((tab) => { + const isActive = activeTab === tab.id; + return ( + onChange(tab.id)} + style={[styles.tab, isActive ? styles.activeTab : null]} + > + {tab.icon} + {tab.label} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + wrap: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderTopWidth: 1, + bottom: 0, + flexDirection: 'row', + gap: 8, + left: 0, + padding: 10, + position: 'absolute', + right: 0 + }, + tab: { + alignItems: 'center', + borderRadius: 8, + flex: 1, + gap: 2, + paddingVertical: 8 + }, + activeTab: { + backgroundColor: '#e5f1ec' + }, + icon: { + color: colors.muted, + fontSize: 18, + fontWeight: '800' + }, + label: { + color: colors.muted, + fontSize: 12, + fontWeight: '700' + }, + activeText: { + color: colors.primary + } +}); diff --git a/src/components/layout/ScreenContainer.js b/src/components/layout/ScreenContainer.js new file mode 100644 index 0000000..95ad862 --- /dev/null +++ b/src/components/layout/ScreenContainer.js @@ -0,0 +1,27 @@ +import { ScrollView, StyleSheet, View } from 'react-native'; + +import { colors } from '../../utils/constants'; + +export default function ScreenContainer({ children, scroll = true }) { + if (!scroll) { + return {children}; + } + + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.background, + flex: 1 + }, + content: { + gap: 18, + padding: 20, + paddingBottom: 110 + } +}); diff --git a/src/components/layout/SectionTitle.js b/src/components/layout/SectionTitle.js new file mode 100644 index 0000000..f84d8e8 --- /dev/null +++ b/src/components/layout/SectionTitle.js @@ -0,0 +1,16 @@ +import { StyleSheet, Text } from 'react-native'; + +import { colors } from '../../utils/constants'; + +export default function SectionTitle({ children }) { + return {children}; +} + +const styles = StyleSheet.create({ + title: { + color: colors.text, + fontSize: 18, + fontWeight: '800', + marginTop: 4 + } +}); diff --git a/src/components/list/ShoppingListItem.js b/src/components/list/ShoppingListItem.js new file mode 100644 index 0000000..69caed9 --- /dev/null +++ b/src/components/list/ShoppingListItem.js @@ -0,0 +1,75 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../../utils/constants'; +import { formatPrice } from '../../utils/formatPrice'; + +export default function ShoppingListItem({ item, onToggle, onDelete }) { + return ( + + + {item.checked ? '✓' : ''} + + + {item.productName} + Menge {item.quantity} · {formatPrice(item.estimatedPrice, 'CHF')} + + + Löschen + + + ); +} + +const styles = StyleSheet.create({ + row: { + alignItems: 'center', + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + flexDirection: 'row', + gap: 12, + padding: 14 + }, + checkbox: { + alignItems: 'center', + borderColor: colors.primary, + borderRadius: 6, + borderWidth: 2, + height: 28, + justifyContent: 'center', + width: 28 + }, + checked: { + backgroundColor: colors.primary + }, + checkText: { + color: colors.surface, + fontWeight: '800' + }, + copy: { + flex: 1 + }, + name: { + color: colors.text, + fontSize: 16, + fontWeight: '800' + }, + done: { + textDecorationLine: 'line-through' + }, + meta: { + color: colors.muted, + fontSize: 13, + marginTop: 3 + }, + deleteButton: { + paddingHorizontal: 8, + paddingVertical: 6 + }, + deleteText: { + color: colors.danger, + fontSize: 13, + fontWeight: '800' + } +}); diff --git a/src/components/product/AlternativeProductCard.js b/src/components/product/AlternativeProductCard.js new file mode 100644 index 0000000..07cd0d5 --- /dev/null +++ b/src/components/product/AlternativeProductCard.js @@ -0,0 +1,26 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import PrimaryButton from '../PrimaryButton'; +import { colors } from '../../utils/constants'; +import ProductCard from './ProductCard'; + +export default function AlternativeProductCard({ item, onChoose, onOpen }) { + return ( + + + {item.reason} + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + gap: 10 + }, + reason: { + color: colors.muted, + fontSize: 14, + lineHeight: 20 + } +}); diff --git a/src/components/product/ProductCard.js b/src/components/product/ProductCard.js new file mode 100644 index 0000000..68691f1 --- /dev/null +++ b/src/components/product/ProductCard.js @@ -0,0 +1,53 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../../utils/constants'; +import { formatPrice } from '../../utils/formatPrice'; +import ProductScoreBadge from './ProductScoreBadge'; + +export default function ProductCard({ product, rating, onPress }) { + return ( + + + + {product.name} + {product.brand || 'Ohne Marke'} · {formatPrice(product.price, product.currency)} + + {rating ? : null} + + {product.category || 'Produkt'} · Barcode {product.barcode} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + gap: 10, + padding: 16 + }, + topRow: { + alignItems: 'flex-start', + flexDirection: 'row', + gap: 12 + }, + copy: { + flex: 1 + }, + name: { + color: colors.text, + fontSize: 17, + fontWeight: '800' + }, + meta: { + color: colors.muted, + fontSize: 14, + marginTop: 4 + }, + note: { + color: colors.muted, + fontSize: 13 + } +}); diff --git a/src/components/product/ProductScoreBadge.js b/src/components/product/ProductScoreBadge.js new file mode 100644 index 0000000..1b48d41 --- /dev/null +++ b/src/components/product/ProductScoreBadge.js @@ -0,0 +1,47 @@ +import { StyleSheet, Text, View } from 'react-native'; + +const labels = { + good: 'Gut', + medium: 'Prüfen', + critical: 'Meiden' +}; + +const statusStyles = { + good: { + backgroundColor: '#dff4e8', + color: '#1f7a4d' + }, + medium: { + backgroundColor: '#fff4d8', + color: '#9a650f' + }, + critical: { + backgroundColor: '#fee4e2', + color: '#b42318' + } +}; + +export default function ProductScoreBadge({ status, score }) { + const style = statusStyles[status] || statusStyles.medium; + + return ( + + + {labels[status] || 'Prüfen'} · {score} + + + ); +} + +const styles = StyleSheet.create({ + badge: { + alignSelf: 'flex-start', + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 6 + }, + text: { + fontSize: 13, + fontWeight: '800' + } +}); diff --git a/src/components/product/RatingCategoryRow.js b/src/components/product/RatingCategoryRow.js new file mode 100644 index 0000000..52a3f14 --- /dev/null +++ b/src/components/product/RatingCategoryRow.js @@ -0,0 +1,50 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../../utils/constants'; +import ProductScoreBadge from './ProductScoreBadge'; + +const labels = { + allergies: 'Allergien', + budget: 'Budget', + nutrition: 'Ernährung', + sustainability: 'Nachhaltigkeit' +}; + +export default function RatingCategoryRow({ category, rating }) { + return ( + + + {labels[category] || category} + {rating.message} + + + + ); +} + +const styles = StyleSheet.create({ + row: { + alignItems: 'center', + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + flexDirection: 'row', + gap: 12, + padding: 14 + }, + copy: { + flex: 1 + }, + title: { + color: colors.text, + fontSize: 15, + fontWeight: '800', + marginBottom: 4 + }, + message: { + color: colors.muted, + fontSize: 14, + lineHeight: 20 + } +}); diff --git a/src/components/scan/BarcodeScannerView.js b/src/components/scan/BarcodeScannerView.js new file mode 100644 index 0000000..85d7cb3 --- /dev/null +++ b/src/components/scan/BarcodeScannerView.js @@ -0,0 +1,60 @@ +import { CameraView } from 'expo-camera'; +import { StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../../utils/constants'; + +export default function BarcodeScannerView({ isScanning, onBarcodeScanned }) { + return ( + + + + + + Barcode in den Rahmen halten. Bei Problemen manuell suchen. + + + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + backgroundColor: '#000000', + borderRadius: 8, + height: 320, + overflow: 'hidden' + }, + camera: { + flex: 1 + }, + overlay: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + padding: 24 + }, + frame: { + borderColor: colors.surface, + borderRadius: 8, + borderWidth: 3, + height: 150, + width: '82%' + }, + hint: { + backgroundColor: 'rgba(0, 0, 0, 0.55)', + borderRadius: 8, + color: colors.surface, + fontSize: 14, + lineHeight: 20, + marginTop: 18, + padding: 10, + textAlign: 'center' + } +}); diff --git a/src/components/scan/ScanErrorModal.js b/src/components/scan/ScanErrorModal.js new file mode 100644 index 0000000..38ae418 --- /dev/null +++ b/src/components/scan/ScanErrorModal.js @@ -0,0 +1,47 @@ +import { Modal, StyleSheet, Text, View } from 'react-native'; + +import PrimaryButton from '../PrimaryButton'; +import SecondaryButton from '../SecondaryButton'; +import { colors } from '../../utils/constants'; + +export default function ScanErrorModal({ visible, message, onRetry, onManualSearch, onClose }) { + return ( + + + + Scan nicht erfolgreich + {message} + + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.45)', + flex: 1, + justifyContent: 'center', + padding: 24 + }, + modal: { + backgroundColor: colors.surface, + borderRadius: 8, + gap: 14, + padding: 20, + width: '100%' + }, + title: { + color: colors.text, + fontSize: 20, + fontWeight: '800' + }, + message: { + color: colors.muted, + fontSize: 15, + lineHeight: 21 + } +}); diff --git a/src/data/mockProducts.js b/src/data/mockProducts.js new file mode 100644 index 0000000..17d1551 --- /dev/null +++ b/src/data/mockProducts.js @@ -0,0 +1,110 @@ +export const mockProducts = [ + { + id: 'p-001', + barcode: '7610200336707', + name: 'Bio Haferdrink', + brand: 'Shoppy Natur', + category: 'Getränke', + price: 2.8, + currency: 'CHF', + ingredients: ['Hafer', 'Wasser', 'Sonnenblumenöl', 'Meersalz'], + allergens: ['Hafer'], + nutrition: { + caloriesPer100g: 46, + sugarPer100g: 3.2, + fatPer100g: 1.5, + proteinPer100g: 1.1, + saltPer100g: 0.1, + nutriScore: 'B' + }, + sustainability: { + ecoScore: 'A', + isBio: true, + originCountry: 'Schweiz', + packaging: 'Tetra Pak' + }, + createdAt: '2026-07-01T08:00:00.000Z', + updatedAt: '2026-07-01T08:00:00.000Z' + }, + { + id: 'p-002', + barcode: '7612345001112', + name: 'Erdnuss Schoko-Riegel', + brand: 'SnackFit', + category: 'Snacks', + price: 1.9, + currency: 'CHF', + ingredients: ['Erdnüsse', 'Zucker', 'Kakao', 'Milchpulver', 'Palmöl'], + allergens: ['Erdnüsse', 'Milch'], + nutrition: { + caloriesPer100g: 520, + sugarPer100g: 36, + fatPer100g: 31, + proteinPer100g: 14, + saltPer100g: 0.4, + nutriScore: 'D' + }, + sustainability: { + ecoScore: 'D', + isBio: false, + originCountry: 'Deutschland', + packaging: 'Kunststoff' + }, + createdAt: '2026-07-01T08:00:00.000Z', + updatedAt: '2026-07-01T08:00:00.000Z' + }, + { + id: 'p-003', + barcode: '7612345002225', + name: 'Protein Joghurt Beeren', + brand: 'Alpina', + category: 'Milchprodukte', + price: 2.4, + currency: 'CHF', + ingredients: ['Joghurt', 'Beeren', 'Milchprotein', 'Süßungsmittel'], + allergens: ['Milch'], + nutrition: { + caloriesPer100g: 82, + sugarPer100g: 5.1, + fatPer100g: 0.5, + proteinPer100g: 10.5, + saltPer100g: 0.12, + nutriScore: 'A' + }, + sustainability: { + ecoScore: 'B', + isBio: false, + originCountry: 'Schweiz', + packaging: 'Becher' + }, + createdAt: '2026-07-01T08:00:00.000Z', + updatedAt: '2026-07-01T08:00:00.000Z' + }, + { + id: 'p-004', + barcode: '7612345003338', + name: 'Vegane Linsen-Bowl', + brand: 'GreenMeal', + category: 'Fertiggericht', + price: 6.5, + currency: 'CHF', + ingredients: ['Linsen', 'Reis', 'Karotten', 'Spinat', 'Rapsöl'], + allergens: [], + nutrition: { + caloriesPer100g: 148, + sugarPer100g: 2.4, + fatPer100g: 4.2, + proteinPer100g: 7.8, + saltPer100g: 0.7, + nutriScore: 'A' + }, + sustainability: { + ecoScore: 'A', + isBio: true, + originCountry: 'Schweiz', + packaging: 'Recycling-Schale' + }, + createdAt: '2026-07-01T08:00:00.000Z', + updatedAt: '2026-07-01T08:00:00.000Z' + } +]; diff --git a/src/screens/AlternativesScreen.js b/src/screens/AlternativesScreen.js new file mode 100644 index 0000000..21fb908 --- /dev/null +++ b/src/screens/AlternativesScreen.js @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import AppHeader from '../components/layout/AppHeader'; +import ScreenContainer from '../components/layout/ScreenContainer'; +import AlternativeProductCard from '../components/product/AlternativeProductCard'; +import { findAlternatives } from '../services/alternativesService'; +import { colors } from '../utils/constants'; + +export default function AlternativesScreen({ product, preferences, productCache, onBack, onChoose, onOpenProduct }) { + const [alternatives, setAlternatives] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + async function loadAlternatives() { + setIsLoading(true); + const results = await findAlternatives(product, preferences, productCache); + if (isMounted) { + setAlternatives(results); + setIsLoading(false); + } + } + + loadAlternatives(); + return () => { + isMounted = false; + }; + }, [product, preferences, productCache]); + + return ( + + + + {isLoading ? Alternativen werden berechnet... : null} + {!isLoading && alternatives.length === 0 ? ( + + Keine Alternative gefunden + Für dieses Produkt gibt es im lokalen Beispieldatensatz noch keinen besseren Vorschlag. + + ) : null} + + {alternatives.map((item) => ( + onChoose(item.product)} + onOpen={() => onOpenProduct(item.product)} + /> + ))} + + ); +} + +const styles = StyleSheet.create({ + note: { + color: colors.muted, + fontSize: 14 + }, + empty: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + padding: 16 + }, + emptyTitle: { + color: colors.text, + fontSize: 16, + fontWeight: '800' + }, + emptyCopy: { + color: colors.muted, + fontSize: 14, + lineHeight: 20, + marginTop: 4 + } +}); diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index c8d2b54..5040161 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -1,81 +1,92 @@ -import { useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import PrimaryButton from '../components/PrimaryButton'; +import AppHeader from '../components/layout/AppHeader'; +import ScreenContainer from '../components/layout/ScreenContainer'; +import SectionTitle from '../components/layout/SectionTitle'; +import ProductCard from '../components/product/ProductCard'; +import { calculateProductRating } from '../services/ratingService'; +import { colors } from '../utils/constants'; -export default function HomeScreen() { - const [status, setStatus] = useState('Bereit für deinen ersten Produkt-Scan.'); - +export default function HomeScreen({ preferences, recentProducts, onStartScan, onOpenProduct }) { return ( - - + + + + Smart Shopping Companion - Shoppy - - Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung. + Bereit für den nächsten Einkauf? + + Prüfe Allergien, Ernährung, Budget und Nachhaltigkeit direkt am Regal. + - - Startseite - {status} - setStatus('Demo-Status: Scanner und Bewertung werden vorbereitet.')} - /> - - + Letzte Scans + {recentProducts.length === 0 ? ( + + Noch keine Produkte gescannt + Starte einen Scan oder nutze die manuelle Suche. + + ) : ( + recentProducts.map((product) => ( + onOpenProduct(product)} + /> + )) + )} + ); } const styles = StyleSheet.create({ - screen: { - flex: 1, - justifyContent: 'center', - padding: 24 - }, - header: { - marginBottom: 28 + hero: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + gap: 12, + padding: 18 }, eyebrow: { - color: '#256f5b', - fontSize: 14, - fontWeight: '700', - marginBottom: 8, + color: colors.primary, + fontSize: 13, + fontWeight: '800', textTransform: 'uppercase' }, title: { - color: '#18201d', - fontSize: 42, + color: colors.text, + fontSize: 25, fontWeight: '800', - marginBottom: 10 + lineHeight: 31 }, - subtitle: { - color: '#4f5f59', - fontSize: 17, - lineHeight: 24 - }, - panel: { - backgroundColor: '#ffffff', - borderRadius: 8, - gap: 16, - padding: 20, - shadowColor: '#000000', - shadowOffset: { - height: 4, - width: 0 - }, - shadowOpacity: 0.08, - shadowRadius: 12 - }, - panelTitle: { - color: '#18201d', - fontSize: 20, - fontWeight: '700' - }, - status: { - color: '#4f5f59', + copy: { + color: colors.muted, fontSize: 16, - lineHeight: 22 + lineHeight: 23 + }, + emptyState: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + padding: 18 + }, + emptyTitle: { + color: colors.text, + fontSize: 16, + fontWeight: '800' + }, + emptyCopy: { + color: colors.muted, + fontSize: 14, + lineHeight: 20, + marginTop: 4 } }); diff --git a/src/screens/ProductDetailScreen.js b/src/screens/ProductDetailScreen.js new file mode 100644 index 0000000..2c826d4 --- /dev/null +++ b/src/screens/ProductDetailScreen.js @@ -0,0 +1,121 @@ +import { useMemo, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import PrimaryButton from '../components/PrimaryButton'; +import SecondaryButton from '../components/SecondaryButton'; +import AppHeader from '../components/layout/AppHeader'; +import ScreenContainer from '../components/layout/ScreenContainer'; +import SectionTitle from '../components/layout/SectionTitle'; +import ProductScoreBadge from '../components/product/ProductScoreBadge'; +import RatingCategoryRow from '../components/product/RatingCategoryRow'; +import { calculateProductRating } from '../services/ratingService'; +import { colors } from '../utils/constants'; +import { formatPrice } from '../utils/formatPrice'; + +export default function ProductDetailScreen({ product, preferences, onBack, onAddToList, onShowAlternatives }) { + const [savedMessage, setSavedMessage] = useState(''); + const rating = useMemo(() => calculateProductRating(product, preferences), [product, preferences]); + + function addToList() { + onAddToList(product); + setSavedMessage('Produkt wurde zur Einkaufsliste hinzugefügt.'); + } + + return ( + + + + + + + {product.brand || 'Ohne Marke'} + {product.name} + {product.category} · {formatPrice(product.price, product.currency)} + + + + + Empfehlung: {rating.overallStatus === 'good' ? 'kaufen' : rating.overallStatus === 'medium' ? 'prüfen' : 'meiden'}. + + + + Bewertungskriterien + {Object.entries(rating.categoryScores).map(([category, categoryRating]) => ( + + ))} + + Produktdaten + + Barcode: {product.barcode} + Zutaten: {(product.ingredients || []).join(', ') || 'Keine Angaben'} + Allergene: {(product.allergens || []).join(', ') || 'Keine'} + Nutri-Score: {product.nutrition?.nutriScore || 'Keine Angabe'} + Eco-Score: {product.sustainability?.ecoScore || 'Keine Angabe'} + + + {savedMessage ? {savedMessage} : null} + + onShowAlternatives(product)} /> + + ); +} + +const styles = StyleSheet.create({ + productPanel: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + gap: 14, + padding: 18 + }, + topRow: { + alignItems: 'flex-start', + flexDirection: 'row', + gap: 12 + }, + copy: { + flex: 1 + }, + brand: { + color: colors.primary, + fontSize: 13, + fontWeight: '800', + textTransform: 'uppercase' + }, + name: { + color: colors.text, + fontSize: 25, + fontWeight: '800', + lineHeight: 31, + marginTop: 4 + }, + meta: { + color: colors.muted, + fontSize: 15, + marginTop: 6 + }, + summary: { + color: colors.text, + fontSize: 16, + fontWeight: '700' + }, + dataPanel: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + gap: 8, + padding: 14 + }, + dataText: { + color: colors.muted, + fontSize: 14, + lineHeight: 20 + }, + saved: { + color: colors.success, + fontSize: 14, + fontWeight: '800' + } +}); diff --git a/src/screens/ProfileScreen.js b/src/screens/ProfileScreen.js new file mode 100644 index 0000000..107fd7b --- /dev/null +++ b/src/screens/ProfileScreen.js @@ -0,0 +1,184 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import BudgetInput from '../components/form/BudgetInput'; +import SettingsSwitchRow from '../components/form/SettingsSwitchRow'; +import AppHeader from '../components/layout/AppHeader'; +import ScreenContainer from '../components/layout/ScreenContainer'; +import SectionTitle from '../components/layout/SectionTitle'; +import { colors, dietLabels } from '../utils/constants'; + +const dietOptions = ['none', 'vegetarian', 'vegan', 'lowSugar', 'highProtein']; +const priorityOptions = ['low', 'medium', 'high']; + +export default function ProfileScreen({ preferences, onChangePreferences }) { + function updatePreference(key, value) { + onChangePreferences({ + ...preferences, + [key]: value + }); + } + + function toggleAllergen(allergen) { + const current = preferences.avoidAllergens || []; + const nextAllergens = current.includes(allergen) + ? current.filter((item) => item !== allergen) + : [...current, allergen]; + updatePreference('avoidAllergens', nextAllergens); + } + + return ( + + + + Ernährungsziel + + {dietOptions.map((option) => ( + updatePreference('preferredDiet', option)} + style={[styles.chip, preferences.preferredDiet === option ? styles.activeChip : null]} + > + + {dietLabels[option]} + + + ))} + + + Allergien vermeiden + + {['Erdnüsse', 'Milch', 'Hafer', 'Gluten'].map((allergen) => { + const isActive = preferences.avoidAllergens?.includes(allergen); + return ( + toggleAllergen(allergen)} + style={[styles.chip, isActive ? styles.dangerChip : null]} + > + {allergen} + + ); + })} + + + updatePreference('maxBudgetPerShoppingTrip', value)} + value={preferences.maxBudgetPerShoppingTrip} + /> + + updatePreference('preferBio', value)} + value={preferences.preferBio} + /> + + updatePreference('offlineModeEnabled', value)} + value={preferences.offlineModeEnabled} + /> + + Prioritäten + updatePreference('nutritionPriority', value)} + value={preferences.nutritionPriority} + /> + updatePreference('sustainabilityPriority', value)} + value={preferences.sustainabilityPriority} + /> + + ); +} + +function PrioritySelector({ label, value, onChange }) { + return ( + + {label} + + {priorityOptions.map((option) => ( + onChange(option)} + style={[styles.priorityChip, value === option ? styles.activeChip : null]} + > + + {option === 'low' ? 'Niedrig' : option === 'medium' ? 'Mittel' : 'Hoch'} + + + ))} + + + ); +} + +const styles = StyleSheet.create({ + optionGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8 + }, + chip: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10 + }, + activeChip: { + backgroundColor: colors.primary, + borderColor: colors.primary + }, + dangerChip: { + backgroundColor: '#fee4e2', + borderColor: '#fecdca' + }, + chipText: { + color: colors.text, + fontSize: 14, + fontWeight: '800' + }, + activeChipText: { + color: colors.surface + }, + dangerChipText: { + color: colors.danger + }, + priorityRow: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + gap: 10, + padding: 14 + }, + priorityLabel: { + color: colors.text, + fontSize: 15, + fontWeight: '800' + }, + priorityOptions: { + flexDirection: 'row', + gap: 8 + }, + priorityChip: { + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + flex: 1, + paddingVertical: 10, + alignItems: 'center' + } +}); diff --git a/src/screens/ScanScreen.js b/src/screens/ScanScreen.js new file mode 100644 index 0000000..22c1b86 --- /dev/null +++ b/src/screens/ScanScreen.js @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import { Platform, StyleSheet, Text, View } from 'react-native'; +import { useCameraPermissions } from 'expo-camera'; + +import PrimaryButton from '../components/PrimaryButton'; +import SecondaryButton from '../components/SecondaryButton'; +import ManualSearchInput from '../components/form/ManualSearchInput'; +import AppHeader from '../components/layout/AppHeader'; +import ScreenContainer from '../components/layout/ScreenContainer'; +import SectionTitle from '../components/layout/SectionTitle'; +import ProductCard from '../components/product/ProductCard'; +import BarcodeScannerView from '../components/scan/BarcodeScannerView'; +import ScanErrorModal from '../components/scan/ScanErrorModal'; +import { searchProducts } from '../services/productApi'; +import { calculateProductRating } from '../services/ratingService'; +import { colors } from '../utils/constants'; + +export default function ScanScreen({ preferences, productCache, onScanBarcode, onOpenProduct }) { + const [permission, requestPermission] = useCameraPermissions(); + const [isScanning, setIsScanning] = useState(true); + const [scanError, setScanError] = useState(''); + const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [manualSearchQuery, setManualSearchQuery] = useState(''); + const [manualResults, setManualResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + async function handleBarcodeScanned(event) { + if (!isScanning) return; + + setIsScanning(false); + const result = await onScanBarcode(event.data); + + if (result?.status !== 'found') { + setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.'); + setIsErrorModalVisible(true); + } + } + + async function handleManualSearch() { + setIsSearching(true); + const results = await searchProducts(manualSearchQuery, productCache, preferences.offlineModeEnabled); + setManualResults(results); + setIsSearching(false); + } + + function retryScan() { + setIsErrorModalVisible(false); + setScanError(''); + setIsScanning(true); + } + + const hasCameraAccess = permission?.granted && Platform.OS !== 'web'; + + return ( + + + + {!permission?.granted && Platform.OS !== 'web' ? ( + + Kamera-Berechtigung benötigt + + Shoppy braucht die Kamera, um Barcodes direkt im Laden zu erfassen. + + + + ) : null} + + {hasCameraAccess ? ( + <> + + setIsScanning((current) => !current)} + /> + + ) : ( + + Scanner-Simulation + + Auf Web oder ohne Kamera kannst du mit Beispiel-Barcodes testen. + + + handleBarcodeScanned({ data: '7610200336707' })} /> + handleBarcodeScanned({ data: '7612345001112' })} /> + + + )} + + Manuelle Suche + + {isSearching ? Suche läuft... : null} + {!isSearching && manualSearchQuery && manualResults.length === 0 ? ( + Keine Treffer gefunden. + ) : null} + {manualResults.map((product) => ( + onOpenProduct(product)} + /> + ))} + + setIsErrorModalVisible(false)} + onRetry={retryScan} + visible={isErrorModalVisible} + /> + + ); +} + +const styles = StyleSheet.create({ + permissionBox: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + gap: 12, + padding: 16 + }, + permissionTitle: { + color: colors.text, + fontSize: 18, + fontWeight: '800' + }, + permissionCopy: { + color: colors.muted, + fontSize: 15, + lineHeight: 21 + }, + demoRow: { + gap: 10 + }, + note: { + color: colors.muted, + fontSize: 14 + } +}); diff --git a/src/screens/ShoppingListScreen.js b/src/screens/ShoppingListScreen.js new file mode 100644 index 0000000..bb34f96 --- /dev/null +++ b/src/screens/ShoppingListScreen.js @@ -0,0 +1,88 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import SecondaryButton from '../components/SecondaryButton'; +import AppHeader from '../components/layout/AppHeader'; +import ScreenContainer from '../components/layout/ScreenContainer'; +import ShoppingListItem from '../components/list/ShoppingListItem'; +import { colors } from '../utils/constants'; +import { formatPrice } from '../utils/formatPrice'; + +export default function ShoppingListScreen({ shoppingList, onToggleItem, onDeleteItem, onStartScan, onCompleteList }) { + const openItems = shoppingList.items.filter((item) => !item.checked).length; + + return ( + + + + + {shoppingList.title} + + Produkte abhaken, löschen oder nach dem Einkauf abschließen. + + + + {shoppingList.items.length === 0 ? ( + + Die Liste ist leer + Scanne ein Produkt und füge es aus der Bewertung heraus hinzu. + + + ) : ( + shoppingList.items.map((item) => ( + onDeleteItem(item.id)} + onToggle={() => onToggleItem(item.id)} + /> + )) + )} + + {shoppingList.items.length > 0 ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + summary: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + padding: 16 + }, + summaryTitle: { + color: colors.text, + fontSize: 19, + fontWeight: '800' + }, + summaryCopy: { + color: colors.muted, + fontSize: 14, + lineHeight: 20, + marginTop: 4 + }, + empty: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 8, + borderWidth: 1, + gap: 12, + padding: 16 + }, + emptyTitle: { + color: colors.text, + fontSize: 16, + fontWeight: '800' + }, + emptyCopy: { + color: colors.muted, + fontSize: 14, + lineHeight: 20 + } +}); diff --git a/src/services/alternativesService.js b/src/services/alternativesService.js new file mode 100644 index 0000000..86fb61b --- /dev/null +++ b/src/services/alternativesService.js @@ -0,0 +1,33 @@ +import { mockProducts } from '../data/mockProducts'; +import { calculateProductRating } from './ratingService'; + +export async function findAlternatives(product, preferences, productCache = {}) { + const candidates = [...mockProducts, ...Object.values(productCache)] + .filter((candidate) => candidate.id !== product.id) + .filter((candidate) => candidate.category === product.category || candidate.price <= (product.price || 999)); + + return Array.from(new Map(candidates.map((candidate) => [candidate.id, candidate])).values()) + .map((candidate) => ({ + product: candidate, + rating: calculateProductRating(candidate, preferences), + reason: buildReason(candidate, preferences) + })) + .sort((left, right) => right.rating.overallScore - left.rating.overallScore) + .slice(0, 3); +} + +function buildReason(product, preferences) { + if (preferences.preferBio && product.sustainability?.isBio) { + return 'Bio-Alternative mit besserem Nachhaltigkeitsprofil.'; + } + + if (preferences.preferredDiet === 'lowSugar' && (product.nutrition?.sugarPer100g || 0) <= 6) { + return 'Weniger Zucker und dadurch besser passend zu deinem Ziel.'; + } + + if ((product.allergens || []).length === 0) { + return 'Keine bekannten Allergene im Beispieldatensatz.'; + } + + return 'Bessere Gesamtbewertung nach deinen Präferenzen.'; +} diff --git a/src/services/productApi.js b/src/services/productApi.js new file mode 100644 index 0000000..464b6c8 --- /dev/null +++ b/src/services/productApi.js @@ -0,0 +1,52 @@ +import { mockProducts } from '../data/mockProducts'; + +function delay(ms = 250) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function lookupProductByBarcode(barcode, productCache = {}, offlineModeEnabled = false) { + await delay(); + + const cachedProduct = productCache[barcode]; + if (cachedProduct) { + return { status: 'found', product: cachedProduct, source: 'cache' }; + } + + if (offlineModeEnabled) { + return { + status: 'error', + errorMessage: 'Offline-Modus aktiv: Dieses Produkt ist noch nicht im lokalen Cache.' + }; + } + + const product = mockProducts.find((item) => item.barcode === barcode); + if (!product) { + return { status: 'notFound', errorMessage: 'Kein Produkt zu diesem Barcode gefunden.' }; + } + + return { status: 'found', product, source: 'mock-api' }; +} + +export async function searchProducts(query, productCache = {}, offlineModeEnabled = false) { + await delay(); + + const normalizedQuery = query.trim().toLowerCase(); + const cachedProducts = Object.values(productCache); + const sourceProducts = offlineModeEnabled ? cachedProducts : [...mockProducts, ...cachedProducts]; + const uniqueProducts = Array.from(new Map(sourceProducts.map((product) => [product.id, product])).values()); + + if (!normalizedQuery) { + return []; + } + + return uniqueProducts.filter((product) => { + const haystack = [ + product.name, + product.brand, + product.category, + product.barcode + ].filter(Boolean).join(' ').toLowerCase(); + + return haystack.includes(normalizedQuery); + }); +} diff --git a/src/services/ratingService.js b/src/services/ratingService.js new file mode 100644 index 0000000..118f27e --- /dev/null +++ b/src/services/ratingService.js @@ -0,0 +1,147 @@ +const scoreMap = { + good: 25, + medium: 14, + critical: 0 +}; + +function priorityWeight(priority) { + if (priority === 'high') return 1.2; + if (priority === 'low') return 0.8; + return 1; +} + +function statusFromScore(score) { + if (score >= 78) return 'good'; + if (score >= 48) return 'medium'; + return 'critical'; +} + +function severityFromStatus(status) { + if (status === 'critical') return 'critical'; + if (status === 'medium') return 'warning'; + return 'info'; +} + +export function calculateProductRating(product, preferences) { + const avoidAllergens = preferences.avoidAllergens || []; + const matchingAllergens = (product.allergens || []).filter((allergen) => + avoidAllergens.some((blockedAllergen) => blockedAllergen.toLowerCase() === allergen.toLowerCase()) + ); + + const allergies = matchingAllergens.length > 0 + ? { + status: 'critical', + message: `Enthält: ${matchingAllergens.join(', ')}.` + } + : { + status: 'good', + message: 'Keine hinterlegten Allergene gefunden.' + }; + + const nutrition = rateNutrition(product, preferences); + const sustainability = rateSustainability(product, preferences); + const budget = rateBudget(product, preferences); + + const categoryScores = { + allergies, + budget, + nutrition, + sustainability + }; + + const weightedScores = [ + scoreMap[allergies.status] * 1.35, + scoreMap[budget.status] * 1, + scoreMap[nutrition.status] * priorityWeight(preferences.nutritionPriority), + scoreMap[sustainability.status] * priorityWeight(preferences.sustainabilityPriority) + ]; + const maxScore = 25 * (1.35 + 1 + priorityWeight(preferences.nutritionPriority) + priorityWeight(preferences.sustainabilityPriority)); + const overallScore = Math.round((weightedScores.reduce((sum, value) => sum + value, 0) / maxScore) * 100); + const overallStatus = statusFromScore(overallScore); + + const reasons = Object.entries(categoryScores).map(([category, value]) => ({ + category, + message: value.message, + severity: severityFromStatus(value.status) + })); + + return { + productId: product.id, + overallStatus, + overallScore, + reasons, + categoryScores + }; +} + +function rateNutrition(product, preferences) { + const nutrition = product.nutrition || {}; + + if (preferences.preferredDiet === 'lowSugar') { + if ((nutrition.sugarPer100g || 0) > 22) { + return { status: 'critical', message: 'Sehr hoher Zuckergehalt für dein Ziel.' }; + } + + if ((nutrition.sugarPer100g || 0) > 8) { + return { status: 'medium', message: 'Zuckergehalt prüfen, liegt über deinem Ziel.' }; + } + } + + if (preferences.preferredDiet === 'highProtein') { + if ((nutrition.proteinPer100g || 0) >= 10) { + return { status: 'good', message: 'Passt gut zu deinem Protein-Ziel.' }; + } + + return { status: 'medium', message: 'Proteinanteil ist eher niedrig.' }; + } + + if (preferences.preferredDiet === 'vegan' && (product.allergens || []).includes('Milch')) { + return { status: 'critical', message: 'Enthält Milch und passt nicht zu vegan.' }; + } + + if (['A', 'B'].includes(nutrition.nutriScore)) { + return { status: 'good', message: `Nutri-Score ${nutrition.nutriScore} ist positiv.` }; + } + + if (nutrition.nutriScore === 'C') { + return { status: 'medium', message: 'Nutri-Score C: in Ordnung, aber nicht optimal.' }; + } + + return { status: 'critical', message: `Nutri-Score ${nutrition.nutriScore || 'unbekannt'} ist kritisch.` }; +} + +function rateSustainability(product, preferences) { + const sustainability = product.sustainability || {}; + + if (preferences.preferBio && !sustainability.isBio) { + return { status: 'medium', message: 'Nicht Bio, obwohl Bio bevorzugt wird.' }; + } + + if (['A', 'B'].includes(sustainability.ecoScore)) { + return { status: 'good', message: `Eco-Score ${sustainability.ecoScore} ist stark.` }; + } + + if (sustainability.ecoScore === 'C') { + return { status: 'medium', message: 'Eco-Score C: akzeptabel, aber verbesserbar.' }; + } + + return { status: 'critical', message: `Eco-Score ${sustainability.ecoScore || 'unbekannt'} ist schwach.` }; +} + +function rateBudget(product, preferences) { + if (typeof product.price !== 'number' || typeof preferences.maxBudgetPerShoppingTrip !== 'number') { + return { status: 'medium', message: 'Preis oder Budget fehlt für eine genaue Prüfung.' }; + } + + const share = product.price / preferences.maxBudgetPerShoppingTrip; + + if (share <= 0.08) { + return { status: 'good', message: 'Preis passt gut zu deinem Einkaufsbudget.' }; + } + + if (share <= 0.18) { + return { status: 'medium', message: 'Preis ist spürbar, aber noch im Rahmen.' }; + } + + return { status: 'critical', message: 'Preis belastet dein Einkaufsbudget stark.' }; +} diff --git a/src/services/storageService.js b/src/services/storageService.js new file mode 100644 index 0000000..f5d4cd7 --- /dev/null +++ b/src/services/storageService.js @@ -0,0 +1,28 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const keys = { + preferences: 'shoppy.preferences', + shoppingList: 'shoppy.shoppingList', + productCache: 'shoppy.productCache', + recentProducts: 'shoppy.recentProducts' +}; + +async function readJson(key, fallbackValue) { + const rawValue = await AsyncStorage.getItem(key); + return rawValue ? JSON.parse(rawValue) : fallbackValue; +} + +async function writeJson(key, value) { + await AsyncStorage.setItem(key, JSON.stringify(value)); +} + +export const storageService = { + loadPreferences: (fallbackValue) => readJson(keys.preferences, fallbackValue), + savePreferences: (preferences) => writeJson(keys.preferences, preferences), + loadShoppingList: (fallbackValue) => readJson(keys.shoppingList, fallbackValue), + saveShoppingList: (shoppingList) => writeJson(keys.shoppingList, shoppingList), + loadProductCache: () => readJson(keys.productCache, {}), + saveProductCache: (cache) => writeJson(keys.productCache, cache), + loadRecentProducts: () => readJson(keys.recentProducts, []), + saveRecentProducts: (products) => writeJson(keys.recentProducts, products) +}; diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 0000000..8795a3d --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,32 @@ +export const colors = { + background: '#f7f3ea', + surface: '#ffffff', + text: '#18201d', + muted: '#5a6a64', + border: '#d8ded9', + primary: '#256f5b', + primaryDark: '#174b3d', + warning: '#b7791f', + danger: '#b42318', + success: '#1f7a4d' +}; + +export const defaultPreferences = { + id: 'local-user', + avoidAllergens: ['Erdnüsse'], + preferredDiet: 'lowSugar', + preferBio: true, + maxBudgetPerShoppingTrip: 45, + currency: 'CHF', + offlineModeEnabled: false, + sustainabilityPriority: 'medium', + nutritionPriority: 'high' +}; + +export const dietLabels = { + none: 'Kein Ziel', + vegetarian: 'Vegetarisch', + vegan: 'Vegan', + lowSugar: 'Wenig Zucker', + highProtein: 'Proteinreich' +}; diff --git a/src/utils/formatPrice.js b/src/utils/formatPrice.js new file mode 100644 index 0000000..72a9cea --- /dev/null +++ b/src/utils/formatPrice.js @@ -0,0 +1,7 @@ +export function formatPrice(value, currency = 'CHF') { + if (typeof value !== 'number') { + return 'Preis offen'; + } + + return `${currency} ${value.toFixed(2)}`; +}