Implement scan MVP flow
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
import { Pressable, StyleSheet, Text } from 'react-native';
|
||||
|
||||
export default function PrimaryButton({ label, onPress }) {
|
||||
import { colors } from '../utils/constants';
|
||||
|
||||
export default function PrimaryButton({ label, onPress, disabled = false }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
pressed ? styles.buttonPressed : null
|
||||
pressed ? styles.buttonPressed : null,
|
||||
disabled ? styles.disabled : null
|
||||
]}
|
||||
>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={[styles.label, disabled ? styles.disabledLabel : null]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +22,7 @@ export default function PrimaryButton({ label, onPress }) {
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#256f5b',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 14
|
||||
@@ -27,8 +31,14 @@ const styles = StyleSheet.create({
|
||||
opacity: 0.82
|
||||
},
|
||||
label: {
|
||||
color: '#ffffff',
|
||||
color: colors.surface,
|
||||
fontSize: 16,
|
||||
fontWeight: '700'
|
||||
},
|
||||
disabled: {
|
||||
backgroundColor: colors.border
|
||||
},
|
||||
disabledLabel: {
|
||||
color: colors.muted
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Pressable, StyleSheet, Text } from 'react-native';
|
||||
|
||||
import { colors } from '../utils/constants';
|
||||
|
||||
export default function SecondaryButton({ label, onPress, disabled = false }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
pressed ? styles.buttonPressed : null,
|
||||
disabled ? styles.disabled : null
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.label, disabled ? styles.disabledLabel : null]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.75
|
||||
},
|
||||
disabled: {
|
||||
borderColor: colors.border
|
||||
},
|
||||
label: {
|
||||
color: colors.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: '700'
|
||||
},
|
||||
disabledLabel: {
|
||||
color: colors.muted
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
|
||||
export default function BudgetInput({ value, currency, onChangeValue }) {
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
<Text style={styles.label}>Maximales Einkaufsbudget</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.currency}>{currency}</Text>
|
||||
<TextInput
|
||||
keyboardType="decimal-pad"
|
||||
onChangeText={(text) => onChangeValue(Number(text.replace(',', '.')) || 0)}
|
||||
style={styles.input}
|
||||
value={String(value ?? '')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 8,
|
||||
padding: 14
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '800'
|
||||
},
|
||||
row: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 10
|
||||
},
|
||||
currency: {
|
||||
color: colors.muted,
|
||||
fontSize: 16,
|
||||
fontWeight: '800'
|
||||
},
|
||||
input: {
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { StyleSheet, TextInput, View } from 'react-native';
|
||||
|
||||
import PrimaryButton from '../PrimaryButton';
|
||||
import { colors } from '../../utils/constants';
|
||||
|
||||
export default function ManualSearchInput({ value, onChangeText, onSubmit, placeholder = 'Produktname oder Barcode' }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
onChangeText={onChangeText}
|
||||
onSubmitEditing={onSubmit}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.muted}
|
||||
returnKeyType="search"
|
||||
style={styles.input}
|
||||
value={value}
|
||||
/>
|
||||
<PrimaryButton label="Suchen" onPress={onSubmit} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
gap: 10
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { StyleSheet, Switch, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
|
||||
export default function SettingsSwitchRow({ label, description, value, onValueChange }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
onValueChange={onValueChange}
|
||||
thumbColor={value ? colors.primary : '#f4f3f4'}
|
||||
trackColor={{ false: colors.border, true: '#b7dbcf' }}
|
||||
value={value}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
padding: 14
|
||||
},
|
||||
copy: {
|
||||
flex: 1
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '800'
|
||||
},
|
||||
description: {
|
||||
color: colors.muted,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginTop: 3
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
|
||||
export default function AppHeader({ title, subtitle, onBack, rightAction }) {
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={styles.row}>
|
||||
{onBack ? (
|
||||
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}>
|
||||
<Text style={styles.backText}>‹</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<View style={styles.titleBlock}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
|
||||
</View>
|
||||
{rightAction ? <View style={styles.rightAction}>{rightAction}</View> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingTop: 6
|
||||
},
|
||||
row: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 12
|
||||
},
|
||||
backButton: {
|
||||
alignItems: 'center',
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
width: 40
|
||||
},
|
||||
backText: {
|
||||
color: colors.primary,
|
||||
fontSize: 30,
|
||||
lineHeight: 34
|
||||
},
|
||||
titleBlock: {
|
||||
flex: 1
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 28,
|
||||
fontWeight: '800'
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.muted,
|
||||
fontSize: 15,
|
||||
lineHeight: 21,
|
||||
marginTop: 4
|
||||
},
|
||||
rightAction: {
|
||||
alignItems: 'flex-end'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'home', label: 'Home', icon: '⌂' },
|
||||
{ id: 'scan', label: 'Scan', icon: '□' },
|
||||
{ id: 'list', label: 'Liste', icon: '✓' },
|
||||
{ id: 'profile', label: 'Profil', icon: '◦' }
|
||||
];
|
||||
|
||||
export default function BottomTabBar({ activeTab, onChange }) {
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
key={tab.id}
|
||||
onPress={() => onChange(tab.id)}
|
||||
style={[styles.tab, isActive ? styles.activeTab : null]}
|
||||
>
|
||||
<Text style={[styles.icon, isActive ? styles.activeText : null]}>{tab.icon}</Text>
|
||||
<Text style={[styles.label, isActive ? styles.activeText : null]}>{tab.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderTopWidth: 1,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
left: 0,
|
||||
padding: 10,
|
||||
position: 'absolute',
|
||||
right: 0
|
||||
},
|
||||
tab: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
paddingVertical: 8
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: '#e5f1ec'
|
||||
},
|
||||
icon: {
|
||||
color: colors.muted,
|
||||
fontSize: 18,
|
||||
fontWeight: '800'
|
||||
},
|
||||
label: {
|
||||
color: colors.muted,
|
||||
fontSize: 12,
|
||||
fontWeight: '700'
|
||||
},
|
||||
activeText: {
|
||||
color: colors.primary
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ScrollView, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
|
||||
export default function ScreenContainer({ children, scroll = true }) {
|
||||
if (!scroll) {
|
||||
return <View style={styles.container}>{children}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.content} style={styles.container}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background,
|
||||
flex: 1
|
||||
},
|
||||
content: {
|
||||
gap: 18,
|
||||
padding: 20,
|
||||
paddingBottom: 110
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StyleSheet, Text } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
|
||||
export default function SectionTitle({ children }) {
|
||||
return <Text style={styles.title}>{children}</Text>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
marginTop: 4
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
import { formatPrice } from '../../utils/formatPrice';
|
||||
|
||||
export default function ShoppingListItem({ item, onToggle, onDelete }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable accessibilityRole="checkbox" onPress={onToggle} style={[styles.checkbox, item.checked ? styles.checked : null]}>
|
||||
<Text style={styles.checkText}>{item.checked ? '✓' : ''}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.name, item.checked ? styles.done : null]}>{item.productName}</Text>
|
||||
<Text style={styles.meta}>Menge {item.quantity} · {formatPrice(item.estimatedPrice, 'CHF')}</Text>
|
||||
</View>
|
||||
<Pressable accessibilityRole="button" onPress={onDelete} style={styles.deleteButton}>
|
||||
<Text style={styles.deleteText}>Löschen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
padding: 14
|
||||
},
|
||||
checkbox: {
|
||||
alignItems: 'center',
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 6,
|
||||
borderWidth: 2,
|
||||
height: 28,
|
||||
justifyContent: 'center',
|
||||
width: 28
|
||||
},
|
||||
checked: {
|
||||
backgroundColor: colors.primary
|
||||
},
|
||||
checkText: {
|
||||
color: colors.surface,
|
||||
fontWeight: '800'
|
||||
},
|
||||
copy: {
|
||||
flex: 1
|
||||
},
|
||||
name: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '800'
|
||||
},
|
||||
done: {
|
||||
textDecorationLine: 'line-through'
|
||||
},
|
||||
meta: {
|
||||
color: colors.muted,
|
||||
fontSize: 13,
|
||||
marginTop: 3
|
||||
},
|
||||
deleteButton: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6
|
||||
},
|
||||
deleteText: {
|
||||
color: colors.danger,
|
||||
fontSize: 13,
|
||||
fontWeight: '800'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import PrimaryButton from '../PrimaryButton';
|
||||
import { colors } from '../../utils/constants';
|
||||
import ProductCard from './ProductCard';
|
||||
|
||||
export default function AlternativeProductCard({ item, onChoose, onOpen }) {
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
<ProductCard product={item.product} rating={item.rating} onPress={onOpen} />
|
||||
<Text style={styles.reason}>{item.reason}</Text>
|
||||
<PrimaryButton label="Beste Alternative wählen" onPress={onChoose} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: {
|
||||
gap: 10
|
||||
},
|
||||
reason: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
import { formatPrice } from '../../utils/formatPrice';
|
||||
import ProductScoreBadge from './ProductScoreBadge';
|
||||
|
||||
export default function ProductCard({ product, rating, onPress }) {
|
||||
return (
|
||||
<Pressable accessibilityRole="button" onPress={onPress} style={styles.card}>
|
||||
<View style={styles.topRow}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.name}>{product.name}</Text>
|
||||
<Text style={styles.meta}>{product.brand || 'Ohne Marke'} · {formatPrice(product.price, product.currency)}</Text>
|
||||
</View>
|
||||
{rating ? <ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} /> : null}
|
||||
</View>
|
||||
<Text style={styles.note}>{product.category || 'Produkt'} · Barcode {product.barcode}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 10,
|
||||
padding: 16
|
||||
},
|
||||
topRow: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
gap: 12
|
||||
},
|
||||
copy: {
|
||||
flex: 1
|
||||
},
|
||||
name: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: '800'
|
||||
},
|
||||
meta: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
marginTop: 4
|
||||
},
|
||||
note: {
|
||||
color: colors.muted,
|
||||
fontSize: 13
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
const labels = {
|
||||
good: 'Gut',
|
||||
medium: 'Prüfen',
|
||||
critical: 'Meiden'
|
||||
};
|
||||
|
||||
const statusStyles = {
|
||||
good: {
|
||||
backgroundColor: '#dff4e8',
|
||||
color: '#1f7a4d'
|
||||
},
|
||||
medium: {
|
||||
backgroundColor: '#fff4d8',
|
||||
color: '#9a650f'
|
||||
},
|
||||
critical: {
|
||||
backgroundColor: '#fee4e2',
|
||||
color: '#b42318'
|
||||
}
|
||||
};
|
||||
|
||||
export default function ProductScoreBadge({ status, score }) {
|
||||
const style = statusStyles[status] || statusStyles.medium;
|
||||
|
||||
return (
|
||||
<View style={[styles.badge, { backgroundColor: style.backgroundColor }]}>
|
||||
<Text style={[styles.text, { color: style.color }]}>
|
||||
{labels[status] || 'Prüfen'} · {score}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
alignSelf: 'flex-start',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6
|
||||
},
|
||||
text: {
|
||||
fontSize: 13,
|
||||
fontWeight: '800'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
import ProductScoreBadge from './ProductScoreBadge';
|
||||
|
||||
const labels = {
|
||||
allergies: 'Allergien',
|
||||
budget: 'Budget',
|
||||
nutrition: 'Ernährung',
|
||||
sustainability: 'Nachhaltigkeit'
|
||||
};
|
||||
|
||||
export default function RatingCategoryRow({ category, rating }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.title}>{labels[category] || category}</Text>
|
||||
<Text style={styles.message}>{rating.message}</Text>
|
||||
</View>
|
||||
<ProductScoreBadge status={rating.status} score={rating.status === 'good' ? 25 : rating.status === 'medium' ? 14 : 0} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
padding: 14
|
||||
},
|
||||
copy: {
|
||||
flex: 1
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4
|
||||
},
|
||||
message: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { CameraView } from 'expo-camera';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
|
||||
export default function BarcodeScannerView({ isScanning, onBarcodeScanned }) {
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
<CameraView
|
||||
barcodeScannerSettings={{
|
||||
barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e', 'code128', 'qr']
|
||||
}}
|
||||
onBarcodeScanned={isScanning ? onBarcodeScanned : undefined}
|
||||
style={styles.camera}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.frame} />
|
||||
<Text style={styles.hint}>
|
||||
Barcode in den Rahmen halten. Bei Problemen manuell suchen.
|
||||
</Text>
|
||||
</View>
|
||||
</CameraView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: {
|
||||
backgroundColor: '#000000',
|
||||
borderRadius: 8,
|
||||
height: 320,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
camera: {
|
||||
flex: 1
|
||||
},
|
||||
overlay: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 24
|
||||
},
|
||||
frame: {
|
||||
borderColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
borderWidth: 3,
|
||||
height: 150,
|
||||
width: '82%'
|
||||
},
|
||||
hint: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||
borderRadius: 8,
|
||||
color: colors.surface,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 18,
|
||||
padding: 10,
|
||||
textAlign: 'center'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Modal, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import PrimaryButton from '../PrimaryButton';
|
||||
import SecondaryButton from '../SecondaryButton';
|
||||
import { colors } from '../../utils/constants';
|
||||
|
||||
export default function ScanErrorModal({ visible, message, onRetry, onManualSearch, onClose }) {
|
||||
return (
|
||||
<Modal animationType="fade" onRequestClose={onClose} transparent visible={visible}>
|
||||
<View style={styles.backdrop}>
|
||||
<View style={styles.modal}>
|
||||
<Text style={styles.title}>Scan nicht erfolgreich</Text>
|
||||
<Text style={styles.message}>{message}</Text>
|
||||
<PrimaryButton label="Erneut scannen" onPress={onRetry} />
|
||||
<SecondaryButton label="Manuell eingeben" onPress={onManualSearch} />
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 24
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
gap: 14,
|
||||
padding: 20,
|
||||
width: '100%'
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 20,
|
||||
fontWeight: '800'
|
||||
},
|
||||
message: {
|
||||
color: colors.muted,
|
||||
fontSize: 15,
|
||||
lineHeight: 21
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
export const mockProducts = [
|
||||
{
|
||||
id: 'p-001',
|
||||
barcode: '7610200336707',
|
||||
name: 'Bio Haferdrink',
|
||||
brand: 'Shoppy Natur',
|
||||
category: 'Getränke',
|
||||
price: 2.8,
|
||||
currency: 'CHF',
|
||||
ingredients: ['Hafer', 'Wasser', 'Sonnenblumenöl', 'Meersalz'],
|
||||
allergens: ['Hafer'],
|
||||
nutrition: {
|
||||
caloriesPer100g: 46,
|
||||
sugarPer100g: 3.2,
|
||||
fatPer100g: 1.5,
|
||||
proteinPer100g: 1.1,
|
||||
saltPer100g: 0.1,
|
||||
nutriScore: 'B'
|
||||
},
|
||||
sustainability: {
|
||||
ecoScore: 'A',
|
||||
isBio: true,
|
||||
originCountry: 'Schweiz',
|
||||
packaging: 'Tetra Pak'
|
||||
},
|
||||
createdAt: '2026-07-01T08:00:00.000Z',
|
||||
updatedAt: '2026-07-01T08:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'p-002',
|
||||
barcode: '7612345001112',
|
||||
name: 'Erdnuss Schoko-Riegel',
|
||||
brand: 'SnackFit',
|
||||
category: 'Snacks',
|
||||
price: 1.9,
|
||||
currency: 'CHF',
|
||||
ingredients: ['Erdnüsse', 'Zucker', 'Kakao', 'Milchpulver', 'Palmöl'],
|
||||
allergens: ['Erdnüsse', 'Milch'],
|
||||
nutrition: {
|
||||
caloriesPer100g: 520,
|
||||
sugarPer100g: 36,
|
||||
fatPer100g: 31,
|
||||
proteinPer100g: 14,
|
||||
saltPer100g: 0.4,
|
||||
nutriScore: 'D'
|
||||
},
|
||||
sustainability: {
|
||||
ecoScore: 'D',
|
||||
isBio: false,
|
||||
originCountry: 'Deutschland',
|
||||
packaging: 'Kunststoff'
|
||||
},
|
||||
createdAt: '2026-07-01T08:00:00.000Z',
|
||||
updatedAt: '2026-07-01T08:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'p-003',
|
||||
barcode: '7612345002225',
|
||||
name: 'Protein Joghurt Beeren',
|
||||
brand: 'Alpina',
|
||||
category: 'Milchprodukte',
|
||||
price: 2.4,
|
||||
currency: 'CHF',
|
||||
ingredients: ['Joghurt', 'Beeren', 'Milchprotein', 'Süßungsmittel'],
|
||||
allergens: ['Milch'],
|
||||
nutrition: {
|
||||
caloriesPer100g: 82,
|
||||
sugarPer100g: 5.1,
|
||||
fatPer100g: 0.5,
|
||||
proteinPer100g: 10.5,
|
||||
saltPer100g: 0.12,
|
||||
nutriScore: 'A'
|
||||
},
|
||||
sustainability: {
|
||||
ecoScore: 'B',
|
||||
isBio: false,
|
||||
originCountry: 'Schweiz',
|
||||
packaging: 'Becher'
|
||||
},
|
||||
createdAt: '2026-07-01T08:00:00.000Z',
|
||||
updatedAt: '2026-07-01T08:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'p-004',
|
||||
barcode: '7612345003338',
|
||||
name: 'Vegane Linsen-Bowl',
|
||||
brand: 'GreenMeal',
|
||||
category: 'Fertiggericht',
|
||||
price: 6.5,
|
||||
currency: 'CHF',
|
||||
ingredients: ['Linsen', 'Reis', 'Karotten', 'Spinat', 'Rapsöl'],
|
||||
allergens: [],
|
||||
nutrition: {
|
||||
caloriesPer100g: 148,
|
||||
sugarPer100g: 2.4,
|
||||
fatPer100g: 4.2,
|
||||
proteinPer100g: 7.8,
|
||||
saltPer100g: 0.7,
|
||||
nutriScore: 'A'
|
||||
},
|
||||
sustainability: {
|
||||
ecoScore: 'A',
|
||||
isBio: true,
|
||||
originCountry: 'Schweiz',
|
||||
packaging: 'Recycling-Schale'
|
||||
},
|
||||
createdAt: '2026-07-01T08:00:00.000Z',
|
||||
updatedAt: '2026-07-01T08:00:00.000Z'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import AlternativeProductCard from '../components/product/AlternativeProductCard';
|
||||
import { findAlternatives } from '../services/alternativesService';
|
||||
import { colors } from '../utils/constants';
|
||||
|
||||
export default function AlternativesScreen({ product, preferences, productCache, onBack, onChoose, onOpenProduct }) {
|
||||
const [alternatives, setAlternatives] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadAlternatives() {
|
||||
setIsLoading(true);
|
||||
const results = await findAlternatives(product, preferences, productCache);
|
||||
if (isMounted) {
|
||||
setAlternatives(results);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadAlternatives();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [product, preferences, productCache]);
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Alternativen"
|
||||
subtitle={`Bessere Optionen für ${product.name}`}
|
||||
onBack={onBack}
|
||||
/>
|
||||
|
||||
{isLoading ? <Text style={styles.note}>Alternativen werden berechnet...</Text> : null}
|
||||
{!isLoading && alternatives.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>Keine Alternative gefunden</Text>
|
||||
<Text style={styles.emptyCopy}>Für dieses Produkt gibt es im lokalen Beispieldatensatz noch keinen besseren Vorschlag.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{alternatives.map((item) => (
|
||||
<AlternativeProductCard
|
||||
item={item}
|
||||
key={item.product.id}
|
||||
onChoose={() => onChoose(item.product)}
|
||||
onOpen={() => onOpenProduct(item.product)}
|
||||
/>
|
||||
))}
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
note: {
|
||||
color: colors.muted,
|
||||
fontSize: 14
|
||||
},
|
||||
empty: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
padding: 16
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '800'
|
||||
},
|
||||
emptyCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 4
|
||||
}
|
||||
});
|
||||
+69
-58
@@ -1,81 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import PrimaryButton from '../components/PrimaryButton';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import SectionTitle from '../components/layout/SectionTitle';
|
||||
import ProductCard from '../components/product/ProductCard';
|
||||
import { calculateProductRating } from '../services/ratingService';
|
||||
import { colors } from '../utils/constants';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const [status, setStatus] = useState('Bereit für deinen ersten Produkt-Scan.');
|
||||
|
||||
export default function HomeScreen({ preferences, recentProducts, onStartScan, onOpenProduct }) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<View style={styles.header}>
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Shoppy"
|
||||
subtitle="Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung."
|
||||
/>
|
||||
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.eyebrow}>Smart Shopping Companion</Text>
|
||||
<Text style={styles.title}>Shoppy</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung.
|
||||
<Text style={styles.title}>Bereit für den nächsten Einkauf?</Text>
|
||||
<Text style={styles.copy}>
|
||||
Prüfe Allergien, Ernährung, Budget und Nachhaltigkeit direkt am Regal.
|
||||
</Text>
|
||||
<PrimaryButton label="Produkt scannen" onPress={onStartScan} />
|
||||
</View>
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelTitle}>Startseite</Text>
|
||||
<Text style={styles.status}>{status}</Text>
|
||||
<PrimaryButton
|
||||
label="Produkt scannen"
|
||||
onPress={() => setStatus('Demo-Status: Scanner und Bewertung werden vorbereitet.')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<SectionTitle>Letzte Scans</SectionTitle>
|
||||
{recentProducts.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyTitle}>Noch keine Produkte gescannt</Text>
|
||||
<Text style={styles.emptyCopy}>Starte einen Scan oder nutze die manuelle Suche.</Text>
|
||||
</View>
|
||||
) : (
|
||||
recentProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
rating={calculateProductRating(product, preferences)}
|
||||
onPress={() => onOpenProduct(product)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 24
|
||||
},
|
||||
header: {
|
||||
marginBottom: 28
|
||||
hero: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
padding: 18
|
||||
},
|
||||
eyebrow: {
|
||||
color: '#256f5b',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
color: colors.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase'
|
||||
},
|
||||
title: {
|
||||
color: '#18201d',
|
||||
fontSize: 42,
|
||||
color: colors.text,
|
||||
fontSize: 25,
|
||||
fontWeight: '800',
|
||||
marginBottom: 10
|
||||
lineHeight: 31
|
||||
},
|
||||
subtitle: {
|
||||
color: '#4f5f59',
|
||||
fontSize: 17,
|
||||
lineHeight: 24
|
||||
},
|
||||
panel: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 8,
|
||||
gap: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: {
|
||||
height: 4,
|
||||
width: 0
|
||||
},
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12
|
||||
},
|
||||
panelTitle: {
|
||||
color: '#18201d',
|
||||
fontSize: 20,
|
||||
fontWeight: '700'
|
||||
},
|
||||
status: {
|
||||
color: '#4f5f59',
|
||||
copy: {
|
||||
color: colors.muted,
|
||||
fontSize: 16,
|
||||
lineHeight: 22
|
||||
lineHeight: 23
|
||||
},
|
||||
emptyState: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
padding: 18
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '800'
|
||||
},
|
||||
emptyCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 4
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import PrimaryButton from '../components/PrimaryButton';
|
||||
import SecondaryButton from '../components/SecondaryButton';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import SectionTitle from '../components/layout/SectionTitle';
|
||||
import ProductScoreBadge from '../components/product/ProductScoreBadge';
|
||||
import RatingCategoryRow from '../components/product/RatingCategoryRow';
|
||||
import { calculateProductRating } from '../services/ratingService';
|
||||
import { colors } from '../utils/constants';
|
||||
import { formatPrice } from '../utils/formatPrice';
|
||||
|
||||
export default function ProductDetailScreen({ product, preferences, onBack, onAddToList, onShowAlternatives }) {
|
||||
const [savedMessage, setSavedMessage] = useState('');
|
||||
const rating = useMemo(() => calculateProductRating(product, preferences), [product, preferences]);
|
||||
|
||||
function addToList() {
|
||||
onAddToList(product);
|
||||
setSavedMessage('Produkt wurde zur Einkaufsliste hinzugefügt.');
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader title="Produktbewertung" subtitle="Persönliche Einschätzung" onBack={onBack} />
|
||||
|
||||
<View style={styles.productPanel}>
|
||||
<View style={styles.topRow}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.brand}>{product.brand || 'Ohne Marke'}</Text>
|
||||
<Text style={styles.name}>{product.name}</Text>
|
||||
<Text style={styles.meta}>{product.category} · {formatPrice(product.price, product.currency)}</Text>
|
||||
</View>
|
||||
<ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} />
|
||||
</View>
|
||||
<Text style={styles.summary}>
|
||||
Empfehlung: {rating.overallStatus === 'good' ? 'kaufen' : rating.overallStatus === 'medium' ? 'prüfen' : 'meiden'}.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<SectionTitle>Bewertungskriterien</SectionTitle>
|
||||
{Object.entries(rating.categoryScores).map(([category, categoryRating]) => (
|
||||
<RatingCategoryRow category={category} key={category} rating={categoryRating} />
|
||||
))}
|
||||
|
||||
<SectionTitle>Produktdaten</SectionTitle>
|
||||
<View style={styles.dataPanel}>
|
||||
<Text style={styles.dataText}>Barcode: {product.barcode}</Text>
|
||||
<Text style={styles.dataText}>Zutaten: {(product.ingredients || []).join(', ') || 'Keine Angaben'}</Text>
|
||||
<Text style={styles.dataText}>Allergene: {(product.allergens || []).join(', ') || 'Keine'}</Text>
|
||||
<Text style={styles.dataText}>Nutri-Score: {product.nutrition?.nutriScore || 'Keine Angabe'}</Text>
|
||||
<Text style={styles.dataText}>Eco-Score: {product.sustainability?.ecoScore || 'Keine Angabe'}</Text>
|
||||
</View>
|
||||
|
||||
{savedMessage ? <Text style={styles.saved}>{savedMessage}</Text> : null}
|
||||
<PrimaryButton label="Zur Einkaufsliste hinzufügen" onPress={addToList} />
|
||||
<SecondaryButton label="Alternative anzeigen" onPress={() => onShowAlternatives(product)} />
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
productPanel: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 14,
|
||||
padding: 18
|
||||
},
|
||||
topRow: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
gap: 12
|
||||
},
|
||||
copy: {
|
||||
flex: 1
|
||||
},
|
||||
brand: {
|
||||
color: colors.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase'
|
||||
},
|
||||
name: {
|
||||
color: colors.text,
|
||||
fontSize: 25,
|
||||
fontWeight: '800',
|
||||
lineHeight: 31,
|
||||
marginTop: 4
|
||||
},
|
||||
meta: {
|
||||
color: colors.muted,
|
||||
fontSize: 15,
|
||||
marginTop: 6
|
||||
},
|
||||
summary: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '700'
|
||||
},
|
||||
dataPanel: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 8,
|
||||
padding: 14
|
||||
},
|
||||
dataText: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20
|
||||
},
|
||||
saved: {
|
||||
color: colors.success,
|
||||
fontSize: 14,
|
||||
fontWeight: '800'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import BudgetInput from '../components/form/BudgetInput';
|
||||
import SettingsSwitchRow from '../components/form/SettingsSwitchRow';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import SectionTitle from '../components/layout/SectionTitle';
|
||||
import { colors, dietLabels } from '../utils/constants';
|
||||
|
||||
const dietOptions = ['none', 'vegetarian', 'vegan', 'lowSugar', 'highProtein'];
|
||||
const priorityOptions = ['low', 'medium', 'high'];
|
||||
|
||||
export default function ProfileScreen({ preferences, onChangePreferences }) {
|
||||
function updatePreference(key, value) {
|
||||
onChangePreferences({
|
||||
...preferences,
|
||||
[key]: value
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAllergen(allergen) {
|
||||
const current = preferences.avoidAllergens || [];
|
||||
const nextAllergens = current.includes(allergen)
|
||||
? current.filter((item) => item !== allergen)
|
||||
: [...current, allergen];
|
||||
updatePreference('avoidAllergens', nextAllergens);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Profil"
|
||||
subtitle="Präferenzen steuern die Produktbewertung global."
|
||||
/>
|
||||
|
||||
<SectionTitle>Ernährungsziel</SectionTitle>
|
||||
<View style={styles.optionGrid}>
|
||||
{dietOptions.map((option) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
key={option}
|
||||
onPress={() => updatePreference('preferredDiet', option)}
|
||||
style={[styles.chip, preferences.preferredDiet === option ? styles.activeChip : null]}
|
||||
>
|
||||
<Text style={[styles.chipText, preferences.preferredDiet === option ? styles.activeChipText : null]}>
|
||||
{dietLabels[option]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<SectionTitle>Allergien vermeiden</SectionTitle>
|
||||
<View style={styles.optionGrid}>
|
||||
{['Erdnüsse', 'Milch', 'Hafer', 'Gluten'].map((allergen) => {
|
||||
const isActive = preferences.avoidAllergens?.includes(allergen);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
key={allergen}
|
||||
onPress={() => toggleAllergen(allergen)}
|
||||
style={[styles.chip, isActive ? styles.dangerChip : null]}
|
||||
>
|
||||
<Text style={[styles.chipText, isActive ? styles.dangerChipText : null]}>{allergen}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<BudgetInput
|
||||
currency={preferences.currency}
|
||||
onChangeValue={(value) => updatePreference('maxBudgetPerShoppingTrip', value)}
|
||||
value={preferences.maxBudgetPerShoppingTrip}
|
||||
/>
|
||||
|
||||
<SettingsSwitchRow
|
||||
description="Bio-Produkte erhalten in der Nachhaltigkeitsbewertung einen Vorteil."
|
||||
label="Bio bevorzugen"
|
||||
onValueChange={(value) => updatePreference('preferBio', value)}
|
||||
value={preferences.preferBio}
|
||||
/>
|
||||
|
||||
<SettingsSwitchRow
|
||||
description="Es werden nur lokal gespeicherte Produkte bewertet."
|
||||
label="Offline-Modus"
|
||||
onValueChange={(value) => updatePreference('offlineModeEnabled', value)}
|
||||
value={preferences.offlineModeEnabled}
|
||||
/>
|
||||
|
||||
<SectionTitle>Prioritäten</SectionTitle>
|
||||
<PrioritySelector
|
||||
label="Ernährung"
|
||||
onChange={(value) => updatePreference('nutritionPriority', value)}
|
||||
value={preferences.nutritionPriority}
|
||||
/>
|
||||
<PrioritySelector
|
||||
label="Nachhaltigkeit"
|
||||
onChange={(value) => updatePreference('sustainabilityPriority', value)}
|
||||
value={preferences.sustainabilityPriority}
|
||||
/>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PrioritySelector({ label, value, onChange }) {
|
||||
return (
|
||||
<View style={styles.priorityRow}>
|
||||
<Text style={styles.priorityLabel}>{label}</Text>
|
||||
<View style={styles.priorityOptions}>
|
||||
{priorityOptions.map((option) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
key={option}
|
||||
onPress={() => onChange(option)}
|
||||
style={[styles.priorityChip, value === option ? styles.activeChip : null]}
|
||||
>
|
||||
<Text style={[styles.chipText, value === option ? styles.activeChipText : null]}>
|
||||
{option === 'low' ? 'Niedrig' : option === 'medium' ? 'Mittel' : 'Hoch'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
optionGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8
|
||||
},
|
||||
chip: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10
|
||||
},
|
||||
activeChip: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary
|
||||
},
|
||||
dangerChip: {
|
||||
backgroundColor: '#fee4e2',
|
||||
borderColor: '#fecdca'
|
||||
},
|
||||
chipText: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: '800'
|
||||
},
|
||||
activeChipText: {
|
||||
color: colors.surface
|
||||
},
|
||||
dangerChipText: {
|
||||
color: colors.danger
|
||||
},
|
||||
priorityRow: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 10,
|
||||
padding: 14
|
||||
},
|
||||
priorityLabel: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '800'
|
||||
},
|
||||
priorityOptions: {
|
||||
flexDirection: 'row',
|
||||
gap: 8
|
||||
},
|
||||
priorityChip: {
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Platform, StyleSheet, Text, View } from 'react-native';
|
||||
import { useCameraPermissions } from 'expo-camera';
|
||||
|
||||
import PrimaryButton from '../components/PrimaryButton';
|
||||
import SecondaryButton from '../components/SecondaryButton';
|
||||
import ManualSearchInput from '../components/form/ManualSearchInput';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import SectionTitle from '../components/layout/SectionTitle';
|
||||
import ProductCard from '../components/product/ProductCard';
|
||||
import BarcodeScannerView from '../components/scan/BarcodeScannerView';
|
||||
import ScanErrorModal from '../components/scan/ScanErrorModal';
|
||||
import { searchProducts } from '../services/productApi';
|
||||
import { calculateProductRating } from '../services/ratingService';
|
||||
import { colors } from '../utils/constants';
|
||||
|
||||
export default function ScanScreen({ preferences, productCache, onScanBarcode, onOpenProduct }) {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isScanning, setIsScanning] = useState(true);
|
||||
const [scanError, setScanError] = useState('');
|
||||
const [isErrorModalVisible, setIsErrorModalVisible] = useState(false);
|
||||
const [manualSearchQuery, setManualSearchQuery] = useState('');
|
||||
const [manualResults, setManualResults] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
async function handleBarcodeScanned(event) {
|
||||
if (!isScanning) return;
|
||||
|
||||
setIsScanning(false);
|
||||
const result = await onScanBarcode(event.data);
|
||||
|
||||
if (result?.status !== 'found') {
|
||||
setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.');
|
||||
setIsErrorModalVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManualSearch() {
|
||||
setIsSearching(true);
|
||||
const results = await searchProducts(manualSearchQuery, productCache, preferences.offlineModeEnabled);
|
||||
setManualResults(results);
|
||||
setIsSearching(false);
|
||||
}
|
||||
|
||||
function retryScan() {
|
||||
setIsErrorModalVisible(false);
|
||||
setScanError('');
|
||||
setIsScanning(true);
|
||||
}
|
||||
|
||||
const hasCameraAccess = permission?.granted && Platform.OS !== 'web';
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Scan"
|
||||
subtitle={preferences.offlineModeEnabled ? 'Offline-Modus aktiv: nur Cache-Produkte verfügbar.' : 'Barcode scannen oder Produkt manuell suchen.'}
|
||||
/>
|
||||
|
||||
{!permission?.granted && Platform.OS !== 'web' ? (
|
||||
<View style={styles.permissionBox}>
|
||||
<Text style={styles.permissionTitle}>Kamera-Berechtigung benötigt</Text>
|
||||
<Text style={styles.permissionCopy}>
|
||||
Shoppy braucht die Kamera, um Barcodes direkt im Laden zu erfassen.
|
||||
</Text>
|
||||
<PrimaryButton label="Kamera erlauben" onPress={requestPermission} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{hasCameraAccess ? (
|
||||
<>
|
||||
<BarcodeScannerView isScanning={isScanning} onBarcodeScanned={handleBarcodeScanned} />
|
||||
<SecondaryButton
|
||||
label={isScanning ? 'Scan pausieren' : 'Scan fortsetzen'}
|
||||
onPress={() => setIsScanning((current) => !current)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.permissionBox}>
|
||||
<Text style={styles.permissionTitle}>Scanner-Simulation</Text>
|
||||
<Text style={styles.permissionCopy}>
|
||||
Auf Web oder ohne Kamera kannst du mit Beispiel-Barcodes testen.
|
||||
</Text>
|
||||
<View style={styles.demoRow}>
|
||||
<SecondaryButton label="Bio Haferdrink" onPress={() => handleBarcodeScanned({ data: '7610200336707' })} />
|
||||
<SecondaryButton label="Schoko-Riegel" onPress={() => handleBarcodeScanned({ data: '7612345001112' })} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SectionTitle>Manuelle Suche</SectionTitle>
|
||||
<ManualSearchInput
|
||||
onChangeText={setManualSearchQuery}
|
||||
onSubmit={handleManualSearch}
|
||||
value={manualSearchQuery}
|
||||
/>
|
||||
{isSearching ? <Text style={styles.note}>Suche läuft...</Text> : null}
|
||||
{!isSearching && manualSearchQuery && manualResults.length === 0 ? (
|
||||
<Text style={styles.note}>Keine Treffer gefunden.</Text>
|
||||
) : null}
|
||||
{manualResults.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
rating={calculateProductRating(product, preferences)}
|
||||
onPress={() => onOpenProduct(product)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<ScanErrorModal
|
||||
message={scanError}
|
||||
onClose={retryScan}
|
||||
onManualSearch={() => setIsErrorModalVisible(false)}
|
||||
onRetry={retryScan}
|
||||
visible={isErrorModalVisible}
|
||||
/>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
permissionBox: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
padding: 16
|
||||
},
|
||||
permissionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
fontWeight: '800'
|
||||
},
|
||||
permissionCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 15,
|
||||
lineHeight: 21
|
||||
},
|
||||
demoRow: {
|
||||
gap: 10
|
||||
},
|
||||
note: {
|
||||
color: colors.muted,
|
||||
fontSize: 14
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import SecondaryButton from '../components/SecondaryButton';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import ShoppingListItem from '../components/list/ShoppingListItem';
|
||||
import { colors } from '../utils/constants';
|
||||
import { formatPrice } from '../utils/formatPrice';
|
||||
|
||||
export default function ShoppingListScreen({ shoppingList, onToggleItem, onDeleteItem, onStartScan, onCompleteList }) {
|
||||
const openItems = shoppingList.items.filter((item) => !item.checked).length;
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Einkaufsliste"
|
||||
subtitle={`${openItems} offene Produkte · ${formatPrice(shoppingList.estimatedTotal, shoppingList.currency)}`}
|
||||
/>
|
||||
|
||||
<View style={styles.summary}>
|
||||
<Text style={styles.summaryTitle}>{shoppingList.title}</Text>
|
||||
<Text style={styles.summaryCopy}>
|
||||
Produkte abhaken, löschen oder nach dem Einkauf abschließen.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{shoppingList.items.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyTitle}>Die Liste ist leer</Text>
|
||||
<Text style={styles.emptyCopy}>Scanne ein Produkt und füge es aus der Bewertung heraus hinzu.</Text>
|
||||
<SecondaryButton label="Produkt scannen" onPress={onStartScan} />
|
||||
</View>
|
||||
) : (
|
||||
shoppingList.items.map((item) => (
|
||||
<ShoppingListItem
|
||||
item={item}
|
||||
key={item.id}
|
||||
onDelete={() => onDeleteItem(item.id)}
|
||||
onToggle={() => onToggleItem(item.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{shoppingList.items.length > 0 ? (
|
||||
<SecondaryButton label="Einkauf abschließen" onPress={onCompleteList} />
|
||||
) : null}
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
summary: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
padding: 16
|
||||
},
|
||||
summaryTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 19,
|
||||
fontWeight: '800'
|
||||
},
|
||||
summaryCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 4
|
||||
},
|
||||
empty: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
padding: 16
|
||||
},
|
||||
emptyTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '800'
|
||||
},
|
||||
emptyCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mockProducts } from '../data/mockProducts';
|
||||
import { calculateProductRating } from './ratingService';
|
||||
|
||||
export async function findAlternatives(product, preferences, productCache = {}) {
|
||||
const candidates = [...mockProducts, ...Object.values(productCache)]
|
||||
.filter((candidate) => candidate.id !== product.id)
|
||||
.filter((candidate) => candidate.category === product.category || candidate.price <= (product.price || 999));
|
||||
|
||||
return Array.from(new Map(candidates.map((candidate) => [candidate.id, candidate])).values())
|
||||
.map((candidate) => ({
|
||||
product: candidate,
|
||||
rating: calculateProductRating(candidate, preferences),
|
||||
reason: buildReason(candidate, preferences)
|
||||
}))
|
||||
.sort((left, right) => right.rating.overallScore - left.rating.overallScore)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function buildReason(product, preferences) {
|
||||
if (preferences.preferBio && product.sustainability?.isBio) {
|
||||
return 'Bio-Alternative mit besserem Nachhaltigkeitsprofil.';
|
||||
}
|
||||
|
||||
if (preferences.preferredDiet === 'lowSugar' && (product.nutrition?.sugarPer100g || 0) <= 6) {
|
||||
return 'Weniger Zucker und dadurch besser passend zu deinem Ziel.';
|
||||
}
|
||||
|
||||
if ((product.allergens || []).length === 0) {
|
||||
return 'Keine bekannten Allergene im Beispieldatensatz.';
|
||||
}
|
||||
|
||||
return 'Bessere Gesamtbewertung nach deinen Präferenzen.';
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mockProducts } from '../data/mockProducts';
|
||||
|
||||
function delay(ms = 250) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function lookupProductByBarcode(barcode, productCache = {}, offlineModeEnabled = false) {
|
||||
await delay();
|
||||
|
||||
const cachedProduct = productCache[barcode];
|
||||
if (cachedProduct) {
|
||||
return { status: 'found', product: cachedProduct, source: 'cache' };
|
||||
}
|
||||
|
||||
if (offlineModeEnabled) {
|
||||
return {
|
||||
status: 'error',
|
||||
errorMessage: 'Offline-Modus aktiv: Dieses Produkt ist noch nicht im lokalen Cache.'
|
||||
};
|
||||
}
|
||||
|
||||
const product = mockProducts.find((item) => item.barcode === barcode);
|
||||
if (!product) {
|
||||
return { status: 'notFound', errorMessage: 'Kein Produkt zu diesem Barcode gefunden.' };
|
||||
}
|
||||
|
||||
return { status: 'found', product, source: 'mock-api' };
|
||||
}
|
||||
|
||||
export async function searchProducts(query, productCache = {}, offlineModeEnabled = false) {
|
||||
await delay();
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const cachedProducts = Object.values(productCache);
|
||||
const sourceProducts = offlineModeEnabled ? cachedProducts : [...mockProducts, ...cachedProducts];
|
||||
const uniqueProducts = Array.from(new Map(sourceProducts.map((product) => [product.id, product])).values());
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return uniqueProducts.filter((product) => {
|
||||
const haystack = [
|
||||
product.name,
|
||||
product.brand,
|
||||
product.category,
|
||||
product.barcode
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
return haystack.includes(normalizedQuery);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
const scoreMap = {
|
||||
good: 25,
|
||||
medium: 14,
|
||||
critical: 0
|
||||
};
|
||||
|
||||
function priorityWeight(priority) {
|
||||
if (priority === 'high') return 1.2;
|
||||
if (priority === 'low') return 0.8;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function statusFromScore(score) {
|
||||
if (score >= 78) return 'good';
|
||||
if (score >= 48) return 'medium';
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
function severityFromStatus(status) {
|
||||
if (status === 'critical') return 'critical';
|
||||
if (status === 'medium') return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
export function calculateProductRating(product, preferences) {
|
||||
const avoidAllergens = preferences.avoidAllergens || [];
|
||||
const matchingAllergens = (product.allergens || []).filter((allergen) =>
|
||||
avoidAllergens.some((blockedAllergen) => blockedAllergen.toLowerCase() === allergen.toLowerCase())
|
||||
);
|
||||
|
||||
const allergies = matchingAllergens.length > 0
|
||||
? {
|
||||
status: 'critical',
|
||||
message: `Enthält: ${matchingAllergens.join(', ')}.`
|
||||
}
|
||||
: {
|
||||
status: 'good',
|
||||
message: 'Keine hinterlegten Allergene gefunden.'
|
||||
};
|
||||
|
||||
const nutrition = rateNutrition(product, preferences);
|
||||
const sustainability = rateSustainability(product, preferences);
|
||||
const budget = rateBudget(product, preferences);
|
||||
|
||||
const categoryScores = {
|
||||
allergies,
|
||||
budget,
|
||||
nutrition,
|
||||
sustainability
|
||||
};
|
||||
|
||||
const weightedScores = [
|
||||
scoreMap[allergies.status] * 1.35,
|
||||
scoreMap[budget.status] * 1,
|
||||
scoreMap[nutrition.status] * priorityWeight(preferences.nutritionPriority),
|
||||
scoreMap[sustainability.status] * priorityWeight(preferences.sustainabilityPriority)
|
||||
];
|
||||
const maxScore = 25 * (1.35 + 1 + priorityWeight(preferences.nutritionPriority) + priorityWeight(preferences.sustainabilityPriority));
|
||||
const overallScore = Math.round((weightedScores.reduce((sum, value) => sum + value, 0) / maxScore) * 100);
|
||||
const overallStatus = statusFromScore(overallScore);
|
||||
|
||||
const reasons = Object.entries(categoryScores).map(([category, value]) => ({
|
||||
category,
|
||||
message: value.message,
|
||||
severity: severityFromStatus(value.status)
|
||||
}));
|
||||
|
||||
return {
|
||||
productId: product.id,
|
||||
overallStatus,
|
||||
overallScore,
|
||||
reasons,
|
||||
categoryScores
|
||||
};
|
||||
}
|
||||
|
||||
function rateNutrition(product, preferences) {
|
||||
const nutrition = product.nutrition || {};
|
||||
|
||||
if (preferences.preferredDiet === 'lowSugar') {
|
||||
if ((nutrition.sugarPer100g || 0) > 22) {
|
||||
return { status: 'critical', message: 'Sehr hoher Zuckergehalt für dein Ziel.' };
|
||||
}
|
||||
|
||||
if ((nutrition.sugarPer100g || 0) > 8) {
|
||||
return { status: 'medium', message: 'Zuckergehalt prüfen, liegt über deinem Ziel.' };
|
||||
}
|
||||
}
|
||||
|
||||
if (preferences.preferredDiet === 'highProtein') {
|
||||
if ((nutrition.proteinPer100g || 0) >= 10) {
|
||||
return { status: 'good', message: 'Passt gut zu deinem Protein-Ziel.' };
|
||||
}
|
||||
|
||||
return { status: 'medium', message: 'Proteinanteil ist eher niedrig.' };
|
||||
}
|
||||
|
||||
if (preferences.preferredDiet === 'vegan' && (product.allergens || []).includes('Milch')) {
|
||||
return { status: 'critical', message: 'Enthält Milch und passt nicht zu vegan.' };
|
||||
}
|
||||
|
||||
if (['A', 'B'].includes(nutrition.nutriScore)) {
|
||||
return { status: 'good', message: `Nutri-Score ${nutrition.nutriScore} ist positiv.` };
|
||||
}
|
||||
|
||||
if (nutrition.nutriScore === 'C') {
|
||||
return { status: 'medium', message: 'Nutri-Score C: in Ordnung, aber nicht optimal.' };
|
||||
}
|
||||
|
||||
return { status: 'critical', message: `Nutri-Score ${nutrition.nutriScore || 'unbekannt'} ist kritisch.` };
|
||||
}
|
||||
|
||||
function rateSustainability(product, preferences) {
|
||||
const sustainability = product.sustainability || {};
|
||||
|
||||
if (preferences.preferBio && !sustainability.isBio) {
|
||||
return { status: 'medium', message: 'Nicht Bio, obwohl Bio bevorzugt wird.' };
|
||||
}
|
||||
|
||||
if (['A', 'B'].includes(sustainability.ecoScore)) {
|
||||
return { status: 'good', message: `Eco-Score ${sustainability.ecoScore} ist stark.` };
|
||||
}
|
||||
|
||||
if (sustainability.ecoScore === 'C') {
|
||||
return { status: 'medium', message: 'Eco-Score C: akzeptabel, aber verbesserbar.' };
|
||||
}
|
||||
|
||||
return { status: 'critical', message: `Eco-Score ${sustainability.ecoScore || 'unbekannt'} ist schwach.` };
|
||||
}
|
||||
|
||||
function rateBudget(product, preferences) {
|
||||
if (typeof product.price !== 'number' || typeof preferences.maxBudgetPerShoppingTrip !== 'number') {
|
||||
return { status: 'medium', message: 'Preis oder Budget fehlt für eine genaue Prüfung.' };
|
||||
}
|
||||
|
||||
const share = product.price / preferences.maxBudgetPerShoppingTrip;
|
||||
|
||||
if (share <= 0.08) {
|
||||
return { status: 'good', message: 'Preis passt gut zu deinem Einkaufsbudget.' };
|
||||
}
|
||||
|
||||
if (share <= 0.18) {
|
||||
return { status: 'medium', message: 'Preis ist spürbar, aber noch im Rahmen.' };
|
||||
}
|
||||
|
||||
return { status: 'critical', message: 'Preis belastet dein Einkaufsbudget stark.' };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const keys = {
|
||||
preferences: 'shoppy.preferences',
|
||||
shoppingList: 'shoppy.shoppingList',
|
||||
productCache: 'shoppy.productCache',
|
||||
recentProducts: 'shoppy.recentProducts'
|
||||
};
|
||||
|
||||
async function readJson(key, fallbackValue) {
|
||||
const rawValue = await AsyncStorage.getItem(key);
|
||||
return rawValue ? JSON.parse(rawValue) : fallbackValue;
|
||||
}
|
||||
|
||||
async function writeJson(key, value) {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export const storageService = {
|
||||
loadPreferences: (fallbackValue) => readJson(keys.preferences, fallbackValue),
|
||||
savePreferences: (preferences) => writeJson(keys.preferences, preferences),
|
||||
loadShoppingList: (fallbackValue) => readJson(keys.shoppingList, fallbackValue),
|
||||
saveShoppingList: (shoppingList) => writeJson(keys.shoppingList, shoppingList),
|
||||
loadProductCache: () => readJson(keys.productCache, {}),
|
||||
saveProductCache: (cache) => writeJson(keys.productCache, cache),
|
||||
loadRecentProducts: () => readJson(keys.recentProducts, []),
|
||||
saveRecentProducts: (products) => writeJson(keys.recentProducts, products)
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
export const colors = {
|
||||
background: '#f7f3ea',
|
||||
surface: '#ffffff',
|
||||
text: '#18201d',
|
||||
muted: '#5a6a64',
|
||||
border: '#d8ded9',
|
||||
primary: '#256f5b',
|
||||
primaryDark: '#174b3d',
|
||||
warning: '#b7791f',
|
||||
danger: '#b42318',
|
||||
success: '#1f7a4d'
|
||||
};
|
||||
|
||||
export const defaultPreferences = {
|
||||
id: 'local-user',
|
||||
avoidAllergens: ['Erdnüsse'],
|
||||
preferredDiet: 'lowSugar',
|
||||
preferBio: true,
|
||||
maxBudgetPerShoppingTrip: 45,
|
||||
currency: 'CHF',
|
||||
offlineModeEnabled: false,
|
||||
sustainabilityPriority: 'medium',
|
||||
nutritionPriority: 'high'
|
||||
};
|
||||
|
||||
export const dietLabels = {
|
||||
none: 'Kein Ziel',
|
||||
vegetarian: 'Vegetarisch',
|
||||
vegan: 'Vegan',
|
||||
lowSugar: 'Wenig Zucker',
|
||||
highProtein: 'Proteinreich'
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export function formatPrice(value, currency = 'CHF') {
|
||||
if (typeof value !== 'number') {
|
||||
return 'Preis offen';
|
||||
}
|
||||
|
||||
return `${currency} ${value.toFixed(2)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user