Implement scan MVP flow
This commit is contained in:
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+158
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
+69
-58
@@ -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.')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<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)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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.' };
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export function formatPrice(value, currency = 'CHF') {
|
||||
if (typeof value !== 'number') {
|
||||
return 'Preis offen';
|
||||
}
|
||||
|
||||
return `${currency} ${value.toFixed(2)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user