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
+15 -5
View File
@@ -1,16 +1,20 @@
import { Pressable, StyleSheet, Text } from 'react-native';
export default function PrimaryButton({ label, onPress }) {
import { colors } from '../utils/constants';
export default function PrimaryButton({ label, onPress, disabled = false }) {
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.button,
pressed ? styles.buttonPressed : null
pressed ? styles.buttonPressed : null,
disabled ? styles.disabled : null
]}
>
<Text style={styles.label}>{label}</Text>
<Text style={[styles.label, disabled ? styles.disabledLabel : null]}>{label}</Text>
</Pressable>
);
}
@@ -18,7 +22,7 @@ export default function PrimaryButton({ label, onPress }) {
const styles = StyleSheet.create({
button: {
alignItems: 'center',
backgroundColor: '#256f5b',
backgroundColor: colors.primary,
borderRadius: 8,
paddingHorizontal: 20,
paddingVertical: 14
@@ -27,8 +31,14 @@ const styles = StyleSheet.create({
opacity: 0.82
},
label: {
color: '#ffffff',
color: colors.surface,
fontSize: 16,
fontWeight: '700'
},
disabled: {
backgroundColor: colors.border
},
disabledLabel: {
color: colors.muted
}
});
+45
View File
@@ -0,0 +1,45 @@
import { Pressable, StyleSheet, Text } from 'react-native';
import { colors } from '../utils/constants';
export default function SecondaryButton({ label, onPress, disabled = false }) {
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.button,
pressed ? styles.buttonPressed : null,
disabled ? styles.disabled : null
]}
>
<Text style={[styles.label, disabled ? styles.disabledLabel : null]}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
alignItems: 'center',
borderColor: colors.primary,
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 18,
paddingVertical: 12
},
buttonPressed: {
opacity: 0.75
},
disabled: {
borderColor: colors.border
},
label: {
color: colors.primary,
fontSize: 15,
fontWeight: '700'
},
disabledLabel: {
color: colors.muted
}
});
+56
View File
@@ -0,0 +1,56 @@
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../utils/constants';
export default function BudgetInput({ value, currency, onChangeValue }) {
return (
<View style={styles.wrap}>
<Text style={styles.label}>Maximales Einkaufsbudget</Text>
<View style={styles.row}>
<Text style={styles.currency}>{currency}</Text>
<TextInput
keyboardType="decimal-pad"
onChangeText={(text) => onChangeValue(Number(text.replace(',', '.')) || 0)}
style={styles.input}
value={String(value ?? '')}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
wrap: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: 8,
borderWidth: 1,
gap: 8,
padding: 14
},
label: {
color: colors.text,
fontSize: 15,
fontWeight: '800'
},
row: {
alignItems: 'center',
flexDirection: 'row',
gap: 10
},
currency: {
color: colors.muted,
fontSize: 16,
fontWeight: '800'
},
input: {
borderColor: colors.border,
borderRadius: 8,
borderWidth: 1,
color: colors.text,
flex: 1,
fontSize: 16,
paddingHorizontal: 12,
paddingVertical: 10
}
});
+38
View File
@@ -0,0 +1,38 @@
import { StyleSheet, TextInput, View } from 'react-native';
import PrimaryButton from '../PrimaryButton';
import { colors } from '../../utils/constants';
export default function ManualSearchInput({ value, onChangeText, onSubmit, placeholder = 'Produktname oder Barcode' }) {
return (
<View style={styles.row}>
<TextInput
autoCapitalize="none"
onChangeText={onChangeText}
onSubmitEditing={onSubmit}
placeholder={placeholder}
placeholderTextColor={colors.muted}
returnKeyType="search"
style={styles.input}
value={value}
/>
<PrimaryButton label="Suchen" onPress={onSubmit} />
</View>
);
}
const styles = StyleSheet.create({
row: {
gap: 10
},
input: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: 8,
borderWidth: 1,
color: colors.text,
fontSize: 16,
paddingHorizontal: 14,
paddingVertical: 12
}
});
+47
View File
@@ -0,0 +1,47 @@
import { StyleSheet, Switch, Text, View } from 'react-native';
import { colors } from '../../utils/constants';
export default function SettingsSwitchRow({ label, description, value, onValueChange }) {
return (
<View style={styles.row}>
<View style={styles.copy}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.description}>{description}</Text>
</View>
<Switch
onValueChange={onValueChange}
thumbColor={value ? colors.primary : '#f4f3f4'}
trackColor={{ false: colors.border, true: '#b7dbcf' }}
value={value}
/>
</View>
);
}
const styles = StyleSheet.create({
row: {
alignItems: 'center',
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: 8,
borderWidth: 1,
flexDirection: 'row',
gap: 12,
padding: 14
},
copy: {
flex: 1
},
label: {
color: colors.text,
fontSize: 15,
fontWeight: '800'
},
description: {
color: colors.muted,
fontSize: 13,
lineHeight: 18,
marginTop: 3
}
});
+64
View File
@@ -0,0 +1,64 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../utils/constants';
export default function AppHeader({ title, subtitle, onBack, rightAction }) {
return (
<View style={styles.header}>
<View style={styles.row}>
{onBack ? (
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}>
<Text style={styles.backText}>‹</Text>
</Pressable>
) : null}
<View style={styles.titleBlock}>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
</View>
{rightAction ? <View style={styles.rightAction}>{rightAction}</View> : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
header: {
paddingTop: 6
},
row: {
alignItems: 'center',
flexDirection: 'row',
gap: 12
},
backButton: {
alignItems: 'center',
borderColor: colors.border,
borderRadius: 8,
borderWidth: 1,
height: 40,
justifyContent: 'center',
width: 40
},
backText: {
color: colors.primary,
fontSize: 30,
lineHeight: 34
},
titleBlock: {
flex: 1
},
title: {
color: colors.text,
fontSize: 28,
fontWeight: '800'
},
subtitle: {
color: colors.muted,
fontSize: 15,
lineHeight: 21,
marginTop: 4
},
rightAction: {
alignItems: 'flex-end'
}
});
+69
View File
@@ -0,0 +1,69 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../utils/constants';
const tabs = [
{ id: 'home', label: 'Home', icon: '⌂' },
{ id: 'scan', label: 'Scan', icon: '□' },
{ id: 'list', label: 'Liste', icon: '✓' },
{ id: 'profile', label: 'Profil', icon: '◦' }
];
export default function BottomTabBar({ activeTab, onChange }) {
return (
<View style={styles.wrap}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<Pressable
accessibilityRole="button"
key={tab.id}
onPress={() => onChange(tab.id)}
style={[styles.tab, isActive ? styles.activeTab : null]}
>
<Text style={[styles.icon, isActive ? styles.activeText : null]}>{tab.icon}</Text>
<Text style={[styles.label, isActive ? styles.activeText : null]}>{tab.label}</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
wrap: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderTopWidth: 1,
bottom: 0,
flexDirection: 'row',
gap: 8,
left: 0,
padding: 10,
position: 'absolute',
right: 0
},
tab: {
alignItems: 'center',
borderRadius: 8,
flex: 1,
gap: 2,
paddingVertical: 8
},
activeTab: {
backgroundColor: '#e5f1ec'
},
icon: {
color: colors.muted,
fontSize: 18,
fontWeight: '800'
},
label: {
color: colors.muted,
fontSize: 12,
fontWeight: '700'
},
activeText: {
color: colors.primary
}
});
+27
View File
@@ -0,0 +1,27 @@
import { ScrollView, StyleSheet, View } from 'react-native';
import { colors } from '../../utils/constants';
export default function ScreenContainer({ children, scroll = true }) {
if (!scroll) {
return <View style={styles.container}>{children}</View>;
}
return (
<ScrollView contentContainerStyle={styles.content} style={styles.container}>
{children}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.background,
flex: 1
},
content: {
gap: 18,
padding: 20,
paddingBottom: 110
}
});
+16
View File
@@ -0,0 +1,16 @@
import { StyleSheet, Text } from 'react-native';
import { colors } from '../../utils/constants';
export default function SectionTitle({ children }) {
return <Text style={styles.title}>{children}</Text>;
}
const styles = StyleSheet.create({
title: {
color: colors.text,
fontSize: 18,
fontWeight: '800',
marginTop: 4
}
});
+75
View File
@@ -0,0 +1,75 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../utils/constants';
import { formatPrice } from '../../utils/formatPrice';
export default function ShoppingListItem({ item, onToggle, onDelete }) {
return (
<View style={styles.row}>
<Pressable accessibilityRole="checkbox" onPress={onToggle} style={[styles.checkbox, item.checked ? styles.checked : null]}>
<Text style={styles.checkText}>{item.checked ? '✓' : ''}</Text>
</Pressable>
<View style={styles.copy}>
<Text style={[styles.name, item.checked ? styles.done : null]}>{item.productName}</Text>
<Text style={styles.meta}>Menge {item.quantity} · {formatPrice(item.estimatedPrice, 'CHF')}</Text>
</View>
<Pressable accessibilityRole="button" onPress={onDelete} style={styles.deleteButton}>
<Text style={styles.deleteText}>Löschen</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
row: {
alignItems: 'center',
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: 8,
borderWidth: 1,
flexDirection: 'row',
gap: 12,
padding: 14
},
checkbox: {
alignItems: 'center',
borderColor: colors.primary,
borderRadius: 6,
borderWidth: 2,
height: 28,
justifyContent: 'center',
width: 28
},
checked: {
backgroundColor: colors.primary
},
checkText: {
color: colors.surface,
fontWeight: '800'
},
copy: {
flex: 1
},
name: {
color: colors.text,
fontSize: 16,
fontWeight: '800'
},
done: {
textDecorationLine: 'line-through'
},
meta: {
color: colors.muted,
fontSize: 13,
marginTop: 3
},
deleteButton: {
paddingHorizontal: 8,
paddingVertical: 6
},
deleteText: {
color: colors.danger,
fontSize: 13,
fontWeight: '800'
}
});
@@ -0,0 +1,26 @@
import { StyleSheet, Text, View } from 'react-native';
import PrimaryButton from '../PrimaryButton';
import { colors } from '../../utils/constants';
import ProductCard from './ProductCard';
export default function AlternativeProductCard({ item, onChoose, onOpen }) {
return (
<View style={styles.wrap}>
<ProductCard product={item.product} rating={item.rating} onPress={onOpen} />
<Text style={styles.reason}>{item.reason}</Text>
<PrimaryButton label="Beste Alternative wählen" onPress={onChoose} />
</View>
);
}
const styles = StyleSheet.create({
wrap: {
gap: 10
},
reason: {
color: colors.muted,
fontSize: 14,
lineHeight: 20
}
});
+53
View File
@@ -0,0 +1,53 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../utils/constants';
import { formatPrice } from '../../utils/formatPrice';
import ProductScoreBadge from './ProductScoreBadge';
export default function ProductCard({ product, rating, onPress }) {
return (
<Pressable accessibilityRole="button" onPress={onPress} style={styles.card}>
<View style={styles.topRow}>
<View style={styles.copy}>
<Text style={styles.name}>{product.name}</Text>
<Text style={styles.meta}>{product.brand || 'Ohne Marke'} · {formatPrice(product.price, product.currency)}</Text>
</View>
{rating ? <ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} /> : null}
</View>
<Text style={styles.note}>{product.category || 'Produkt'} · Barcode {product.barcode}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: 8,
borderWidth: 1,
gap: 10,
padding: 16
},
topRow: {
alignItems: 'flex-start',
flexDirection: 'row',
gap: 12
},
copy: {
flex: 1
},
name: {
color: colors.text,
fontSize: 17,
fontWeight: '800'
},
meta: {
color: colors.muted,
fontSize: 14,
marginTop: 4
},
note: {
color: colors.muted,
fontSize: 13
}
});
@@ -0,0 +1,47 @@
import { StyleSheet, Text, View } from 'react-native';
const labels = {
good: 'Gut',
medium: 'Prüfen',
critical: 'Meiden'
};
const statusStyles = {
good: {
backgroundColor: '#dff4e8',
color: '#1f7a4d'
},
medium: {
backgroundColor: '#fff4d8',
color: '#9a650f'
},
critical: {
backgroundColor: '#fee4e2',
color: '#b42318'
}
};
export default function ProductScoreBadge({ status, score }) {
const style = statusStyles[status] || statusStyles.medium;
return (
<View style={[styles.badge, { backgroundColor: style.backgroundColor }]}>
<Text style={[styles.text, { color: style.color }]}>
{labels[status] || 'Prüfen'} · {score}
</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
alignSelf: 'flex-start',
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 6
},
text: {
fontSize: 13,
fontWeight: '800'
}
});
@@ -0,0 +1,50 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../../utils/constants';
import ProductScoreBadge from './ProductScoreBadge';
const labels = {
allergies: 'Allergien',
budget: 'Budget',
nutrition: 'Ernährung',
sustainability: 'Nachhaltigkeit'
};
export default function RatingCategoryRow({ category, rating }) {
return (
<View style={styles.row}>
<View style={styles.copy}>
<Text style={styles.title}>{labels[category] || category}</Text>
<Text style={styles.message}>{rating.message}</Text>
</View>
<ProductScoreBadge status={rating.status} score={rating.status === 'good' ? 25 : rating.status === 'medium' ? 14 : 0} />
</View>
);
}
const styles = StyleSheet.create({
row: {
alignItems: 'center',
backgroundColor: colors.surface,
borderColor: colors.border,
borderRadius: 8,
borderWidth: 1,
flexDirection: 'row',
gap: 12,
padding: 14
},
copy: {
flex: 1
},
title: {
color: colors.text,
fontSize: 15,
fontWeight: '800',
marginBottom: 4
},
message: {
color: colors.muted,
fontSize: 14,
lineHeight: 20
}
});
+60
View File
@@ -0,0 +1,60 @@
import { CameraView } from 'expo-camera';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../../utils/constants';
export default function BarcodeScannerView({ isScanning, onBarcodeScanned }) {
return (
<View style={styles.wrap}>
<CameraView
barcodeScannerSettings={{
barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e', 'code128', 'qr']
}}
onBarcodeScanned={isScanning ? onBarcodeScanned : undefined}
style={styles.camera}
>
<View style={styles.overlay}>
<View style={styles.frame} />
<Text style={styles.hint}>
Barcode in den Rahmen halten. Bei Problemen manuell suchen.
</Text>
</View>
</CameraView>
</View>
);
}
const styles = StyleSheet.create({
wrap: {
backgroundColor: '#000000',
borderRadius: 8,
height: 320,
overflow: 'hidden'
},
camera: {
flex: 1
},
overlay: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
padding: 24
},
frame: {
borderColor: colors.surface,
borderRadius: 8,
borderWidth: 3,
height: 150,
width: '82%'
},
hint: {
backgroundColor: 'rgba(0, 0, 0, 0.55)',
borderRadius: 8,
color: colors.surface,
fontSize: 14,
lineHeight: 20,
marginTop: 18,
padding: 10,
textAlign: 'center'
}
});
+47
View File
@@ -0,0 +1,47 @@
import { Modal, StyleSheet, Text, View } from 'react-native';
import PrimaryButton from '../PrimaryButton';
import SecondaryButton from '../SecondaryButton';
import { colors } from '../../utils/constants';
export default function ScanErrorModal({ visible, message, onRetry, onManualSearch, onClose }) {
return (
<Modal animationType="fade" onRequestClose={onClose} transparent visible={visible}>
<View style={styles.backdrop}>
<View style={styles.modal}>
<Text style={styles.title}>Scan nicht erfolgreich</Text>
<Text style={styles.message}>{message}</Text>
<PrimaryButton label="Erneut scannen" onPress={onRetry} />
<SecondaryButton label="Manuell eingeben" onPress={onManualSearch} />
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.45)',
flex: 1,
justifyContent: 'center',
padding: 24
},
modal: {
backgroundColor: colors.surface,
borderRadius: 8,
gap: 14,
padding: 20,
width: '100%'
},
title: {
color: colors.text,
fontSize: 20,
fontWeight: '800'
},
message: {
color: colors.muted,
fontSize: 15,
lineHeight: 21
}
});