385 lines
12 KiB
TypeScript
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 },
|
|
});
|