9 Commits

Author SHA1 Message Date
Fretux 8754430ee1 Refactor profile screen layout and user preference handling
Android Release Build / build-android (push) Successful in 13m44s
2026-10-16 18:02:09 +02:00
Fretux 42376e61f7 Refactor profile screen layout and user preference handling
Android Release Build / build-android (push) Successful in 13m36s
2026-10-16 16:52:24 +02:00
Fretux 2e9ce5ee1f Refactor profile screen layout and user preference handling
Android Release Build / build-android (push) Failing after 8m45s
2026-10-16 16:29:48 +02:00
Fretux 6bd596004d Refactor profile screen layout and user preference handling
Android Release Build / build-android (push) Failing after 7m22s
2026-10-16 16:11:55 +02:00
Fretux 6681a54671 Refactor profile screen layout and user preference handling
Android Release Build / build-android (push) Has been cancelled
2026-10-16 16:07:20 +02:00
Fretux 326e66bcff Update styling and accessibility, refine product and shopping list functionalities
Android Release Build / build-android (push) Failing after 5m51s
2026-10-16 15:49:34 +02:00
Fretux defc1fbc13 Update styling and accessibility, refine product and shopping list functionalities 2026-10-16 15:49:22 +02:00
Fretux 6938afb26c Rename app from "Shoppy" to "Shoppinguin" across codebase and documentation. Handle migration of legacy storage keys. 2026-10-16 15:40:55 +02:00
Fretux 9b80580f9c Handle error states and accessibility improvements 2026-10-16 15:09:20 +02:00
32 changed files with 1127 additions and 325 deletions
+55
View File
@@ -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
+92 -27
View File
@@ -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
}
});
+24 -5
View File
@@ -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
```
+5 -5
View File
@@ -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": {
+2 -2
View File
@@ -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
View File
@@ -1,5 +1,5 @@
{
"name": "shoppy",
"name": "shoppinguin",
"version": "1.0.0",
"private": true,
"main": "node_modules/expo/AppEntry.js",
+13 -4
View File
@@ -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
+3 -1
View File
@@ -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
},
+27 -11
View File
@@ -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
},
+5 -3
View File
@@ -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,
+10 -3
View File
@@ -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
},
+8 -7
View File
@@ -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,
+24 -11
View File
@@ -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 -4
View File
@@ -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%'
}
});
+3 -2
View File
@@ -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
}
});
+33 -13
View File
@@ -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 -4
View File
@@ -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,
+1 -1
View File
@@ -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
},
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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%'
+1 -1
View File
@@ -29,7 +29,7 @@ const styles = StyleSheet.create({
},
modal: {
backgroundColor: colors.surface,
borderRadius: 8,
borderRadius: 20,
gap: 14,
padding: 20,
width: '100%'
+1 -1
View File
@@ -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
View File
@@ -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'
}
});
+61 -22
View File
@@ -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
View File
@@ -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
}
});
+179
View File
@@ -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
View File
@@ -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'
}
});
+164 -25
View File
@@ -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'
}
});
+3 -3
View File
@@ -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.'
}
];
+29 -8
View File
@@ -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
View File
@@ -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,