Implement scan MVP flow
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import AlternativeProductCard from '../components/product/AlternativeProductCard';
|
||||
import { findAlternatives } from '../services/alternativesService';
|
||||
import { colors } from '../utils/constants';
|
||||
|
||||
export default function AlternativesScreen({ product, preferences, productCache, onBack, onChoose, onOpenProduct }) {
|
||||
const [alternatives, setAlternatives] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadAlternatives() {
|
||||
setIsLoading(true);
|
||||
const results = await findAlternatives(product, preferences, productCache);
|
||||
if (isMounted) {
|
||||
setAlternatives(results);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadAlternatives();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [product, preferences, productCache]);
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Alternativen"
|
||||
subtitle={`Bessere Optionen für ${product.name}`}
|
||||
onBack={onBack}
|
||||
/>
|
||||
|
||||
{isLoading ? <Text style={styles.note}>Alternativen werden berechnet...</Text> : null}
|
||||
{!isLoading && alternatives.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>Keine Alternative gefunden</Text>
|
||||
<Text style={styles.emptyCopy}>Für dieses Produkt gibt es im lokalen Beispieldatensatz noch keinen besseren Vorschlag.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{alternatives.map((item) => (
|
||||
<AlternativeProductCard
|
||||
item={item}
|
||||
key={item.product.id}
|
||||
onChoose={() => onChoose(item.product)}
|
||||
onOpen={() => onOpenProduct(item.product)}
|
||||
/>
|
||||
))}
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
note: {
|
||||
color: colors.muted,
|
||||
fontSize: 14
|
||||
},
|
||||
empty: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
padding: 16
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '800'
|
||||
},
|
||||
emptyCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 4
|
||||
}
|
||||
});
|
||||
+69
-58
@@ -1,81 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import PrimaryButton from '../components/PrimaryButton';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import SectionTitle from '../components/layout/SectionTitle';
|
||||
import ProductCard from '../components/product/ProductCard';
|
||||
import { calculateProductRating } from '../services/ratingService';
|
||||
import { colors } from '../utils/constants';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const [status, setStatus] = useState('Bereit für deinen ersten Produkt-Scan.');
|
||||
|
||||
export default function HomeScreen({ preferences, recentProducts, onStartScan, onOpenProduct }) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.header}>
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Shoppy"
|
||||
subtitle="Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung."
|
||||
/>
|
||||
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.eyebrow}>Smart Shopping Companion</Text>
|
||||
<Text style={styles.title}>Shoppy</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung.
|
||||
<Text style={styles.title}>Bereit für den nächsten Einkauf?</Text>
|
||||
<Text style={styles.copy}>
|
||||
Prüfe Allergien, Ernährung, Budget und Nachhaltigkeit direkt am Regal.
|
||||
</Text>
|
||||
<PrimaryButton label="Produkt scannen" onPress={onStartScan} />
|
||||
</View>
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelTitle}>Startseite</Text>
|
||||
<Text style={styles.status}>{status}</Text>
|
||||
<PrimaryButton
|
||||
label="Produkt scannen"
|
||||
onPress={() => setStatus('Demo-Status: Scanner und Bewertung werden vorbereitet.')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<SectionTitle>Letzte Scans</SectionTitle>
|
||||
{recentProducts.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyTitle}>Noch keine Produkte gescannt</Text>
|
||||
<Text style={styles.emptyCopy}>Starte einen Scan oder nutze die manuelle Suche.</Text>
|
||||
</View>
|
||||
) : (
|
||||
recentProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
rating={calculateProductRating(product, preferences)}
|
||||
onPress={() => onOpenProduct(product)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 24
|
||||
},
|
||||
header: {
|
||||
marginBottom: 28
|
||||
hero: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
padding: 18
|
||||
},
|
||||
eyebrow: {
|
||||
color: '#256f5b',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
color: colors.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase'
|
||||
},
|
||||
title: {
|
||||
color: '#18201d',
|
||||
fontSize: 42,
|
||||
color: colors.text,
|
||||
fontSize: 25,
|
||||
fontWeight: '800',
|
||||
marginBottom: 10
|
||||
lineHeight: 31
|
||||
},
|
||||
subtitle: {
|
||||
color: '#4f5f59',
|
||||
fontSize: 17,
|
||||
lineHeight: 24
|
||||
},
|
||||
panel: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 8,
|
||||
gap: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: {
|
||||
height: 4,
|
||||
width: 0
|
||||
},
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12
|
||||
},
|
||||
panelTitle: {
|
||||
color: '#18201d',
|
||||
fontSize: 20,
|
||||
fontWeight: '700'
|
||||
},
|
||||
status: {
|
||||
color: '#4f5f59',
|
||||
copy: {
|
||||
color: colors.muted,
|
||||
fontSize: 16,
|
||||
lineHeight: 22
|
||||
lineHeight: 23
|
||||
},
|
||||
emptyState: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
padding: 18
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '800'
|
||||
},
|
||||
emptyCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 4
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import PrimaryButton from '../components/PrimaryButton';
|
||||
import SecondaryButton from '../components/SecondaryButton';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import SectionTitle from '../components/layout/SectionTitle';
|
||||
import ProductScoreBadge from '../components/product/ProductScoreBadge';
|
||||
import RatingCategoryRow from '../components/product/RatingCategoryRow';
|
||||
import { calculateProductRating } from '../services/ratingService';
|
||||
import { colors } from '../utils/constants';
|
||||
import { formatPrice } from '../utils/formatPrice';
|
||||
|
||||
export default function ProductDetailScreen({ product, preferences, onBack, onAddToList, onShowAlternatives }) {
|
||||
const [savedMessage, setSavedMessage] = useState('');
|
||||
const rating = useMemo(() => calculateProductRating(product, preferences), [product, preferences]);
|
||||
|
||||
function addToList() {
|
||||
onAddToList(product);
|
||||
setSavedMessage('Produkt wurde zur Einkaufsliste hinzugefügt.');
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader title="Produktbewertung" subtitle="Persönliche Einschätzung" onBack={onBack} />
|
||||
|
||||
<View style={styles.productPanel}>
|
||||
<View style={styles.topRow}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.brand}>{product.brand || 'Ohne Marke'}</Text>
|
||||
<Text style={styles.name}>{product.name}</Text>
|
||||
<Text style={styles.meta}>{product.category} · {formatPrice(product.price, product.currency)}</Text>
|
||||
</View>
|
||||
<ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} />
|
||||
</View>
|
||||
<Text style={styles.summary}>
|
||||
Empfehlung: {rating.overallStatus === 'good' ? 'kaufen' : rating.overallStatus === 'medium' ? 'prüfen' : 'meiden'}.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<SectionTitle>Bewertungskriterien</SectionTitle>
|
||||
{Object.entries(rating.categoryScores).map(([category, categoryRating]) => (
|
||||
<RatingCategoryRow category={category} key={category} rating={categoryRating} />
|
||||
))}
|
||||
|
||||
<SectionTitle>Produktdaten</SectionTitle>
|
||||
<View style={styles.dataPanel}>
|
||||
<Text style={styles.dataText}>Barcode: {product.barcode}</Text>
|
||||
<Text style={styles.dataText}>Zutaten: {(product.ingredients || []).join(', ') || 'Keine Angaben'}</Text>
|
||||
<Text style={styles.dataText}>Allergene: {(product.allergens || []).join(', ') || 'Keine'}</Text>
|
||||
<Text style={styles.dataText}>Nutri-Score: {product.nutrition?.nutriScore || 'Keine Angabe'}</Text>
|
||||
<Text style={styles.dataText}>Eco-Score: {product.sustainability?.ecoScore || 'Keine Angabe'}</Text>
|
||||
</View>
|
||||
|
||||
{savedMessage ? <Text style={styles.saved}>{savedMessage}</Text> : null}
|
||||
<PrimaryButton label="Zur Einkaufsliste hinzufügen" onPress={addToList} />
|
||||
<SecondaryButton label="Alternative anzeigen" onPress={() => onShowAlternatives(product)} />
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
productPanel: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 14,
|
||||
padding: 18
|
||||
},
|
||||
topRow: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
gap: 12
|
||||
},
|
||||
copy: {
|
||||
flex: 1
|
||||
},
|
||||
brand: {
|
||||
color: colors.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase'
|
||||
},
|
||||
name: {
|
||||
color: colors.text,
|
||||
fontSize: 25,
|
||||
fontWeight: '800',
|
||||
lineHeight: 31,
|
||||
marginTop: 4
|
||||
},
|
||||
meta: {
|
||||
color: colors.muted,
|
||||
fontSize: 15,
|
||||
marginTop: 6
|
||||
},
|
||||
summary: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '700'
|
||||
},
|
||||
dataPanel: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 8,
|
||||
padding: 14
|
||||
},
|
||||
dataText: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20
|
||||
},
|
||||
saved: {
|
||||
color: colors.success,
|
||||
fontSize: 14,
|
||||
fontWeight: '800'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import BudgetInput from '../components/form/BudgetInput';
|
||||
import SettingsSwitchRow from '../components/form/SettingsSwitchRow';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import SectionTitle from '../components/layout/SectionTitle';
|
||||
import { colors, dietLabels } from '../utils/constants';
|
||||
|
||||
const dietOptions = ['none', 'vegetarian', 'vegan', 'lowSugar', 'highProtein'];
|
||||
const priorityOptions = ['low', 'medium', 'high'];
|
||||
|
||||
export default function ProfileScreen({ preferences, onChangePreferences }) {
|
||||
function updatePreference(key, value) {
|
||||
onChangePreferences({
|
||||
...preferences,
|
||||
[key]: value
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAllergen(allergen) {
|
||||
const current = preferences.avoidAllergens || [];
|
||||
const nextAllergens = current.includes(allergen)
|
||||
? current.filter((item) => item !== allergen)
|
||||
: [...current, allergen];
|
||||
updatePreference('avoidAllergens', nextAllergens);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Profil"
|
||||
subtitle="Präferenzen steuern die Produktbewertung global."
|
||||
/>
|
||||
|
||||
<SectionTitle>Ernährungsziel</SectionTitle>
|
||||
<View style={styles.optionGrid}>
|
||||
{dietOptions.map((option) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
key={option}
|
||||
onPress={() => updatePreference('preferredDiet', option)}
|
||||
style={[styles.chip, preferences.preferredDiet === option ? styles.activeChip : null]}
|
||||
>
|
||||
<Text style={[styles.chipText, preferences.preferredDiet === option ? styles.activeChipText : null]}>
|
||||
{dietLabels[option]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<SectionTitle>Allergien vermeiden</SectionTitle>
|
||||
<View style={styles.optionGrid}>
|
||||
{['Erdnüsse', 'Milch', 'Hafer', 'Gluten'].map((allergen) => {
|
||||
const isActive = preferences.avoidAllergens?.includes(allergen);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
key={allergen}
|
||||
onPress={() => toggleAllergen(allergen)}
|
||||
style={[styles.chip, isActive ? styles.dangerChip : null]}
|
||||
>
|
||||
<Text style={[styles.chipText, isActive ? styles.dangerChipText : null]}>{allergen}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<BudgetInput
|
||||
currency={preferences.currency}
|
||||
onChangeValue={(value) => updatePreference('maxBudgetPerShoppingTrip', value)}
|
||||
value={preferences.maxBudgetPerShoppingTrip}
|
||||
/>
|
||||
|
||||
<SettingsSwitchRow
|
||||
description="Bio-Produkte erhalten in der Nachhaltigkeitsbewertung einen Vorteil."
|
||||
label="Bio bevorzugen"
|
||||
onValueChange={(value) => updatePreference('preferBio', value)}
|
||||
value={preferences.preferBio}
|
||||
/>
|
||||
|
||||
<SettingsSwitchRow
|
||||
description="Es werden nur lokal gespeicherte Produkte bewertet."
|
||||
label="Offline-Modus"
|
||||
onValueChange={(value) => updatePreference('offlineModeEnabled', value)}
|
||||
value={preferences.offlineModeEnabled}
|
||||
/>
|
||||
|
||||
<SectionTitle>Prioritäten</SectionTitle>
|
||||
<PrioritySelector
|
||||
label="Ernährung"
|
||||
onChange={(value) => updatePreference('nutritionPriority', value)}
|
||||
value={preferences.nutritionPriority}
|
||||
/>
|
||||
<PrioritySelector
|
||||
label="Nachhaltigkeit"
|
||||
onChange={(value) => updatePreference('sustainabilityPriority', value)}
|
||||
value={preferences.sustainabilityPriority}
|
||||
/>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PrioritySelector({ label, value, onChange }) {
|
||||
return (
|
||||
<View style={styles.priorityRow}>
|
||||
<Text style={styles.priorityLabel}>{label}</Text>
|
||||
<View style={styles.priorityOptions}>
|
||||
{priorityOptions.map((option) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
key={option}
|
||||
onPress={() => onChange(option)}
|
||||
style={[styles.priorityChip, value === option ? styles.activeChip : null]}
|
||||
>
|
||||
<Text style={[styles.chipText, value === option ? styles.activeChipText : null]}>
|
||||
{option === 'low' ? 'Niedrig' : option === 'medium' ? 'Mittel' : 'Hoch'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
optionGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8
|
||||
},
|
||||
chip: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10
|
||||
},
|
||||
activeChip: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary
|
||||
},
|
||||
dangerChip: {
|
||||
backgroundColor: '#fee4e2',
|
||||
borderColor: '#fecdca'
|
||||
},
|
||||
chipText: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: '800'
|
||||
},
|
||||
activeChipText: {
|
||||
color: colors.surface
|
||||
},
|
||||
dangerChipText: {
|
||||
color: colors.danger
|
||||
},
|
||||
priorityRow: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 10,
|
||||
padding: 14
|
||||
},
|
||||
priorityLabel: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '800'
|
||||
},
|
||||
priorityOptions: {
|
||||
flexDirection: 'row',
|
||||
gap: 8
|
||||
},
|
||||
priorityChip: {
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Platform, StyleSheet, Text, View } from 'react-native';
|
||||
import { useCameraPermissions } from 'expo-camera';
|
||||
|
||||
import PrimaryButton from '../components/PrimaryButton';
|
||||
import SecondaryButton from '../components/SecondaryButton';
|
||||
import ManualSearchInput from '../components/form/ManualSearchInput';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import SectionTitle from '../components/layout/SectionTitle';
|
||||
import ProductCard from '../components/product/ProductCard';
|
||||
import BarcodeScannerView from '../components/scan/BarcodeScannerView';
|
||||
import ScanErrorModal from '../components/scan/ScanErrorModal';
|
||||
import { searchProducts } from '../services/productApi';
|
||||
import { calculateProductRating } from '../services/ratingService';
|
||||
import { colors } from '../utils/constants';
|
||||
|
||||
export default function ScanScreen({ preferences, productCache, onScanBarcode, onOpenProduct }) {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isScanning, setIsScanning] = useState(true);
|
||||
const [scanError, setScanError] = useState('');
|
||||
const [isErrorModalVisible, setIsErrorModalVisible] = useState(false);
|
||||
const [manualSearchQuery, setManualSearchQuery] = useState('');
|
||||
const [manualResults, setManualResults] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
async function handleBarcodeScanned(event) {
|
||||
if (!isScanning) return;
|
||||
|
||||
setIsScanning(false);
|
||||
const result = await onScanBarcode(event.data);
|
||||
|
||||
if (result?.status !== 'found') {
|
||||
setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.');
|
||||
setIsErrorModalVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManualSearch() {
|
||||
setIsSearching(true);
|
||||
const results = await searchProducts(manualSearchQuery, productCache, preferences.offlineModeEnabled);
|
||||
setManualResults(results);
|
||||
setIsSearching(false);
|
||||
}
|
||||
|
||||
function retryScan() {
|
||||
setIsErrorModalVisible(false);
|
||||
setScanError('');
|
||||
setIsScanning(true);
|
||||
}
|
||||
|
||||
const hasCameraAccess = permission?.granted && Platform.OS !== 'web';
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Scan"
|
||||
subtitle={preferences.offlineModeEnabled ? 'Offline-Modus aktiv: nur Cache-Produkte verfügbar.' : 'Barcode scannen oder Produkt manuell suchen.'}
|
||||
/>
|
||||
|
||||
{!permission?.granted && Platform.OS !== 'web' ? (
|
||||
<View style={styles.permissionBox}>
|
||||
<Text style={styles.permissionTitle}>Kamera-Berechtigung benötigt</Text>
|
||||
<Text style={styles.permissionCopy}>
|
||||
Shoppy braucht die Kamera, um Barcodes direkt im Laden zu erfassen.
|
||||
</Text>
|
||||
<PrimaryButton label="Kamera erlauben" onPress={requestPermission} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{hasCameraAccess ? (
|
||||
<>
|
||||
<BarcodeScannerView isScanning={isScanning} onBarcodeScanned={handleBarcodeScanned} />
|
||||
<SecondaryButton
|
||||
label={isScanning ? 'Scan pausieren' : 'Scan fortsetzen'}
|
||||
onPress={() => setIsScanning((current) => !current)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.permissionBox}>
|
||||
<Text style={styles.permissionTitle}>Scanner-Simulation</Text>
|
||||
<Text style={styles.permissionCopy}>
|
||||
Auf Web oder ohne Kamera kannst du mit Beispiel-Barcodes testen.
|
||||
</Text>
|
||||
<View style={styles.demoRow}>
|
||||
<SecondaryButton label="Bio Haferdrink" onPress={() => handleBarcodeScanned({ data: '7610200336707' })} />
|
||||
<SecondaryButton label="Schoko-Riegel" onPress={() => handleBarcodeScanned({ data: '7612345001112' })} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SectionTitle>Manuelle Suche</SectionTitle>
|
||||
<ManualSearchInput
|
||||
onChangeText={setManualSearchQuery}
|
||||
onSubmit={handleManualSearch}
|
||||
value={manualSearchQuery}
|
||||
/>
|
||||
{isSearching ? <Text style={styles.note}>Suche läuft...</Text> : null}
|
||||
{!isSearching && manualSearchQuery && manualResults.length === 0 ? (
|
||||
<Text style={styles.note}>Keine Treffer gefunden.</Text>
|
||||
) : null}
|
||||
{manualResults.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
rating={calculateProductRating(product, preferences)}
|
||||
onPress={() => onOpenProduct(product)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<ScanErrorModal
|
||||
message={scanError}
|
||||
onClose={retryScan}
|
||||
onManualSearch={() => setIsErrorModalVisible(false)}
|
||||
onRetry={retryScan}
|
||||
visible={isErrorModalVisible}
|
||||
/>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
permissionBox: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
padding: 16
|
||||
},
|
||||
permissionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
fontWeight: '800'
|
||||
},
|
||||
permissionCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 15,
|
||||
lineHeight: 21
|
||||
},
|
||||
demoRow: {
|
||||
gap: 10
|
||||
},
|
||||
note: {
|
||||
color: colors.muted,
|
||||
fontSize: 14
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import SecondaryButton from '../components/SecondaryButton';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import ShoppingListItem from '../components/list/ShoppingListItem';
|
||||
import { colors } from '../utils/constants';
|
||||
import { formatPrice } from '../utils/formatPrice';
|
||||
|
||||
export default function ShoppingListScreen({ shoppingList, onToggleItem, onDeleteItem, onStartScan, onCompleteList }) {
|
||||
const openItems = shoppingList.items.filter((item) => !item.checked).length;
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Einkaufsliste"
|
||||
subtitle={`${openItems} offene Produkte · ${formatPrice(shoppingList.estimatedTotal, shoppingList.currency)}`}
|
||||
/>
|
||||
|
||||
<View style={styles.summary}>
|
||||
<Text style={styles.summaryTitle}>{shoppingList.title}</Text>
|
||||
<Text style={styles.summaryCopy}>
|
||||
Produkte abhaken, löschen oder nach dem Einkauf abschließen.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{shoppingList.items.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>Die Liste ist leer</Text>
|
||||
<Text style={styles.emptyCopy}>Scanne ein Produkt und füge es aus der Bewertung heraus hinzu.</Text>
|
||||
<SecondaryButton label="Produkt scannen" onPress={onStartScan} />
|
||||
</View>
|
||||
) : (
|
||||
shoppingList.items.map((item) => (
|
||||
<ShoppingListItem
|
||||
item={item}
|
||||
key={item.id}
|
||||
onDelete={() => onDeleteItem(item.id)}
|
||||
onToggle={() => onToggleItem(item.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{shoppingList.items.length > 0 ? (
|
||||
<SecondaryButton label="Einkauf abschließen" onPress={onCompleteList} />
|
||||
) : null}
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
summary: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
padding: 16
|
||||
},
|
||||
summaryTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 19,
|
||||
fontWeight: '800'
|
||||
},
|
||||
summaryCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 4
|
||||
},
|
||||
empty: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
padding: 16
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '800'
|
||||
},
|
||||
emptyCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user