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