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 { 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 (
<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 (
<SafeAreaView style={styles.container}>
<HomeScreen />
<StatusBar style="dark" />
{renderRoute()}
{route.name === 'tabs' && isHydrated ? (
<BottomTabBar activeTab={activeTab} onChange={setActiveTab} />
) : null}
</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({
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'
}
});
+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
- 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:
+7 -1
View File
@@ -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"
}
+158 -1
View File
@@ -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",
+7 -1
View File
@@ -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"
+15 -5
View File
@@ -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 (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
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>
);
}
@@ -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
}
});
+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 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 (
<View style={styles.screen}>
<View style={styles.header}>
<ScreenContainer>
<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.title}>Shoppy</Text>
<Text style={styles.subtitle}>
Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung.
<Text style={styles.title}>Bereit für den nächsten Einkauf?</Text>
<Text style={styles.copy}>
Prüfe Allergien, Ernährung, Budget und Nachhaltigkeit direkt am Regal.
</Text>
<PrimaryButton label="Produkt scannen" onPress={onStartScan} />
</View>
<View style={styles.panel}>
<Text style={styles.panelTitle}>Startseite</Text>
<Text style={styles.status}>{status}</Text>
<PrimaryButton
label="Produkt scannen"
onPress={() => setStatus('Demo-Status: Scanner und Bewertung werden vorbereitet.')}
<SectionTitle>Letzte Scans</SectionTitle>
{recentProducts.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyTitle}>Noch keine Produkte gescannt</Text>
<Text style={styles.emptyCopy}>Starte einen Scan oder nutze die manuelle Suche.</Text>
</View>
) : (
recentProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
rating={calculateProductRating(product, preferences)}
onPress={() => onOpenProduct(product)}
/>
</View>
</View>
))
)}
</ScreenContainer>
);
}
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
}
});
+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)}`;
}