Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8754430ee1 | |||
| 42376e61f7 | |||
| 2e9ce5ee1f | |||
| 6bd596004d | |||
| 6681a54671 | |||
| 326e66bcff | |||
| defc1fbc13 | |||
| 6938afb26c | |||
| 9b80580f9c |
@@ -0,0 +1,55 @@
|
||||
name: Android Release Build
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Quellcode auschecken
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Node.js einrichten
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
|
||||
- name: Java einrichten
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Android SDK einrichten
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: platform-tools
|
||||
|
||||
- name: Abhängigkeiten installieren
|
||||
run: npm ci
|
||||
|
||||
- name: Android-Projekt erzeugen
|
||||
run: npx expo prebuild --platform android --no-install
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: '1'
|
||||
|
||||
- name: Release-APK bauen
|
||||
run: ./gradlew --no-daemon :app:assembleRelease
|
||||
working-directory: android
|
||||
|
||||
- name: APK-Ausgabe prüfen
|
||||
run: ls -lh android/app/build/outputs/apk/release/*.apk
|
||||
|
||||
- name: APK als Build-Artefakt hochladen
|
||||
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: shoppinguin-android-release-${{ gitea.run_number }}
|
||||
path: android/app/build/outputs/apk/release/*.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -32,33 +32,33 @@ 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('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function hydrate() {
|
||||
const [
|
||||
storedPreferences,
|
||||
storedShoppingList,
|
||||
storedProductCache,
|
||||
storedManualPrices,
|
||||
storedRecentProducts
|
||||
] = await Promise.all([
|
||||
storageService.loadPreferences(defaultPreferences),
|
||||
storageService.loadShoppingList(initialShoppingList),
|
||||
storageService.loadProductCache(),
|
||||
storageService.loadManualPrices(),
|
||||
storageService.loadRecentProducts()
|
||||
]);
|
||||
try {
|
||||
const [storedPreferences, storedShoppingList, storedProductCache, storedManualPrices, storedRecentProducts] = await Promise.all([
|
||||
storageService.loadPreferences(defaultPreferences),
|
||||
storageService.loadShoppingList(initialShoppingList),
|
||||
storageService.loadProductCache(),
|
||||
storageService.loadManualPrices(),
|
||||
storageService.loadRecentProducts()
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setPreferences(storedPreferences);
|
||||
setShoppingList(storedShoppingList);
|
||||
setProductCache(storedProductCache);
|
||||
setManualPrices(storedManualPrices);
|
||||
setRecentProducts(storedRecentProducts);
|
||||
setIsHydrated(true);
|
||||
if (isMounted) {
|
||||
setPreferences({ ...defaultPreferences, ...storedPreferences });
|
||||
setShoppingList({ ...initialShoppingList, ...storedShoppingList, items: storedShoppingList?.items || [] });
|
||||
setProductCache(storedProductCache || {});
|
||||
setManualPrices(storedManualPrices || {});
|
||||
setRecentProducts(Array.isArray(storedRecentProducts) ? storedRecentProducts : []);
|
||||
setIsHydrated(true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) setStorageError('Deine lokalen Daten konnten nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,27 @@ export default function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
function retryHydration() {
|
||||
setStorageError('');
|
||||
setIsHydrated(false);
|
||||
// Reloading the app is not necessary for the normal path, but this keeps
|
||||
// the retry action explicit if a storage adapter fails temporarily.
|
||||
Promise.all([
|
||||
storageService.loadPreferences(defaultPreferences),
|
||||
storageService.loadShoppingList(initialShoppingList),
|
||||
storageService.loadProductCache(),
|
||||
storageService.loadManualPrices(),
|
||||
storageService.loadRecentProducts()
|
||||
]).then(([nextPreferences, nextList, nextCache, nextPrices, nextRecent]) => {
|
||||
setPreferences({ ...defaultPreferences, ...nextPreferences });
|
||||
setShoppingList({ ...initialShoppingList, ...nextList, items: nextList?.items || [] });
|
||||
setProductCache(nextCache || {});
|
||||
setManualPrices(nextPrices || {});
|
||||
setRecentProducts(Array.isArray(nextRecent) ? nextRecent : []);
|
||||
setIsHydrated(true);
|
||||
}).catch(() => setStorageError('Bitte starte Shoppinguin erneut, um die lokalen Daten zu laden.'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isHydrated) storageService.savePreferences(preferences);
|
||||
}, [isHydrated, preferences]);
|
||||
@@ -113,10 +134,14 @@ export default function App() {
|
||||
<ShoppingListScreen
|
||||
onCompleteList={completeShoppingList}
|
||||
onDeleteItem={deleteListItem}
|
||||
onUndoDelete={undoDeleteListItem}
|
||||
onStartScan={() => setActiveTab('scan')}
|
||||
onOpenProfile={() => setActiveTab('profile')}
|
||||
onToggleItem={toggleListItem}
|
||||
onChangeItemQuantity={changeListItemQuantity}
|
||||
onUpdateItemPrice={updateListItemPrice}
|
||||
preferences={preferences}
|
||||
lastDeletedItem={lastDeletedListItem}
|
||||
shoppingList={shoppingList}
|
||||
/>
|
||||
);
|
||||
@@ -132,14 +157,16 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeScreen
|
||||
onOpenProduct={openProduct}
|
||||
onStartScan={() => setActiveTab('scan')}
|
||||
preferences={preferences}
|
||||
recentProducts={recentProducts}
|
||||
<HomeScreen
|
||||
onOpenProduct={openProduct}
|
||||
onOpenList={() => setActiveTab('list')}
|
||||
onStartScan={() => setActiveTab('scan')}
|
||||
preferences={preferences}
|
||||
recentProducts={recentProducts}
|
||||
shoppingList={shoppingList}
|
||||
/>
|
||||
);
|
||||
}, [activeTab, manualPrices, preferences, productCache, recentProducts, shoppingList]);
|
||||
}, [activeTab, lastDeletedListItem, manualPrices, preferences, productCache, recentProducts, shoppingList]);
|
||||
|
||||
async function scanProductByBarcode(barcode) {
|
||||
const result = await lookupProductByBarcode(
|
||||
@@ -243,6 +270,7 @@ export default function App() {
|
||||
? {
|
||||
...item,
|
||||
quantity: item.quantity + 1,
|
||||
checked: false,
|
||||
packageQuantityGrams: item.packageQuantityGrams ?? product.packageQuantityGrams,
|
||||
packageQuantityLabel: item.packageQuantityLabel ?? product.packageQuantityLabel,
|
||||
servingQuantityGrams: item.servingQuantityGrams ?? product.servingQuantityGrams,
|
||||
@@ -311,7 +339,21 @@ export default function App() {
|
||||
}));
|
||||
}
|
||||
|
||||
function changeListItemQuantity(itemId, change) {
|
||||
setShoppingList((current) => {
|
||||
const items = current.items.map((item) =>
|
||||
item.id === itemId
|
||||
? { ...item, quantity: Math.max(1, (item.quantity || 1) + change) }
|
||||
: item
|
||||
);
|
||||
return { ...current, items, estimatedTotal: calculateEstimatedTotal(items) };
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -322,7 +364,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()
|
||||
@@ -356,7 +410,8 @@ export default function App() {
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<View style={styles.loading}>
|
||||
<Text style={styles.loadingText}>Shoppy wird geladen...</Text>
|
||||
<Text style={styles.loadingText}>{storageError || 'Shoppinguin wird geladen...'}</Text>
|
||||
{storageError ? <Text onPress={retryHydration} style={styles.retryText}>Erneut versuchen</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -366,6 +421,10 @@ export default function App() {
|
||||
<ProductDetailScreen
|
||||
onAddToList={addProductToShoppingList}
|
||||
onBack={goBackToTabs}
|
||||
onOpenList={() => {
|
||||
setActiveTab('list');
|
||||
goBackToTabs();
|
||||
}}
|
||||
onSaveManualPrice={saveManualPriceForProduct}
|
||||
onShowRatingCriteria={showRatingCriteria}
|
||||
onShowAlternatives={showAlternatives}
|
||||
@@ -443,5 +502,11 @@ const styles = StyleSheet.create({
|
||||
color: colors.muted,
|
||||
fontSize: 16,
|
||||
fontWeight: '700'
|
||||
},
|
||||
retryText: {
|
||||
color: colors.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
marginTop: 16
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Shoppy
|
||||
# Shoppinguin
|
||||
|
||||
Smart Shopping Companion
|
||||
|
||||
## App-Idee
|
||||
|
||||
Shoppy ist eine mobile Einkaufshilfe für den Supermarkt. Nutzerinnen und Nutzer scannen Produkte per Smartphone-Kamera oder Barcode, erhalten direkt eine persönliche Einschätzung und treffen dadurch bessere Kaufentscheidungen.
|
||||
Shoppinguin ist eine mobile Einkaufshilfe für den Supermarkt. Nutzerinnen und Nutzer scannen Produkte per Smartphone-Kamera oder Barcode, erhalten direkt eine persönliche Einschätzung und treffen dadurch bessere Kaufentscheidungen.
|
||||
|
||||
Die Bewertung berücksichtigt Zutaten, Preis, Nachhaltigkeitsmerkmale und individuelle Ziele wie Allergien, Ernährung, Budget und Nachhaltigkeit.
|
||||
|
||||
## Zielgruppe
|
||||
|
||||
Die App richtet sich an Privatpersonen, die im Supermarkt schnell bessere Kaufentscheidungen treffen möchten. Besonders relevant ist Shoppy für Menschen mit Allergien oder Unverträglichkeiten, ernährungsbewusste Käuferinnen und Käufer, Studierende und Familien mit Budgetfokus sowie Konsumentinnen und Konsumenten mit Nachhaltigkeitsanspruch.
|
||||
Die App richtet sich an Privatpersonen, die im Supermarkt schnell bessere Kaufentscheidungen treffen möchten. Besonders relevant ist Shoppinguin für Menschen mit Allergien oder Unverträglichkeiten, ernährungsbewusste Käuferinnen und Käufer, Studierende und Familien mit Budgetfokus sowie Konsumentinnen und Konsumenten mit Nachhaltigkeitsanspruch.
|
||||
|
||||
## Wichtigste Funktionen
|
||||
|
||||
@@ -49,7 +49,7 @@ Der One-Pager aus Aufgabe 6.1 beschreibt das Go-to-Market-Konzept:
|
||||
## Ordnerstruktur
|
||||
|
||||
```text
|
||||
Shoppy/
|
||||
Shoppinguin/
|
||||
|-- assets/
|
||||
|-- src/
|
||||
| |-- assets/
|
||||
@@ -86,6 +86,25 @@ Die App nutzt für Barcodes `https://world.openfoodfacts.org/api/v2/product/{bar
|
||||
|
||||
## Runbook
|
||||
|
||||
### Automatischer Android-Build mit Gitea Actions
|
||||
|
||||
Der Workflow in `.gitea/workflows/android-release.yml` startet bei jedem Push
|
||||
in ein Gitea-Repository. Er installiert die Abhängigkeiten mit `npm ci`,
|
||||
erzeugt das nicht versionierte Android-Projekt per Expo und kompiliert mit
|
||||
Gradle eine Release-APK. Die APK ist im jeweiligen Gitea-Actions-Lauf als
|
||||
Artefakt `shoppinguin-android-release-<Laufnummer>` herunterladbar.
|
||||
|
||||
Voraussetzungen: Gitea Actions ist für das Repository aktiviert und ein
|
||||
Linux-Runner mit dem Label `ubuntu-latest` ist registriert. Der Runner benötigt
|
||||
Netzwerkzugang für npm, Gradle und das Android SDK sowie ausreichend Speicher
|
||||
für einen Android-Build. `origin` zeigt auf das Gitea-Repository; der Workflow
|
||||
startet, sobald die Änderungen dorthin gepusht werden.
|
||||
|
||||
Die APK wird derzeit im Release-Modus mit dem von Expo erzeugten Debug-Schlüssel
|
||||
signiert. Sie eignet sich zum Testen, aber nicht als Store-Release. Für eine
|
||||
Veröffentlichung muss ein eigener Release-Schlüssel sicher in Gitea hinterlegt
|
||||
und die Android-Signierung entsprechend konfiguriert werden.
|
||||
|
||||
Projekt installieren:
|
||||
|
||||
```bash
|
||||
@@ -113,5 +132,5 @@ Für den Barcode-Scan muss die Kamera-Berechtigung erlaubt werden. Auf Web oder
|
||||
Repository-Link für den Kursleiter:
|
||||
|
||||
```text
|
||||
https://github.com/Fretuks/Shoppy
|
||||
https://git.bachmannj.ch/Fretux/Shoppy
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Shoppy",
|
||||
"slug": "shoppy",
|
||||
"name": "Shoppinguin",
|
||||
"slug": "shoppinguin",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -14,10 +14,10 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Shoppy nutzt die Kamera, um Produkt-Barcodes im Supermarkt zu scannen.",
|
||||
"NSCameraUsageDescription": "Shoppinguin nutzt die Kamera, um Produkt-Barcodes im Supermarkt zu scannen.",
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"bundleIdentifier": "net.fretux.shoppy"
|
||||
"bundleIdentifier": "net.fretux.shoppinguin"
|
||||
},
|
||||
"android": {
|
||||
"permissions": [
|
||||
@@ -26,7 +26,7 @@
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#f7f3ea"
|
||||
},
|
||||
"package": "net.fretux.shoppy"
|
||||
"package": "net.fretux.shoppinguin"
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
|
||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "shoppy",
|
||||
"name": "shoppinguin",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shoppy",
|
||||
"name": "shoppinguin",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "shoppy",
|
||||
"name": "shoppinguin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "node_modules/expo/AppEntry.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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
@@ -10,11 +10,12 @@ export default function PrimaryButton({ label, onPress, disabled = false }) {
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
tone === 'light' ? styles.lightButton : null,
|
||||
pressed ? styles.buttonPressed : null,
|
||||
disabled ? styles.disabled : null
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.label, disabled ? styles.disabledLabel : null]}>{label}</Text>
|
||||
<Text style={[styles.label, tone === 'light' ? styles.lightLabel : null, disabled ? styles.disabledLabel : null]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.wrap}>
|
||||
<Text style={styles.label}>Maximales Einkaufsbudget</Text>
|
||||
<Text style={styles.label}>Betrag (optional)</Text>
|
||||
<Text style={styles.hint}>Ohne Budget bleibt die Preisbewertung offen.</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.currency}>{currency}</Text>
|
||||
<TextInput
|
||||
accessibilityLabel="Budgetbetrag"
|
||||
keyboardType="decimal-pad"
|
||||
onChangeText={(text) => onChangeValue(Number(text.replace(',', '.')) || 0)}
|
||||
onChangeText={updateValue}
|
||||
placeholder="z. B. 45.00"
|
||||
placeholderTextColor={colors.muted}
|
||||
style={styles.input}
|
||||
value={String(value ?? '')}
|
||||
value={inputValue}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.periodGrid}>
|
||||
@@ -23,6 +36,7 @@ export default function BudgetInput({ value, currency, period, onChangePeriod, o
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isActive }}
|
||||
key={option}
|
||||
onPress={() => onChangePeriod(option)}
|
||||
style={[styles.periodChip, isActive ? styles.activePeriodChip : null]}
|
||||
@@ -40,12 +54,12 @@ export default function BudgetInput({ value, currency, period, onChangePeriod, o
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
gap: 10,
|
||||
padding: 14
|
||||
gap: 10
|
||||
},
|
||||
hint: {
|
||||
color: colors.muted,
|
||||
fontSize: 13,
|
||||
lineHeight: 18
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
@@ -64,7 +78,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
input: {
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
@@ -80,9 +94,11 @@ const styles = StyleSheet.create({
|
||||
periodChip: {
|
||||
alignItems: 'center',
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
flexGrow: 1,
|
||||
minHeight: 44,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 9
|
||||
},
|
||||
|
||||
@@ -3,20 +3,22 @@ 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' }) {
|
||||
export default function ManualSearchInput({ value, onChangeText, onSubmit, disabled = false, placeholder = 'Produktname oder Barcode' }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<TextInput
|
||||
accessibilityLabel="Produktname oder Barcode"
|
||||
autoCapitalize="none"
|
||||
onChangeText={onChangeText}
|
||||
onSubmitEditing={onSubmit}
|
||||
editable={!disabled}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.muted}
|
||||
returnKeyType="search"
|
||||
style={styles.input}
|
||||
value={value}
|
||||
/>
|
||||
<PrimaryButton label="Suchen" onPress={onSubmit} />
|
||||
<PrimaryButton disabled={disabled} label="Suchen" onPress={onSubmit} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +30,7 @@ const styles = StyleSheet.create({
|
||||
input: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
|
||||
@@ -2,14 +2,15 @@ import { StyleSheet, Switch, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../../utils/constants';
|
||||
|
||||
export default function SettingsSwitchRow({ label, description, value, onValueChange }) {
|
||||
export default function SettingsSwitchRow({ label, description, value, onValueChange, embedded = false }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.row, embedded && styles.embeddedRow]}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
accessibilityLabel={label}
|
||||
onValueChange={onValueChange}
|
||||
thumbColor={value ? colors.primary : '#f4f3f4'}
|
||||
trackColor={{ false: colors.border, true: '#b7dbcf' }}
|
||||
@@ -24,12 +25,18 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
padding: 14
|
||||
},
|
||||
embeddedRow: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
borderRadius: 0,
|
||||
padding: 0
|
||||
},
|
||||
copy: {
|
||||
flex: 1
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function AppHeader({ title, subtitle, onBack, rightAction }) {
|
||||
<View style={styles.header}>
|
||||
<View style={styles.row}>
|
||||
{onBack ? (
|
||||
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}>
|
||||
<Pressable accessibilityLabel="Zurück" accessibilityRole="button" onPress={onBack} style={styles.backButton}>
|
||||
<Text style={styles.backText}>‹</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
@@ -23,7 +23,7 @@ export default function AppHeader({ title, subtitle, onBack, rightAction }) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
paddingTop: 6
|
||||
paddingBottom: 2
|
||||
},
|
||||
row: {
|
||||
alignItems: 'center',
|
||||
@@ -33,11 +33,11 @@ const styles = StyleSheet.create({
|
||||
backButton: {
|
||||
alignItems: 'center',
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
height: 40,
|
||||
height: 44,
|
||||
justifyContent: 'center',
|
||||
width: 40
|
||||
width: 44
|
||||
},
|
||||
backText: {
|
||||
color: colors.primary,
|
||||
@@ -49,8 +49,9 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 28,
|
||||
fontWeight: '800'
|
||||
fontSize: 29,
|
||||
fontWeight: '800',
|
||||
letterSpacing: -0.6
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.muted,
|
||||
|
||||
@@ -3,20 +3,23 @@ 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: '◦' }
|
||||
{ id: 'home', label: 'Start', icon: '⌂' },
|
||||
{ id: 'scan', label: 'Scannen', icon: '▣' },
|
||||
{ id: 'list', label: 'Liste', icon: '☷' },
|
||||
{ id: 'profile', label: 'Profil', icon: '◉' }
|
||||
];
|
||||
|
||||
export default function BottomTabBar({ activeTab, onChange }) {
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
<View style={styles.inner}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityLabel={`${tab.label} öffnen`}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isActive }}
|
||||
key={tab.id}
|
||||
onPress={() => onChange(tab.id)}
|
||||
style={[styles.tab, isActive ? styles.activeTab : null]}
|
||||
@@ -26,6 +29,7 @@ export default function BottomTabBar({ activeTab, onChange }) {
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -36,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: {
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function ScreenContainer({ children, scroll = true }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.content} style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled" style={styles.container}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
@@ -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%'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { colors } from '../../utils/constants';
|
||||
import { formatPrice } from '../../utils/formatPrice';
|
||||
|
||||
export default function ShoppingListItem({ item, onToggle, onDelete, onUpdatePrice }) {
|
||||
export default function ShoppingListItem({ item, onToggle, onDelete, onChangeQuantity, onUpdatePrice }) {
|
||||
const [priceText, setPriceText] = useState(priceToInputValue(item.estimatedPrice));
|
||||
const [priceError, setPriceError] = useState('');
|
||||
|
||||
@@ -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) {
|
||||
@@ -25,13 +26,23 @@ export default function ShoppingListItem({ item, onToggle, onDelete, onUpdatePri
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable accessibilityRole="checkbox" onPress={onToggle} style={[styles.checkbox, item.checked ? styles.checked : null]}>
|
||||
<View style={[styles.row, item.checked && styles.completedRow]}>
|
||||
<Pressable accessibilityLabel={`${item.productName} ${item.checked ? 'als offen markieren' : 'abhaken'}`} accessibilityRole="checkbox" accessibilityState={{ checked: item.checked }} 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, item.currency || 'CHF')}</Text>
|
||||
<Text style={styles.meta}>{item.quantity} × {formatPrice(item.estimatedPrice, item.currency || 'CHF')}{item.quantity > 1 && typeof item.estimatedPrice === 'number' ? ` · ${formatPrice(item.estimatedPrice * item.quantity, item.currency || 'CHF')}` : ''}</Text>
|
||||
<View style={styles.quantityRow}>
|
||||
<Text style={styles.quantityLabel}>Menge</Text>
|
||||
<Pressable accessibilityLabel={`Menge von ${item.productName} verringern`} accessibilityRole="button" accessibilityState={{ disabled: item.quantity <= 1 }} disabled={item.quantity <= 1} onPress={() => onChangeQuantity(-1)} style={[styles.quantityButton, item.quantity <= 1 && styles.disabledButton]}>
|
||||
<Text style={styles.quantityButtonText}>−</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.quantityValue}>{item.quantity}</Text>
|
||||
<Pressable accessibilityLabel={`Menge von ${item.productName} erhöhen`} accessibilityRole="button" onPress={() => onChangeQuantity(1)} style={styles.quantityButton}>
|
||||
<Text style={styles.quantityButtonText}>+</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.priceRow}>
|
||||
<Text style={styles.currency}>{item.currency || 'CHF'}</Text>
|
||||
<TextInput
|
||||
@@ -51,7 +62,7 @@ export default function ShoppingListItem({ item, onToggle, onDelete, onUpdatePri
|
||||
</View>
|
||||
{priceError ? <Text style={styles.errorText}>{priceError}</Text> : null}
|
||||
</View>
|
||||
<Pressable accessibilityRole="button" onPress={onDelete} style={styles.deleteButton}>
|
||||
<Pressable accessibilityLabel={`${item.productName} löschen`} accessibilityRole="button" onPress={onDelete} style={styles.deleteButton}>
|
||||
<Text style={styles.deleteText}>Löschen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -74,11 +85,18 @@ function parsePrice(value) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
completedRow: { backgroundColor: '#f2f7f4' },
|
||||
quantityRow: { alignItems: 'center', flexDirection: 'row', gap: 8, marginTop: 10 },
|
||||
quantityLabel: { color: colors.muted, fontSize: 13, fontWeight: '700', marginRight: 4 },
|
||||
quantityButton: { alignItems: 'center', backgroundColor: colors.surfaceTint, borderRadius: 9, height: 36, justifyContent: 'center', width: 36 },
|
||||
disabledButton: { opacity: 0.4 },
|
||||
quantityButtonText: { color: colors.primaryDark, fontSize: 21, fontWeight: '700' },
|
||||
quantityValue: { color: colors.text, fontSize: 15, fontWeight: '800', minWidth: 22, textAlign: 'center' },
|
||||
row: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
@@ -87,11 +105,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
|
||||
@@ -101,7 +119,8 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '800'
|
||||
},
|
||||
copy: {
|
||||
flex: 1
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
name: {
|
||||
color: colors.text,
|
||||
@@ -129,7 +148,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
priceInput: {
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
@@ -144,8 +163,9 @@ const styles = StyleSheet.create({
|
||||
marginTop: 4
|
||||
},
|
||||
deleteButton: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6
|
||||
justifyContent: 'center',
|
||||
minHeight: 44,
|
||||
paddingHorizontal: 4
|
||||
},
|
||||
deleteText: {
|
||||
color: colors.danger,
|
||||
|
||||
@@ -9,7 +9,10 @@ export default function AlternativeProductCard({ item, onChoose, onOpen }) {
|
||||
<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} />
|
||||
<PrimaryButton
|
||||
label={typeof item.product.price === 'number' ? 'Zur Liste hinzufügen' : 'Produkt ansehen'}
|
||||
onPress={onChoose}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ export default function ProductCard({ product, rating, onPress }) {
|
||||
</Text>
|
||||
<Text style={styles.source}>{sourceLabel(product.dataSource)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
{rating ? <ProductScoreBadge status={rating.overallStatus} score={rating.overallScore} /> : null}
|
||||
<Text style={styles.openLabel}>Details ansehen ›</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%'
|
||||
|
||||
@@ -29,7 +29,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
borderRadius: 20,
|
||||
gap: 14,
|
||||
padding: 20,
|
||||
width: '100%'
|
||||
|
||||
@@ -74,7 +74,7 @@ const styles = StyleSheet.create({
|
||||
empty: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
padding: 16
|
||||
},
|
||||
|
||||
+73
-27
@@ -1,4 +1,4 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import PrimaryButton from '../components/PrimaryButton';
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
@@ -7,32 +7,47 @@ 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, onStartScan, onOpenProduct }) {
|
||||
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;
|
||||
const completedItems = (shoppingList?.items?.length || 0) - openItems;
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Shoppy"
|
||||
subtitle="Scanne Produkte im Supermarkt und erhalte eine persönliche Kaufempfehlung."
|
||||
title="Shoppinguin"
|
||||
subtitle="Deine Entscheidungshilfe direkt am Regal"
|
||||
/>
|
||||
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.eyebrow}>Smart Shopping Companion</Text>
|
||||
<Text style={styles.title}>Bereit für den nächsten Einkauf?</Text>
|
||||
<Text style={styles.eyebrow}>PRODUKTE BESSER VERSTEHEN</Text>
|
||||
<Text style={styles.title}>Wissen, was drinsteckt.</Text>
|
||||
<Text style={styles.copy}>
|
||||
Prüfe Allergien, Ernährung, Budget und Nachhaltigkeit direkt am Regal.
|
||||
Barcode scannen und sofort sehen, wie ein Produkt zu deinen Zielen passt.
|
||||
</Text>
|
||||
<PrimaryButton label="Produkt scannen" onPress={onStartScan} />
|
||||
<PrimaryButton label="Produkt scannen" onPress={onStartScan} tone="light" />
|
||||
</View>
|
||||
|
||||
<Pressable accessibilityRole="button" onPress={onOpenList} style={styles.listShortcut}>
|
||||
<View style={styles.listShortcutText}>
|
||||
<Text style={styles.listShortcutTitle}>Dein Einkauf</Text>
|
||||
<Text style={styles.listShortcutCopy}>
|
||||
{shoppingList?.items?.length ? `${completedItems} von ${shoppingList.items.length} erledigt · ${openItems} noch offen` : 'Deine Liste ist noch leer'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.listShortcutTotal}>
|
||||
{shoppingList?.items?.length ? formatPrice(shoppingList.estimatedTotal, shoppingList.currency) : 'Liste öffnen'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{frequentProducts.length > 0 ? (
|
||||
<>
|
||||
<SectionTitle>Häufig gekauft</SectionTitle>
|
||||
<SectionTitle>Häufig gescannt</SectionTitle>
|
||||
{frequentProducts.map((product) => (
|
||||
<View key={`frequent-${product.id}`} style={styles.frequentWrap}>
|
||||
<ProductCard
|
||||
@@ -46,11 +61,11 @@ export default function HomeScreen({ preferences, recentProducts, onStartScan, o
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<SectionTitle>Letzte Scans</SectionTitle>
|
||||
<SectionTitle>Zuletzt angesehen</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>
|
||||
<Text style={styles.emptyTitle}>Dein erster Scan wartet</Text>
|
||||
<Text style={styles.emptyCopy}>Scanne einen Barcode oder suche ein Produkt nach Namen. Deine letzten Produkte erscheinen dann hier.</Text>
|
||||
</View>
|
||||
) : (
|
||||
recentProducts.map((product) => (
|
||||
@@ -68,29 +83,30 @@ export default function HomeScreen({ preferences, recentProducts, onStartScan, o
|
||||
|
||||
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
|
||||
@@ -105,9 +121,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,
|
||||
@@ -119,5 +135,35 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 4
|
||||
},
|
||||
listShortcut: {
|
||||
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: 17,
|
||||
fontWeight: '800'
|
||||
},
|
||||
listShortcutCopy: {
|
||||
color: colors.muted,
|
||||
fontSize: 13
|
||||
},
|
||||
listShortcutTotal: {
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
textAlign: 'right'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<ScreenContainer>
|
||||
<AppHeader title="Produktbewertung" subtitle="Persönliche Einschätzung" onBack={onBack} />
|
||||
<AppHeader title="Produkt im Check" subtitle="Bewertung nach deinen Einstellungen" onBack={onBack} />
|
||||
|
||||
<View style={styles.productPanel}>
|
||||
<ProductImage imageUrl={product.imageUrl} productName={product.name} size="large" />
|
||||
@@ -68,24 +70,27 @@ export default function ProductDetailScreen({
|
||||
</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 style={[styles.recommendation, styles[`${rating.overallStatus}Recommendation`]]}>
|
||||
<Text style={[styles.recommendationTitle, styles[`${rating.overallStatus}Text`]]}>{recommendation}</Text>
|
||||
<Text style={styles.recommendationCopy}>Tippe unten auf ein Kriterium, um die Bewertung nachzuvollziehen.</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!hasProductPrice ? (
|
||||
<View style={styles.pricePanel}>
|
||||
<Text style={styles.priceTitle}>Preis ergänzen</Text>
|
||||
<Text style={styles.priceTitle}>Ladenpreis ergänzen</Text>
|
||||
<Text style={styles.priceCopy}>
|
||||
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.
|
||||
</Text>
|
||||
<View style={styles.priceInputRow}>
|
||||
<Text style={styles.currency}>{currency}</Text>
|
||||
<TextInput
|
||||
accessibilityLabel="Ladenpreis"
|
||||
keyboardType="decimal-pad"
|
||||
onChangeText={(value) => {
|
||||
setManualPrice(value);
|
||||
setPriceError('');
|
||||
setSavedMessage('');
|
||||
}}
|
||||
placeholder="z. B. 3.50"
|
||||
placeholderTextColor={colors.muted}
|
||||
@@ -97,6 +102,17 @@ export default function ProductDetailScreen({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{savedMessage ? (
|
||||
<View style={styles.savedPanel}>
|
||||
<Text style={styles.saved}>{savedMessage}</Text>
|
||||
<Pressable accessibilityRole="button" onPress={onOpenList}>
|
||||
<Text style={styles.savedLink}>Einkaufsliste ansehen ›</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
<PrimaryButton label="Zur Einkaufsliste hinzufügen" onPress={addToList} />
|
||||
<SecondaryButton label="Bessere Alternativen suchen" onPress={() => onShowAlternatives(product)} />
|
||||
|
||||
<SectionTitle>Bewertungskriterien</SectionTitle>
|
||||
{Object.entries(rating.categoryScores).map(([category, categoryRating]) => (
|
||||
<RatingCategoryRow
|
||||
@@ -111,14 +127,11 @@ export default function ProductDetailScreen({
|
||||
<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}>Allergene: {(product.allergens || []).join(', ') || 'Keine Angaben'}</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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
+181
-100
@@ -4,105 +4,150 @@ 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';
|
||||
import { budgetPeriodLabels, colors, dietLabels } from '../utils/constants';
|
||||
import { formatPrice } from '../utils/formatPrice';
|
||||
|
||||
const dietOptions = ['none', 'vegetarian', 'vegan', 'lowSugar', 'highProtein'];
|
||||
const allergenOptions = ['Erdnüsse', 'Milch', 'Hafer', 'Gluten'];
|
||||
const priorityOptions = ['low', 'medium', 'high'];
|
||||
const priorityLabels = { low: 'Niedrig', medium: 'Mittel', high: 'Hoch' };
|
||||
|
||||
export default function ProfileScreen({ preferences, onChangePreferences }) {
|
||||
function updatePreference(key, value) {
|
||||
onChangePreferences({
|
||||
...preferences,
|
||||
[key]: value
|
||||
});
|
||||
onChangePreferences({ ...preferences, [key]: value });
|
||||
}
|
||||
|
||||
function toggleAllergen(allergen) {
|
||||
const current = preferences.avoidAllergens || [];
|
||||
const nextAllergens = current.includes(allergen)
|
||||
updatePreference('avoidAllergens', current.includes(allergen)
|
||||
? current.filter((item) => item !== allergen)
|
||||
: [...current, allergen];
|
||||
updatePreference('avoidAllergens', nextAllergens);
|
||||
: [...current, allergen]);
|
||||
}
|
||||
|
||||
const allergenCount = preferences.avoidAllergens?.length || 0;
|
||||
const budget = preferences.maxBudgetPerShoppingTrip;
|
||||
const budgetSummary = typeof budget === 'number' && budget > 0
|
||||
? `${formatPrice(budget, preferences.currency)} pro ${budgetPeriodLabels[preferences.budgetPeriod] || 'Woche'}`
|
||||
: 'Kein Budget festgelegt';
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Profil"
|
||||
subtitle="Präferenzen steuern die Produktbewertung global."
|
||||
/>
|
||||
<AppHeader title="Einstellungen" subtitle="So werden Produkte für dich bewertet" />
|
||||
|
||||
<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>
|
||||
<SettingsSection
|
||||
title="Ernährung & Allergien"
|
||||
summary={`${dietLabels[preferences.preferredDiet] || 'Kein Ziel'} · ${allergenCount === 0 ? 'Keine Allergene gewählt' : `${allergenCount} Allergene gewählt`}`}
|
||||
>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>Ernährungsziel</Text>
|
||||
<View style={styles.optionGrid}>
|
||||
{dietOptions.map((option) => (
|
||||
<SelectionChip
|
||||
active={preferences.preferredDiet === option}
|
||||
key={option}
|
||||
label={dietLabels[option]}
|
||||
onPress={() => updatePreference('preferredDiet', option)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.divider} />
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>Allergene vermeiden</Text>
|
||||
<Text style={styles.fieldHint}>Wähle nur relevante Allergene. Fehlen Produktangaben, bleibt die Bewertung unsicher.</Text>
|
||||
<View style={styles.optionGrid}>
|
||||
{allergenOptions.map((allergen) => (
|
||||
<SelectionChip
|
||||
active={preferences.avoidAllergens?.includes(allergen)}
|
||||
danger
|
||||
key={allergen}
|
||||
label={allergen}
|
||||
onPress={() => toggleAllergen(allergen)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<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>
|
||||
<SettingsSection title="Budget" summary={budgetSummary}>
|
||||
<BudgetInput
|
||||
currency={preferences.currency}
|
||||
onChangePeriod={(value) => updatePreference('budgetPeriod', value)}
|
||||
onChangeValue={(value) => updatePreference('maxBudgetPerShoppingTrip', value)}
|
||||
period={preferences.budgetPeriod || 'week'}
|
||||
value={budget}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<BudgetInput
|
||||
currency={preferences.currency}
|
||||
onChangePeriod={(value) => updatePreference('budgetPeriod', value)}
|
||||
onChangeValue={(value) => updatePreference('maxBudgetPerShoppingTrip', value)}
|
||||
period={preferences.budgetPeriod || 'week'}
|
||||
value={preferences.maxBudgetPerShoppingTrip}
|
||||
/>
|
||||
<SettingsSection
|
||||
title="Bewertung"
|
||||
summary={`Ernährung ${priorityLabels[preferences.nutritionPriority] || 'Mittel'} · Nachhaltigkeit ${priorityLabels[preferences.sustainabilityPriority] || 'Mittel'}`}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
description="Bio-Produkte erhalten in der Nachhaltigkeitsbewertung einen Vorteil."
|
||||
embedded
|
||||
label="Bio bevorzugen"
|
||||
onValueChange={(value) => updatePreference('preferBio', value)}
|
||||
value={preferences.preferBio}
|
||||
/>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.fieldLabel}>Prioritäten</Text>
|
||||
<Text style={styles.fieldHint}>Hoch bedeutet, dass dieser Bereich stärker in die Gesamtbewertung einfliesst.</Text>
|
||||
<PrioritySelector
|
||||
label="Ernährung"
|
||||
onChange={(value) => updatePreference('nutritionPriority', value)}
|
||||
value={preferences.nutritionPriority}
|
||||
/>
|
||||
<PrioritySelector
|
||||
label="Nachhaltigkeit"
|
||||
onChange={(value) => updatePreference('sustainabilityPriority', value)}
|
||||
value={preferences.sustainabilityPriority}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<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}
|
||||
/>
|
||||
<SettingsSection
|
||||
title="App & Daten"
|
||||
summary={preferences.offlineModeEnabled ? 'Offline-Modus aktiv' : 'Online-Suche aktiv'}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
description="Im Offline-Modus werden nur lokal gespeicherte Produkte gefunden."
|
||||
embedded
|
||||
label="Offline-Modus"
|
||||
onValueChange={(value) => updatePreference('offlineModeEnabled', value)}
|
||||
value={preferences.offlineModeEnabled}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({ title, summary, children }) {
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<Text style={styles.sectionSummary}>{summary}</Text>
|
||||
</View>
|
||||
<View style={styles.sectionContent}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionChip({ active, danger = false, label, onPress }) {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: Boolean(active) }}
|
||||
onPress={onPress}
|
||||
style={[styles.chip, active && (danger ? styles.dangerChip : styles.activeChip)]}
|
||||
>
|
||||
<Text style={[styles.chipText, active && (danger ? styles.dangerChipText : styles.activeChipText)]}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function PrioritySelector({ label, value, onChange }) {
|
||||
return (
|
||||
<View style={styles.priorityRow}>
|
||||
@@ -111,12 +156,13 @@ function PrioritySelector({ label, value, onChange }) {
|
||||
{priorityOptions.map((option) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: value === option }}
|
||||
key={option}
|
||||
onPress={() => onChange(option)}
|
||||
style={[styles.priorityChip, value === option ? styles.activeChip : null]}
|
||||
style={[styles.priorityChip, value === option && styles.activeChip]}
|
||||
>
|
||||
<Text style={[styles.chipText, value === option ? styles.activeChipText : null]}>
|
||||
{option === 'low' ? 'Niedrig' : option === 'medium' ? 'Mittel' : 'Hoch'}
|
||||
<Text style={[styles.chipText, value === option && styles.activeChipText]}>
|
||||
{priorityLabels[option]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
@@ -126,6 +172,48 @@ function PrioritySelector({ label, value, onChange }) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
sectionHeader: {
|
||||
backgroundColor: colors.surfaceTint,
|
||||
gap: 4,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 15
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 19,
|
||||
fontWeight: '800'
|
||||
},
|
||||
sectionSummary: {
|
||||
color: colors.primaryDark,
|
||||
fontSize: 13,
|
||||
lineHeight: 18
|
||||
},
|
||||
sectionContent: {
|
||||
gap: 14,
|
||||
padding: 18
|
||||
},
|
||||
field: { gap: 10 },
|
||||
fieldLabel: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '800'
|
||||
},
|
||||
fieldHint: {
|
||||
color: colors.muted,
|
||||
fontSize: 13,
|
||||
lineHeight: 19
|
||||
},
|
||||
divider: {
|
||||
backgroundColor: colors.border,
|
||||
height: 1
|
||||
},
|
||||
optionGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
@@ -134,10 +222,12 @@ const styles = StyleSheet.create({
|
||||
chip: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
minHeight: 44,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10
|
||||
paddingVertical: 9
|
||||
},
|
||||
activeChip: {
|
||||
backgroundColor: colors.primary,
|
||||
@@ -152,35 +242,26 @@ const styles = StyleSheet.create({
|
||||
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
|
||||
},
|
||||
activeChipText: { color: colors.surface },
|
||||
dangerChipText: { color: colors.danger },
|
||||
priorityRow: { gap: 8 },
|
||||
priorityLabel: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: '800'
|
||||
fontSize: 14,
|
||||
fontWeight: '700'
|
||||
},
|
||||
priorityOptions: {
|
||||
flexDirection: 'row',
|
||||
gap: 8
|
||||
},
|
||||
priorityChip: {
|
||||
alignItems: 'center',
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center'
|
||||
justifyContent: 'center',
|
||||
minHeight: 44,
|
||||
paddingHorizontal: 4
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import AppHeader from '../components/layout/AppHeader';
|
||||
import ScreenContainer from '../components/layout/ScreenContainer';
|
||||
import ProductScoreBadge from '../components/product/ProductScoreBadge';
|
||||
import { calculateProductRating } from '../services/ratingService';
|
||||
import { colors } from '../utils/constants';
|
||||
|
||||
const labels = {
|
||||
allergies: 'Allergien',
|
||||
budget: 'Budget',
|
||||
ingredients: 'Zutaten',
|
||||
nutrition: 'Ernährung',
|
||||
sustainability: 'Nachhaltigkeit'
|
||||
};
|
||||
|
||||
const defaultDetails = {
|
||||
allergies: {
|
||||
positive: 'Keine blockierten Allergene und vollständige Allergenangaben verbessern diese Bewertung.',
|
||||
penalty: 'Treffer mit deinen vermiedenen Allergenen setzen diese Kategorie hart nach unten. Fehlende Allergenangaben werden als Unsicherheit markiert.'
|
||||
},
|
||||
budget: {
|
||||
positive: 'Ein niedriger Anteil am Budget pro Zeitraum verbessert diese Bewertung.',
|
||||
penalty: 'Ein hoher Preis relativ zu deinem Budget reduziert die Bewertung. Fehlende Preise werden als ungenaue Prüfung markiert.'
|
||||
},
|
||||
ingredients: {
|
||||
positive: 'Keine kritischen Zusatzstoffe, kein Palmöl und passende Vegan-/Vegetarisch-Analyse verbessern diese Bewertung.',
|
||||
penalty: 'Kritische Zusatzstoffe, Palmöl, viele Zusatzstoffe oder ein Konflikt mit deinem Ernährungsziel reduzieren diese Bewertung.'
|
||||
},
|
||||
nutrition: {
|
||||
positive: 'Guter Nutri-Score, niedrige kritische Nährwert-Level und passende Werte für dein Ernährungsziel verbessern diese Bewertung.',
|
||||
penalty: 'Hohe Werte bei Zucker, Salz, Fett oder gesättigten Fettsäuren sowie Konflikte mit Low-Sugar- oder High-Protein-Zielen reduzieren diese Bewertung.'
|
||||
},
|
||||
sustainability: {
|
||||
positive: 'Guter Eco-Score, niedriger CO2-Fussabdruck, regionale Herkunft, bessere Verpackung und Bio bei Bio-Präferenz verbessern diese Bewertung.',
|
||||
penalty: 'Schwacher Eco-Score, hoher CO2-Fussabdruck, belastende Verpackung, weite Herkunft oder fehlendes Bio bei Bio-Präferenz reduzieren diese Bewertung.'
|
||||
}
|
||||
};
|
||||
|
||||
export default function RatingCriteriaDetailScreen({ product, preferences, category, onBack }) {
|
||||
const rating = calculateProductRating(product, preferences);
|
||||
const categoryRating = rating.categoryScores[category];
|
||||
const title = labels[category] || category;
|
||||
const fallbackDetails = defaultDetails[category] || defaultDetails.ingredients;
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader title={title} subtitle="Bewertungsdetail" onBack={onBack} />
|
||||
|
||||
<View style={styles.summaryPanel}>
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryCopy}>
|
||||
<Text style={styles.eyebrow}>Kriterium</Text>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.message}>{categoryRating.message}</Text>
|
||||
</View>
|
||||
<ProductScoreBadge status={categoryRating.status} score={categoryRating.score} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{(categoryRating.missingData || []).length > 0 ? (
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelTitle}>Fehlende Daten</Text>
|
||||
{categoryRating.missingData.map((message) => (
|
||||
<Text key={message} style={styles.warningText}>{message}</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{(categoryRating.details || []).length > 0 ? (
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelTitle}>So entsteht die Bewertung</Text>
|
||||
{categoryRating.details.map((detail) => (
|
||||
<View key={`${detail.title}-${detail.meta || ''}`} style={styles.detailItem}>
|
||||
<Text style={detail.type === 'positive' ? styles.positiveTitle : styles.penaltyTitle}>
|
||||
{detail.title}
|
||||
</Text>
|
||||
<Text style={styles.detailBody}>{detail.body}</Text>
|
||||
{detail.meta ? <Text style={styles.detailMeta}>{detail.meta}</Text> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelTitle}>Was die Bewertung verbessert</Text>
|
||||
<Text style={styles.detailBody}>{fallbackDetails.positive}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.panel}>
|
||||
<Text style={styles.panelTitle}>Was die Bewertung senkt</Text>
|
||||
<Text style={styles.detailBody}>{fallbackDetails.penalty}</Text>
|
||||
</View>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
summaryPanel: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
padding: 16
|
||||
},
|
||||
summaryRow: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
gap: 12
|
||||
},
|
||||
summaryCopy: {
|
||||
flex: 1
|
||||
},
|
||||
eyebrow: {
|
||||
color: colors.primary,
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase'
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 23,
|
||||
fontWeight: '800',
|
||||
lineHeight: 29,
|
||||
marginTop: 4
|
||||
},
|
||||
message: {
|
||||
color: colors.muted,
|
||||
fontSize: 15,
|
||||
lineHeight: 21,
|
||||
marginTop: 8
|
||||
},
|
||||
panel: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
gap: 10,
|
||||
padding: 16
|
||||
},
|
||||
panelTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '800'
|
||||
},
|
||||
detailItem: {
|
||||
borderTopColor: colors.border,
|
||||
borderTopWidth: 1,
|
||||
gap: 5,
|
||||
paddingTop: 10
|
||||
},
|
||||
positiveTitle: {
|
||||
color: colors.success,
|
||||
fontSize: 14,
|
||||
fontWeight: '800'
|
||||
},
|
||||
penaltyTitle: {
|
||||
color: colors.danger,
|
||||
fontSize: 14,
|
||||
fontWeight: '800'
|
||||
},
|
||||
detailBody: {
|
||||
color: colors.muted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20
|
||||
},
|
||||
detailMeta: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
lineHeight: 19
|
||||
},
|
||||
warningText: {
|
||||
color: colors.warning,
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
lineHeight: 19
|
||||
}
|
||||
});
|
||||
+83
-20
@@ -24,15 +24,23 @@ 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;
|
||||
|
||||
setIsScanning(false);
|
||||
setIsLoadingProduct(true);
|
||||
const result = await onScanBarcode(event.data).finally(() => {
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = await onScanBarcode(event.data);
|
||||
} catch (error) {
|
||||
result = { status: 'error', errorMessage: 'Das Produkt konnte gerade nicht geladen werden.' };
|
||||
} finally {
|
||||
setIsLoadingProduct(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (result?.status !== 'found') {
|
||||
setScanError(result?.errorMessage || 'Der Barcode konnte nicht verarbeitet werden.');
|
||||
@@ -41,10 +49,30 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
|
||||
}
|
||||
|
||||
async function handleManualSearch() {
|
||||
if (!manualSearchQuery.trim() || isSearching) return;
|
||||
|
||||
setIsSearching(true);
|
||||
const results = await searchProducts(manualSearchQuery, productCache, preferences.offlineModeEnabled);
|
||||
setManualResults(results);
|
||||
setIsSearching(false);
|
||||
setSearchError('');
|
||||
try {
|
||||
const query = manualSearchQuery.trim();
|
||||
if (/^\d{8,14}$/.test(query)) {
|
||||
const result = await onScanBarcode(query);
|
||||
if (result.status !== 'found') {
|
||||
setManualResults([]);
|
||||
setHasSearched(false);
|
||||
setSearchError(result.errorMessage || 'Zu diesem Barcode wurde kein Produkt gefunden.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const results = await searchProducts(query, 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);
|
||||
}
|
||||
}
|
||||
|
||||
function retryScan() {
|
||||
@@ -58,17 +86,19 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Scan"
|
||||
subtitle={preferences.offlineModeEnabled ? 'Offline-Modus aktiv: nur Cache-Produkte verfügbar.' : 'Barcode scannen oder Produkt manuell suchen.'}
|
||||
title="Produkt scannen"
|
||||
subtitle={preferences.offlineModeEnabled ? 'Offline aktiv · nur gespeicherte Produkte' : 'Barcode scannen oder nach einem Produkt suchen'}
|
||||
/>
|
||||
|
||||
{!permission?.granted && Platform.OS !== 'web' ? (
|
||||
{permission && !permission.granted && Platform.OS !== 'web' ? (
|
||||
<View style={styles.permissionBox}>
|
||||
<Text style={styles.permissionTitle}>Kamera-Berechtigung benötigt</Text>
|
||||
<Text style={styles.permissionTitle}>Kamera für den Scan freigeben</Text>
|
||||
<Text style={styles.permissionCopy}>
|
||||
Shoppy 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.'}
|
||||
</Text>
|
||||
<PrimaryButton label="Kamera erlauben" onPress={requestPermission} />
|
||||
{permission.canAskAgain ? <PrimaryButton label="Kamera erlauben" onPress={requestPermission} /> : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -76,34 +106,46 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
|
||||
<>
|
||||
<BarcodeScannerView isLoadingProduct={isLoadingProduct} isScanning={isScanning} onBarcodeScanned={handleBarcodeScanned} />
|
||||
<SecondaryButton
|
||||
disabled={isLoadingProduct}
|
||||
label={isScanning ? 'Scan pausieren' : 'Scan fortsetzen'}
|
||||
onPress={() => setIsScanning((current) => !current)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.permissionBox}>
|
||||
<Text style={styles.permissionTitle}>Scanner-Simulation</Text>
|
||||
<Text style={styles.permissionTitle}>Beispielprodukt öffnen</Text>
|
||||
<Text style={styles.permissionCopy}>
|
||||
Auf Web oder ohne Kamera kannst du Open-Food-Facts-Barcodes testen.
|
||||
Du kannst einen Beispiel-Barcode ausprobieren oder unten nach einem Produkt suchen.
|
||||
</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' })} />
|
||||
<SecondaryButton label="Weiteres Beispiel" onPress={() => handleBarcodeScanned({ data: '737628064502' })} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SectionTitle>Manuelle Suche</SectionTitle>
|
||||
<SectionTitle>Nach Produkt suchen</SectionTitle>
|
||||
<ManualSearchInput
|
||||
onChangeText={setManualSearchQuery}
|
||||
onChangeText={(value) => {
|
||||
setManualSearchQuery(value);
|
||||
setManualResults([]);
|
||||
setHasSearched(false);
|
||||
setSearchError('');
|
||||
}}
|
||||
onSubmit={handleManualSearch}
|
||||
disabled={isSearching}
|
||||
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>
|
||||
{searchError ? <Text style={styles.errorNote}>{searchError}</Text> : null}
|
||||
{!isSearching && hasSearched && manualResults.length === 0 ? (
|
||||
<View style={styles.emptyResults}>
|
||||
<Text style={styles.permissionTitle}>Kein passendes Produkt gefunden</Text>
|
||||
<Text style={styles.permissionCopy}>Prüfe die Schreibweise oder suche nach dem Barcode.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{manualResults.length > 0 ? <Text style={styles.resultsCount}>{manualResults.length} Treffer</Text> : null}
|
||||
{manualResults.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
@@ -116,7 +158,10 @@ export default function ScanScreen({ preferences, productCache, onScanBarcode, o
|
||||
<ScanErrorModal
|
||||
message={scanError}
|
||||
onClose={retryScan}
|
||||
onManualSearch={() => setIsErrorModalVisible(false)}
|
||||
onManualSearch={() => {
|
||||
setIsErrorModalVisible(false);
|
||||
setIsScanning(true);
|
||||
}}
|
||||
onRetry={retryScan}
|
||||
visible={isErrorModalVisible}
|
||||
/>
|
||||
@@ -128,11 +173,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,
|
||||
@@ -149,5 +202,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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,28 @@ import { formatPrice } from '../utils/formatPrice';
|
||||
export default function ShoppingListScreen({
|
||||
preferences,
|
||||
shoppingList,
|
||||
lastDeletedItem,
|
||||
onToggleItem,
|
||||
onChangeItemQuantity,
|
||||
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 completedItems = shoppingList.items.length - openItems;
|
||||
const openTotal = shoppingList.items.reduce((sum, item) =>
|
||||
sum + (!item.checked && typeof item.estimatedPrice === 'number' ? item.estimatedPrice * item.quantity : 0), 0);
|
||||
const progress = shoppingList.items.length ? completedItems / shoppingList.items.length : 0;
|
||||
const itemsWithoutPrice = shoppingList.items.filter((item) => typeof item.estimatedPrice !== 'number').length;
|
||||
const openItemsWithoutPrice = shoppingList.items.filter((item) => !item.checked && 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 +42,44 @@ export default function ShoppingListScreen({
|
||||
<ScreenContainer>
|
||||
<AppHeader
|
||||
title="Einkaufsliste"
|
||||
subtitle={`${openItems} offene Produkte · ${formatPrice(shoppingList.estimatedTotal, shoppingList.currency)}`}
|
||||
subtitle={shoppingList.items.length ? `${openItems} offen · ${shoppingList.items.length} insgesamt` : 'Alles für deinen nächsten Einkauf'}
|
||||
/>
|
||||
|
||||
{wasCompleted && shoppingList.items.length === 0 ? (
|
||||
<View style={styles.completedNotice}>
|
||||
<Text style={styles.completedText}>Einkauf abgeschlossen. Deine neue Liste ist bereit.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{lastDeletedItem ? (
|
||||
<View style={styles.undoNotice}>
|
||||
<Text style={styles.undoText}>{lastDeletedItem.item.productName} entfernt</Text>
|
||||
<Pressable accessibilityRole="button" onPress={onUndoDelete}>
|
||||
<Text style={styles.undoAction}>Rückgängig</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<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 style={styles.budgetBox}>
|
||||
<Text style={styles.summaryTitle}>Deine Liste</Text>
|
||||
<Text style={styles.totalLabel}>{itemsWithoutPrice ? 'Bekannte Summe' : 'Voraussichtliche Summe'}</Text>
|
||||
<Text style={styles.totalValue}>{formatPrice(shoppingList.estimatedTotal, shoppingList.currency)}</Text>
|
||||
{itemsWithoutPrice ? <Text style={styles.summaryCopy}>{itemsWithoutPrice} {itemsWithoutPrice === 1 ? 'Produkt ohne Preis ist' : 'Produkte ohne Preis sind'} nicht eingerechnet.</Text> : null}
|
||||
{shoppingList.items.length > 0 ? (
|
||||
<View style={styles.shoppingProgress}>
|
||||
<View style={styles.progressHeader}>
|
||||
<Text style={styles.progressLabel}>{completedItems} von {shoppingList.items.length} erledigt</Text>
|
||||
<Text style={styles.progressLabel}>{Math.round(progress * 100)} %</Text>
|
||||
</View>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, styles.goodFill, { width: `${progress * 100}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.remainingCopy}>Noch offen: {formatPrice(openTotal, shoppingList.currency)}{openItemsWithoutPrice ? ` plus ${openItemsWithoutPrice} Artikel ohne Preis` : ''}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{hasBudget ? <View style={styles.budgetBox}>
|
||||
<View style={styles.budgetHeader}>
|
||||
<Text style={styles.budgetLabel}>Budget pro {budgetPeriodLabels[preferences.budgetPeriod] || 'Woche'}</Text>
|
||||
<Text style={styles.budgetLabel}>Anteil am Budget pro {budgetPeriodLabels[preferences.budgetPeriod] || 'Woche'}</Text>
|
||||
<Text style={[styles.budgetValue, styles[budgetStatus]]}>
|
||||
{formatPrice(shoppingList.estimatedTotal, shoppingList.currency)} von {formatPrice(budget, shoppingList.currency)}
|
||||
</Text>
|
||||
@@ -51,7 +93,11 @@ export default function ShoppingListScreen({
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View> : (
|
||||
<Pressable accessibilityRole="button" onPress={onOpenProfile} style={styles.budgetLink}>
|
||||
<Text style={styles.budgetLinkText}>Budget im Profil festlegen ›</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{dailyWarnings.length > 0 ? (
|
||||
@@ -83,19 +129,36 @@ export default function ShoppingListScreen({
|
||||
<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)}
|
||||
onUpdatePrice={onUpdateItemPrice}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{openItems > 0 ? <Text style={styles.groupTitle}>Noch einkaufen · {openItems}</Text> : null}
|
||||
{shoppingList.items.filter((item) => !item.checked).map((item) => (
|
||||
<ShoppingListItem item={item} key={item.id} onDelete={() => onDeleteItem(item.id)} onToggle={() => onToggleItem(item.id)} onChangeQuantity={(change) => onChangeItemQuantity(item.id, change)} onUpdatePrice={onUpdateItemPrice} />
|
||||
))}
|
||||
{completedItems > 0 ? <Text style={styles.groupTitle}>Erledigt · {completedItems}</Text> : null}
|
||||
{shoppingList.items.filter((item) => item.checked).map((item) => (
|
||||
<ShoppingListItem item={item} key={item.id} onDelete={() => onDeleteItem(item.id)} onToggle={() => onToggleItem(item.id)} onChangeQuantity={(change) => onChangeItemQuantity(item.id, change)} onUpdatePrice={onUpdateItemPrice} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{shoppingList.items.length > 0 ? (
|
||||
<SecondaryButton label="Einkauf abschließen" onPress={onCompleteList} />
|
||||
<>
|
||||
<SecondaryButton label="Weiteres Produkt scannen" onPress={onStartScan} />
|
||||
{isConfirming ? (
|
||||
<View style={styles.confirmPanel}>
|
||||
<Text style={styles.confirmTitle}>Einkauf abschliessen?</Text>
|
||||
<Text style={styles.confirmCopy}>Die aktuelle Liste wird geleert. Gespeicherte Produktdaten bleiben erhalten.</Text>
|
||||
<PrimaryButton label="Ja, Einkauf abschliessen" onPress={() => {
|
||||
onCompleteList();
|
||||
setIsConfirming(false);
|
||||
setWasCompleted(true);
|
||||
}} />
|
||||
<SecondaryButton label="Abbrechen" onPress={() => setIsConfirming(false)} />
|
||||
</View>
|
||||
) : (
|
||||
<SecondaryButton label="Einkauf abschliessen" onPress={() => setIsConfirming(true)} />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</ScreenContainer>
|
||||
);
|
||||
@@ -110,12 +173,17 @@ function formatAmount(value, unit) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
shoppingProgress: { marginTop: 18 },
|
||||
progressHeader: { flexDirection: 'row', justifyContent: 'space-between' },
|
||||
progressLabel: { color: colors.primaryDark, fontSize: 13, fontWeight: '800' },
|
||||
remainingCopy: { color: colors.muted, fontSize: 13, marginTop: 8 },
|
||||
groupTitle: { color: colors.text, fontSize: 16, fontWeight: '800', marginTop: 4 },
|
||||
summary: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
padding: 16
|
||||
padding: 18
|
||||
},
|
||||
summaryTitle: {
|
||||
color: colors.text,
|
||||
@@ -126,11 +194,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 +271,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
warningBox: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 14
|
||||
},
|
||||
@@ -202,7 +294,7 @@ const styles = StyleSheet.create({
|
||||
empty: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
gap: 12,
|
||||
padding: 16
|
||||
@@ -216,5 +308,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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const dailyLimitRules = [
|
||||
unit: 'mg',
|
||||
nutritionKey: 'caffeineMgPer100g',
|
||||
severity: 'critical',
|
||||
message: 'Mehr Koffein als der Tagesrichtwert in Shoppy. Auf mehrere Tage aufteilen.'
|
||||
message: 'Mehr Koffein als der Tagesrichtwert in Shoppinguin. Auf mehrere Tage aufteilen.'
|
||||
},
|
||||
{
|
||||
key: 'sugar',
|
||||
@@ -15,7 +15,7 @@ const dailyLimitRules = [
|
||||
unit: 'g',
|
||||
nutritionKey: 'sugarPer100g',
|
||||
severity: 'warning',
|
||||
message: 'Mehr Zucker als der Tagesrichtwert in Shoppy. Auf mehrere Tage aufteilen.'
|
||||
message: 'Mehr Zucker als der Tagesrichtwert in Shoppinguin. Auf mehrere Tage aufteilen.'
|
||||
},
|
||||
{
|
||||
key: 'salt',
|
||||
@@ -24,7 +24,7 @@ const dailyLimitRules = [
|
||||
unit: 'g',
|
||||
nutritionKey: 'saltPer100g',
|
||||
severity: 'warning',
|
||||
message: 'Mehr Salz als der Tagesrichtwert in Shoppy. Auf mehrere Tage aufteilen.'
|
||||
message: 'Mehr Salz als der Tagesrichtwert in Shoppinguin. Auf mehrere Tage aufteilen.'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const keys = {
|
||||
preferences: 'shoppy.preferences',
|
||||
shoppingList: 'shoppy.shoppingList',
|
||||
productCache: 'shoppy.productCache',
|
||||
manualPrices: 'shoppy.manualPrices',
|
||||
recentProducts: 'shoppy.recentProducts'
|
||||
preferences: 'shoppinguin.preferences',
|
||||
shoppingList: 'shoppinguin.shoppingList',
|
||||
productCache: 'shoppinguin.productCache',
|
||||
manualPrices: 'shoppinguin.manualPrices',
|
||||
recentProducts: 'shoppinguin.recentProducts'
|
||||
};
|
||||
|
||||
const legacyPrefix = 'shoppy.';
|
||||
|
||||
async function readJson(key, fallbackValue) {
|
||||
const rawValue = await AsyncStorage.getItem(key);
|
||||
return rawValue ? JSON.parse(rawValue) : fallbackValue;
|
||||
try {
|
||||
let rawValue = await AsyncStorage.getItem(key);
|
||||
if (rawValue === null) {
|
||||
const legacyKey = key.replace(/^shoppinguin\./, legacyPrefix);
|
||||
rawValue = await AsyncStorage.getItem(legacyKey);
|
||||
if (rawValue !== null) {
|
||||
await AsyncStorage.setItem(key, rawValue);
|
||||
}
|
||||
}
|
||||
return rawValue ? JSON.parse(rawValue) : fallbackValue;
|
||||
} catch (error) {
|
||||
// A damaged value should not prevent the app from starting. The next save
|
||||
// will replace it with a valid value.
|
||||
console.warn(`Shoppinguin konnte ${key} nicht laden.`, error);
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJson(key, value) {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
try {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
// Storage is best-effort; the in-memory state remains usable.
|
||||
console.warn(`Shoppinguin konnte ${key} nicht speichern.`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export const storageService = {
|
||||
|
||||
+11
-10
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user