First commit
This commit is contained in:
@@ -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' },
|
||||
});
|
||||
Reference in New Issue
Block a user