First commit

This commit is contained in:
2026-06-01 23:16:10 +02:00
commit 1ea182f68d
56 changed files with 42848 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
import { Tabs, Redirect } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons';
import { StyleSheet } from 'react-native';
import { COLORS } from '../../src/constants';
import { useHouseholdStore } from '../../src/hooks/useHousehold';
export default function TabLayout() {
const household = useHouseholdStore((s) => s.household);
const isInitialized = useHouseholdStore((s) => s.isInitialized);
const shoppingList = useHouseholdStore((s) => s.shoppingList);
const unChecked = shoppingList.filter((e) => !e.isChecked).length;
if (isInitialized && !household) {
return <Redirect href="/onboarding" />;
}
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: COLORS.primary,
tabBarInactiveTintColor: COLORS.textSecondary,
tabBarStyle: styles.tabBar,
tabBarLabelStyle: styles.tabLabel,
headerStyle: styles.header,
headerTitleStyle: styles.headerTitle,
headerShadowVisible: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Inventar',
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="kitchen" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="shopping"
options={{
title: 'Einkauf',
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="shopping-cart" size={size} color={color} />
),
tabBarBadge: unChecked > 0 ? unChecked : undefined,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Einstellungen',
tabBarIcon: ({ color, size }) => (
<MaterialIcons name="settings" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
tabBar: {
backgroundColor: COLORS.white,
borderTopColor: COLORS.border,
borderTopWidth: StyleSheet.hairlineWidth,
elevation: 0,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: -2 },
},
tabLabel: {
fontSize: 11,
fontWeight: '600',
},
header: {
backgroundColor: COLORS.white,
},
headerTitle: {
fontWeight: '700',
fontSize: 17,
color: COLORS.text,
},
});
+484
View File
@@ -0,0 +1,484 @@
import React, { useState, useMemo } from 'react';
import {
View,
Text,
FlatList,
TextInput,
TouchableOpacity,
StyleSheet,
Modal,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons';
import { useHouseholdStore } from '../../src/hooks/useHousehold';
import { ItemCard } from '../../src/components/inventory/ItemCard';
import { updateItemQuantity } from '../../src/services/items';
import { COLORS, CATEGORY_LABELS, BASE_CATEGORIES } from '../../src/constants';
import { useCustomOptions } from '../../src/hooks/useCustomOptions';
import { isExpiringSoon, isExpired } from '../../src/utils';
import Toast from 'react-native-toast-message';
type SortKey = 'name_asc' | 'name_desc' | 'qty_asc' | 'qty_desc' | 'expiry_asc' | 'added_desc';
type MhdFilter = 'all' | 'expiring' | 'expired';
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: 'name_asc', label: 'Name AZ' },
{ key: 'name_desc', label: 'Name ZA' },
{ key: 'qty_asc', label: 'Menge ↑' },
{ key: 'qty_desc', label: 'Menge ↓' },
{ key: 'expiry_asc', label: 'MHD ↑' },
{ key: 'added_desc', label: 'Neueste' },
];
const MHD_OPTIONS: { key: MhdFilter; label: string }[] = [
{ key: 'all', label: 'Alle' },
{ key: 'expiring', label: 'Bald ablaufend' },
{ key: 'expired', label: 'Abgelaufen' },
];
export default function InventoryScreen() {
const items = useHouseholdStore((s) => s.items);
const household = useHouseholdStore((s) => s.household);
const deviceId = useHouseholdStore((s) => s.deviceId);
const [search, setSearch] = useState('');
const [filterVisible, setFilterVisible] = useState(false);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedLocations, setSelectedLocations] = useState<string[]>([]);
const [mhdFilter, setMhdFilter] = useState<MhdFilter>('all');
const [sortKey, setSortKey] = useState<SortKey>('name_asc');
const { options: categoryOptions } = useCustomOptions('houseorg_custom_categories', [...BASE_CATEGORIES]);
const availableLocations = useMemo(
() => Array.from(new Set(items.map((i) => i.storageLocation).filter(Boolean))).sort() as string[],
[items]
);
const activeFilterCount =
selectedCategories.length +
selectedLocations.length +
(mhdFilter !== 'all' ? 1 : 0) +
(sortKey !== 'name_asc' ? 1 : 0);
const filtered = useMemo(() => {
const term = search.toLowerCase();
let result = items.filter((item) => {
if (term && !item.name.toLowerCase().includes(term)) return false;
if (selectedCategories.length > 0 && !selectedCategories.includes(item.category)) return false;
if (selectedLocations.length > 0 && !selectedLocations.includes(item.storageLocation)) return false;
if (mhdFilter === 'expiring' && !isExpiringSoon(item.expiryDate)) return false;
if (mhdFilter === 'expired' && !isExpired(item.expiryDate)) return false;
return true;
});
return [...result].sort((a, b) => {
switch (sortKey) {
case 'name_asc': return a.name.localeCompare(b.name, 'de');
case 'name_desc': return b.name.localeCompare(a.name, 'de');
case 'qty_asc': return a.quantity - b.quantity;
case 'qty_desc': return b.quantity - a.quantity;
case 'expiry_asc':
if (!a.expiryDate && !b.expiryDate) return 0;
if (!a.expiryDate) return 1;
if (!b.expiryDate) return -1;
return a.expiryDate.getTime() - b.expiryDate.getTime();
case 'added_desc': return b.createdAt.getTime() - a.createdAt.getTime();
default: return 0;
}
});
}, [items, search, selectedCategories, selectedLocations, mhdFilter, sortKey]);
const toggleCategory = (cat: string) =>
setSelectedCategories((prev) =>
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
);
const toggleLocation = (loc: string) =>
setSelectedLocations((prev) =>
prev.includes(loc) ? prev.filter((l) => l !== loc) : [...prev, loc]
);
const resetFilters = () => {
setSelectedCategories([]);
setSelectedLocations([]);
setMhdFilter('all');
setSortKey('name_asc');
};
const handleQuantityChange = async (itemId: string, qty: number) => {
if (!household) return;
try {
await updateItemQuantity(household.id, itemId, qty);
} catch {
Toast.show({ type: 'error', text1: 'Fehler beim Speichern' });
}
};
const activeSummary = [
selectedCategories.length > 0 &&
`${selectedCategories.length} Kategorie${selectedCategories.length > 1 ? 'n' : ''}`,
selectedLocations.length > 0 &&
`${selectedLocations.length} Ort${selectedLocations.length > 1 ? 'e' : ''}`,
mhdFilter !== 'all' && (mhdFilter === 'expiring' ? 'Bald ablaufend' : 'Abgelaufen'),
sortKey !== 'name_asc' && SORT_OPTIONS.find((s) => s.key === sortKey)?.label,
]
.filter(Boolean)
.join(' · ');
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.searchRow}>
<View style={styles.searchBox}>
<MaterialIcons name="search" size={18} color={COLORS.textSecondary} />
<TextInput
style={styles.searchInput}
placeholder="Suchen…"
placeholderTextColor={COLORS.textSecondary}
value={search}
onChangeText={setSearch}
clearButtonMode="while-editing"
/>
</View>
<TouchableOpacity
style={[styles.filterBtn, activeFilterCount > 0 && styles.filterBtnActive]}
onPress={() => setFilterVisible(true)}
accessibilityLabel="Filter"
>
<MaterialIcons
name="tune"
size={20}
color={activeFilterCount > 0 ? COLORS.white : COLORS.text}
/>
{activeFilterCount > 0 && (
<View style={styles.filterBadge}>
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.addBtn}
onPress={() =>
router.push({
pathname: '/modals/add-item',
params: { householdId: household?.id ?? '', deviceId: deviceId ?? '' },
})
}
accessibilityLabel="Artikel hinzufügen"
>
<MaterialIcons name="add" size={24} color={COLORS.white} />
</TouchableOpacity>
</View>
{activeFilterCount > 0 && (
<View style={styles.activeFilterBar}>
<Text style={styles.activeFilterText} numberOfLines={1}>
{activeSummary}
</Text>
<TouchableOpacity onPress={resetFilters} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={styles.resetText}>Zurücksetzen</Text>
</TouchableOpacity>
</View>
)}
<FlatList
data={filtered}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ItemCard
item={item}
onPress={() =>
router.push({ pathname: '/modals/item-detail', params: { itemId: item.id } })
}
onQuantityChange={(qty) => handleQuantityChange(item.id, qty)}
/>
)}
contentContainerStyle={[styles.list, filtered.length === 0 && styles.listEmpty]}
ListEmptyComponent={
<View style={styles.empty}>
<View style={styles.emptyIconWrap}>
<MaterialIcons
name={search || activeFilterCount > 0 ? 'search-off' : 'kitchen'}
size={36}
color={COLORS.primaryLight}
/>
</View>
<Text style={styles.emptyTitle}>
{search || activeFilterCount > 0 ? 'Keine Treffer' : 'Noch leer'}
</Text>
<Text style={styles.emptyText}>
{search || activeFilterCount > 0
? 'Filter oder Suche anpassen.'
: 'Tippe auf + und fotografiere deinen ersten Artikel.'}
</Text>
</View>
}
/>
<Modal visible={filterVisible} transparent animationType="slide">
<View style={styles.modalContainer}>
<TouchableOpacity style={styles.modalOverlay} onPress={() => setFilterVisible(false)} />
<View style={styles.filterSheet}>
<View style={styles.filterHandle} />
<View style={styles.filterHeader}>
<Text style={styles.filterTitle}>Filter & Sortierung</Text>
<TouchableOpacity onPress={resetFilters}>
<Text style={styles.resetText}>Zurücksetzen</Text>
</TouchableOpacity>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.filterScrollContent}
>
<FilterSection title="Sortierung">
<View style={styles.chipWrap}>
{SORT_OPTIONS.map(({ key, label }) => (
<TouchableOpacity
key={key}
style={[styles.chip, sortKey === key && styles.chipActive]}
onPress={() => setSortKey(key)}
>
<Text style={[styles.chipText, sortKey === key && styles.chipTextActive]}>
{label}
</Text>
</TouchableOpacity>
))}
</View>
</FilterSection>
<FilterSection title="Kategorie">
<View style={styles.chipWrap}>
{categoryOptions.map((cat) => (
<TouchableOpacity
key={cat}
style={[styles.chip, selectedCategories.includes(cat) && styles.chipActive]}
onPress={() => toggleCategory(cat)}
>
<Text
style={[
styles.chipText,
selectedCategories.includes(cat) && styles.chipTextActive,
]}
>
{CATEGORY_LABELS[cat] ?? cat}
</Text>
</TouchableOpacity>
))}
</View>
</FilterSection>
{availableLocations.length > 0 && (
<FilterSection title="Lagerort">
<View style={styles.chipWrap}>
{availableLocations.map((loc) => (
<TouchableOpacity
key={loc}
style={[styles.chip, selectedLocations.includes(loc) && styles.chipActive]}
onPress={() => toggleLocation(loc)}
>
<Text
style={[
styles.chipText,
selectedLocations.includes(loc) && styles.chipTextActive,
]}
>
{loc}
</Text>
</TouchableOpacity>
))}
</View>
</FilterSection>
)}
<FilterSection title="MHD">
<View style={styles.chipWrap}>
{MHD_OPTIONS.map(({ key, label }) => (
<TouchableOpacity
key={key}
style={[styles.chip, mhdFilter === key && styles.chipActive]}
onPress={() => setMhdFilter(key)}
>
<Text style={[styles.chipText, mhdFilter === key && styles.chipTextActive]}>
{label}
</Text>
</TouchableOpacity>
))}
</View>
</FilterSection>
</ScrollView>
<TouchableOpacity style={styles.applyBtn} onPress={() => setFilterVisible(false)}>
<Text style={styles.applyBtnText}>
{filtered.length === 0
? 'Keine Treffer'
: `${filtered.length} Artikel anzeigen`}
</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
function FilterSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.filterSection}>
<Text style={styles.filterSectionTitle}>{title}</Text>
{children}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.surface },
searchRow: {
flexDirection: 'row',
gap: 10,
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 8,
},
searchBox: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: COLORS.white,
borderRadius: 12,
paddingHorizontal: 12,
gap: 8,
height: 46,
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 4,
elevation: 1,
},
searchInput: { flex: 1, fontSize: 15, color: COLORS.text },
filterBtn: {
width: 46,
height: 46,
borderRadius: 14,
backgroundColor: COLORS.white,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOpacity: 0.04,
shadowRadius: 4,
elevation: 1,
},
filterBtnActive: { backgroundColor: COLORS.primary },
filterBadge: {
position: 'absolute',
top: -4,
right: -4,
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: COLORS.danger,
justifyContent: 'center',
alignItems: 'center',
},
filterBadgeText: { fontSize: 10, fontWeight: '700', color: COLORS.white },
addBtn: {
width: 46,
height: 46,
borderRadius: 14,
backgroundColor: COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
shadowColor: COLORS.primary,
shadowOpacity: 0.35,
shadowRadius: 8,
shadowOffset: { width: 0, height: 3 },
elevation: 4,
},
activeFilterBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingBottom: 6,
gap: 8,
},
activeFilterText: { fontSize: 12, color: COLORS.textSecondary, flex: 1 },
resetText: { fontSize: 12, color: COLORS.primary, fontWeight: '600' },
list: { paddingVertical: 8, paddingBottom: 32 },
listEmpty: { flex: 1 },
empty: { flex: 1, paddingTop: 80, alignItems: 'center', gap: 10, paddingHorizontal: 32 },
emptyIconWrap: {
width: 72,
height: 72,
borderRadius: 24,
backgroundColor: '#E8F5E9',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 4,
},
emptyTitle: { fontSize: 17, fontWeight: '600', color: COLORS.text },
emptyText: { color: COLORS.textSecondary, textAlign: 'center', fontSize: 14, lineHeight: 20 },
// Modal
modalContainer: { flex: 1, justifyContent: 'flex-end' },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)' },
filterSheet: {
backgroundColor: COLORS.white,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '78%',
paddingTop: 12,
},
filterHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: COLORS.border,
alignSelf: 'center',
marginBottom: 16,
},
filterHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
marginBottom: 4,
},
filterTitle: { fontSize: 17, fontWeight: '700', color: COLORS.text },
filterScrollContent: { paddingHorizontal: 20, paddingBottom: 8 },
filterSection: { marginTop: 22 },
filterSectionTitle: {
fontSize: 11,
fontWeight: '600',
color: COLORS.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.7,
marginBottom: 10,
},
chipWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: COLORS.surface,
borderWidth: 1,
borderColor: COLORS.border,
},
chipActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
chipText: { fontSize: 13, color: COLORS.text, fontWeight: '500' },
chipTextActive: { color: COLORS.white, fontWeight: '600' },
applyBtn: {
margin: 16,
marginTop: 12,
backgroundColor: COLORS.primary,
borderRadius: 14,
paddingVertical: 16,
alignItems: 'center',
shadowColor: COLORS.primary,
shadowOpacity: 0.28,
shadowRadius: 8,
shadowOffset: { width: 0, height: 3 },
elevation: 4,
},
applyBtnText: { color: COLORS.white, fontSize: 16, fontWeight: '600' },
});
+210
View File
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
Switch,
StyleSheet,
Alert,
Share,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
import { kv } from '../../src/lib/kv';
import { useHouseholdStore } from '../../src/hooks/useHousehold';
import { regenerateInviteToken, buildInviteLink } from '../../src/services/household';
import { updateFcmToken, requestNotificationPermission } from '../../src/services/notifications';
import { COLORS } from '../../src/constants';
import Toast from 'react-native-toast-message';
const NOTIF_KEY = 'houseorg_notifications_enabled';
export default function SettingsScreen() {
const household = useHouseholdStore((s) => s.household);
const setHousehold = useHouseholdStore((s) => s.setHousehold);
const [notificationsEnabled, setNotificationsEnabled] = useState<boolean | null>(null);
useEffect(() => {
kv.getItem(NOTIF_KEY).then((val) => {
setNotificationsEnabled(val !== 'false');
});
}, []);
const handleShareInvite = async () => {
if (!household) return;
const link = buildInviteLink(household.id, household.inviteToken);
const text = `Tritt unserem Haushalt bei: ${link}`;
if (typeof navigator !== 'undefined' && (navigator as any).share) {
await (navigator as any).share({ text, title: 'HouseOrg Einladung' });
} else if (typeof navigator !== 'undefined' && (navigator as any).clipboard) {
await (navigator as any).clipboard.writeText(text);
Toast.show({ type: 'success', text1: 'Link kopiert' });
} else {
await Share.share({ message: text, title: 'HouseOrg Einladung' });
}
};
const handleRegenerateLink = async () => {
if (!household) return;
Alert.alert(
'Link erneuern',
'Der alte Link wird ungültig. Alle Mitglieder können sich damit nicht mehr neu anmelden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Erneuern',
style: 'destructive',
onPress: async () => {
try {
const newToken = await regenerateInviteToken(household.id);
setHousehold({ ...household, inviteToken: newToken });
Toast.show({ type: 'success', text1: 'Neuer Einladungslink erstellt' });
} catch {
Toast.show({ type: 'error', text1: 'Fehler beim Erneuern' });
}
},
},
]
);
};
const handleNotificationToggle = async (enabled: boolean) => {
if (!household) return;
if (enabled) {
const granted = await requestNotificationPermission();
if (!granted) {
Toast.show({ type: 'error', text1: 'Benachrichtigungen nicht erlaubt' });
return;
}
}
setNotificationsEnabled(enabled);
await kv.setItem(NOTIF_KEY, String(enabled));
await updateFcmToken(household.id, enabled);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<ScrollView showsVerticalScrollIndicator={false}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Haushalt</Text>
<View style={styles.card}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: '#E8F5E9' }]}>
<MaterialIcons name="home" size={18} color={COLORS.primary} />
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Name</Text>
<Text style={styles.rowValue}>{household?.name}</Text>
</View>
</View>
<View style={styles.divider} />
<TouchableOpacity style={styles.row} onPress={handleShareInvite}>
<View style={[styles.iconWrap, { backgroundColor: '#E3F2FD' }]}>
<MaterialIcons name="share" size={18} color="#1E88E5" />
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Einladungslink teilen</Text>
<Text style={styles.rowHint}>Mitglieder einladen</Text>
</View>
<MaterialIcons name="chevron-right" size={20} color={COLORS.textSecondary} />
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.row} onPress={handleRegenerateLink}>
<View style={[styles.iconWrap, { backgroundColor: '#FFF3E0' }]}>
<MaterialIcons name="refresh" size={18} color={COLORS.warning} />
</View>
<View style={styles.rowContent}>
<Text style={[styles.rowLabel, { color: COLORS.warning }]}>
Einladungslink erneuern
</Text>
<Text style={styles.rowHint}>Alter Link wird ungültig</Text>
</View>
</TouchableOpacity>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Benachrichtigungen</Text>
<View style={styles.card}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: '#F3E5F5' }]}>
<MaterialIcons name="notifications" size={18} color="#8E24AA" />
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Push-Benachrichtigungen</Text>
<Text style={styles.rowHint}>Einkaufsliste & MHD-Warnungen</Text>
</View>
<Switch
value={notificationsEnabled ?? false}
disabled={notificationsEnabled === null}
onValueChange={handleNotificationToggle}
trackColor={{ false: COLORS.border, true: COLORS.primaryLight }}
thumbColor={notificationsEnabled ? COLORS.primary : COLORS.white}
/>
</View>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>App</Text>
<View style={styles.card}>
<View style={styles.row}>
<View style={[styles.iconWrap, { backgroundColor: COLORS.surface }]}>
<MaterialIcons name="info-outline" size={18} color={COLORS.textSecondary} />
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Version</Text>
<Text style={styles.rowValue}>1.0.0</Text>
</View>
</View>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.surface },
section: { marginTop: 28, paddingHorizontal: 16 },
sectionTitle: {
fontSize: 12,
fontWeight: '600',
color: COLORS.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.6,
marginBottom: 10,
},
card: {
backgroundColor: COLORS.white,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 1,
},
row: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
gap: 12,
},
iconWrap: {
width: 34,
height: 34,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
rowContent: { flex: 1 },
rowLabel: { fontSize: 15, fontWeight: '500', color: COLORS.text },
rowValue: { fontSize: 13, color: COLORS.textSecondary, marginTop: 2 },
rowHint: { fontSize: 12, color: COLORS.textSecondary, marginTop: 1 },
divider: { height: StyleSheet.hairlineWidth, backgroundColor: COLORS.border, marginLeft: 60 },
});
+384
View File
@@ -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 },
});