Implement scan MVP flow

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