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 { StatusBar } from 'expo-status-bar';
|
||||||
import { SafeAreaView, StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
|
import BottomTabBar from './src/components/layout/BottomTabBar';
|
||||||
|
import AlternativesScreen from './src/screens/AlternativesScreen';
|
||||||
import HomeScreen from './src/screens/HomeScreen';
|
import HomeScreen from './src/screens/HomeScreen';
|
||||||
|
import ProductDetailScreen from './src/screens/ProductDetailScreen';
|
||||||
|
import ProfileScreen from './src/screens/ProfileScreen';
|
||||||
|
import ScanScreen from './src/screens/ScanScreen';
|
||||||
|
import ShoppingListScreen from './src/screens/ShoppingListScreen';
|
||||||
|
import { lookupProductByBarcode } from './src/services/productApi';
|
||||||
|
import { storageService } from './src/services/storageService';
|
||||||
|
import { colors, defaultPreferences } from './src/utils/constants';
|
||||||
|
|
||||||
|
const initialShoppingList = {
|
||||||
|
id: 'active-list',
|
||||||
|
title: 'Aktueller Einkauf',
|
||||||
|
items: [],
|
||||||
|
estimatedTotal: 0,
|
||||||
|
currency: 'CHF',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [activeTab, setActiveTab] = useState('home');
|
||||||
|
const [route, setRoute] = useState({ name: 'tabs' });
|
||||||
|
const [preferences, setPreferences] = useState(defaultPreferences);
|
||||||
|
const [shoppingList, setShoppingList] = useState(initialShoppingList);
|
||||||
|
const [productCache, setProductCache] = useState({});
|
||||||
|
const [recentProducts, setRecentProducts] = useState([]);
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function hydrate() {
|
||||||
|
const [
|
||||||
|
storedPreferences,
|
||||||
|
storedShoppingList,
|
||||||
|
storedProductCache,
|
||||||
|
storedRecentProducts
|
||||||
|
] = await Promise.all([
|
||||||
|
storageService.loadPreferences(defaultPreferences),
|
||||||
|
storageService.loadShoppingList(initialShoppingList),
|
||||||
|
storageService.loadProductCache(),
|
||||||
|
storageService.loadRecentProducts()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setPreferences(storedPreferences);
|
||||||
|
setShoppingList(storedShoppingList);
|
||||||
|
setProductCache(storedProductCache);
|
||||||
|
setRecentProducts(storedRecentProducts);
|
||||||
|
setIsHydrated(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrate();
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHydrated) storageService.savePreferences(preferences);
|
||||||
|
}, [isHydrated, preferences]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHydrated) storageService.saveShoppingList(shoppingList);
|
||||||
|
}, [isHydrated, shoppingList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHydrated) storageService.saveProductCache(productCache);
|
||||||
|
}, [isHydrated, productCache]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isHydrated) storageService.saveRecentProducts(recentProducts);
|
||||||
|
}, [isHydrated, recentProducts]);
|
||||||
|
|
||||||
|
const routeProduct = route.product;
|
||||||
|
|
||||||
|
const tabScreen = useMemo(() => {
|
||||||
|
if (activeTab === 'scan') {
|
||||||
|
return (
|
||||||
|
<ScanScreen
|
||||||
|
onOpenProduct={openProduct}
|
||||||
|
onScanBarcode={scanProductByBarcode}
|
||||||
|
preferences={preferences}
|
||||||
|
productCache={productCache}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'list') {
|
||||||
|
return (
|
||||||
|
<ShoppingListScreen
|
||||||
|
onCompleteList={completeShoppingList}
|
||||||
|
onDeleteItem={deleteListItem}
|
||||||
|
onStartScan={() => setActiveTab('scan')}
|
||||||
|
onToggleItem={toggleListItem}
|
||||||
|
shoppingList={shoppingList}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'profile') {
|
||||||
|
return (
|
||||||
|
<ProfileScreen
|
||||||
|
onChangePreferences={setPreferences}
|
||||||
|
preferences={preferences}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HomeScreen
|
||||||
|
onOpenProduct={openProduct}
|
||||||
|
onStartScan={() => setActiveTab('scan')}
|
||||||
|
preferences={preferences}
|
||||||
|
recentProducts={recentProducts}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [activeTab, preferences, productCache, recentProducts, shoppingList]);
|
||||||
|
|
||||||
|
async function scanProductByBarcode(barcode) {
|
||||||
|
const result = await lookupProductByBarcode(
|
||||||
|
barcode,
|
||||||
|
productCache,
|
||||||
|
preferences.offlineModeEnabled
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.status === 'found') {
|
||||||
|
openProduct(result.product);
|
||||||
|
addProductToCache(result.product);
|
||||||
|
addRecentProduct(result.product);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProduct(product) {
|
||||||
|
addProductToCache(product);
|
||||||
|
addRecentProduct(product);
|
||||||
|
setRoute({ name: 'productDetail', product });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAlternatives(product) {
|
||||||
|
setRoute({ name: 'alternatives', product });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProductToCache(product) {
|
||||||
|
setProductCache((current) => ({
|
||||||
|
...current,
|
||||||
|
[product.barcode]: product
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecentProduct(product) {
|
||||||
|
setRecentProducts((current) => {
|
||||||
|
const withoutDuplicate = current.filter((item) => item.id !== product.id);
|
||||||
|
return [product, ...withoutDuplicate].slice(0, 5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProductToShoppingList(product) {
|
||||||
|
setShoppingList((current) => {
|
||||||
|
const existingItem = current.items.find((item) => item.productId === product.id);
|
||||||
|
const items = existingItem
|
||||||
|
? current.items.map((item) =>
|
||||||
|
item.productId === product.id
|
||||||
|
? { ...item, quantity: item.quantity + 1 }
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
...current.items,
|
||||||
|
{
|
||||||
|
id: `item-${Date.now()}`,
|
||||||
|
productId: product.id,
|
||||||
|
productName: product.name,
|
||||||
|
quantity: 1,
|
||||||
|
estimatedPrice: product.price,
|
||||||
|
checked: false,
|
||||||
|
addedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
currency: product.currency || current.currency,
|
||||||
|
estimatedTotal: calculateEstimatedTotal(items),
|
||||||
|
items
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleListItem(itemId) {
|
||||||
|
setShoppingList((current) => ({
|
||||||
|
...current,
|
||||||
|
items: current.items.map((item) =>
|
||||||
|
item.id === itemId ? { ...item, checked: !item.checked } : item
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteListItem(itemId) {
|
||||||
|
setShoppingList((current) => {
|
||||||
|
const items = current.items.filter((item) => item.id !== itemId);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
estimatedTotal: calculateEstimatedTotal(items),
|
||||||
|
items
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeShoppingList() {
|
||||||
|
setShoppingList({
|
||||||
|
...initialShoppingList,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBackToTabs() {
|
||||||
|
setRoute({ name: 'tabs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoute() {
|
||||||
|
if (!isHydrated) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<Text style={styles.loadingText}>Shoppy wird geladen...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.name === 'productDetail' && routeProduct) {
|
||||||
|
return (
|
||||||
|
<ProductDetailScreen
|
||||||
|
onAddToList={addProductToShoppingList}
|
||||||
|
onBack={goBackToTabs}
|
||||||
|
onShowAlternatives={showAlternatives}
|
||||||
|
preferences={preferences}
|
||||||
|
product={routeProduct}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.name === 'alternatives' && routeProduct) {
|
||||||
|
return (
|
||||||
|
<AlternativesScreen
|
||||||
|
onBack={() => setRoute({ name: 'productDetail', product: routeProduct })}
|
||||||
|
onChoose={(product) => {
|
||||||
|
addProductToShoppingList(product);
|
||||||
|
setActiveTab('list');
|
||||||
|
setRoute({ name: 'tabs' });
|
||||||
|
}}
|
||||||
|
onOpenProduct={openProduct}
|
||||||
|
preferences={preferences}
|
||||||
|
product={routeProduct}
|
||||||
|
productCache={productCache}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabScreen;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<HomeScreen />
|
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
|
{renderRoute()}
|
||||||
|
{route.name === 'tabs' && isHydrated ? (
|
||||||
|
<BottomTabBar activeTab={activeTab} onChange={setActiveTab} />
|
||||||
|
) : null}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calculateEstimatedTotal(items) {
|
||||||
|
return items.reduce((sum, item) => {
|
||||||
|
const price = typeof item.estimatedPrice === 'number' ? item.estimatedPrice : 0;
|
||||||
|
return sum + price * item.quantity;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
flex: 1
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#f7f3ea'
|
justifyContent: 'center',
|
||||||
|
padding: 24
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: colors.muted,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ Die App richtet sich an Privatpersonen, die im Supermarkt schnell bessere Kaufen
|
|||||||
- Persönliche Empfehlung anzeigen: kaufen, prüfen oder meiden
|
- Persönliche Empfehlung anzeigen: kaufen, prüfen oder meiden
|
||||||
- Nutzerprofil mit Allergien, Ernährungszielen, Budget und Nachhaltigkeitspräferenzen
|
- Nutzerprofil mit Allergien, Ernährungszielen, Budget und Nachhaltigkeitspräferenzen
|
||||||
- Kurze Begründung zur Bewertung und mögliche Alternativhinweise
|
- Kurze Begründung zur Bewertung und mögliche Alternativhinweise
|
||||||
|
- Manuelle Produktsuche, falls der Barcode nicht erkannt wird
|
||||||
|
- Lokale Einkaufsliste mit Menge, Preis, Abhaken und Löschen
|
||||||
|
- Lokaler Produktcache und Offline-Modus für gespeicherte Produkte
|
||||||
- Später geplant: erweiterte Profile, Budgettracking, Nachhaltigkeitsfilter und Rezeptvorschläge
|
- Später geplant: erweiterte Profile, Budgettracking, Nachhaltigkeitsfilter und Rezeptvorschläge
|
||||||
|
|
||||||
## One-Pager
|
## One-Pager
|
||||||
@@ -60,7 +63,23 @@ Shoppy/
|
|||||||
|
|
||||||
## Beispiel-Screen
|
## Beispiel-Screen
|
||||||
|
|
||||||
Die App enthält aktuell einen einfachen Startbildschirm mit Titel, kurzem App-Versprechen, Button und Status-Text. Der Screen dient als Grundlage für die spätere Scanner- und Bewertungsfunktion.
|
Die App enthält jetzt einen MVP-Flow mit Bottom-Tabs:
|
||||||
|
|
||||||
|
- Home: Startseite und letzte Scans
|
||||||
|
- Scan: Kamera-Scanner, Beispiel-Barcodes und manuelle Suche
|
||||||
|
- Produktdetail: Ampelbewertung nach Allergien, Budget, Ernährung und Nachhaltigkeit
|
||||||
|
- Alternativen: bessere Produktvorschläge aus dem lokalen Beispieldatensatz
|
||||||
|
- Einkaufsliste: Produkte hinzufügen, abhaken, löschen und Gesamtsumme sehen
|
||||||
|
- Profil: Präferenzen, Allergien, Budget, Bio-Vorliebe und Offline-Modus verwalten
|
||||||
|
|
||||||
|
Beispiel-Barcodes für Tests ohne echte Produkte:
|
||||||
|
|
||||||
|
```text
|
||||||
|
7610200336707 - Bio Haferdrink
|
||||||
|
7612345001112 - Erdnuss Schoko-Riegel
|
||||||
|
7612345002225 - Protein Joghurt Beeren
|
||||||
|
7612345003338 - Vegane Linsen-Bowl
|
||||||
|
```
|
||||||
|
|
||||||
## Runbook
|
## Runbook
|
||||||
|
|
||||||
@@ -84,6 +103,8 @@ npx expo start
|
|||||||
|
|
||||||
Danach kann die App mit Expo Go auf einem Smartphone oder in einem Emulator gestartet werden.
|
Danach kann die App mit Expo Go auf einem Smartphone oder in einem Emulator gestartet werden.
|
||||||
|
|
||||||
|
Für den Barcode-Scan muss die Kamera-Berechtigung erlaubt werden. Auf Web oder ohne Kamera kann der Scanner über die Beispiel-Barcodes und die manuelle Suche getestet werden.
|
||||||
|
|
||||||
## Repository teilen
|
## Repository teilen
|
||||||
|
|
||||||
Repository-Link für den Kursleiter:
|
Repository-Link für den Kursleiter:
|
||||||
|
|||||||
@@ -12,9 +12,15 @@
|
|||||||
"**/*"
|
"**/*"
|
||||||
],
|
],
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"infoPlist": {
|
||||||
|
"NSCameraUsageDescription": "Shoppy nutzt die Kamera, um Produkt-Barcodes im Supermarkt zu scannen."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"permissions": [
|
||||||
|
"CAMERA"
|
||||||
|
],
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#f7f3ea"
|
"backgroundColor": "#f7f3ea"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+158
-1
@@ -8,9 +8,15 @@
|
|||||||
"name": "shoppy",
|
"name": "shoppy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "~3.2.3",
|
||||||
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"expo": "~51.0.0",
|
"expo": "~51.0.0",
|
||||||
|
"expo-camera": "~15.0.16",
|
||||||
|
"expo-status-bar": "~1.12.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-native": "0.74.5"
|
"react-dom": "18.2.0",
|
||||||
|
"react-native": "0.74.5",
|
||||||
|
"react-native-web": "~0.19.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.0"
|
"@babel/core": "^7.24.0"
|
||||||
@@ -2636,6 +2642,15 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@expo/metro-runtime": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-v5ji+fAGi7B9YavrxvekuF8gXEV/5fz0+PhaED5AaFDnbGB4IJIbpaiqK9nqZV1axjGZNQSw6Q8TsnFetCR3bQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@expo/osascript": {
|
"node_modules/@expo/osascript": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.0.tgz",
|
||||||
@@ -3330,6 +3345,18 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-async-storage/async-storage": {
|
||||||
|
"version": "1.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz",
|
||||||
|
"integrity": "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"merge-options": "^3.0.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": "^0.0.0-0 || >=0.60 <1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native-community/cli": {
|
"node_modules/@react-native-community/cli": {
|
||||||
"version": "13.6.9",
|
"version": "13.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-13.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-13.6.9.tgz",
|
||||||
@@ -6306,6 +6333,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-in-js-utils": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hyphenate-style-name": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dag-map": {
|
"node_modules/dag-map": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/dag-map/-/dag-map-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/dag-map/-/dag-map-1.0.2.tgz",
|
||||||
@@ -7053,6 +7089,18 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-camera": {
|
||||||
|
"version": "15.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-15.0.16.tgz",
|
||||||
|
"integrity": "sha512-FLE02DMqkjwsb7IugKAqQvBe6s+TCQeb5LupO1+r//wAhBwmHncOrc6zV95ZEC2f9PTPK34nFH/s8CDGiVzIAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"invariant": "^2.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "16.0.2",
|
"version": "16.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-16.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-16.0.2.tgz",
|
||||||
@@ -7161,6 +7209,12 @@
|
|||||||
"invariant": "^2.2.4"
|
"invariant": "^2.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-status-bar": {
|
||||||
|
"version": "1.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-1.12.1.tgz",
|
||||||
|
"integrity": "sha512-/t3xdbS8KB0prj5KG5w7z+wZPFlPtkgs95BsmrP/E7Q0xHXTcDcQ6Cu2FkFuRM+PKTb17cJDnLkawyS5vDLxMA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/exponential-backoff": {
|
"node_modules/exponential-backoff": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||||
@@ -7183,6 +7237,12 @@
|
|||||||
"node": ">=8.6.0"
|
"node": ">=8.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-loops": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "4.5.6",
|
"version": "4.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
|
||||||
@@ -7941,6 +8001,12 @@
|
|||||||
"node": ">=10.17.0"
|
"node": ">=10.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hyphenate-style-name": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -8048,6 +8114,16 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/inline-style-prefixer": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-FwXmZC2zbeeS7NzGjJ6pAiqRhXR0ugUShSNb6GApMl6da0/XGc4MOJsoWAywia52EEWbXNSy0pzkwz/+Y+swSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-in-js-utils": "^3.1.0",
|
||||||
|
"fast-loops": "^1.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/internal-ip": {
|
"node_modules/internal-ip": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz",
|
||||||
@@ -8453,6 +8529,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-plain-object": {
|
"node_modules/is-plain-object": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
|
||||||
@@ -9848,6 +9933,18 @@
|
|||||||
"integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==",
|
"integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/merge-options": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-plain-obj": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge-stream": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
@@ -11297,6 +11394,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pretty-bytes": {
|
"node_modules/pretty-bytes": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||||
@@ -11531,6 +11634,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "18.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||||
|
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.1.0",
|
||||||
|
"scheduler": "^0.23.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dom/node_modules/scheduler": {
|
||||||
|
"version": "0.23.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -11598,6 +11723,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-web": {
|
||||||
|
"version": "0.19.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",
|
||||||
|
"integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.6",
|
||||||
|
"@react-native/normalize-colors": "^0.74.1",
|
||||||
|
"fbjs": "^3.0.4",
|
||||||
|
"inline-style-prefixer": "^6.0.1",
|
||||||
|
"memoize-one": "^6.0.0",
|
||||||
|
"nullthrows": "^1.1.1",
|
||||||
|
"postcss-value-parser": "^4.2.0",
|
||||||
|
"styleq": "^0.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-web/node_modules/memoize-one": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-native/node_modules/@jest/types": {
|
"node_modules/react-native/node_modules/@jest/types": {
|
||||||
"version": "26.6.2",
|
"version": "26.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
|
||||||
@@ -13001,6 +13152,12 @@
|
|||||||
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/styleq": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sucrase": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.34.0",
|
"version": "3.34.0",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
|
||||||
|
|||||||
+7
-1
@@ -10,9 +10,15 @@
|
|||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "~3.2.3",
|
||||||
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"expo": "~51.0.0",
|
"expo": "~51.0.0",
|
||||||
|
"expo-camera": "~15.0.16",
|
||||||
|
"expo-status-bar": "~1.12.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-native": "0.74.5"
|
"react-dom": "18.2.0",
|
||||||
|
"react-native": "0.74.5",
|
||||||
|
"react-native-web": "~0.19.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.0"
|
"@babel/core": "^7.24.0"
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Pressable, StyleSheet, Text } from 'react-native';
|
import { Pressable, StyleSheet, Text } from 'react-native';
|
||||||
|
|
||||||
export default function PrimaryButton({ label, onPress }) {
|
import { colors } from '../utils/constants';
|
||||||
|
|
||||||
|
export default function PrimaryButton({ label, onPress, disabled = false }) {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
disabled={disabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.button,
|
styles.button,
|
||||||
pressed ? styles.buttonPressed : null
|
pressed ? styles.buttonPressed : null,
|
||||||
|
disabled ? styles.disabled : null
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.label}>{label}</Text>
|
<Text style={[styles.label, disabled ? styles.disabledLabel : null]}>{label}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -18,7 +22,7 @@ export default function PrimaryButton({ label, onPress }) {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
button: {
|
button: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#256f5b',
|
backgroundColor: colors.primary,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 14
|
paddingVertical: 14
|
||||||
@@ -27,8 +31,14 @@ const styles = StyleSheet.create({
|
|||||||
opacity: 0.82
|
opacity: 0.82
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
color: '#ffffff',
|
color: colors.surface,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700'
|
fontWeight: '700'
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
backgroundColor: colors.border
|
||||||
|
},
|
||||||
|
disabledLabel: {
|
||||||
|
color: colors.muted
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
+68
-57
@@ -1,81 +1,92 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
import PrimaryButton from '../components/PrimaryButton';
|
import PrimaryButton from '../components/PrimaryButton';
|
||||||
|
import AppHeader from '../components/layout/AppHeader';
|
||||||
|
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||||
|
import SectionTitle from '../components/layout/SectionTitle';
|
||||||
|
import ProductCard from '../components/product/ProductCard';
|
||||||
|
import { calculateProductRating } from '../services/ratingService';
|
||||||
|
import { colors } from '../utils/constants';
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen({ preferences, recentProducts, onStartScan, onOpenProduct }) {
|
||||||
const [status, setStatus] = useState('Bereit für deinen ersten Produkt-Scan.');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.screen}>
|
<ScreenContainer>
|
||||||
<View style={styles.header}>
|
<AppHeader
|
||||||
|
title="Shoppy"
|
||||||
|
subtitle="Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.hero}>
|
||||||
<Text style={styles.eyebrow}>Smart Shopping Companion</Text>
|
<Text style={styles.eyebrow}>Smart Shopping Companion</Text>
|
||||||
<Text style={styles.title}>Shoppy</Text>
|
<Text style={styles.title}>Bereit für den nächsten Einkauf?</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.copy}>
|
||||||
Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung.
|
Prüfe Allergien, Ernährung, Budget und Nachhaltigkeit direkt am Regal.
|
||||||
</Text>
|
</Text>
|
||||||
|
<PrimaryButton label="Produkt scannen" onPress={onStartScan} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.panel}>
|
<SectionTitle>Letzte Scans</SectionTitle>
|
||||||
<Text style={styles.panelTitle}>Startseite</Text>
|
{recentProducts.length === 0 ? (
|
||||||
<Text style={styles.status}>{status}</Text>
|
<View style={styles.emptyState}>
|
||||||
<PrimaryButton
|
<Text style={styles.emptyTitle}>Noch keine Produkte gescannt</Text>
|
||||||
label="Produkt scannen"
|
<Text style={styles.emptyCopy}>Starte einen Scan oder nutze die manuelle Suche.</Text>
|
||||||
onPress={() => setStatus('Demo-Status: Scanner und Bewertung werden vorbereitet.')}
|
</View>
|
||||||
|
) : (
|
||||||
|
recentProducts.map((product) => (
|
||||||
|
<ProductCard
|
||||||
|
key={product.id}
|
||||||
|
product={product}
|
||||||
|
rating={calculateProductRating(product, preferences)}
|
||||||
|
onPress={() => onOpenProduct(product)}
|
||||||
/>
|
/>
|
||||||
</View>
|
))
|
||||||
</View>
|
)}
|
||||||
|
</ScreenContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
screen: {
|
hero: {
|
||||||
flex: 1,
|
backgroundColor: colors.surface,
|
||||||
justifyContent: 'center',
|
borderColor: colors.border,
|
||||||
padding: 24
|
borderRadius: 8,
|
||||||
},
|
borderWidth: 1,
|
||||||
header: {
|
gap: 12,
|
||||||
marginBottom: 28
|
padding: 18
|
||||||
},
|
},
|
||||||
eyebrow: {
|
eyebrow: {
|
||||||
color: '#256f5b',
|
color: colors.primary,
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '700',
|
fontWeight: '800',
|
||||||
marginBottom: 8,
|
|
||||||
textTransform: 'uppercase'
|
textTransform: 'uppercase'
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
color: '#18201d',
|
color: colors.text,
|
||||||
fontSize: 42,
|
fontSize: 25,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
marginBottom: 10
|
lineHeight: 31
|
||||||
},
|
},
|
||||||
subtitle: {
|
copy: {
|
||||||
color: '#4f5f59',
|
color: colors.muted,
|
||||||
fontSize: 17,
|
|
||||||
lineHeight: 24
|
|
||||||
},
|
|
||||||
panel: {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderRadius: 8,
|
|
||||||
gap: 16,
|
|
||||||
padding: 20,
|
|
||||||
shadowColor: '#000000',
|
|
||||||
shadowOffset: {
|
|
||||||
height: 4,
|
|
||||||
width: 0
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.08,
|
|
||||||
shadowRadius: 12
|
|
||||||
},
|
|
||||||
panelTitle: {
|
|
||||||
color: '#18201d',
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: '700'
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
color: '#4f5f59',
|
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 22
|
lineHeight: 23
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 18
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '800'
|
||||||
|
},
|
||||||
|
emptyCopy: {
|
||||||
|
color: colors.muted,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginTop: 4
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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