First commit
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useHouseholdStore } from '../../src/hooks/useHousehold';
|
||||
import { ShoppingItem } from '../../src/components/shopping/ShoppingItem';
|
||||
import {
|
||||
addToShoppingList,
|
||||
checkOffItem,
|
||||
removeShoppingEntry,
|
||||
clearCheckedItems,
|
||||
} from '../../src/services/shopping';
|
||||
import { COLORS } from '../../src/constants';
|
||||
import { ShoppingListEntry } from '../../src/types';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
type ListRow =
|
||||
| { type: 'section'; id: string; title: string; count: number; clearable?: boolean }
|
||||
| { type: 'item'; id: string; entry: ShoppingListEntry };
|
||||
|
||||
export default function ShoppingScreen() {
|
||||
const shoppingList = useHouseholdStore((s) => s.shoppingList);
|
||||
const household = useHouseholdStore((s) => s.household);
|
||||
const deviceId = useHouseholdStore((s) => s.deviceId);
|
||||
const [manualItem, setManualItem] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [checkoffEntry, setCheckoffEntry] = useState<ShoppingListEntry | null>(null);
|
||||
const [quantityBought, setQuantityBought] = useState('1');
|
||||
|
||||
const listData = useMemo<ListRow[]>(() => {
|
||||
const term = search.toLowerCase().trim();
|
||||
const matches = (e: ShoppingListEntry) => !term || e.name.toLowerCase().includes(term);
|
||||
|
||||
const pending = shoppingList.filter((e) => !e.isChecked && matches(e));
|
||||
const checked = shoppingList.filter((e) => e.isChecked && matches(e));
|
||||
|
||||
const rows: ListRow[] = [];
|
||||
if (pending.length > 0) {
|
||||
rows.push({ type: 'section', id: 'sec-pending', title: 'Zu kaufen', count: pending.length });
|
||||
pending.forEach((e) => rows.push({ type: 'item', id: e.id, entry: e }));
|
||||
}
|
||||
if (checked.length > 0) {
|
||||
rows.push({ type: 'section', id: 'sec-checked', title: 'Im Wagen', count: checked.length, clearable: true });
|
||||
checked.forEach((e) => rows.push({ type: 'item', id: e.id, entry: e }));
|
||||
}
|
||||
return rows;
|
||||
}, [shoppingList, search]);
|
||||
|
||||
const handleAddManual = async () => {
|
||||
if (!household || !manualItem.trim()) return;
|
||||
await addToShoppingList(household.id, {
|
||||
itemId: null,
|
||||
name: manualItem.trim(),
|
||||
suggestedQuantity: 1,
|
||||
unit: 'Stück',
|
||||
autoAdded: false,
|
||||
});
|
||||
setManualItem('');
|
||||
};
|
||||
|
||||
const handleCheckOff = (entry: ShoppingListEntry) => {
|
||||
setCheckoffEntry(entry);
|
||||
setQuantityBought(String(entry.suggestedQuantity));
|
||||
};
|
||||
|
||||
const confirmCheckOff = async () => {
|
||||
if (!household || !checkoffEntry || !deviceId) return;
|
||||
const qty = parseFloat(quantityBought.replace(',', '.'));
|
||||
if (isNaN(qty) || qty < 0) {
|
||||
Toast.show({ type: 'error', text1: 'Ungültige Menge' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await checkOffItem(household.id, checkoffEntry.id, deviceId, qty);
|
||||
} catch {
|
||||
Toast.show({ type: 'error', text1: 'Fehler beim Abhaken' });
|
||||
}
|
||||
setCheckoffEntry(null);
|
||||
};
|
||||
|
||||
const handleRemove = async (entryId: string) => {
|
||||
if (!household) return;
|
||||
await removeShoppingEntry(household.id, entryId);
|
||||
};
|
||||
|
||||
const handleClearChecked = async () => {
|
||||
if (!household) return;
|
||||
try {
|
||||
await clearCheckedItems(household.id);
|
||||
} catch {
|
||||
Toast.show({ type: 'error', text1: 'Fehler beim Löschen' });
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: ListRow }) => {
|
||||
if (item.type === 'section') {
|
||||
return (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{item.title}</Text>
|
||||
<View style={styles.sectionBadge}>
|
||||
<Text style={styles.sectionBadgeText}>{item.count}</Text>
|
||||
</View>
|
||||
{item.clearable && (
|
||||
<TouchableOpacity onPress={handleClearChecked} style={styles.clearBtn}>
|
||||
<Text style={styles.clearBtnText}>Alle löschen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ShoppingItem
|
||||
entry={item.entry}
|
||||
onCheckOff={() => handleCheckOff(item.entry)}
|
||||
onRemove={() => handleRemove(item.entry.id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.addRow}>
|
||||
<View style={styles.addInputWrapper}>
|
||||
<MaterialIcons name="add-shopping-cart" size={18} color={COLORS.textSecondary} />
|
||||
<TextInput
|
||||
style={styles.addInput}
|
||||
placeholder="Artikel hinzufügen…"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
value={manualItem}
|
||||
onChangeText={setManualItem}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleAddManual}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.addBtn, !manualItem.trim() && styles.addBtnDisabled]}
|
||||
onPress={handleAddManual}
|
||||
disabled={!manualItem.trim()}
|
||||
>
|
||||
<MaterialIcons name="add" size={22} color={COLORS.white} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{shoppingList.length > 0 && (
|
||||
<View style={styles.searchRow}>
|
||||
<MaterialIcons name="search" size={18} color={COLORS.textSecondary} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder="Suchen…"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
{search.length > 0 && Platform.OS === 'android' && (
|
||||
<TouchableOpacity onPress={() => setSearch('')} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<MaterialIcons name="close" size={16} color={COLORS.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={listData}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={[styles.list, listData.length === 0 && styles.listEmpty]}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.empty}>
|
||||
<View style={styles.emptyIcon}>
|
||||
<MaterialIcons
|
||||
name={search ? 'search-off' : 'shopping-cart'}
|
||||
size={32}
|
||||
color={COLORS.primaryLight}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>
|
||||
{search ? 'Keine Treffer' : 'Alles erledigt!'}
|
||||
</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
{search
|
||||
? `Kein Artikel mit „${search}" gefunden.`
|
||||
: 'Die Einkaufsliste ist leer.'}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal visible={!!checkoffEntry} transparent animationType="slide">
|
||||
<KeyboardAvoidingView
|
||||
style={styles.overlay}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View style={styles.dialog}>
|
||||
<View style={styles.dialogHandle} />
|
||||
<Text style={styles.dialogTitle}>Wie viele wurden gekauft?</Text>
|
||||
<Text style={styles.dialogItem}>{checkoffEntry?.name}</Text>
|
||||
<TextInput
|
||||
style={styles.dialogInput}
|
||||
value={quantityBought}
|
||||
onChangeText={setQuantityBought}
|
||||
keyboardType="numeric"
|
||||
selectTextOnFocus
|
||||
autoFocus
|
||||
/>
|
||||
<Text style={styles.dialogUnit}>{checkoffEntry?.unit}</Text>
|
||||
<View style={styles.dialogActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.dialogBtnCancel}
|
||||
onPress={() => setCheckoffEntry(null)}
|
||||
>
|
||||
<Text style={styles.dialogBtnCancelText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.dialogBtnConfirm} onPress={confirmCheckOff}>
|
||||
<Text style={styles.dialogBtnConfirmText}>Bestätigen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: COLORS.surface },
|
||||
addRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: COLORS.white,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
addInputWrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
gap: 8,
|
||||
height: 46,
|
||||
},
|
||||
addInput: { flex: 1, fontSize: 15, color: COLORS.text },
|
||||
addBtn: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 14,
|
||||
backgroundColor: COLORS.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
addBtnDisabled: { opacity: 0.4 },
|
||||
searchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: COLORS.white,
|
||||
borderRadius: 12,
|
||||
marginHorizontal: 16,
|
||||
marginTop: 10,
|
||||
marginBottom: 2,
|
||||
paddingHorizontal: 12,
|
||||
height: 42,
|
||||
gap: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 4,
|
||||
elevation: 1,
|
||||
},
|
||||
searchInput: { flex: 1, fontSize: 15, color: COLORS.text },
|
||||
list: { paddingVertical: 8, paddingBottom: 32 },
|
||||
listEmpty: { flex: 1 },
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: COLORS.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
sectionBadge: {
|
||||
backgroundColor: COLORS.border,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
sectionBadgeText: { fontSize: 12, fontWeight: '600', color: COLORS.textSecondary },
|
||||
clearBtn: { marginLeft: 'auto' },
|
||||
clearBtnText: { fontSize: 12, fontWeight: '600', color: COLORS.danger },
|
||||
empty: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 48,
|
||||
gap: 10,
|
||||
},
|
||||
emptyIcon: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: '#E8F5E9',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyTitle: { fontSize: 17, fontWeight: '600', color: COLORS.text },
|
||||
emptyText: { fontSize: 14, color: COLORS.textSecondary, textAlign: 'center' },
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
dialog: {
|
||||
backgroundColor: COLORS.white,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
padding: 24,
|
||||
paddingBottom: 40,
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
dialogHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: COLORS.border,
|
||||
marginBottom: 6,
|
||||
},
|
||||
dialogTitle: { fontSize: 18, fontWeight: '700', color: COLORS.text, textAlign: 'center' },
|
||||
dialogItem: { fontSize: 15, color: COLORS.textSecondary },
|
||||
dialogInput: {
|
||||
borderWidth: 2,
|
||||
borderColor: COLORS.primary,
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
fontSize: 34,
|
||||
fontWeight: '700',
|
||||
width: 140,
|
||||
textAlign: 'center',
|
||||
color: COLORS.text,
|
||||
marginTop: 4,
|
||||
},
|
||||
dialogUnit: { fontSize: 14, color: COLORS.textSecondary, marginTop: -2 },
|
||||
dialogActions: { flexDirection: 'row', gap: 12, marginTop: 8, width: '100%' },
|
||||
dialogBtnCancel: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
},
|
||||
dialogBtnConfirm: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.primary,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
dialogBtnCancelText: { color: COLORS.text, fontWeight: '600', fontSize: 15 },
|
||||
dialogBtnConfirmText: { color: COLORS.white, fontWeight: '600', fontSize: 15 },
|
||||
});
|
||||
Reference in New Issue
Block a user