initial commit

This commit is contained in:
2026-07-01 13:54:21 +02:00
parent ac7ba88422
commit 43ef81b8e8
16 changed files with 1061 additions and 228 deletions
+4 -2
View File
@@ -7,6 +7,8 @@ import AlternativeProductCard from '../components/product/AlternativeProductCard
import { findAlternatives } from '../services/alternativesService';
import { colors } from '../utils/constants';
const DISPLAY_ALTERNATIVE_LIMIT = 5;
export default function AlternativesScreen({ product, preferences, productCache, onBack, onChoose, onOpenProduct }) {
const [alternatives, setAlternatives] = useState([]);
const [isLoading, setIsLoading] = useState(true);
@@ -41,11 +43,11 @@ export default function AlternativesScreen({ product, preferences, productCache,
{!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>
<Text style={styles.emptyCopy}>Für dieses Produkt wurde in ähnlichen Kategorien noch keine klar bessere Option gefunden.</Text>
</View>
) : null}
{alternatives.map((item) => (
{alternatives.slice(0, DISPLAY_ALTERNATIVE_LIMIT).map((item) => (
<AlternativeProductCard
item={item}
key={item.product.id}
+31
View File
@@ -9,6 +9,11 @@ import { calculateProductRating } from '../services/ratingService';
import { colors } from '../utils/constants';
export default function HomeScreen({ preferences, recentProducts, onStartScan, onOpenProduct }) {
const frequentProducts = [...recentProducts]
.filter((product) => (product.scanCount || 0) > 1)
.sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0))
.slice(0, 3);
return (
<ScreenContainer>
<AppHeader
@@ -25,6 +30,22 @@ export default function HomeScreen({ preferences, recentProducts, onStartScan, o
<PrimaryButton label="Produkt scannen" onPress={onStartScan} />
</View>
{frequentProducts.length > 0 ? (
<>
<SectionTitle>Häufig gekauft</SectionTitle>
{frequentProducts.map((product) => (
<View key={`frequent-${product.id}`} style={styles.frequentWrap}>
<ProductCard
product={product}
rating={calculateProductRating(product, preferences)}
onPress={() => onOpenProduct(product)}
/>
<Text style={styles.scanCount}>{product.scanCount} Scans</Text>
</View>
))}
</>
) : null}
<SectionTitle>Letzte Scans</SectionTitle>
{recentProducts.length === 0 ? (
<View style={styles.emptyState}>
@@ -71,6 +92,16 @@ const styles = StyleSheet.create({
fontSize: 16,
lineHeight: 23
},
frequentWrap: {
gap: 6
},
scanCount: {
alignSelf: 'flex-start',
color: colors.primary,
fontSize: 12,
fontWeight: '800',
marginLeft: 12
},
emptyState: {
backgroundColor: colors.surface,
borderColor: colors.border,
+35 -2
View File
@@ -13,7 +13,14 @@ import { calculateProductRating } from '../services/ratingService';
import { colors } from '../utils/constants';
import { formatPrice } from '../utils/formatPrice';
export default function ProductDetailScreen({ product, preferences, onBack, onAddToList, onShowAlternatives }) {
export default function ProductDetailScreen({
product,
preferences,
onBack,
onAddToList,
onSaveManualPrice,
onShowAlternatives
}) {
const [savedMessage, setSavedMessage] = useState('');
const [manualPrice, setManualPrice] = useState('');
const [priceError, setPriceError] = useState('');
@@ -34,6 +41,10 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
return;
}
if (typeof pricedProduct.price === 'number') {
onSaveManualPrice(pricedProduct, pricedProduct.price);
}
onAddToList(pricedProduct);
setPriceError('');
setSavedMessage('Produkt wurde zur Einkaufsliste hinzugefügt.');
@@ -44,7 +55,7 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
<AppHeader title="Produktbewertung" subtitle="Persönliche Einschätzung" onBack={onBack} />
<View style={styles.productPanel}>
<ProductImage imageUrl={product.imageUrl} size="large" />
<ProductImage imageUrl={product.imageUrl} productName={product.name} size="large" />
<View style={styles.topRow}>
<View style={styles.copy}>
<Text style={styles.brand}>{product.brand || 'Ohne Marke'}</Text>
@@ -52,6 +63,7 @@ export default function ProductDetailScreen({ product, preferences, onBack, onAd
<Text style={styles.meta}>
{product.category || 'Produkt'} · {formatPrice(pricedProduct.price, currency)}
</Text>
<Text style={styles.source}>{sourceLabel(product.dataSource)}</Text>
</View>
<ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} />
</View>
@@ -116,6 +128,16 @@ function parseManualPrice(value) {
return Math.round(parsedValue * 100) / 100;
}
function sourceLabel(source) {
const labels = {
cache: 'Quelle: lokaler Cache',
local: 'Quelle: lokale Beispieldaten',
'open-food-facts': 'Quelle: Open Food Facts'
};
return labels[source] || 'Quelle: unbekannt';
}
const styles = StyleSheet.create({
productPanel: {
backgroundColor: colors.surface,
@@ -151,6 +173,17 @@ const styles = StyleSheet.create({
fontSize: 15,
marginTop: 6
},
source: {
alignSelf: 'flex-start',
backgroundColor: '#eef3ef',
borderRadius: 8,
color: colors.primaryDark,
fontSize: 12,
fontWeight: '800',
marginTop: 10,
paddingHorizontal: 8,
paddingVertical: 4
},
summary: {
color: colors.text,
fontSize: 16,
+7 -2
View File
@@ -18,6 +18,7 @@ import { colors } from '../utils/constants';
export default function ScanScreen({ preferences, productCache, onScanBarcode, onOpenProduct }) {
const [permission, requestPermission] = useCameraPermissions();
const [isScanning, setIsScanning] = useState(true);
const [isLoadingProduct, setIsLoadingProduct] = useState(false);
const [scanError, setScanError] = useState('');
const [isErrorModalVisible, setIsErrorModalVisible] = useState(false);
const [manualSearchQuery, setManualSearchQuery] = useState('');
@@ -28,7 +29,10 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
if (!isScanning) return;
setIsScanning(false);
const result = await onScanBarcode(event.data);
setIsLoadingProduct(true);
const result = await onScanBarcode(event.data).finally(() => {
setIsLoadingProduct(false);
});
if (result?.status !== 'found') {
setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.');
@@ -70,7 +74,7 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
{hasCameraAccess ? (
<>
<BarcodeScannerView isScanning={isScanning} onBarcodeScanned={handleBarcodeScanned} />
<BarcodeScannerView isLoadingProduct={isLoadingProduct} isScanning={isScanning} onBarcodeScanned={handleBarcodeScanned} />
<SecondaryButton
label={isScanning ? 'Scan pausieren' : 'Scan fortsetzen'}
onPress={() => setIsScanning((current) => !current)}
@@ -82,6 +86,7 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
<Text style={styles.permissionCopy}>
Auf Web oder ohne Kamera kannst du Open-Food-Facts-Barcodes testen.
</Text>
{isLoadingProduct ? <Text style={styles.note}>Produkt wird geladen...</Text> : null}
<View style={styles.demoRow}>
<SecondaryButton label="Nutella" onPress={() => handleBarcodeScanned({ data: '3017620422003' })} />
<SecondaryButton label="OpenFoodFacts Beispiel" onPress={() => handleBarcodeScanned({ data: '737628064502' })} />
+134 -2
View File
@@ -4,11 +4,24 @@ 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 { calculateShoppingListWarnings } from '../services/shoppingListWarningsService';
import { budgetPeriodLabels, colors } from '../utils/constants';
import { formatPrice } from '../utils/formatPrice';
export default function ShoppingListScreen({ shoppingList, onToggleItem, onDeleteItem, onStartScan, onCompleteList }) {
export default function ShoppingListScreen({
preferences,
shoppingList,
onToggleItem,
onDeleteItem,
onUpdateItemPrice,
onStartScan,
onCompleteList
}) {
const openItems = shoppingList.items.filter((item) => !item.checked).length;
const budget = preferences.maxBudgetPerShoppingTrip;
const budgetShare = typeof budget === 'number' && budget > 0 ? shoppingList.estimatedTotal / budget : 0;
const budgetStatus = budgetShare <= 0.75 ? 'good' : budgetShare <= 1 ? 'medium' : 'critical';
const dailyWarnings = calculateShoppingListWarnings(shoppingList);
return (
<ScreenContainer>
@@ -22,8 +35,47 @@ export default function ShoppingListScreen({ shoppingList, onToggleItem, onDelet
<Text style={styles.summaryCopy}>
Produkte abhaken, löschen oder nach dem Einkauf abschließen.
</Text>
<View style={styles.budgetBox}>
<View style={styles.budgetHeader}>
<Text style={styles.budgetLabel}>Budget pro {budgetPeriodLabels[preferences.budgetPeriod] || 'Woche'}</Text>
<Text style={[styles.budgetValue, styles[budgetStatus]]}>
{formatPrice(shoppingList.estimatedTotal, shoppingList.currency)} von {formatPrice(budget, shoppingList.currency)}
</Text>
</View>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
styles[`${budgetStatus}Fill`],
{ width: `${Math.min(budgetShare * 100, 100)}%` }
]}
/>
</View>
</View>
</View>
{dailyWarnings.length > 0 ? (
<View style={styles.warningList}>
{dailyWarnings.map((warning) => (
<View
key={warning.key}
style={[
styles.warningBox,
warning.severity === 'critical' ? styles.criticalWarningBox : styles.mediumWarningBox
]}
>
<Text style={[
styles.warningTitle,
warning.severity === 'critical' ? styles.critical : styles.medium
]}>
{warning.label}: {formatAmount(warning.total, warning.unit)} von {formatAmount(warning.limit, warning.unit)}
</Text>
<Text style={styles.warningCopy}>{warning.message}</Text>
</View>
))}
</View>
) : null}
{shoppingList.items.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>Die Liste ist leer</Text>
@@ -37,6 +89,7 @@ export default function ShoppingListScreen({ shoppingList, onToggleItem, onDelet
key={item.id}
onDelete={() => onDeleteItem(item.id)}
onToggle={() => onToggleItem(item.id)}
onUpdatePrice={onUpdateItemPrice}
/>
))
)}
@@ -48,6 +101,14 @@ export default function ShoppingListScreen({ shoppingList, onToggleItem, onDelet
);
}
function formatAmount(value, unit) {
const roundedValue = unit === 'mg'
? Math.round(value)
: Math.round(value * 10) / 10;
return `${roundedValue} ${unit}`;
}
const styles = StyleSheet.create({
summary: {
backgroundColor: colors.surface,
@@ -67,6 +128,77 @@ const styles = StyleSheet.create({
lineHeight: 20,
marginTop: 4
},
budgetBox: {
marginTop: 14
},
budgetHeader: {
alignItems: 'flex-start',
gap: 4,
justifyContent: 'space-between'
},
budgetLabel: {
color: colors.muted,
fontSize: 13,
fontWeight: '800'
},
budgetValue: {
fontSize: 15,
fontWeight: '800'
},
progressTrack: {
backgroundColor: '#edf1ee',
borderRadius: 999,
height: 10,
marginTop: 10,
overflow: 'hidden'
},
progressFill: {
borderRadius: 999,
height: '100%'
},
good: {
color: colors.success
},
medium: {
color: colors.warning
},
critical: {
color: colors.danger
},
goodFill: {
backgroundColor: colors.success
},
mediumFill: {
backgroundColor: colors.warning
},
criticalFill: {
backgroundColor: colors.danger
},
warningList: {
gap: 10
},
warningBox: {
backgroundColor: colors.surface,
borderRadius: 8,
borderWidth: 1,
padding: 14
},
mediumWarningBox: {
borderColor: colors.warning
},
criticalWarningBox: {
borderColor: colors.danger
},
warningTitle: {
fontSize: 15,
fontWeight: '800'
},
warningCopy: {
color: colors.text,
fontSize: 14,
lineHeight: 20,
marginTop: 4
},
empty: {
backgroundColor: colors.surface,
borderColor: colors.border,