diff --git a/App.js b/App.js index 1f61c97..4d2e9ef 100644 --- a/App.js +++ b/App.js @@ -32,6 +32,7 @@ export default function App() { const [productCache, setProductCache] = useState({}); const [manualPrices, setManualPrices] = useState({}); const [recentProducts, setRecentProducts] = useState([]); + const [lastDeletedListItem, setLastDeletedListItem] = useState(null); const [isHydrated, setIsHydrated] = useState(false); const [storageError, setStorageError] = useState(''); @@ -133,10 +134,13 @@ export default function App() { setActiveTab('scan')} + onOpenProfile={() => setActiveTab('profile')} onToggleItem={toggleListItem} onUpdateItemPrice={updateListItemPrice} preferences={preferences} + lastDeletedItem={lastDeletedListItem} shoppingList={shoppingList} /> ); @@ -161,7 +165,7 @@ export default function App() { shoppingList={shoppingList} /> ); - }, [activeTab, manualPrices, preferences, productCache, recentProducts, shoppingList]); + }, [activeTab, lastDeletedListItem, manualPrices, preferences, productCache, recentProducts, shoppingList]); async function scanProductByBarcode(barcode) { const result = await lookupProductByBarcode( @@ -334,6 +338,9 @@ export default function App() { } function deleteListItem(itemId) { + const index = shoppingList.items.findIndex((item) => item.id === itemId); + if (index < 0) return; + setLastDeletedListItem({ item: shoppingList.items[index], index }); setShoppingList((current) => { const items = current.items.filter((item) => item.id !== itemId); return { @@ -344,7 +351,19 @@ export default function App() { }); } + function undoDeleteListItem() { + if (!lastDeletedListItem) return; + setShoppingList((current) => { + if (current.items.some((item) => item.id === lastDeletedListItem.item.id)) return current; + const items = [...current.items]; + items.splice(Math.min(lastDeletedListItem.index, items.length), 0, lastDeletedListItem.item); + return { ...current, items, estimatedTotal: calculateEstimatedTotal(items) }; + }); + setLastDeletedListItem(null); + } + function completeShoppingList() { + setLastDeletedListItem(null); setShoppingList({ ...initialShoppingList, createdAt: new Date().toISOString() @@ -389,6 +408,10 @@ export default function App() { { + setActiveTab('list'); + goBackToTabs(); + }} onSaveManualPrice={saveManualPriceForProduct} onShowRatingCriteria={showRatingCriteria} onShowAlternatives={showAlternatives} diff --git a/src/components/PrimaryButton.js b/src/components/PrimaryButton.js index 3ce398e..2d84529 100644 --- a/src/components/PrimaryButton.js +++ b/src/components/PrimaryButton.js @@ -2,7 +2,7 @@ import { Pressable, StyleSheet, Text } from 'react-native'; import { colors } from '../utils/constants'; -export default function PrimaryButton({ label, onPress, disabled = false }) { +export default function PrimaryButton({ label, onPress, disabled = false, tone = 'default' }) { return ( [ styles.button, + tone === 'light' ? styles.lightButton : null, pressed ? styles.buttonPressed : null, disabled ? styles.disabled : null ]} > - {label} + {label} ); } @@ -23,17 +24,25 @@ const styles = StyleSheet.create({ button: { alignItems: 'center', backgroundColor: colors.primary, - borderRadius: 8, + borderRadius: 14, + justifyContent: 'center', + minHeight: 50, paddingHorizontal: 20, paddingVertical: 14 }, buttonPressed: { opacity: 0.82 }, + lightButton: { + backgroundColor: colors.surface + }, label: { color: colors.surface, fontSize: 16, - fontWeight: '700' + fontWeight: '800' + }, + lightLabel: { + color: colors.primaryDark }, disabled: { backgroundColor: colors.border diff --git a/src/components/SecondaryButton.js b/src/components/SecondaryButton.js index 0d0b473..fb58c84 100644 --- a/src/components/SecondaryButton.js +++ b/src/components/SecondaryButton.js @@ -23,8 +23,10 @@ const styles = StyleSheet.create({ button: { alignItems: 'center', borderColor: colors.primary, - borderRadius: 8, + borderRadius: 14, borderWidth: 1, + justifyContent: 'center', + minHeight: 48, paddingHorizontal: 18, paddingVertical: 12 }, diff --git a/src/components/form/BudgetInput.js b/src/components/form/BudgetInput.js index be80bd6..7bb294d 100644 --- a/src/components/form/BudgetInput.js +++ b/src/components/form/BudgetInput.js @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { budgetPeriodLabels, colors } from '../../utils/constants'; @@ -5,16 +6,28 @@ import { budgetPeriodLabels, colors } from '../../utils/constants'; const budgetPeriods = ['day', 'week', 'month', 'shoppingTrip']; export default function BudgetInput({ value, currency, period, onChangePeriod, onChangeValue }) { + const [inputValue, setInputValue] = useState(String(value ?? '')); + + function updateValue(text) { + setInputValue(text); + const parsedValue = Number(text.replace(',', '.')); + onChangeValue(text.trim() && Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : undefined); + } + return ( - Maximales Einkaufsbudget + Dein Budget (optional) + Ohne Budget bleibt die Preisbewertung offen. {currency} onChangeValue(Number(text.replace(',', '.')) || 0)} + onChangeText={updateValue} + placeholder="z. B. 45.00" + placeholderTextColor={colors.muted} style={styles.input} - value={String(value ?? '')} + value={inputValue} /> @@ -23,6 +36,7 @@ export default function BudgetInput({ value, currency, period, onChangePeriod, o return ( onChangePeriod(option)} style={[styles.periodChip, isActive ? styles.activePeriodChip : null]} @@ -42,11 +56,16 @@ const styles = StyleSheet.create({ wrap: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 18, borderWidth: 1, gap: 10, padding: 14 }, + hint: { + color: colors.muted, + fontSize: 13, + lineHeight: 18 + }, label: { color: colors.text, fontSize: 15, @@ -64,7 +83,7 @@ const styles = StyleSheet.create({ }, input: { borderColor: colors.border, - borderRadius: 8, + borderRadius: 12, borderWidth: 1, color: colors.text, flex: 1, @@ -80,7 +99,7 @@ const styles = StyleSheet.create({ periodChip: { alignItems: 'center', borderColor: colors.border, - borderRadius: 8, + borderRadius: 12, borderWidth: 1, flexGrow: 1, paddingHorizontal: 10, diff --git a/src/components/form/ManualSearchInput.js b/src/components/form/ManualSearchInput.js index 0b494d2..664c6f5 100644 --- a/src/components/form/ManualSearchInput.js +++ b/src/components/form/ManualSearchInput.js @@ -7,6 +7,7 @@ export default function ManualSearchInput({ value, onChangeText, onSubmit, disab return ( {description} + {tabs.map((tab) => { const isActive = activeTab === tab.id; return ( onChange(tab.id)} style={[styles.tab, isActive ? styles.activeTab : null]} @@ -27,6 +29,7 @@ export default function BottomTabBar({ activeTab, onChange }) { ); })} + ); } @@ -37,26 +40,35 @@ const styles = StyleSheet.create({ borderColor: colors.border, borderTopWidth: 1, bottom: 0, - flexDirection: 'row', - gap: 8, left: 0, - padding: 10, + paddingHorizontal: 12, + paddingTop: 9, + paddingBottom: 12, position: 'absolute', right: 0 }, + inner: { + alignSelf: 'center', + flexDirection: 'row', + gap: 6, + maxWidth: 720, + width: '100%' + }, tab: { alignItems: 'center', - borderRadius: 8, + borderRadius: 13, flex: 1, gap: 2, - paddingVertical: 8 + minHeight: 58, + justifyContent: 'center', + paddingVertical: 6 }, activeTab: { - backgroundColor: '#e5f1ec' + backgroundColor: colors.surfaceTint }, icon: { color: colors.muted, - fontSize: 18, + fontSize: 20, fontWeight: '800' }, label: { diff --git a/src/components/layout/ScreenContainer.js b/src/components/layout/ScreenContainer.js index 95ad862..2c467fb 100644 --- a/src/components/layout/ScreenContainer.js +++ b/src/components/layout/ScreenContainer.js @@ -8,7 +8,7 @@ export default function ScreenContainer({ children, scroll = true }) { } return ( - + {children} ); @@ -20,8 +20,12 @@ const styles = StyleSheet.create({ flex: 1 }, content: { - gap: 18, - padding: 20, - paddingBottom: 110 + alignSelf: 'center', + gap: 20, + maxWidth: 720, + paddingHorizontal: 20, + paddingTop: 22, + paddingBottom: 124, + width: '100%' } }); diff --git a/src/components/layout/SectionTitle.js b/src/components/layout/SectionTitle.js index f84d8e8..0f71711 100644 --- a/src/components/layout/SectionTitle.js +++ b/src/components/layout/SectionTitle.js @@ -9,8 +9,9 @@ export default function SectionTitle({ children }) { const styles = StyleSheet.create({ title: { color: colors.text, - fontSize: 18, + fontSize: 20, fontWeight: '800', - marginTop: 4 + letterSpacing: -0.3, + marginTop: 6 } }); diff --git a/src/components/list/ShoppingListItem.js b/src/components/list/ShoppingListItem.js index cc03344..2e5fa1f 100644 --- a/src/components/list/ShoppingListItem.js +++ b/src/components/list/ShoppingListItem.js @@ -13,6 +13,7 @@ export default function ShoppingListItem({ item, onToggle, onDelete, onUpdatePri }, [item.estimatedPrice]); function savePrice() { + if (!priceText.trim() && typeof item.estimatedPrice !== 'number') return; const parsedPrice = parsePrice(priceText); if (parsedPrice === undefined) { @@ -26,12 +27,12 @@ export default function ShoppingListItem({ item, onToggle, onDelete, onUpdatePri return ( - + {item.checked ? '✓' : ''} {item.productName} - Menge {item.quantity} · {formatPrice(item.estimatedPrice, item.currency || 'CHF')} + {item.quantity} × {formatPrice(item.estimatedPrice, item.currency || 'CHF')}{item.quantity > 1 && typeof item.estimatedPrice === 'number' ? ` · ${formatPrice(item.estimatedPrice * item.quantity, item.currency || 'CHF')}` : ''} {item.currency || 'CHF'} {priceError ? {priceError} : null} - + Löschen @@ -78,7 +79,7 @@ const styles = StyleSheet.create({ alignItems: 'center', backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 18, borderWidth: 1, flexDirection: 'row', gap: 12, @@ -87,11 +88,11 @@ const styles = StyleSheet.create({ checkbox: { alignItems: 'center', borderColor: colors.primary, - borderRadius: 6, + borderRadius: 10, borderWidth: 2, - height: 28, + height: 44, justifyContent: 'center', - width: 28 + width: 44 }, checked: { backgroundColor: colors.primary @@ -129,7 +130,7 @@ const styles = StyleSheet.create({ }, priceInput: { borderColor: colors.border, - borderRadius: 8, + borderRadius: 10, borderWidth: 1, color: colors.text, fontSize: 14, @@ -144,8 +145,9 @@ const styles = StyleSheet.create({ marginTop: 4 }, deleteButton: { - paddingHorizontal: 8, - paddingVertical: 6 + justifyContent: 'center', + minHeight: 44, + paddingHorizontal: 4 }, deleteText: { color: colors.danger, diff --git a/src/components/product/AlternativeProductCard.js b/src/components/product/AlternativeProductCard.js index 07cd0d5..acf5cbd 100644 --- a/src/components/product/AlternativeProductCard.js +++ b/src/components/product/AlternativeProductCard.js @@ -9,7 +9,10 @@ export default function AlternativeProductCard({ item, onChoose, onOpen }) { {item.reason} - + ); } diff --git a/src/components/product/ProductCard.js b/src/components/product/ProductCard.js index f6d8bd1..1857cce 100644 --- a/src/components/product/ProductCard.js +++ b/src/components/product/ProductCard.js @@ -20,7 +20,10 @@ export default function ProductCard({ product, rating, onPress }) { {sourceLabel(product.dataSource)} + + {rating ? : null} + Details ansehen › ); @@ -40,7 +43,7 @@ const styles = StyleSheet.create({ card: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 18, borderWidth: 1, gap: 10, padding: 16 @@ -50,12 +53,25 @@ const styles = StyleSheet.create({ flexDirection: 'row', gap: 12 }, + footer: { + alignItems: 'center', + borderTopColor: colors.border, + borderTopWidth: 1, + flexDirection: 'row', + justifyContent: 'space-between', + paddingTop: 11 + }, + openLabel: { + color: colors.primary, + fontSize: 13, + fontWeight: '800' + }, copy: { flex: 1 }, name: { color: colors.text, - fontSize: 17, + fontSize: 16, fontWeight: '800' }, meta: { @@ -67,11 +83,11 @@ const styles = StyleSheet.create({ color: colors.muted, fontSize: 13, lineHeight: 18, - marginTop: 8 + marginTop: 5 }, source: { alignSelf: 'flex-start', - backgroundColor: '#eef3ef', + backgroundColor: colors.surfaceTint, borderRadius: 8, color: colors.primaryDark, fontSize: 12, diff --git a/src/components/product/ProductScoreBadge.js b/src/components/product/ProductScoreBadge.js index 1b48d41..a89da36 100644 --- a/src/components/product/ProductScoreBadge.js +++ b/src/components/product/ProductScoreBadge.js @@ -36,7 +36,7 @@ export default function ProductScoreBadge({ status, score }) { const styles = StyleSheet.create({ badge: { alignSelf: 'flex-start', - borderRadius: 8, + borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6 }, diff --git a/src/components/product/RatingCategoryRow.js b/src/components/product/RatingCategoryRow.js index 48563e0..241e2ce 100644 --- a/src/components/product/RatingCategoryRow.js +++ b/src/components/product/RatingCategoryRow.js @@ -34,7 +34,7 @@ const styles = StyleSheet.create({ alignItems: 'center', backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 16, borderWidth: 1, flexDirection: 'row', gap: 12, diff --git a/src/components/scan/BarcodeScannerView.js b/src/components/scan/BarcodeScannerView.js index d9296a7..9d602d9 100644 --- a/src/components/scan/BarcodeScannerView.js +++ b/src/components/scan/BarcodeScannerView.js @@ -27,7 +27,7 @@ export default function BarcodeScannerView({ isLoadingProduct, isScanning, onBar const styles = StyleSheet.create({ wrap: { backgroundColor: '#000000', - borderRadius: 8, + borderRadius: 18, height: 320, overflow: 'hidden' }, @@ -42,7 +42,7 @@ const styles = StyleSheet.create({ }, frame: { borderColor: colors.surface, - borderRadius: 8, + borderRadius: 14, borderWidth: 3, height: 150, width: '82%' diff --git a/src/components/scan/ScanErrorModal.js b/src/components/scan/ScanErrorModal.js index 38ae418..2b9af95 100644 --- a/src/components/scan/ScanErrorModal.js +++ b/src/components/scan/ScanErrorModal.js @@ -29,7 +29,7 @@ const styles = StyleSheet.create({ }, modal: { backgroundColor: colors.surface, - borderRadius: 8, + borderRadius: 20, gap: 14, padding: 20, width: '100%' diff --git a/src/screens/AlternativesScreen.js b/src/screens/AlternativesScreen.js index 8c47632..fbd0ff3 100644 --- a/src/screens/AlternativesScreen.js +++ b/src/screens/AlternativesScreen.js @@ -74,7 +74,7 @@ const styles = StyleSheet.create({ empty: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 18, borderWidth: 1, padding: 16 }, diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index e88237d..3adc284 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -7,40 +7,46 @@ import SectionTitle from '../components/layout/SectionTitle'; import ProductCard from '../components/product/ProductCard'; import { calculateProductRating } from '../services/ratingService'; import { colors } from '../utils/constants'; +import { formatPrice } from '../utils/formatPrice'; export default function HomeScreen({ preferences, recentProducts, shoppingList, onStartScan, onOpenProduct, onOpenList }) { const frequentProducts = [...recentProducts] .filter((product) => (product.scanCount || 0) > 1) .sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)) .slice(0, 3); + const openItems = shoppingList?.items?.filter((item) => !item.checked).length || 0; return ( - Smart Shopping Companion - Bereit für den nächsten Einkauf? + PRODUKTE BESSER VERSTEHEN + Wissen, was drinsteckt. - Prüfe Allergien, Ernährung, Budget und Nachhaltigkeit direkt am Regal. + Barcode scannen und sofort sehen, wie ein Produkt zu deinen Zielen passt. - - {shoppingList?.items?.length > 0 ? ( - - Einkaufsliste - - {shoppingList.items.filter((item) => !item.checked).length} offene Produkte - - - ) : null} + + + + Dein Einkauf + + {shoppingList?.items?.length ? `${openItems} noch offen · ${shoppingList.items.length} insgesamt` : 'Deine Liste ist noch leer'} + + + + {shoppingList?.items?.length ? formatPrice(shoppingList.estimatedTotal, shoppingList.currency) : 'Liste öffnen'} + + + {frequentProducts.length > 0 ? ( <> - Häufig gekauft + Häufig gescannt {frequentProducts.map((product) => ( ) : null} - Letzte Scans + Zuletzt angesehen {recentProducts.length === 0 ? ( - Noch keine Produkte gescannt - Starte einen Scan oder nutze die manuelle Suche. + Dein erster Scan wartet + Scanne einen Barcode oder suche ein Produkt nach Namen. Deine letzten Produkte erscheinen dann hier. ) : ( recentProducts.map((product) => ( @@ -76,29 +82,30 @@ export default function HomeScreen({ preferences, recentProducts, shoppingList, const styles = StyleSheet.create({ hero: { - backgroundColor: colors.surface, - borderColor: colors.border, - borderRadius: 8, - borderWidth: 1, - gap: 12, - padding: 18 + backgroundColor: colors.primaryDark, + borderRadius: 22, + gap: 14, + padding: 24 }, eyebrow: { - color: colors.primary, - fontSize: 13, + color: '#b8e7ce', + fontSize: 12, fontWeight: '800', + letterSpacing: 1.2, textTransform: 'uppercase' }, title: { - color: colors.text, - fontSize: 25, + color: colors.surface, + fontSize: 31, fontWeight: '800', - lineHeight: 31 + letterSpacing: -0.7, + lineHeight: 37 }, copy: { - color: colors.muted, + color: '#e2eee7', fontSize: 16, - lineHeight: 23 + lineHeight: 23, + marginBottom: 6 }, frequentWrap: { gap: 6 @@ -113,9 +120,9 @@ const styles = StyleSheet.create({ emptyState: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 18, borderWidth: 1, - padding: 18 + padding: 20 }, emptyTitle: { color: colors.text, @@ -129,18 +136,33 @@ const styles = StyleSheet.create({ marginTop: 4 }, listShortcut: { - backgroundColor: '#eaf4ef', - borderRadius: 8, - gap: 2, - padding: 12 + alignItems: 'center', + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 18, + borderWidth: 1, + flexDirection: 'row', + gap: 12, + minHeight: 80, + padding: 18 + }, + listShortcutText: { + flex: 1, + gap: 4 }, listShortcutTitle: { color: colors.primaryDark, - fontSize: 15, + fontSize: 17, fontWeight: '800' }, listShortcutCopy: { color: colors.muted, fontSize: 13 + }, + listShortcutTotal: { + color: colors.primary, + fontSize: 14, + fontWeight: '800', + textAlign: 'right' } }); diff --git a/src/screens/ProductDetailScreen.js b/src/screens/ProductDetailScreen.js index 3a5171f..1ad2edd 100644 --- a/src/screens/ProductDetailScreen.js +++ b/src/screens/ProductDetailScreen.js @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import { StyleSheet, Text, TextInput, View } from 'react-native'; +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import PrimaryButton from '../components/PrimaryButton'; import SecondaryButton from '../components/SecondaryButton'; @@ -18,6 +18,7 @@ export default function ProductDetailScreen({ preferences, onBack, onAddToList, + onOpenList, onSaveManualPrice, onShowRatingCriteria, onShowAlternatives @@ -34,26 +35,27 @@ export default function ProductDetailScreen({ price: hasProductPrice ? product.price : parsedManualPrice }), [currency, hasProductPrice, parsedManualPrice, product]); const rating = useMemo(() => calculateProductRating(pricedProduct, preferences), [pricedProduct, preferences]); + const recommendation = rating.overallStatus === 'good' ? 'Passt gut zu dir' : rating.overallStatus === 'medium' ? 'Genauer prüfen' : 'Lieber meiden'; function addToList() { - if (!hasProductPrice && parsedManualPrice === undefined) { + if (!hasProductPrice && manualPrice.trim() && parsedManualPrice === undefined) { setSavedMessage(''); - setPriceError('Bitte gib einen Preis ein, bevor du das Produkt hinzufügst.'); + setPriceError('Bitte gib einen gültigen Preis ein oder lasse das Feld leer.'); return; } - if (typeof pricedProduct.price === 'number') { + if (!hasProductPrice && typeof pricedProduct.price === 'number') { onSaveManualPrice(pricedProduct, pricedProduct.price); } onAddToList(pricedProduct); setPriceError(''); - setSavedMessage('Produkt wurde zur Einkaufsliste hinzugefügt.'); + setSavedMessage('Zur Einkaufsliste hinzugefügt.'); } return ( - + @@ -68,24 +70,27 @@ export default function ProductDetailScreen({ - - Empfehlung: {rating.overallStatus === 'good' ? 'kaufen' : rating.overallStatus === 'medium' ? 'prüfen' : 'meiden'}. - + + {recommendation} + Tippe unten auf ein Kriterium, um die Bewertung nachzuvollziehen. + {!hasProductPrice ? ( - Preis ergänzen + Ladenpreis ergänzen - Open Food Facts liefert keine Preise. Gib den Ladenpreis ein, damit die Einkaufsliste und Budgetprüfung stimmen. + Optional. Ohne Preis kannst du das Produkt speichern; die Einkaufssumme bleibt dann unvollständig. {currency} { setManualPrice(value); setPriceError(''); + setSavedMessage(''); }} placeholder="z. B. 3.50" placeholderTextColor={colors.muted} @@ -97,6 +102,17 @@ export default function ProductDetailScreen({ ) : null} + {savedMessage ? ( + + {savedMessage} + + Einkaufsliste ansehen › + + + ) : null} + + onShowAlternatives(product)} /> + Bewertungskriterien {Object.entries(rating.categoryScores).map(([category, categoryRating]) => ( Barcode: {product.barcode} Zutaten: {(product.ingredients || []).join(', ') || 'Keine Angaben'} - Allergene: {(product.allergens || []).join(', ') || 'Keine'} + Allergene: {(product.allergens || []).join(', ') || 'Keine Angaben'} Nutri-Score: {product.nutrition?.nutriScore || 'Keine Angabe'} Eco-Score: {product.sustainability?.ecoScore || 'Keine Angabe'} - {savedMessage ? {savedMessage} : null} - - onShowAlternatives(product)} /> ); } @@ -148,7 +161,7 @@ const styles = StyleSheet.create({ productPanel: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 20, borderWidth: 1, gap: 14, padding: 18 @@ -190,15 +203,30 @@ const styles = StyleSheet.create({ paddingHorizontal: 8, paddingVertical: 4 }, - summary: { + recommendation: { + borderRadius: 14, + gap: 4, + padding: 14 + }, + goodRecommendation: { backgroundColor: '#e4f4e9' }, + mediumRecommendation: { backgroundColor: '#fff3d7' }, + criticalRecommendation: { backgroundColor: '#fee9e7' }, + recommendationTitle: { + fontSize: 17, + fontWeight: '800' + }, + goodText: { color: colors.success }, + mediumText: { color: '#8a5914' }, + criticalText: { color: colors.danger }, + recommendationCopy: { color: colors.text, - fontSize: 16, - fontWeight: '700' + fontSize: 13, + lineHeight: 19 }, pricePanel: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 18, borderWidth: 1, gap: 10, padding: 16 @@ -225,7 +253,7 @@ const styles = StyleSheet.create({ }, priceInput: { borderColor: colors.border, - borderRadius: 8, + borderRadius: 12, borderWidth: 1, color: colors.text, flex: 1, @@ -241,7 +269,7 @@ const styles = StyleSheet.create({ dataPanel: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 18, borderWidth: 1, gap: 8, padding: 14 @@ -255,5 +283,16 @@ const styles = StyleSheet.create({ color: colors.success, fontSize: 14, fontWeight: '800' + }, + savedPanel: { + backgroundColor: colors.surfaceTint, + borderRadius: 14, + gap: 6, + padding: 14 + }, + savedLink: { + color: colors.primary, + fontSize: 14, + fontWeight: '800' } }); diff --git a/src/screens/ProfileScreen.js b/src/screens/ProfileScreen.js index 42bb8fa..84535c6 100644 --- a/src/screens/ProfileScreen.js +++ b/src/screens/ProfileScreen.js @@ -30,7 +30,7 @@ export default function ProfileScreen({ preferences, onChangePreferences }) { Ernährungsziel @@ -38,6 +38,7 @@ export default function ProfileScreen({ preferences, onChangePreferences }) { {dietOptions.map((option) => ( updatePreference('preferredDiet', option)} style={[styles.chip, preferences.preferredDiet === option ? styles.activeChip : null]} @@ -50,12 +51,14 @@ export default function ProfileScreen({ preferences, onChangePreferences }) { Allergien vermeiden + Wähle nur Allergene, die für dich relevant sind. Fehlende Produktangaben bleiben als unsicher markiert. {['Erdnüsse', 'Milch', 'Hafer', 'Gluten'].map((allergen) => { const isActive = preferences.avoidAllergens?.includes(allergen); return ( toggleAllergen(allergen)} style={[styles.chip, isActive ? styles.dangerChip : null]} @@ -111,6 +114,7 @@ function PrioritySelector({ label, value, onChange }) { {priorityOptions.map((option) => ( onChange(option)} style={[styles.priorityChip, value === option ? styles.activeChip : null]} @@ -131,10 +135,16 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', gap: 8 }, + sectionHint: { + color: colors.muted, + fontSize: 13, + lineHeight: 19, + marginTop: -10 + }, chip: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 12, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 10 @@ -161,7 +171,7 @@ const styles = StyleSheet.create({ priorityRow: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 16, borderWidth: 1, gap: 10, padding: 14 @@ -177,7 +187,7 @@ const styles = StyleSheet.create({ }, priorityChip: { borderColor: colors.border, - borderRadius: 8, + borderRadius: 12, borderWidth: 1, flex: 1, paddingVertical: 10, diff --git a/src/screens/ScanScreen.js b/src/screens/ScanScreen.js index e7502af..ed06e13 100644 --- a/src/screens/ScanScreen.js +++ b/src/screens/ScanScreen.js @@ -24,6 +24,8 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o const [manualSearchQuery, setManualSearchQuery] = useState(''); const [manualResults, setManualResults] = useState([]); const [isSearching, setIsSearching] = useState(false); + const [hasSearched, setHasSearched] = useState(false); + const [searchError, setSearchError] = useState(''); async function handleBarcodeScanned(event) { if (!isScanning) return; @@ -50,9 +52,14 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o if (!manualSearchQuery.trim() || isSearching) return; setIsSearching(true); + setSearchError(''); try { const results = await searchProducts(manualSearchQuery, productCache, preferences.offlineModeEnabled); setManualResults(results); + setHasSearched(true); + } catch (error) { + setManualResults([]); + setSearchError('Die Suche ist gerade nicht verfügbar. Bitte versuche es erneut.'); } finally { setIsSearching(false); } @@ -69,17 +76,19 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o return ( - {!permission?.granted && Platform.OS !== 'web' ? ( + {permission && !permission.granted && Platform.OS !== 'web' ? ( - Kamera-Berechtigung benötigt + Kamera für den Scan freigeben - Shoppinguin braucht die Kamera, um Barcodes direkt im Laden zu erfassen. + {permission.canAskAgain + ? 'Shoppinguin braucht die Kamera, um Barcodes direkt im Laden zu erfassen.' + : 'Aktiviere den Kamerazugriff in den Einstellungen deines Geräts. Die Produktsuche funktioniert auch ohne Kamera.'} - + {permission.canAskAgain ? : null} ) : null} @@ -87,35 +96,46 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o <> setIsScanning((current) => !current)} /> ) : ( - Scanner-Simulation + Beispielprodukt öffnen - Auf Web oder ohne Kamera kannst du Open-Food-Facts-Barcodes testen. + Du kannst einen Beispiel-Barcode ausprobieren oder unten nach einem Produkt suchen. {isLoadingProduct ? Produkt wird geladen... : null} handleBarcodeScanned({ data: '3017620422003' })} /> - handleBarcodeScanned({ data: '737628064502' })} /> + handleBarcodeScanned({ data: '737628064502' })} /> )} - Manuelle Suche + Nach Produkt suchen { + setManualSearchQuery(value); + setManualResults([]); + setHasSearched(false); + setSearchError(''); + }} onSubmit={handleManualSearch} disabled={isSearching} value={manualSearchQuery} /> {isSearching ? Suche läuft... : null} - {!isSearching && manualSearchQuery && manualResults.length === 0 ? ( - Keine Treffer gefunden. + {searchError ? {searchError} : null} + {!isSearching && hasSearched && manualResults.length === 0 ? ( + + Kein passendes Produkt gefunden + Prüfe die Schreibweise oder suche nach dem Barcode. + ) : null} + {manualResults.length > 0 ? {manualResults.length} Treffer : null} {manualResults.map((product) => ( setIsErrorModalVisible(false)} + onManualSearch={() => { + setIsErrorModalVisible(false); + setIsScanning(true); + }} onRetry={retryScan} visible={isErrorModalVisible} /> @@ -140,11 +163,19 @@ const styles = StyleSheet.create({ permissionBox: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 18, borderWidth: 1, gap: 12, padding: 16 }, + emptyResults: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 16, + borderWidth: 1, + gap: 6, + padding: 16 + }, permissionTitle: { color: colors.text, fontSize: 18, @@ -161,5 +192,15 @@ const styles = StyleSheet.create({ note: { color: colors.muted, fontSize: 14 + }, + errorNote: { + color: colors.danger, + fontSize: 14, + fontWeight: '700' + }, + resultsCount: { + color: colors.muted, + fontSize: 13, + fontWeight: '700' } }); diff --git a/src/screens/ShoppingListScreen.js b/src/screens/ShoppingListScreen.js index 94efefc..7bd0611 100644 --- a/src/screens/ShoppingListScreen.js +++ b/src/screens/ShoppingListScreen.js @@ -1,5 +1,7 @@ -import { StyleSheet, Text, View } from 'react-native'; +import { useState } from 'react'; +import { Pressable, 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'; @@ -11,15 +13,22 @@ import { formatPrice } from '../utils/formatPrice'; export default function ShoppingListScreen({ preferences, shoppingList, + lastDeletedItem, onToggleItem, onDeleteItem, + onUndoDelete, onUpdateItemPrice, onStartScan, + onOpenProfile, onCompleteList }) { + const [isConfirming, setIsConfirming] = useState(false); + const [wasCompleted, setWasCompleted] = useState(false); const openItems = shoppingList.items.filter((item) => !item.checked).length; + const itemsWithoutPrice = shoppingList.items.filter((item) => typeof item.estimatedPrice !== 'number').length; const budget = preferences.maxBudgetPerShoppingTrip; - const budgetShare = typeof budget === 'number' && budget > 0 ? shoppingList.estimatedTotal / budget : 0; + const hasBudget = typeof budget === 'number' && budget > 0; + const budgetShare = hasBudget ? shoppingList.estimatedTotal / budget : 0; const budgetStatus = budgetShare <= 0.75 ? 'good' : budgetShare <= 1 ? 'medium' : 'critical'; const dailyWarnings = calculateShoppingListWarnings(shoppingList); @@ -27,17 +36,32 @@ export default function ShoppingListScreen({ + {wasCompleted && shoppingList.items.length === 0 ? ( + + Einkauf abgeschlossen. Deine neue Liste ist bereit. + + ) : null} + + {lastDeletedItem ? ( + + {lastDeletedItem.item.productName} entfernt + + Rückgängig + + + ) : null} + - {shoppingList.title} - - Produkte abhaken, löschen oder nach dem Einkauf abschliessen. - - + Deine Liste + {itemsWithoutPrice ? 'Bekannte Summe' : 'Voraussichtliche Summe'} + {formatPrice(shoppingList.estimatedTotal, shoppingList.currency)} + {itemsWithoutPrice ? {itemsWithoutPrice} {itemsWithoutPrice === 1 ? 'Produkt ohne Preis ist' : 'Produkte ohne Preis sind'} nicht eingerechnet. : null} + {hasBudget ? - Budget pro {budgetPeriodLabels[preferences.budgetPeriod] || 'Woche'} + Anteil am Budget pro {budgetPeriodLabels[preferences.budgetPeriod] || 'Woche'} {formatPrice(shoppingList.estimatedTotal, shoppingList.currency)} von {formatPrice(budget, shoppingList.currency)} @@ -51,7 +75,11 @@ export default function ShoppingListScreen({ ]} /> - + : ( + + Budget im Profil festlegen › + + )} {dailyWarnings.length > 0 ? ( @@ -95,7 +123,23 @@ export default function ShoppingListScreen({ )} {shoppingList.items.length > 0 ? ( - + <> + + {isConfirming ? ( + + Einkauf abschliessen? + Die aktuelle Liste wird geleert. Gespeicherte Produktdaten bleiben erhalten. + { + onCompleteList(); + setIsConfirming(false); + setWasCompleted(true); + }} /> + setIsConfirming(false)} /> + + ) : ( + setIsConfirming(true)} /> + )} + ) : null} ); @@ -113,9 +157,9 @@ const styles = StyleSheet.create({ summary: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 20, borderWidth: 1, - padding: 16 + padding: 18 }, summaryTitle: { color: colors.text, @@ -126,11 +170,35 @@ const styles = StyleSheet.create({ color: colors.muted, fontSize: 14, lineHeight: 20, - marginTop: 4 + marginTop: 7 + }, + totalLabel: { + color: colors.muted, + fontSize: 13, + fontWeight: '700', + marginTop: 16 + }, + totalValue: { + color: colors.text, + fontSize: 30, + fontWeight: '800', + letterSpacing: -0.6, + marginTop: 2 }, budgetBox: { marginTop: 14 }, + budgetLink: { + minHeight: 44, + justifyContent: 'center', + alignSelf: 'flex-start', + marginTop: 8 + }, + budgetLinkText: { + color: colors.primary, + fontSize: 14, + fontWeight: '800' + }, budgetHeader: { alignItems: 'flex-start', gap: 4, @@ -179,7 +247,7 @@ const styles = StyleSheet.create({ }, warningBox: { backgroundColor: colors.surface, - borderRadius: 8, + borderRadius: 16, borderWidth: 1, padding: 14 }, @@ -202,7 +270,7 @@ const styles = StyleSheet.create({ empty: { backgroundColor: colors.surface, borderColor: colors.border, - borderRadius: 8, + borderRadius: 18, borderWidth: 1, gap: 12, padding: 16 @@ -216,5 +284,52 @@ const styles = StyleSheet.create({ color: colors.muted, fontSize: 14, lineHeight: 20 + }, + confirmPanel: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 18, + borderWidth: 1, + gap: 12, + padding: 18 + }, + confirmTitle: { + color: colors.text, + fontSize: 18, + fontWeight: '800' + }, + confirmCopy: { + color: colors.muted, + fontSize: 14, + lineHeight: 20 + }, + completedNotice: { + backgroundColor: colors.surfaceTint, + borderRadius: 14, + padding: 14 + }, + completedText: { + color: colors.primaryDark, + fontSize: 14, + fontWeight: '800' + }, + undoNotice: { + alignItems: 'center', + backgroundColor: colors.text, + borderRadius: 14, + flexDirection: 'row', + gap: 12, + padding: 14 + }, + undoText: { + color: colors.surface, + flex: 1, + fontSize: 14, + fontWeight: '700' + }, + undoAction: { + color: '#b8e7ce', + fontSize: 14, + fontWeight: '800' } }); diff --git a/src/utils/constants.js b/src/utils/constants.js index ecb4de3..d9251c7 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -1,11 +1,12 @@ export const colors = { - background: '#f7f3ea', + background: '#f6f8f5', surface: '#ffffff', - text: '#18201d', - muted: '#5a6a64', - border: '#d8ded9', - primary: '#256f5b', - primaryDark: '#174b3d', + surfaceTint: '#e9f4ef', + text: '#16251f', + muted: '#52665d', + border: '#dce7e0', + primary: '#176b50', + primaryDark: '#104934', warning: '#b7791f', danger: '#b42318', success: '#1f7a4d' @@ -13,10 +14,10 @@ export const colors = { export const defaultPreferences = { id: 'local-user', - avoidAllergens: ['Erdnüsse'], - preferredDiet: 'lowSugar', - preferBio: true, - maxBudgetPerShoppingTrip: 45, + avoidAllergens: [], + preferredDiet: 'none', + preferBio: false, + maxBudgetPerShoppingTrip: undefined, budgetPeriod: 'week', currency: 'CHF', offlineModeEnabled: false,