Implement scan MVP flow

This commit is contained in:
2026-07-01 10:33:17 +02:00
parent 86612fbbca
commit 9caa6b05ad
34 changed files with 2318 additions and 70 deletions
+287 -3
View File
@@ -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 { 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 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() { 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 (
<ScanScreen
onOpenProduct={openProduct}
onScanBarcode={scanProductByBarcode}
preferences={preferences}
productCache={productCache}
/>
);
}
if (activeTab === 'list') {
return (
<ShoppingListScreen
onCompleteList={completeShoppingList}
onDeleteItem={deleteListItem}
onStartScan={() => setActiveTab('scan')}
onToggleItem={toggleListItem}
shoppingList={shoppingList}
/>
);
}
if (activeTab === 'profile') {
return (
<ProfileScreen
onChangePreferences={setPreferences}
preferences={preferences}
/>
);
}
return (
<HomeScreen
onOpenProduct={openProduct}
onStartScan={() => 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 (
<View style={styles.loading}>
<Text style={styles.loadingText}>Shoppy wird geladen...</Text>
</View>
);
}
if (route.name === 'productDetail' && routeProduct) {
return (
<ProductDetailScreen
onAddToList={addProductToShoppingList}
onBack={goBackToTabs}
onShowAlternatives={showAlternatives}
preferences={preferences}
product={routeProduct}
/>
);
}
if (route.name === 'alternatives' && routeProduct) {
return (
<AlternativesScreen
onBack={() => 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 ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<HomeScreen />
<StatusBar style="dark" /> <StatusBar style="dark" />
{renderRoute()}
{route.name === 'tabs' && isHydrated ? (
<BottomTabBar activeTab={activeTab} onChange={setActiveTab} />
) : null}
</SafeAreaView> </SafeAreaView>
); );
} }
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({ const styles = StyleSheet.create({
container: { container: {
backgroundColor: colors.background,
flex: 1
},
loading: {
alignItems: 'center',
flex: 1, flex: 1,
backgroundColor: '#f7f3ea' justifyContent: 'center',
padding: 24
},
loadingText: {
color: colors.muted,
fontSize: 16,
fontWeight: '700'
} }
}); });
+22 -1
View File
@@ -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 - Persönliche Empfehlung anzeigen: kaufen, prüfen oder meiden
- Nutzerprofil mit Allergien, Ernährungszielen, Budget und Nachhaltigkeitspräferenzen - Nutzerprofil mit Allergien, Ernährungszielen, Budget und Nachhaltigkeitspräferenzen
- Kurze Begründung zur Bewertung und mögliche Alternativhinweise - 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 - Später geplant: erweiterte Profile, Budgettracking, Nachhaltigkeitsfilter und Rezeptvorschläge
## One-Pager ## One-Pager
@@ -60,7 +63,23 @@ Shoppy/
## Beispiel-Screen ## 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 ## Runbook
@@ -84,6 +103,8 @@ npx expo start
Danach kann die App mit Expo Go auf einem Smartphone oder in einem Emulator gestartet werden. 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 teilen
Repository-Link für den Kursleiter: Repository-Link für den Kursleiter:
+7 -1
View File
@@ -12,9 +12,15 @@
"**/*" "**/*"
], ],
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "Shoppy nutzt die Kamera, um Produkt-Barcodes im Supermarkt zu scannen."
}
}, },
"android": { "android": {
"permissions": [
"CAMERA"
],
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#f7f3ea" "backgroundColor": "#f7f3ea"
} }
+158 -1
View File
@@ -8,9 +8,15 @@
"name": "shoppy", "name": "shoppy",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~3.2.3",
"@react-native-async-storage/async-storage": "1.23.1",
"expo": "~51.0.0", "expo": "~51.0.0",
"expo-camera": "~15.0.16",
"expo-status-bar": "~1.12.1",
"react": "18.2.0", "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": { "devDependencies": {
"@babel/core": "^7.24.0" "@babel/core": "^7.24.0"
@@ -2636,6 +2642,15 @@
"node": ">= 10.0.0" "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": { "node_modules/@expo/osascript": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.0.tgz", "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.0.tgz",
@@ -3330,6 +3345,18 @@
"node": ">=14" "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": { "node_modules/@react-native-community/cli": {
"version": "13.6.9", "version": "13.6.9",
"resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-13.6.9.tgz", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-13.6.9.tgz",
@@ -6306,6 +6333,15 @@
"node": ">=8" "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": { "node_modules/dag-map": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/dag-map/-/dag-map-1.0.2.tgz", "resolved": "https://registry.npmjs.org/dag-map/-/dag-map-1.0.2.tgz",
@@ -7053,6 +7089,18 @@
"expo": "*" "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": { "node_modules/expo-constants": {
"version": "16.0.2", "version": "16.0.2",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-16.0.2.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-16.0.2.tgz",
@@ -7161,6 +7209,12 @@
"invariant": "^2.2.4" "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": { "node_modules/exponential-backoff": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -7183,6 +7237,12 @@
"node": ">=8.6.0" "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": { "node_modules/fast-xml-parser": {
"version": "4.5.6", "version": "4.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
@@ -7941,6 +8001,12 @@
"node": ">=10.17.0" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -8048,6 +8114,16 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC" "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": { "node_modules/internal-ip": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz", "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz",
@@ -8453,6 +8529,15 @@
"node": ">=8" "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": { "node_modules/is-plain-object": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", "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==", "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==",
"license": "BSD-2-Clause" "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": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -11297,6 +11394,12 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "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": { "node_modules/react-native/node_modules/@jest/types": {
"version": "26.6.2", "version": "26.6.2",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
@@ -13001,6 +13152,12 @@
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
"license": "MIT" "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": { "node_modules/sucrase": {
"version": "3.34.0", "version": "3.34.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
+7 -1
View File
@@ -10,9 +10,15 @@
"web": "expo start --web" "web": "expo start --web"
}, },
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~3.2.3",
"@react-native-async-storage/async-storage": "1.23.1",
"expo": "~51.0.0", "expo": "~51.0.0",
"expo-camera": "~15.0.16",
"expo-status-bar": "~1.12.1",
"react": "18.2.0", "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": { "devDependencies": {
"@babel/core": "^7.24.0" "@babel/core": "^7.24.0"
+15 -5
View File
@@ -1,16 +1,20 @@
import { Pressable, StyleSheet, Text } from 'react-native'; 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 ( return (
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
disabled={disabled}
onPress={onPress} onPress={onPress}
style={({ pressed }) => [ style={({ pressed }) => [
styles.button, styles.button,
pressed ? styles.buttonPressed : null pressed ? styles.buttonPressed : null,
disabled ? styles.disabled : null
]} ]}
> >
<Text style={styles.label}>{label}</Text> <Text style={[styles.label, disabled ? styles.disabledLabel : null]}>{label}</Text>
</Pressable> </Pressable>
); );
} }
@@ -18,7 +22,7 @@ export default function PrimaryButton({ label, onPress }) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
button: { button: {
alignItems: 'center', alignItems: 'center',
backgroundColor: '#256f5b', backgroundColor: colors.primary,
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 20, paddingHorizontal: 20,
paddingVertical: 14 paddingVertical: 14
@@ -27,8 +31,14 @@ const styles = StyleSheet.create({
opacity: 0.82 opacity: 0.82
}, },
label: { label: {
color: '#ffffff', color: colors.surface,
fontSize: 16, fontSize: 16,
fontWeight: '700' fontWeight: '700'
},
disabled: {
backgroundColor: colors.border
},
disabledLabel: {
color: colors.muted
} }
}); });
+45
View File
@@ -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 (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.button,
pressed ? styles.buttonPressed : null,
disabled ? styles.disabled : null
]}
>
<Text style={[styles.label, disabled ? styles.disabledLabel : null]}>{label}</Text>
</Pressable>
);
}
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
}
});
+56
View File
@@ -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 (
<View style={styles.wrap}>
<Text style={styles.label}>Maximales Einkaufsbudget</Text>
<View style={styles.row}>
<Text style={styles.currency}>{currency}</Text>
<TextInput
keyboardType="decimal-pad"
onChangeText={(text) => onChangeValue(Number(text.replace(',', '.')) || 0)}
style={styles.input}
value={String(value ?? '')}
/>
</View>
</View>
);
}
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
}
});
+38
View File
@@ -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 (
<View style={styles.row}>
<TextInput
autoCapitalize="none"
onChangeText={onChangeText}
onSubmitEditing={onSubmit}
placeholder={placeholder}
placeholderTextColor={colors.muted}
returnKeyType="search"
style={styles.input}
value={value}
/>
<PrimaryButton label="Suchen" onPress={onSubmit} />
</View>
);
}
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
}
});
+47
View File
@@ -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 (
<View style={styles.row}>
<View style={styles.copy}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.description}>{description}</Text>
</View>
<Switch
onValueChange={onValueChange}
thumbColor={value ? colors.primary : '#f4f3f4'}
trackColor={{ false: colors.border, true: '#b7dbcf' }}
value={value}
/>
</View>
);
}
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
}
});
+64
View File
@@ -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 (
<View style={styles.header}>
<View style={styles.row}>
{onBack ? (
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}>
<Text style={styles.backText}>‹</Text>
</Pressable>
) : null}
<View style={styles.titleBlock}>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
</View>
{rightAction ? <View style={styles.rightAction}>{rightAction}</View> : null}
</View>
</View>
);
}
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'
}
});
+69
View File
@@ -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 (
<View style={styles.wrap}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<Pressable
accessibilityRole="button"
key={tab.id}
onPress={() => onChange(tab.id)}
style={[styles.tab, isActive ? styles.activeTab : null]}
>
<Text style={[styles.icon, isActive ? styles.activeText : null]}>{tab.icon}</Text>
<Text style={[styles.label, isActive ? styles.activeText : null]}>{tab.label}</Text>
</Pressable>
);
})}
</View>
);
}
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
}
});
+27
View File
@@ -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 <View style={styles.container}>{children}</View>;
}
return (
<ScrollView contentContainerStyle={styles.content} style={styles.container}>
{children}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.background,
flex: 1
},
content: {
gap: 18,
padding: 20,
paddingBottom: 110
}
});
+16
View File
@@ -0,0 +1,16 @@
import { StyleSheet, Text } from 'react-native';
import { colors } from '../../utils/constants';
export default function SectionTitle({ children }) {
return <Text style={styles.title}>{children}</Text>;
}
const styles = StyleSheet.create({
title: {
color: colors.text,
fontSize: 18,
fontWeight: '800',
marginTop: 4
}
});
+75
View File
@@ -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 (
<View style={styles.row}>
<Pressable accessibilityRole="checkbox" onPress={onToggle} style={[styles.checkbox, item.checked ? styles.checked : null]}>
<Text style={styles.checkText}>{item.checked ? '✓' : ''}</Text>
</Pressable>
<View style={styles.copy}>
<Text style={[styles.name, item.checked ? styles.done : null]}>{item.productName}</Text>
<Text style={styles.meta}>Menge {item.quantity} · {formatPrice(item.estimatedPrice, 'CHF')}</Text>
</View>
<Pressable accessibilityRole="button" onPress={onDelete} style={styles.deleteButton}>
<Text style={styles.deleteText}>Löschen</Text>
</Pressable>
</View>
);
}
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'
}
});
@@ -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 (
<View style={styles.wrap}>
<ProductCard product={item.product} rating={item.rating} onPress={onOpen} />
<Text style={styles.reason}>{item.reason}</Text>
<PrimaryButton label="Beste Alternative wählen" onPress={onChoose} />
</View>
);
}
const styles = StyleSheet.create({
wrap: {
gap: 10
},
reason: {
color: colors.muted,
fontSize: 14,
lineHeight: 20
}
});
+53
View File
@@ -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 (
<Pressable accessibilityRole="button" onPress={onPress} style={styles.card}>
<View style={styles.topRow}>
<View style={styles.copy}>
<Text style={styles.name}>{product.name}</Text>
<Text style={styles.meta}>{product.brand || 'Ohne Marke'} · {formatPrice(product.price, product.currency)}</Text>
</View>
{rating ? <ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} /> : null}
</View>
<Text style={styles.note}>{product.category || 'Produkt'} · Barcode {product.barcode}</Text>
</Pressable>
);
}
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
}
});
@@ -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 (
<View style={[styles.badge, { backgroundColor: style.backgroundColor }]}>
<Text style={[styles.text, { color: style.color }]}>
{labels[status] || 'Prüfen'} · {score}
</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
alignSelf: 'flex-start',
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 6
},
text: {
fontSize: 13,
fontWeight: '800'
}
});
@@ -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 (
<View style={styles.row}>
<View style={styles.copy}>
<Text style={styles.title}>{labels[category] || category}</Text>
<Text style={styles.message}>{rating.message}</Text>
</View>
<ProductScoreBadge status={rating.status} score={rating.status === 'good' ? 25 : rating.status === 'medium' ? 14 : 0} />
</View>
);
}
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
}
});
+60
View File
@@ -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 (
<View style={styles.wrap}>
<CameraView
barcodeScannerSettings={{
barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e', 'code128', 'qr']
}}
onBarcodeScanned={isScanning ? onBarcodeScanned : undefined}
style={styles.camera}
>
<View style={styles.overlay}>
<View style={styles.frame} />
<Text style={styles.hint}>
Barcode in den Rahmen halten. Bei Problemen manuell suchen.
</Text>
</View>
</CameraView>
</View>
);
}
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'
}
});
+47
View File
@@ -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 (
<Modal animationType="fade" onRequestClose={onClose} transparent visible={visible}>
<View style={styles.backdrop}>
<View style={styles.modal}>
<Text style={styles.title}>Scan nicht erfolgreich</Text>
<Text style={styles.message}>{message}</Text>
<PrimaryButton label="Erneut scannen" onPress={onRetry} />
<SecondaryButton label="Manuell eingeben" onPress={onManualSearch} />
</View>
</View>
</Modal>
);
}
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
}
});
+110
View File
@@ -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'
}
];
+83
View File
@@ -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 (
<ScreenContainer>
<AppHeader
title="Alternativen"
subtitle={`Bessere Optionen für ${product.name}`}
onBack={onBack}
/>
{isLoading ? <Text style={styles.note}>Alternativen werden berechnet...</Text> : null}
{!isLoading && alternatives.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>Keine Alternative gefunden</Text>
<Text style={styles.emptyCopy}>Für dieses Produkt gibt es im lokalen Beispieldatensatz noch keinen besseren Vorschlag.</Text>
</View>
) : null}
{alternatives.map((item) => (
<AlternativeProductCard
item={item}
key={item.product.id}
onChoose={() => onChoose(item.product)}
onOpen={() => onOpenProduct(item.product)}
/>
))}
</ScreenContainer>
);
}
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
}
});
+68 -57
View File
@@ -1,81 +1,92 @@
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import PrimaryButton from '../components/PrimaryButton'; 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() { export default function HomeScreen({ preferences, recentProducts, onStartScan, onOpenProduct }) {
const [status, setStatus] = useState('Bereit für deinen ersten Produkt-Scan.');
return ( return (
<View style={styles.screen}> <ScreenContainer>
<View style={styles.header}> <AppHeader
title="Shoppy"
subtitle="Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung."
/>
<View style={styles.hero}>
<Text style={styles.eyebrow}>Smart Shopping Companion</Text> <Text style={styles.eyebrow}>Smart Shopping Companion</Text>
<Text style={styles.title}>Shoppy</Text> <Text style={styles.title}>Bereit für den nächsten Einkauf?</Text>
<Text style={styles.subtitle}> <Text style={styles.copy}>
Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung. Prüfe Allergien, Ernährung, Budget und Nachhaltigkeit direkt am Regal.
</Text> </Text>
<PrimaryButton label="Produkt scannen" onPress={onStartScan} />
</View> </View>
<View style={styles.panel}> <SectionTitle>Letzte Scans</SectionTitle>
<Text style={styles.panelTitle}>Startseite</Text> {recentProducts.length === 0 ? (
<Text style={styles.status}>{status}</Text> <View style={styles.emptyState}>
<PrimaryButton <Text style={styles.emptyTitle}>Noch keine Produkte gescannt</Text>
label="Produkt scannen" <Text style={styles.emptyCopy}>Starte einen Scan oder nutze die manuelle Suche.</Text>
onPress={() => setStatus('Demo-Status: Scanner und Bewertung werden vorbereitet.')} </View>
) : (
recentProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
rating={calculateProductRating(product, preferences)}
onPress={() => onOpenProduct(product)}
/> />
</View> ))
</View> )}
</ScreenContainer>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
screen: { hero: {
flex: 1, backgroundColor: colors.surface,
justifyContent: 'center', borderColor: colors.border,
padding: 24 borderRadius: 8,
}, borderWidth: 1,
header: { gap: 12,
marginBottom: 28 padding: 18
}, },
eyebrow: { eyebrow: {
color: '#256f5b', color: colors.primary,
fontSize: 14, fontSize: 13,
fontWeight: '700', fontWeight: '800',
marginBottom: 8,
textTransform: 'uppercase' textTransform: 'uppercase'
}, },
title: { title: {
color: '#18201d', color: colors.text,
fontSize: 42, fontSize: 25,
fontWeight: '800', fontWeight: '800',
marginBottom: 10 lineHeight: 31
}, },
subtitle: { copy: {
color: '#4f5f59', color: colors.muted,
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',
fontSize: 16, 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
} }
}); });
+121
View File
@@ -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 (
<ScreenContainer>
<AppHeader title="Produktbewertung" subtitle="Persönliche Einschätzung" onBack={onBack} />
<View style={styles.productPanel}>
<View style={styles.topRow}>
<View style={styles.copy}>
<Text style={styles.brand}>{product.brand || 'Ohne Marke'}</Text>
<Text style={styles.name}>{product.name}</Text>
<Text style={styles.meta}>{product.category} · {formatPrice(product.price, product.currency)}</Text>
</View>
<ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} />
</View>
<Text style={styles.summary}>
Empfehlung: {rating.overallStatus === 'good' ? 'kaufen' : rating.overallStatus === 'medium' ? 'prüfen' : 'meiden'}.
</Text>
</View>
<SectionTitle>Bewertungskriterien</SectionTitle>
{Object.entries(rating.categoryScores).map(([category, categoryRating]) => (
<RatingCategoryRow category={category} key={category} rating={categoryRating} />
))}
<SectionTitle>Produktdaten</SectionTitle>
<View style={styles.dataPanel}>
<Text style={styles.dataText}>Barcode: {product.barcode}</Text>
<Text style={styles.dataText}>Zutaten: {(product.ingredients || []).join(', ') || 'Keine Angaben'}</Text>
<Text style={styles.dataText}>Allergene: {(product.allergens || []).join(', ') || 'Keine'}</Text>
<Text style={styles.dataText}>Nutri-Score: {product.nutrition?.nutriScore || 'Keine Angabe'}</Text>
<Text style={styles.dataText}>Eco-Score: {product.sustainability?.ecoScore || 'Keine Angabe'}</Text>
</View>
{savedMessage ? <Text style={styles.saved}>{savedMessage}</Text> : null}
<PrimaryButton label="Zur Einkaufsliste hinzufügen" onPress={addToList} />
<SecondaryButton label="Alternative anzeigen" onPress={() => onShowAlternatives(product)} />
</ScreenContainer>
);
}
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'
}
});
+184
View File
@@ -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 (
<ScreenContainer>
<AppHeader
title="Profil"
subtitle="Präferenzen steuern die Produktbewertung global."
/>
<SectionTitle>Ernährungsziel</SectionTitle>
<View style={styles.optionGrid}>
{dietOptions.map((option) => (
<Pressable
accessibilityRole="button"
key={option}
onPress={() => updatePreference('preferredDiet', option)}
style={[styles.chip, preferences.preferredDiet === option ? styles.activeChip : null]}
>
<Text style={[styles.chipText, preferences.preferredDiet === option ? styles.activeChipText : null]}>
{dietLabels[option]}
</Text>
</Pressable>
))}
</View>
<SectionTitle>Allergien vermeiden</SectionTitle>
<View style={styles.optionGrid}>
{['Erdnüsse', 'Milch', 'Hafer', 'Gluten'].map((allergen) => {
const isActive = preferences.avoidAllergens?.includes(allergen);
return (
<Pressable
accessibilityRole="button"
key={allergen}
onPress={() => toggleAllergen(allergen)}
style={[styles.chip, isActive ? styles.dangerChip : null]}
>
<Text style={[styles.chipText, isActive ? styles.dangerChipText : null]}>{allergen}</Text>
</Pressable>
);
})}
</View>
<BudgetInput
currency={preferences.currency}
onChangeValue={(value) => updatePreference('maxBudgetPerShoppingTrip', value)}
value={preferences.maxBudgetPerShoppingTrip}
/>
<SettingsSwitchRow
description="Bio-Produkte erhalten in der Nachhaltigkeitsbewertung einen Vorteil."
label="Bio bevorzugen"
onValueChange={(value) => updatePreference('preferBio', value)}
value={preferences.preferBio}
/>
<SettingsSwitchRow
description="Es werden nur lokal gespeicherte Produkte bewertet."
label="Offline-Modus"
onValueChange={(value) => updatePreference('offlineModeEnabled', value)}
value={preferences.offlineModeEnabled}
/>
<SectionTitle>Prioritäten</SectionTitle>
<PrioritySelector
label="Ernährung"
onChange={(value) => updatePreference('nutritionPriority', value)}
value={preferences.nutritionPriority}
/>
<PrioritySelector
label="Nachhaltigkeit"
onChange={(value) => updatePreference('sustainabilityPriority', value)}
value={preferences.sustainabilityPriority}
/>
</ScreenContainer>
);
}
function PrioritySelector({ label, value, onChange }) {
return (
<View style={styles.priorityRow}>
<Text style={styles.priorityLabel}>{label}</Text>
<View style={styles.priorityOptions}>
{priorityOptions.map((option) => (
<Pressable
accessibilityRole="button"
key={option}
onPress={() => onChange(option)}
style={[styles.priorityChip, value === option ? styles.activeChip : null]}
>
<Text style={[styles.chipText, value === option ? styles.activeChipText : null]}>
{option === 'low' ? 'Niedrig' : option === 'medium' ? 'Mittel' : 'Hoch'}
</Text>
</Pressable>
))}
</View>
</View>
);
}
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'
}
});
+148
View File
@@ -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 (
<ScreenContainer>
<AppHeader
title="Scan"
subtitle={preferences.offlineModeEnabled ? 'Offline-Modus aktiv: nur Cache-Produkte verfügbar.' : 'Barcode scannen oder Produkt manuell suchen.'}
/>
{!permission?.granted && Platform.OS !== 'web' ? (
<View style={styles.permissionBox}>
<Text style={styles.permissionTitle}>Kamera-Berechtigung benötigt</Text>
<Text style={styles.permissionCopy}>
Shoppy braucht die Kamera, um Barcodes direkt im Laden zu erfassen.
</Text>
<PrimaryButton label="Kamera erlauben" onPress={requestPermission} />
</View>
) : null}
{hasCameraAccess ? (
<>
<BarcodeScannerView isScanning={isScanning} onBarcodeScanned={handleBarcodeScanned} />
<SecondaryButton
label={isScanning ? 'Scan pausieren' : 'Scan fortsetzen'}
onPress={() => setIsScanning((current) => !current)}
/>
</>
) : (
<View style={styles.permissionBox}>
<Text style={styles.permissionTitle}>Scanner-Simulation</Text>
<Text style={styles.permissionCopy}>
Auf Web oder ohne Kamera kannst du mit Beispiel-Barcodes testen.
</Text>
<View style={styles.demoRow}>
<SecondaryButton label="Bio Haferdrink" onPress={() => handleBarcodeScanned({ data: '7610200336707' })} />
<SecondaryButton label="Schoko-Riegel" onPress={() => handleBarcodeScanned({ data: '7612345001112' })} />
</View>
</View>
)}
<SectionTitle>Manuelle Suche</SectionTitle>
<ManualSearchInput
onChangeText={setManualSearchQuery}
onSubmit={handleManualSearch}
value={manualSearchQuery}
/>
{isSearching ? <Text style={styles.note}>Suche läuft...</Text> : null}
{!isSearching && manualSearchQuery && manualResults.length === 0 ? (
<Text style={styles.note}>Keine Treffer gefunden.</Text>
) : null}
{manualResults.map((product) => (
<ProductCard
key={product.id}
product={product}
rating={calculateProductRating(product, preferences)}
onPress={() => onOpenProduct(product)}
/>
))}
<ScanErrorModal
message={scanError}
onClose={retryScan}
onManualSearch={() => setIsErrorModalVisible(false)}
onRetry={retryScan}
visible={isErrorModalVisible}
/>
</ScreenContainer>
);
}
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
}
});
+88
View File
@@ -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 (
<ScreenContainer>
<AppHeader
title="Einkaufsliste"
subtitle={`${openItems} offene Produkte · ${formatPrice(shoppingList.estimatedTotal, shoppingList.currency)}`}
/>
<View style={styles.summary}>
<Text style={styles.summaryTitle}>{shoppingList.title}</Text>
<Text style={styles.summaryCopy}>
Produkte abhaken, löschen oder nach dem Einkauf abschließen.
</Text>
</View>
{shoppingList.items.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>Die Liste ist leer</Text>
<Text style={styles.emptyCopy}>Scanne ein Produkt und füge es aus der Bewertung heraus hinzu.</Text>
<SecondaryButton label="Produkt scannen" onPress={onStartScan} />
</View>
) : (
shoppingList.items.map((item) => (
<ShoppingListItem
item={item}
key={item.id}
onDelete={() => onDeleteItem(item.id)}
onToggle={() => onToggleItem(item.id)}
/>
))
)}
{shoppingList.items.length > 0 ? (
<SecondaryButton label="Einkauf abschließen" onPress={onCompleteList} />
) : null}
</ScreenContainer>
);
}
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
}
});
+33
View File
@@ -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.';
}
+52
View File
@@ -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);
});
}
+147
View File
@@ -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.' };
}
+28
View File
@@ -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)
};
+32
View File
@@ -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'
};
+7
View File
@@ -0,0 +1,7 @@
export function formatPrice(value, currency = 'CHF') {
if (typeof value !== 'number') {
return 'Preis offen';
}
return `${currency} ${value.toFixed(2)}`;
}