Files
HouseOrg/app/(tabs)/shopping.tsx
T
2026-06-01 23:16:10 +02:00

385 lines
12 KiB
TypeScript

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 },
});