First commit
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 A–Z' },
|
||||
{ key: 'name_desc', label: 'Name Z–A' },
|
||||
{ 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' },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
import React from 'react';
|
||||
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<meta name="theme-color" content="#2D6A4F" />
|
||||
<meta name="description" content="Gemeinsam den Haushalt organisieren" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="HouseOrg" />
|
||||
<link rel="apple-touch-icon" href="/assets/icon.png" />
|
||||
<ScrollViewStyleReset />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import 'react-native-get-random-values';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import { useHouseholdStore, useRealtimeSync } from '../src/hooks/useHousehold';
|
||||
import { useNetworkSync } from '../src/hooks/useNetworkSync';
|
||||
import { getOrCreateDeviceId, getStoredHouseholdId, getHousehold, registerMember } from '../src/services/household';
|
||||
import { getFcmToken } from '../src/services/notifications';
|
||||
import { COLORS } from '../src/constants';
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('timeout')), ms)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
function AppInit() {
|
||||
const setHousehold = useHouseholdStore((s) => s.setHousehold);
|
||||
const setDeviceId = useHouseholdStore((s) => s.setDeviceId);
|
||||
const setInitialized = useHouseholdStore((s) => s.setInitialized);
|
||||
const [hydrated, setHydrated] = useState(() => useHouseholdStore.persist.hasHydrated());
|
||||
|
||||
useRealtimeSync();
|
||||
useNetworkSync();
|
||||
|
||||
// Step 1: wait for Zustand AsyncStorage hydration
|
||||
useEffect(() => {
|
||||
if (hydrated) return;
|
||||
console.log('[AppInit] waiting for store hydration...');
|
||||
const unsub = useHouseholdStore.persist.onFinishHydration(() => {
|
||||
console.log('[AppInit] store hydrated');
|
||||
setHydrated(true);
|
||||
});
|
||||
return unsub;
|
||||
}, [hydrated]);
|
||||
|
||||
// Step 2: run init logic after hydration is confirmed
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
|
||||
console.log('[AppInit] init start');
|
||||
(async () => {
|
||||
try {
|
||||
const deviceId = await getOrCreateDeviceId();
|
||||
console.log('[AppInit] deviceId:', deviceId.slice(0, 8));
|
||||
setDeviceId(deviceId);
|
||||
|
||||
const alreadyHasHousehold = useHouseholdStore.getState().household != null;
|
||||
console.log('[AppInit] alreadyHasHousehold:', alreadyHasHousehold);
|
||||
|
||||
if (!alreadyHasHousehold) {
|
||||
const householdId = await getStoredHouseholdId();
|
||||
console.log('[AppInit] storedHouseholdId:', householdId);
|
||||
|
||||
if (householdId) {
|
||||
try {
|
||||
console.log('[AppInit] fetching household from PocketBase...');
|
||||
const household = await withTimeout(getHousehold(householdId), 5000);
|
||||
if (household) {
|
||||
console.log('[AppInit] household loaded:', household.name);
|
||||
setHousehold(household);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[AppInit] getHousehold failed (offline/timeout):', String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AppInit] unexpected error:', e);
|
||||
} finally {
|
||||
console.log('[AppInit] setInitialized()');
|
||||
setInitialized();
|
||||
const h = useHouseholdStore.getState().household;
|
||||
if (h) {
|
||||
getFcmToken()
|
||||
.catch(() => null)
|
||||
.then((token) => registerMember(h.id, token).catch(() => {}));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [hydrated]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const isInitialized = useHouseholdStore((s) => s.isInitialized);
|
||||
const setInitialized = useHouseholdStore((s) => s.setInitialized);
|
||||
|
||||
// Absolute safety net: force init after 8s regardless of what happened above
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!useHouseholdStore.getState().isInitialized) {
|
||||
console.warn('[RootLayout] safety timeout fired — forcing init');
|
||||
setInitialized();
|
||||
}
|
||||
}, 8000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<AppInit />
|
||||
{!isInitialized ? (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: COLORS.white }}>
|
||||
<ActivityIndicator size="large" color={COLORS.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Stack>
|
||||
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="modals/add-item"
|
||||
options={{ presentation: 'modal', title: 'Artikel hinzufügen' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="modals/item-detail"
|
||||
options={{ presentation: 'modal', title: 'Artikel' }}
|
||||
/>
|
||||
</Stack>
|
||||
<Toast />
|
||||
</>
|
||||
)}
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react';
|
||||
import { View, ActivityIndicator, Alert } from 'react-native';
|
||||
import { useLocalSearchParams, router } from 'expo-router';
|
||||
import { joinHousehold, getHousehold, registerMember } from '../src/services/household';
|
||||
import { useHouseholdStore } from '../src/hooks/useHousehold';
|
||||
import { getFcmToken } from '../src/services/notifications';
|
||||
import { COLORS } from '../src/constants';
|
||||
|
||||
export default function JoinScreen() {
|
||||
const { householdId, token } = useLocalSearchParams<{ householdId: string; token: string }>();
|
||||
const setHousehold = useHouseholdStore((s) => s.setHousehold);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!householdId || !token) {
|
||||
Alert.alert('Ungültiger Link');
|
||||
router.replace('/onboarding');
|
||||
return;
|
||||
}
|
||||
const success = await joinHousehold(householdId, token);
|
||||
if (!success) {
|
||||
Alert.alert('Ungültiger oder abgelaufener Einladungslink.');
|
||||
router.replace('/onboarding');
|
||||
return;
|
||||
}
|
||||
const household = await getHousehold(householdId);
|
||||
if (!household) {
|
||||
Alert.alert('Haushalt nicht gefunden.');
|
||||
router.replace('/onboarding');
|
||||
return;
|
||||
}
|
||||
const fcmToken = await getFcmToken();
|
||||
await registerMember(householdId, fcmToken);
|
||||
setHousehold(household);
|
||||
router.replace('/(tabs)');
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: COLORS.white }}>
|
||||
<ActivityIndicator size="large" color={COLORS.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { DatePickerModal } from '../../src/components/ui/DatePicker';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { CameraView, useCameraPermissions, BarcodeScanningResult } from 'expo-camera';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { createItem } from '../../src/services/items';
|
||||
import { recognizeItemFromPhoto } from '../../src/services/ai';
|
||||
import { lookupBarcode } from '../../src/services/barcode';
|
||||
import { getStoredHouseholdId, getOrCreateDeviceId } from '../../src/services/household';
|
||||
import { useHouseholdStore } from '../../src/hooks/useHousehold';
|
||||
import { COLORS, CATEGORY_LABELS, BASE_CATEGORIES, STORAGE_LOCATIONS } from '../../src/constants';
|
||||
import { ChipSelectInput } from '../../src/components/ui/ChipSelectInput';
|
||||
import { useCustomOptions } from '../../src/hooks/useCustomOptions';
|
||||
import { ItemCategory } from '../../src/types';
|
||||
|
||||
type Step = 'camera' | 'form';
|
||||
|
||||
export default function AddItemModal() {
|
||||
const { householdId: paramHouseholdId, deviceId: paramDeviceId } = useLocalSearchParams<{ householdId: string; deviceId: string }>();
|
||||
const householdFromStore = useHouseholdStore((s) => s.household);
|
||||
const deviceIdFromStore = useHouseholdStore((s) => s.deviceId);
|
||||
|
||||
const [householdId, setHouseholdId] = useState(paramHouseholdId || householdFromStore?.id || '');
|
||||
const [deviceId, setDeviceId] = useState(paramDeviceId || deviceIdFromStore || '');
|
||||
|
||||
useEffect(() => {
|
||||
const resolve = async () => {
|
||||
if (!householdId) {
|
||||
const stored = await getStoredHouseholdId();
|
||||
if (stored) setHouseholdId(stored);
|
||||
}
|
||||
if (!deviceId) {
|
||||
const stored = await getOrCreateDeviceId();
|
||||
if (stored) setDeviceId(stored);
|
||||
}
|
||||
};
|
||||
resolve();
|
||||
}, []);
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
|
||||
const [step, setStep] = useState<Step>('camera');
|
||||
const [mode, setMode] = useState<'photo' | 'barcode'>('photo');
|
||||
const [recognizing, setRecognizing] = useState(false);
|
||||
const recognizingRef = useRef(false);
|
||||
const [photoUri, setPhotoUri] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
|
||||
// Form fields
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState<ItemCategory>('food');
|
||||
const [quantity, setQuantity] = useState('1');
|
||||
const [unit, setUnit] = useState<string>('Stück');
|
||||
const [minStock, setMinStock] = useState('0');
|
||||
const [storageLocation, setStorageLocation] = useState('');
|
||||
const [shoppingLocation, setShoppingLocation] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [barcode, setBarcode] = useState<string | null>(null);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
|
||||
const { options: locationOptions, customOptions: customLocations, addOption: addLocation, removeOption: removeLocation } = useCustomOptions(
|
||||
'houseorg_custom_locations',
|
||||
STORAGE_LOCATIONS
|
||||
);
|
||||
const { options: categoryOptions, customOptions: customCategories, addOption: addCategory, removeOption: removeCategory } = useCustomOptions(
|
||||
'houseorg_custom_categories',
|
||||
[...BASE_CATEGORIES]
|
||||
);
|
||||
|
||||
const takePhoto = async () => {
|
||||
if (!cameraRef.current) return;
|
||||
setRecognizing(true);
|
||||
try {
|
||||
const photo = await cameraRef.current.takePictureAsync({ quality: 0.8, base64: false });
|
||||
if (!photo) return;
|
||||
setPhotoUri(photo.uri);
|
||||
|
||||
const result = await recognizeItemFromPhoto(photo.uri);
|
||||
setName(result.name);
|
||||
setDescription(result.description);
|
||||
setCategory(result.category);
|
||||
setStep('form');
|
||||
} catch {
|
||||
Alert.alert('KI-Erkennung fehlgeschlagen', 'Bitte manuell ausfüllen.');
|
||||
setStep('form');
|
||||
} finally {
|
||||
setRecognizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onBarcodeScanned = async (result: BarcodeScanningResult) => {
|
||||
if (recognizingRef.current) return;
|
||||
recognizingRef.current = true;
|
||||
setRecognizing(true);
|
||||
setBarcode(result.data);
|
||||
try {
|
||||
const product = await lookupBarcode(result.data);
|
||||
if (product) {
|
||||
setName(product.name);
|
||||
setDescription(product.description);
|
||||
setCategory(product.category);
|
||||
if (product.imageUrl) setPhotoUri(product.imageUrl);
|
||||
} else {
|
||||
Alert.alert('Barcode nicht gefunden', 'Bitte manuell ausfüllen.');
|
||||
}
|
||||
} catch (e: any) {
|
||||
Alert.alert('Barcode-Abfrage fehlgeschlagen', e?.message ?? 'Netzwerkfehler');
|
||||
} finally {
|
||||
recognizingRef.current = false;
|
||||
setRecognizing(false);
|
||||
setStep('form');
|
||||
}
|
||||
};
|
||||
|
||||
const pickImage = () => {
|
||||
Alert.alert('Foto hinzufügen', '', [
|
||||
{
|
||||
text: 'Kamera',
|
||||
onPress: async () => {
|
||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.8, allowsEditing: true, aspect: [4, 3] });
|
||||
if (!result.canceled) setPhotoUri(result.assets[0].uri);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Galerie',
|
||||
onPress: async () => {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ quality: 0.8, allowsEditing: true, aspect: [4, 3] });
|
||||
if (!result.canceled) setPhotoUri(result.assets[0].uri);
|
||||
},
|
||||
},
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!householdId) {
|
||||
Alert.alert('Fehler', 'Kein Haushalt gefunden. Bitte App neu starten.');
|
||||
return;
|
||||
}
|
||||
if (!deviceId) {
|
||||
Alert.alert('Fehler', 'Geräte-ID fehlt. Bitte App neu starten.');
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Bitte gib einen Namen ein.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
console.log('Saving item to household:', householdId);
|
||||
await createItem(householdId, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
category,
|
||||
quantity: parseFloat(quantity) || 0,
|
||||
unit,
|
||||
minStockThreshold: parseFloat(minStock) || 0,
|
||||
photoUrl: null,
|
||||
storageLocation: storageLocation.trim(),
|
||||
shoppingLocation: shoppingLocation.trim(),
|
||||
price: price ? parseFloat(price) : null,
|
||||
expiryDate,
|
||||
barcode,
|
||||
addedByDevice: deviceId,
|
||||
photoUri: photoUri && !photoUri.startsWith('http') ? photoUri : undefined,
|
||||
});
|
||||
console.log('Item saved successfully');
|
||||
router.back();
|
||||
} catch (e: any) {
|
||||
console.error('createItem error:', e);
|
||||
Alert.alert('Speichern fehlgeschlagen', e?.message ?? String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!permission) return null;
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View style={styles.permissionContainer}>
|
||||
<MaterialIcons name="camera-alt" size={64} color={COLORS.primaryLight} />
|
||||
<Text style={styles.permissionText}>Kamera-Zugriff benötigt</Text>
|
||||
<TouchableOpacity style={styles.btn} onPress={requestPermission}>
|
||||
<Text style={styles.btnText}>Zugriff erlauben</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'camera') {
|
||||
return (
|
||||
<View style={styles.cameraContainer}>
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
barcodeScannerSettings={mode === 'barcode' ? { barcodeTypes: ['ean13', 'ean8', 'qr', 'upc_e', 'upc_a', 'code128'] } : undefined}
|
||||
onBarcodeScanned={mode === 'barcode' ? onBarcodeScanned : undefined}
|
||||
/>
|
||||
|
||||
{recognizing && (
|
||||
<View style={styles.overlay}>
|
||||
<ActivityIndicator size="large" color={COLORS.white} />
|
||||
<Text style={styles.overlayText}>
|
||||
{mode === 'photo' ? 'Artikel wird erkannt…' : 'Barcode wird gesucht…'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.cameraTop}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.cameraTopBtn}>
|
||||
<MaterialIcons name="close" size={24} color={COLORS.white} />
|
||||
</TouchableOpacity>
|
||||
{Platform.OS !== 'web' && (
|
||||
<View style={styles.modeToggle}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modeBtn, mode === 'photo' && styles.modeBtnActive]}
|
||||
onPress={() => setMode('photo')}
|
||||
>
|
||||
<Text style={styles.modeBtnText}>Foto</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modeBtn, mode === 'barcode' && styles.modeBtnActive]}
|
||||
onPress={() => setMode('barcode')}
|
||||
>
|
||||
<Text style={styles.modeBtnText}>Barcode</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={() => { setStep('form'); }}
|
||||
style={styles.cameraTopBtn}
|
||||
>
|
||||
<Text style={{ color: COLORS.white, fontSize: 13 }}>Manuell</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{mode === 'photo' && !recognizing && (
|
||||
<View style={styles.cameraBottom}>
|
||||
<TouchableOpacity style={styles.shutter} onPress={takePhoto}>
|
||||
<View style={styles.shutterInner} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{mode === 'barcode' && (
|
||||
<View style={styles.barcodeHint}>
|
||||
<Text style={styles.barcodeHintText}>Barcode in den Rahmen halten</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.form} contentContainerStyle={styles.formContent} keyboardShouldPersistTaps="handled">
|
||||
|
||||
{/* Foto-Sektion */}
|
||||
<TouchableOpacity onPress={pickImage} style={styles.photoSection} activeOpacity={0.8}>
|
||||
{photoUri ? (
|
||||
<>
|
||||
<Image source={{ uri: photoUri }} style={styles.preview} />
|
||||
<View style={styles.photoOverlay}>
|
||||
<MaterialIcons name="photo-camera" size={18} color={COLORS.white} />
|
||||
<Text style={styles.photoOverlayText}>Foto ändern</Text>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.photoPlaceholder}>
|
||||
<MaterialIcons name="add-a-photo" size={32} color={COLORS.primaryLight} />
|
||||
<Text style={styles.photoPlaceholderText}>Foto hinzufügen</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<Field label="Name *">
|
||||
<TextInput style={styles.input} value={name} onChangeText={setName} placeholder="z. B. Haferflocken" />
|
||||
</Field>
|
||||
|
||||
<Field label="Beschreibung">
|
||||
<TextInput style={[styles.input, styles.inputMulti]} value={description} onChangeText={setDescription} placeholder="Kurze Beschreibung" multiline />
|
||||
</Field>
|
||||
|
||||
<Field label="Kategorie">
|
||||
<ChipSelectInput
|
||||
value={category}
|
||||
onSelect={setCategory}
|
||||
options={categoryOptions}
|
||||
onAddOption={addCategory}
|
||||
onRemoveOption={removeCategory}
|
||||
removableOptions={customCategories}
|
||||
getLabel={(cat) => CATEGORY_LABELS[cat] ?? cat}
|
||||
addPlaceholder="Neue Kategorie…"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Field label="Menge" style={{ flex: 1 }}>
|
||||
<TextInput style={styles.input} value={quantity} onChangeText={setQuantity} keyboardType="numeric" />
|
||||
</Field>
|
||||
<Field label="Einheit" style={{ flex: 1 }}>
|
||||
<TextInput style={styles.input} value={unit} onChangeText={setUnit} placeholder="Stück" />
|
||||
</Field>
|
||||
</View>
|
||||
|
||||
<Field label="Mindestbestand (Warnschwelle)">
|
||||
<TextInput style={styles.input} value={minStock} onChangeText={setMinStock} keyboardType="numeric" placeholder="0" />
|
||||
</Field>
|
||||
|
||||
<Field label="Mindesthaltbarkeitsdatum">
|
||||
<TouchableOpacity
|
||||
style={[styles.input, styles.dateInput]}
|
||||
onPress={() => setShowDatePicker(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons name="event" size={18} color={expiryDate ? COLORS.text : COLORS.textSecondary} />
|
||||
<Text style={[styles.dateText, !expiryDate && styles.datePlaceholder]}>
|
||||
{expiryDate ? format(expiryDate, 'dd. MMMM yyyy', { locale: de }) : 'Kein MHD'}
|
||||
</Text>
|
||||
{expiryDate && (
|
||||
<TouchableOpacity onPress={() => setExpiryDate(null)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<MaterialIcons name="close" size={16} color={COLORS.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{showDatePicker && (
|
||||
<DatePickerModal
|
||||
value={expiryDate ?? new Date()}
|
||||
minimumDate={new Date()}
|
||||
onChange={setExpiryDate}
|
||||
onDismiss={() => setShowDatePicker(false)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Lagerort">
|
||||
<ChipSelectInput
|
||||
value={storageLocation}
|
||||
onSelect={setStorageLocation}
|
||||
options={locationOptions}
|
||||
onAddOption={addLocation}
|
||||
onRemoveOption={removeLocation}
|
||||
removableOptions={customLocations}
|
||||
addPlaceholder="Neuer Lagerort…"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Einkaufsort">
|
||||
<TextInput style={styles.input} value={shoppingLocation} onChangeText={setShoppingLocation} placeholder="z. B. REWE" />
|
||||
</Field>
|
||||
|
||||
<Field label="Preis (€)">
|
||||
<TextInput style={styles.input} value={price} onChangeText={setPrice} keyboardType="numeric" placeholder="0.00" />
|
||||
</Field>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, saving && styles.btnDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color={COLORS.white} />
|
||||
) : (
|
||||
<Text style={styles.btnText}>Artikel speichern</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children, style }: { label: string; children: React.ReactNode; style?: object }) {
|
||||
return (
|
||||
<View style={[{ marginBottom: 16 }, style]}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
permissionContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, padding: 32 },
|
||||
permissionText: { fontSize: 18, fontWeight: '600', color: COLORS.text, textAlign: 'center' },
|
||||
cameraContainer: { flex: 1, backgroundColor: '#000' },
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', alignItems: 'center', gap: 16 },
|
||||
overlayText: { color: COLORS.white, fontSize: 16, fontWeight: '600' },
|
||||
cameraTop: { position: 'absolute', top: 48, left: 0, right: 0, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16 },
|
||||
cameraTopBtn: { padding: 8 },
|
||||
modeToggle: { flex: 1, flexDirection: 'row', justifyContent: 'center', gap: 8 },
|
||||
modeBtn: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.2)' },
|
||||
modeBtnActive: { backgroundColor: COLORS.white },
|
||||
modeBtnText: { color: COLORS.white, fontWeight: '600', fontSize: 14 },
|
||||
cameraBottom: { position: 'absolute', bottom: 48, left: 0, right: 0, alignItems: 'center' },
|
||||
shutter: { width: 72, height: 72, borderRadius: 36, backgroundColor: 'rgba(255,255,255,0.3)', justifyContent: 'center', alignItems: 'center', borderWidth: 3, borderColor: COLORS.white },
|
||||
shutterInner: { width: 56, height: 56, borderRadius: 28, backgroundColor: COLORS.white },
|
||||
barcodeHint: { position: 'absolute', bottom: 80, left: 0, right: 0, alignItems: 'center' },
|
||||
barcodeHintText: { color: COLORS.white, fontSize: 14, backgroundColor: 'rgba(0,0,0,0.5)', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8 },
|
||||
form: { flex: 1, backgroundColor: COLORS.white },
|
||||
formContent: { padding: 24, paddingBottom: 48 },
|
||||
photoSection: { marginBottom: 20, borderRadius: 12, overflow: 'hidden' },
|
||||
preview: { width: '100%', height: 180, borderRadius: 12, resizeMode: 'cover' },
|
||||
photoOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
photoOverlayText: { color: COLORS.white, fontSize: 13, fontWeight: '600' },
|
||||
photoPlaceholder: {
|
||||
height: 120,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
borderColor: COLORS.border,
|
||||
borderStyle: 'dashed',
|
||||
backgroundColor: COLORS.surface,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
photoPlaceholderText: { fontSize: 14, color: COLORS.primaryLight, fontWeight: '500' },
|
||||
label: { fontSize: 13, fontWeight: '600', color: COLORS.textSecondary, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.3 },
|
||||
input: { borderWidth: 1.5, borderColor: COLORS.border, borderRadius: 12, padding: 13, fontSize: 16, backgroundColor: COLORS.surface, color: COLORS.text },
|
||||
inputMulti: { height: 80, textAlignVertical: 'top' },
|
||||
dateInput: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
dateText: { flex: 1, fontSize: 16, color: COLORS.text },
|
||||
datePlaceholder: { color: COLORS.textSecondary },
|
||||
dateConfirm: { alignSelf: 'flex-end', marginTop: 8, paddingHorizontal: 16, paddingVertical: 8, backgroundColor: COLORS.primary, borderRadius: 8 },
|
||||
dateConfirmText: { color: COLORS.white, fontWeight: '600', fontSize: 14 },
|
||||
segmented: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
segBtn: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1.5, borderColor: COLORS.border },
|
||||
segBtnActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
|
||||
segBtnText: { fontSize: 13, color: COLORS.textSecondary, fontWeight: '500' },
|
||||
segBtnTextActive: { color: COLORS.white },
|
||||
row: { flexDirection: 'row', gap: 12 },
|
||||
btn: {
|
||||
backgroundColor: COLORS.primary,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
shadowColor: COLORS.primary,
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 4,
|
||||
},
|
||||
btnDisabled: { opacity: 0.6, shadowOpacity: 0 },
|
||||
btnText: { color: COLORS.white, fontSize: 16, fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { DatePickerModal } from '../../src/components/ui/DatePicker';
|
||||
import { useHouseholdStore } from '../../src/hooks/useHousehold';
|
||||
import { updateItem, deleteItem, updateItemQuantity } from '../../src/services/items';
|
||||
import { QuantityControl } from '../../src/components/ui/QuantityControl';
|
||||
import { COLORS, CATEGORY_LABELS, STORAGE_LOCATIONS } from '../../src/constants';
|
||||
import { ChipSelectInput } from '../../src/components/ui/ChipSelectInput';
|
||||
import { useCustomOptions } from '../../src/hooks/useCustomOptions';
|
||||
import { formatDate, isExpired, isExpiringSoon } from '../../src/utils';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
export default function ItemDetailModal() {
|
||||
const { itemId } = useLocalSearchParams<{ itemId: string }>();
|
||||
const household = useHouseholdStore((s) => s.household);
|
||||
const items = useHouseholdStore((s) => s.items);
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [newPhotoUri, setNewPhotoUri] = useState<string | null>(null);
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
|
||||
const [name, setName] = useState(item?.name ?? '');
|
||||
const [description, setDescription] = useState(item?.description ?? '');
|
||||
const [storageLocation, setStorageLocation] = useState(item?.storageLocation ?? '');
|
||||
const [shoppingLocation, setShoppingLocation] = useState(item?.shoppingLocation ?? '');
|
||||
const [price, setPrice] = useState(item?.price != null ? String(item.price) : '');
|
||||
const [minStock, setMinStock] = useState(String(item?.minStockThreshold ?? 0));
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(item?.expiryDate ?? null);
|
||||
|
||||
const { options: locationOptions, customOptions: customLocations, addOption: addLocation, removeOption: removeLocation } = useCustomOptions(
|
||||
'houseorg_custom_locations',
|
||||
STORAGE_LOCATIONS
|
||||
);
|
||||
|
||||
if (!item || !household) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text>Artikel nicht gefunden.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const pickPhoto = () => {
|
||||
Alert.alert('Foto', '', [
|
||||
{
|
||||
text: 'Kamera',
|
||||
onPress: async () => {
|
||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.8, allowsEditing: true, aspect: [4, 3] });
|
||||
if (!result.canceled) setNewPhotoUri(result.assets[0].uri);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Galerie',
|
||||
onPress: async () => {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ quality: 0.8, allowsEditing: true, aspect: [4, 3] });
|
||||
if (!result.canceled) setNewPhotoUri(result.assets[0].uri);
|
||||
},
|
||||
},
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateItem(household.id, item.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
storageLocation: storageLocation.trim(),
|
||||
shoppingLocation: shoppingLocation.trim(),
|
||||
price: price ? parseFloat(price) : null,
|
||||
minStockThreshold: parseFloat(minStock) || 0,
|
||||
expiryDate,
|
||||
...(newPhotoUri ? { photoUri: newPhotoUri } : {}),
|
||||
});
|
||||
setNewPhotoUri(null);
|
||||
setEditing(false);
|
||||
Toast.show({ type: 'success', text1: 'Gespeichert' });
|
||||
} catch {
|
||||
Toast.show({ type: 'error', text1: 'Fehler beim Speichern' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert('Artikel löschen', `Möchtest du "${item.name}" wirklich löschen?`, [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteItem(household.id, item.id);
|
||||
router.back();
|
||||
} catch {
|
||||
Toast.show({ type: 'error', text1: 'Fehler beim Löschen' });
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleQuantityChange = async (qty: number) => {
|
||||
try {
|
||||
await updateItemQuantity(household.id, item.id, qty);
|
||||
} catch {
|
||||
Toast.show({ type: 'error', text1: 'Fehler beim Speichern' });
|
||||
}
|
||||
};
|
||||
|
||||
const expired = isExpired(item.expiryDate);
|
||||
const expiringSoon = isExpiringSoon(item.expiryDate);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{editing ? (
|
||||
<TouchableOpacity style={styles.photoEditContainer} onPress={pickPhoto} activeOpacity={0.85}>
|
||||
{(newPhotoUri ?? item.photoUrl) ? (
|
||||
<>
|
||||
<Image source={{ uri: newPhotoUri ?? item.photoUrl! }} style={styles.photo} />
|
||||
<View style={styles.photoEditOverlay}>
|
||||
<MaterialIcons name="edit" size={18} color={COLORS.white} />
|
||||
<Text style={styles.photoEditText}>Foto ändern</Text>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.photoPlaceholder}>
|
||||
<MaterialIcons name="add-a-photo" size={32} color={COLORS.primary} />
|
||||
<Text style={styles.photoPlaceholderText}>Foto hinzufügen</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
item.photoUrl && <Image source={{ uri: item.photoUrl }} style={styles.photo} />
|
||||
)}
|
||||
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.headerInfo}>
|
||||
{editing ? (
|
||||
<TextInput style={styles.titleInput} value={name} onChangeText={setName} />
|
||||
) : (
|
||||
<Text style={styles.title}>{item.name}</Text>
|
||||
)}
|
||||
<Text style={styles.category}>{CATEGORY_LABELS[item.category]}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => (editing ? handleSave() : setEditing(true))} disabled={saving}>
|
||||
{saving ? (
|
||||
<ActivityIndicator size="small" color={COLORS.primary} />
|
||||
) : (
|
||||
<MaterialIcons
|
||||
name={editing ? 'check' : 'edit'}
|
||||
size={24}
|
||||
color={editing ? COLORS.primary : COLORS.textSecondary}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.quantitySection}>
|
||||
<Text style={styles.sectionLabel}>Bestand</Text>
|
||||
<QuantityControl
|
||||
quantity={item.quantity}
|
||||
unit={item.unit}
|
||||
minStockThreshold={item.minStockThreshold}
|
||||
onChangeQuantity={handleQuantityChange}
|
||||
/>
|
||||
{item.onShoppingList && (
|
||||
<Text style={styles.onListHint}>Auf der Einkaufsliste</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{(expiringSoon || expired) && (
|
||||
<View style={[styles.mhdAlert, expired ? styles.mhdAlertExpired : styles.mhdAlertWarn]}>
|
||||
<MaterialIcons
|
||||
name={expired ? 'error' : 'warning'}
|
||||
size={18}
|
||||
color={expired ? COLORS.danger : COLORS.warning}
|
||||
/>
|
||||
<Text style={styles.mhdAlertText}>
|
||||
{expired ? 'Abgelaufen!' : `MHD läuft in ≤ 3 Tagen ab`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.detailsCard}>
|
||||
<DetailRow label="Beschreibung" value={editing ? undefined : item.description}>
|
||||
{editing && (
|
||||
<TextInput
|
||||
style={styles.editInput}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
multiline
|
||||
/>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Lagerort" value={editing ? undefined : item.storageLocation || '—'}>
|
||||
{editing && (
|
||||
<ChipSelectInput
|
||||
value={storageLocation}
|
||||
onSelect={setStorageLocation}
|
||||
options={locationOptions}
|
||||
onAddOption={addLocation}
|
||||
onRemoveOption={removeLocation}
|
||||
removableOptions={customLocations}
|
||||
addPlaceholder="Neuer Lagerort…"
|
||||
/>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Einkaufsort" value={editing ? undefined : item.shoppingLocation || '—'}>
|
||||
{editing && (
|
||||
<TextInput style={styles.editInput} value={shoppingLocation} onChangeText={setShoppingLocation} />
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Mindestbestand" value={editing ? undefined : `${item.minStockThreshold} ${item.unit}`}>
|
||||
{editing && (
|
||||
<TextInput style={styles.editInput} value={minStock} onChangeText={setMinStock} keyboardType="numeric" />
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Preis" value={editing ? undefined : item.price != null ? `${item.price.toFixed(2)} €` : '—'}>
|
||||
{editing && (
|
||||
<TextInput style={styles.editInput} value={price} onChangeText={setPrice} keyboardType="numeric" />
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="MHD" value={editing ? undefined : formatDate(item.expiryDate)}>
|
||||
{editing && (
|
||||
<TouchableOpacity onPress={() => setShowDatePicker(true)} style={styles.datePicker}>
|
||||
<Text style={styles.datePickerText}>{formatDate(expiryDate) || 'Datum wählen'}</Text>
|
||||
<MaterialIcons name="calendar-today" size={18} color={COLORS.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</DetailRow>
|
||||
{showDatePicker && (
|
||||
<DatePickerModal
|
||||
value={expiryDate ?? new Date()}
|
||||
minimumDate={new Date()}
|
||||
onChange={setExpiryDate}
|
||||
onDismiss={() => setShowDatePicker(false)}
|
||||
/>
|
||||
)}
|
||||
<DetailRow label="Barcode" value={item.barcode || '—'} last />
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.deleteBtn} onPress={handleDelete}>
|
||||
<MaterialIcons name="delete" size={18} color={COLORS.danger} />
|
||||
<Text style={styles.deleteBtnText}>Artikel löschen</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
<Toast />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
children,
|
||||
last,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
children?: React.ReactNode;
|
||||
last?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View style={[styles.detailRow, !last && styles.detailRowBorder]}>
|
||||
<Text style={styles.detailLabel}>{label}</Text>
|
||||
{children ?? <Text style={styles.detailValue}>{value}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: COLORS.white },
|
||||
content: { paddingBottom: 48 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
photo: { width: '100%', height: 220, resizeMode: 'cover' },
|
||||
photoEditContainer: { width: '100%', height: 220 },
|
||||
photoEditOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||
paddingVertical: 10,
|
||||
},
|
||||
photoEditText: { color: COLORS.white, fontSize: 14, fontWeight: '600' },
|
||||
photoPlaceholder: {
|
||||
width: '100%',
|
||||
height: 220,
|
||||
backgroundColor: COLORS.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
photoPlaceholderText: { color: COLORS.primary, fontSize: 14, fontWeight: '600' },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'flex-start', padding: 20, gap: 12 },
|
||||
headerInfo: { flex: 1 },
|
||||
title: { fontSize: 24, fontWeight: '700', color: COLORS.text },
|
||||
titleInput: { fontSize: 24, fontWeight: '700', color: COLORS.text, borderBottomWidth: 2, borderBottomColor: COLORS.primary },
|
||||
category: { fontSize: 14, color: COLORS.textSecondary, marginTop: 4 },
|
||||
quantitySection: { paddingHorizontal: 20, paddingBottom: 16, gap: 8 },
|
||||
sectionLabel: { fontSize: 12, fontWeight: '600', color: COLORS.textSecondary, textTransform: 'uppercase', letterSpacing: 0.4 },
|
||||
onListHint: { fontSize: 12, color: COLORS.warning, fontWeight: '500' },
|
||||
mhdAlert: { flexDirection: 'row', alignItems: 'center', gap: 8, marginHorizontal: 20, marginBottom: 12, padding: 12, borderRadius: 10 },
|
||||
mhdAlertWarn: { backgroundColor: '#FFF3CD' },
|
||||
mhdAlertExpired: { backgroundColor: '#F8D7DA' },
|
||||
mhdAlertText: { fontSize: 14, fontWeight: '500', color: COLORS.text },
|
||||
detailsCard: { marginHorizontal: 16, borderRadius: 14, overflow: 'hidden', borderWidth: 1, borderColor: COLORS.border },
|
||||
detailRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 14, gap: 12 },
|
||||
detailRowBorder: { borderBottomWidth: 1, borderBottomColor: COLORS.border },
|
||||
detailLabel: { fontSize: 14, color: COLORS.textSecondary, fontWeight: '500', minWidth: 100 },
|
||||
detailValue: { fontSize: 14, color: COLORS.text, flex: 1, textAlign: 'right' },
|
||||
editInput: { fontSize: 14, color: COLORS.text, flex: 1, textAlign: 'right', borderBottomWidth: 1, borderBottomColor: COLORS.primary },
|
||||
datePicker: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
datePickerText: { fontSize: 14, color: COLORS.primary },
|
||||
deleteBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginTop: 32, marginHorizontal: 16, padding: 14, borderRadius: 12, borderWidth: 1.5, borderColor: COLORS.danger },
|
||||
deleteBtnText: { color: COLORS.danger, fontSize: 15, fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import React, { useState } from 'react';
|
||||
import { router } from 'expo-router';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import * as Device from 'expo-device';
|
||||
import { useHouseholdStore } from '../src/hooks/useHousehold';
|
||||
import { createHousehold, joinHousehold, getHousehold, registerMember } from '../src/services/household';
|
||||
import { getFcmToken } from '../src/services/notifications';
|
||||
import { COLORS } from '../src/constants';
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const setHousehold = useHouseholdStore((s) => s.setHousehold);
|
||||
const [householdName, setHouseholdName] = useState('');
|
||||
const [deviceName, setDeviceName] = useState(Device.deviceName ?? '');
|
||||
const [inviteLink, setInviteLink] = useState('');
|
||||
const [tab, setTab] = useState<'create' | 'join'>('create');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!householdName.trim()) {
|
||||
Alert.alert('Pflichtfeld', 'Bitte gib einen Haushaltsnamen ein.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const household = await createHousehold(householdName.trim());
|
||||
setHousehold(household);
|
||||
router.replace('/');
|
||||
const fcmToken = await getFcmToken().catch(() => null);
|
||||
await registerMember(household.id, fcmToken, deviceName).catch((e) =>
|
||||
console.warn('registerMember failed (non-critical):', e)
|
||||
);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Fehler', 'Haushalt konnte nicht erstellt werden. Bitte Internetverbindung prüfen.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoin = async () => {
|
||||
const raw = inviteLink.trim();
|
||||
const qIndex = raw.indexOf('?');
|
||||
if (!raw || qIndex === -1) {
|
||||
Alert.alert('Ungültiger Link', 'Füge den vollständigen Einladungslink ein.');
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(raw.slice(qIndex + 1));
|
||||
const householdId = params.get('householdId') ?? '';
|
||||
const token = params.get('token') ?? '';
|
||||
if (!householdId || !token) {
|
||||
Alert.alert('Ungültiger Link', 'Der Link enthält keine gültigen Zugangsdaten.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await joinHousehold(householdId, token);
|
||||
if (!success) {
|
||||
Alert.alert('Abgelaufener Link', 'Dieser Einladungslink ist ungültig oder wurde erneuert. Bitte einen neuen Link anfordern.');
|
||||
return;
|
||||
}
|
||||
|
||||
const household = await getHousehold(householdId);
|
||||
if (!household) {
|
||||
Alert.alert('Fehler', 'Haushalt nicht gefunden. Bitte erneut versuchen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fcmToken = await getFcmToken().catch(() => null);
|
||||
await registerMember(householdId, fcmToken, deviceName);
|
||||
setHousehold(household);
|
||||
router.replace('/');
|
||||
} catch {
|
||||
Alert.alert('Verbindungsfehler', 'Bitte Internetverbindung prüfen und erneut versuchen.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardView}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.heroIcon}>
|
||||
<MaterialIcons name="home" size={42} color={COLORS.white} />
|
||||
</View>
|
||||
<Text style={styles.title}>HouseOrg</Text>
|
||||
<Text style={styles.subtitle}>Gemeinsam organisiert</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.tabs}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, tab === 'create' && styles.tabActive]}
|
||||
onPress={() => setTab('create')}
|
||||
>
|
||||
<Text style={[styles.tabText, tab === 'create' && styles.tabTextActive]}>
|
||||
Neu erstellen
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, tab === 'join' && styles.tabActive]}
|
||||
onPress={() => setTab('join')}
|
||||
>
|
||||
<Text style={[styles.tabText, tab === 'join' && styles.tabTextActive]}>
|
||||
Beitreten
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
{tab === 'create' ? (
|
||||
<>
|
||||
<Text style={styles.label}>Haushaltsname</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="z. B. Familie Müller"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
value={householdName}
|
||||
onChangeText={setHouseholdName}
|
||||
returnKeyType="next"
|
||||
autoFocus
|
||||
/>
|
||||
<Text style={styles.label}>Mein Gerätename</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="z. B. Aiméns iPhone"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
value={deviceName}
|
||||
onChangeText={setDeviceName}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleCreate}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, loading && styles.btnDisabled]}
|
||||
onPress={handleCreate}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={COLORS.white} />
|
||||
) : (
|
||||
<>
|
||||
<MaterialIcons name="home" size={18} color={COLORS.white} />
|
||||
<Text style={styles.btnText}>Haushalt erstellen</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.label}>Einladungslink</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.inputMultiline]}
|
||||
placeholder="houseorg://join?householdId=…"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
value={inviteLink}
|
||||
onChangeText={setInviteLink}
|
||||
multiline
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={styles.label}>Mein Gerätename</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="z. B. Aiméns iPhone"
|
||||
placeholderTextColor={COLORS.textSecondary}
|
||||
value={deviceName}
|
||||
onChangeText={setDeviceName}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleJoin}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, loading && styles.btnDisabled]}
|
||||
onPress={handleJoin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={COLORS.white} />
|
||||
) : (
|
||||
<>
|
||||
<MaterialIcons name="group-add" size={18} color={COLORS.white} />
|
||||
<Text style={styles.btnText}>Beitreten</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: { flex: 1, backgroundColor: COLORS.primary },
|
||||
keyboardView: { flex: 1 },
|
||||
scroll: { flexGrow: 1 },
|
||||
hero: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 56,
|
||||
paddingBottom: 52,
|
||||
paddingHorizontal: 24,
|
||||
backgroundColor: COLORS.primary,
|
||||
},
|
||||
heroIcon: {
|
||||
width: 84,
|
||||
height: 84,
|
||||
borderRadius: 26,
|
||||
backgroundColor: 'rgba(255,255,255,0.18)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 36,
|
||||
fontWeight: '700',
|
||||
color: COLORS.white,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 17,
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
marginTop: 6,
|
||||
},
|
||||
card: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
padding: 24,
|
||||
paddingTop: 28,
|
||||
},
|
||||
tabs: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: COLORS.white,
|
||||
borderRadius: 14,
|
||||
padding: 4,
|
||||
marginBottom: 26,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 1,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
tabActive: { backgroundColor: COLORS.primary },
|
||||
tabText: { fontSize: 14, color: COLORS.textSecondary, fontWeight: '500' },
|
||||
tabTextActive: { color: COLORS.white, fontWeight: '600' },
|
||||
form: { gap: 12 },
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: COLORS.text,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1.5,
|
||||
borderColor: COLORS.border,
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
backgroundColor: COLORS.white,
|
||||
color: COLORS.text,
|
||||
},
|
||||
inputMultiline: { height: 100, textAlignVertical: 'top' },
|
||||
btn: {
|
||||
backgroundColor: COLORS.primary,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
shadowColor: COLORS.primary,
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 4,
|
||||
},
|
||||
btnDisabled: { opacity: 0.6, shadowOpacity: 0 },
|
||||
btnText: { color: COLORS.white, fontSize: 16, fontWeight: '600' },
|
||||
});
|
||||
Reference in New Issue
Block a user