initial commit
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user