import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { ActivityIndicator, Alert, FlatList, Image, InteractionManager, KeyboardAvoidingView, Modal, Pressable, Platform, ScrollView, StyleSheet, TextInput, View, } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import { ResizeMode, Video } from 'expo-av'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { ThemedButton } from '@/components/themed-button'; import { IconButton } from '@/components/icon-button'; import { Colors, Fonts } from '@/constants/theme'; import { useTranslation } from '@/localization/i18n'; import { dbPromise, initCoreTables } from '@/services/db'; import { useColorScheme } from '@/hooks/use-color-scheme'; import { ZoomImageModal } from '@/components/zoom-image-modal'; type FieldRow = { id: number; name: string | null; area_ha: number | null; notes: string | null; photo_uri: string | null; created_at: string | null; updated_at: string | null; }; type FieldMediaRow = { uri: string | null; }; export default function FieldsScreen() { const { t } = useTranslation(); const router = useRouter(); const navigation = useNavigation(); const { from } = useLocalSearchParams<{ from?: string }>(); const theme = useColorScheme() ?? 'light'; const palette = Colors[theme]; useLayoutEffect(() => { if (from === 'home') { navigation.setOptions({ headerLeft: () => ( router.replace('/')} hitSlop={10} style={{ paddingHorizontal: 8 }}> ), }); } }, [from, navigation, palette.text, router]); const pageSize = 12; const [fields, setFields] = useState([]); const [status, setStatus] = useState(t('fields.loading')); const [name, setName] = useState(''); const [areaHa, setAreaHa] = useState(''); const [notes, setNotes] = useState(''); const [photoUri, setPhotoUri] = useState(null); const [mediaUris, setMediaUris] = useState([]); const [newModalVisible, setNewModalVisible] = useState(false); const [editingId, setEditingId] = useState(null); const [editModalVisible, setEditModalVisible] = useState(false); const [editName, setEditName] = useState(''); const [editAreaHa, setEditAreaHa] = useState(''); const [editNotes, setEditNotes] = useState(''); const [editPhotoUri, setEditPhotoUri] = useState(null); const [editMediaUris, setEditMediaUris] = useState([]); const [zoomUri, setZoomUri] = useState(null); const [pendingZoomUri, setPendingZoomUri] = useState(null); const [sheetZoomUri, setSheetZoomUri] = useState(null); function closeSheetViewer() { setSheetZoomUri(null); setEditModalVisible(false); setNewModalVisible(false); setEditingId(null); setEditErrors({}); setNewErrors({}); } const [newErrors, setNewErrors] = useState<{ name?: string; area?: string }>({}); const [editErrors, setEditErrors] = useState<{ name?: string; area?: string }>({}); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [loadingMore, setLoadingMore] = useState(false); useEffect(() => { let isActive = true; async function loadFields() { await fetchFieldsPage(1, true, isActive); } loadFields(); return () => { isActive = false; }; }, [t]); useFocusEffect( useCallback(() => { fetchFieldsPage(1, true); }, []) ); useEffect(() => { if (!editModalVisible && !newModalVisible && pendingZoomUri) { const uri = pendingZoomUri; setPendingZoomUri(null); InteractionManager.runAfterInteractions(() => { setTimeout(() => setZoomUri(uri), 150); }); } }, [editModalVisible, newModalVisible, pendingZoomUri]); async function fetchFieldsPage(pageToLoad: number, replace: boolean, isActive = true) { try { await initCoreTables(); const db = await dbPromise; const rows = await db.getAllAsync( 'SELECT id, name, area_ha, notes, photo_uri, created_at, updated_at FROM fields ORDER BY id DESC LIMIT ? OFFSET ?;', pageSize, (pageToLoad - 1) * pageSize ); if (!isActive) return; setFields((prev) => (replace ? rows : [...prev, ...rows])); setHasMore(rows.length === pageSize); setPage(pageToLoad); if (replace) { setStatus(rows.length === 0 ? t('fields.empty') : ''); } } catch (error) { if (isActive) setStatus(`Error: ${String(error)}`); } finally { if (isActive) setLoadingMore(false); } } async function handleLoadMore() { if (loadingMore || !hasMore) return; setLoadingMore(true); const nextPage = page + 1; await fetchFieldsPage(nextPage, false); } async function handleSave() { const trimmedName = name.trim(); const area = areaHa.trim() ? Number(areaHa) : null; const nextErrors: { name?: string; area?: string } = {}; if (!trimmedName) { nextErrors.name = t('fields.nameRequired'); } if (areaHa.trim() && !Number.isFinite(area)) { nextErrors.area = t('fields.areaInvalid'); } setNewErrors(nextErrors); if (Object.keys(nextErrors).length > 0) { setStatus(nextErrors.name ?? nextErrors.area ?? t('fields.nameRequired')); return false; } try { const db = await dbPromise; const now = new Date().toISOString(); const primaryUri = mediaUris[0] ?? normalizeMediaUri(photoUri); const result = await db.runAsync( 'INSERT INTO fields (name, area_ha, notes, photo_uri, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?);', trimmedName, area, notes.trim() || null, primaryUri ?? null, now, now ); const fieldId = Number(result.lastInsertRowId); if (fieldId) { const mediaToInsert = uniqueMediaUris([ ...mediaUris, ...(normalizeMediaUri(photoUri) ? [normalizeMediaUri(photoUri) as string] : []), ]); for (const uri of mediaToInsert) { await db.runAsync( 'INSERT INTO field_media (field_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);', fieldId, uri, isVideoUri(uri) ? 'video' : 'image', now ); } } setName(''); setAreaHa(''); setNotes(''); setPhotoUri(null); setMediaUris([]); setNewErrors({}); await fetchFieldsPage(1, true); setStatus(t('fields.saved')); return true; } catch (error) { setStatus(`Error: ${String(error)}`); return false; } } async function handleDelete(id: number) { try { const db = await dbPromise; await db.runAsync('DELETE FROM field_media WHERE field_id = ?;', id); await db.runAsync('DELETE FROM fields WHERE id = ?;', id); await fetchFieldsPage(1, true); } catch (error) { setStatus(`Error: ${String(error)}`); } } function confirmDelete(id: number) { Alert.alert( t('fields.deleteTitle'), t('fields.deleteMessage'), [ { text: t('fields.cancel'), style: 'cancel' }, { text: t('fields.delete'), style: 'destructive', onPress: () => handleDelete(id) }, ] ); } async function startEdit(field: FieldRow) { router.push(`/logbook/fields/${field.id}`); } function cancelEdit() { setEditingId(null); setEditName(''); setEditAreaHa(''); setEditNotes(''); setEditPhotoUri(null); setEditMediaUris([]); setEditErrors({}); setSheetZoomUri(null); setEditModalVisible(false); setStatus(''); } async function handleUpdate() { if (!editingId) return; const trimmedName = editName.trim(); const area = editAreaHa.trim() ? Number(editAreaHa) : null; const nextErrors: { name?: string; area?: string } = {}; if (!trimmedName) { nextErrors.name = t('fields.nameRequired'); } if (editAreaHa.trim() && !Number.isFinite(area)) { nextErrors.area = t('fields.areaInvalid'); } setEditErrors(nextErrors); if (Object.keys(nextErrors).length > 0) { setStatus(nextErrors.name ?? nextErrors.area ?? t('fields.nameRequired')); return; } try { const db = await dbPromise; const now = new Date().toISOString(); const primaryUri = editMediaUris[0] ?? normalizeMediaUri(editPhotoUri); await db.runAsync( 'UPDATE fields SET name = ?, area_ha = ?, notes = ?, photo_uri = ?, updated_at = ? WHERE id = ?;', trimmedName, area, editNotes.trim() || null, primaryUri ?? null, now, editingId ); await db.runAsync('DELETE FROM field_media WHERE field_id = ?;', editingId); const mediaToInsert = uniqueMediaUris([ ...editMediaUris, ...(normalizeMediaUri(editPhotoUri) ? [normalizeMediaUri(editPhotoUri) as string] : []), ]); for (const uri of mediaToInsert) { await db.runAsync( 'INSERT INTO field_media (field_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);', editingId, uri, isVideoUri(uri) ? 'video' : 'image', now ); } setEditModalVisible(false); setEditingId(null); setEditErrors({}); await fetchFieldsPage(1, true); setStatus(t('fields.saved')); } catch (error) { setStatus(`Error: ${String(error)}`); } } const inputStyle = [ styles.input, { borderColor: palette.border, backgroundColor: palette.input, color: palette.text, }, ]; return ( <> String(item.id)} extraData={[photoUri, editModalVisible, editPhotoUri, name, areaHa, notes, status]} onEndReached={handleLoadMore} onEndReachedThreshold={0.4} renderItem={({ item }) => { const mediaUri = normalizeMediaUri(item.photo_uri); return ( startEdit(item)}> {item.name || t('fields.unnamed')} {item.area_ha !== null ? ( {t('fields.areaLabel')} {item.area_ha} ) : null} {mediaUri ? ( isVideoUri(mediaUri) ? ( ); }} ItemSeparatorComponent={() => } ListHeaderComponent={ {t('fields.title')} {status ? ( {status} ) : null} { setNewErrors({}); setNewModalVisible(true); }}> {t('fields.new')} } ListFooterComponent={ {loadingMore ? : null} } /> { if (sheetZoomUri) { setSheetZoomUri(null); return; } cancelEdit(); }} /> { event.stopPropagation(); }} onStartShouldSetResponder={() => true} pointerEvents={sheetZoomUri ? 'none' : 'auto'}> {t('fields.edit')} {t('fields.name')} * { setEditName(value); if (editErrors.name) { setEditErrors((prev) => ({ ...prev, name: undefined })); } }} placeholder={t('fields.name')} placeholderTextColor={palette.placeholder} style={inputStyle} /> {editErrors.name ? {editErrors.name} : null} {t('fields.area')} { setEditAreaHa(value); if (editErrors.area) { setEditErrors((prev) => ({ ...prev, area: undefined })); } }} placeholder={t('fields.areaPlaceholder')} placeholderTextColor={palette.placeholder} style={inputStyle} keyboardType="decimal-pad" /> {editErrors.area ? {editErrors.area} : null} {t('fields.notes')} {t('fields.addMedia')} {editMediaUris.length > 0 ? ( isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? ( { event.stopPropagation(); setSheetZoomUri(normalizeMediaUri(editPhotoUri) as string); }}> ) : ( { event.stopPropagation(); setSheetZoomUri(normalizeMediaUri(editPhotoUri) as string); }}> console.log( '[Fields] Edit image error:', normalizeMediaUri(editPhotoUri) as string, error.nativeEvent ) } onLoad={() => console.log( '[Fields] Edit image loaded:', normalizeMediaUri(editPhotoUri) as string ) } /> ) ) : ( {t('fields.noPhoto')} )} {editMediaUris.length > 0 ? ( {editMediaUris.map((uri) => ( { event.stopPropagation(); setSheetZoomUri(uri); }}> {isVideoUri(uri) ? ( ) : ( )} { event.stopPropagation(); setEditMediaUris((prev) => { const next = prev.filter((item) => item !== uri); setEditPhotoUri((current) => (current === uri ? next[0] ?? null : current)); return next; }); }}> × ))} ) : null} handlePickPhoto( (uri) => { if (!uri) return; setEditPhotoUri((prev) => prev ?? uri); }, (uris) => { if (uris.length === 0) return; setEditMediaUris((prev) => uniqueMediaUris([...prev, ...uris])); setEditPhotoUri((prev) => prev ?? uris[0]); } ) } variant="secondary" /> handleTakePhoto( (uri) => { if (!uri) return; setEditPhotoUri((prev) => prev ?? uri); setEditMediaUris((prev) => uniqueMediaUris([...prev, uri])); }, (code) => setStatus( code === 'cameraDenied' ? t('tasks.cameraDenied') : t('tasks.cameraError') ) ) } variant="secondary" /> {sheetZoomUri ? ( setSheetZoomUri(null)} /> {t('common.close')} {isVideoUri(sheetZoomUri) ? ( ) : null} setNewModalVisible(false)}> { if (sheetZoomUri) { setSheetZoomUri(null); return; } setNewModalVisible(false); }} /> { event.stopPropagation(); }} onStartShouldSetResponder={() => true} pointerEvents={sheetZoomUri ? 'none' : 'auto'}> {t('fields.new')} {t('fields.name')} * { setName(value); if (newErrors.name) { setNewErrors((prev) => ({ ...prev, name: undefined })); } }} placeholder={t('fields.name')} placeholderTextColor={palette.placeholder} style={inputStyle} /> {newErrors.name ? {newErrors.name} : null} {t('fields.area')} { setAreaHa(value); if (newErrors.area) { setNewErrors((prev) => ({ ...prev, area: undefined })); } }} placeholder={t('fields.areaPlaceholder')} placeholderTextColor={palette.placeholder} style={inputStyle} keyboardType="decimal-pad" /> {newErrors.area ? {newErrors.area} : null} {t('fields.notes')} {t('fields.addMedia')} {mediaUris.length > 0 ? ( isVideoUri(normalizeMediaUri(photoUri) as string) ? ( { event.stopPropagation(); setSheetZoomUri(normalizeMediaUri(photoUri) as string); }}> ) : ( { event.stopPropagation(); setSheetZoomUri(normalizeMediaUri(photoUri) as string); }}> console.log( '[Fields] New image error:', normalizeMediaUri(photoUri) as string, error.nativeEvent ) } onLoad={() => console.log( '[Fields] New image loaded:', normalizeMediaUri(photoUri) as string ) } /> ) ) : ( {t('fields.noPhoto')} )} {mediaUris.length > 0 ? ( {mediaUris.map((uri) => ( { event.stopPropagation(); setSheetZoomUri(uri); }}> {isVideoUri(uri) ? ( ) : ( )} { event.stopPropagation(); setMediaUris((prev) => { const next = prev.filter((item) => item !== uri); setPhotoUri((current) => (current === uri ? next[0] ?? null : current)); return next; }); }}> × ))} ) : null} handlePickPhoto( (uri) => { if (!uri) return; setPhotoUri((prev) => prev ?? uri); }, (uris) => { if (uris.length === 0) return; setMediaUris((prev) => uniqueMediaUris([...prev, ...uris])); setPhotoUri((prev) => prev ?? uris[0]); } ) } variant="secondary" /> handleTakePhoto( (uri) => { if (!uri) return; setPhotoUri((prev) => prev ?? uri); setMediaUris((prev) => uniqueMediaUris([...prev, uri])); }, (code) => setStatus( code === 'cameraDenied' ? t('tasks.cameraDenied') : t('tasks.cameraError') ) ) } variant="secondary" /> setNewModalVisible(false)} variant="secondary" /> { const ok = await handleSave(); if (ok) setNewModalVisible(false); }} /> {sheetZoomUri ? ( setSheetZoomUri(null)} /> {t('common.close')} {isVideoUri(sheetZoomUri) ? ( ) : null} setZoomUri(null)} /> ); } function formatDate(value: string) { try { return new Date(value).toLocaleString(); } catch { return value; } } async function handlePickPhoto( setter: (value: string | null) => void, onAdd?: (uris: string[]) => void ) { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: getMediaTypes(), quality: 1, allowsMultipleSelection: true, selectionLimit: 0, }); if (result.canceled) return; const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[]; if (uris.length === 0) return; console.log('[Fields] Picked media:', uris); setter(uris[0]); onAdd?.(uris); } async function handleTakePhoto( setter: (value: string | null) => void, onError?: (msg: string) => void ) { try { const permission = await ImagePicker.requestCameraPermissionsAsync(); if (!permission.granted) { onError?.('cameraDenied'); return; } const result = await ImagePicker.launchCameraAsync({ mediaTypes: getMediaTypes(), quality: 1, }); if (result.canceled) return; const asset = result.assets[0]; console.log('[Fields] Captured media:', asset.uri); setter(asset.uri); } catch { onError?.('cameraError'); } } function getMediaTypes() { const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown }; }).MediaType; const imageType = mediaType?.Image ?? mediaType?.Images; const videoType = mediaType?.Video ?? mediaType?.Videos; if (imageType && videoType) { return [imageType, videoType]; } return imageType ?? videoType ?? ['images', 'videos']; } function isVideoUri(uri: string) { return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri); } function normalizeMediaUri(uri?: string | null) { if (typeof uri !== 'string') return null; const trimmed = uri.trim(); return trimmed ? trimmed : null; } function uniqueMediaUris(uris: string[]) { const seen = new Set(); const result: string[] = []; for (const uri of uris) { if (!uri || seen.has(uri)) continue; seen.add(uri); result.push(uri); } return result; } const styles = StyleSheet.create({ hero: { backgroundColor: '#E8E6DA', aspectRatio: 16 / 9, width: '100%', }, heroImage: { width: '100%', height: '100%', }, titleContainer: { gap: 8, paddingHorizontal: 16, paddingVertical: 12, }, section: { gap: 8, marginBottom: 16, paddingHorizontal: 16, }, newButton: { flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 10, borderWidth: 1, borderColor: '#B9B9B9', paddingHorizontal: 12, paddingVertical: 10, alignSelf: 'flex-start', }, newButtonText: { fontSize: 15, fontWeight: '600', }, card: { borderRadius: 12, borderWidth: 1, borderColor: '#C6C6C6', padding: 12, marginHorizontal: 16, gap: 6, backgroundColor: '#FFFFFF', }, meta: { opacity: 0.7, }, input: { borderRadius: 10, borderWidth: 1, borderColor: '#B9B9B9', paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, }, requiredMark: { color: '#C0392B', fontWeight: '700', }, errorText: { color: '#C0392B', fontSize: 12, }, photoPreview: { width: '100%', height: 200, borderRadius: 12, }, buttonRow: { alignSelf: 'flex-start', flexDirection: 'row', gap: 8, alignItems: 'center', width: '100%', }, metaEnd: { marginLeft: 'auto', opacity: 0.7, fontSize: 12, }, cancelRow: { marginTop: 8, }, modalActions: { flexDirection: 'row', justifyContent: 'space-between', gap: 12, }, sheetOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.3)', justifyContent: 'flex-end', }, sheetBackdrop: { ...StyleSheet.absoluteFillObject, }, sheet: { borderTopLeftRadius: 16, borderTopRightRadius: 16, borderWidth: 1, borderColor: '#C6C6C6', padding: 16, backgroundColor: '#FFFFFF', gap: 10, maxHeight: '85%', }, sheetContent: { gap: 10, paddingBottom: 80, }, sheetZoomOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.85)', justifyContent: 'center', alignItems: 'center', zIndex: 10, elevation: 10, }, sheetZoomBackdrop: { ...StyleSheet.absoluteFillObject, zIndex: 0, }, sheetZoomContent: { width: '100%', height: '100%', paddingHorizontal: 16, paddingTop: 24, paddingBottom: 24, justifyContent: 'center', zIndex: 1, }, sheetZoomClose: { position: 'absolute', top: 24, right: 16, zIndex: 2, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 14, }, sheetZoomCloseText: { color: '#FFFFFF', fontWeight: '600', }, sheetZoomScroll: { flex: 1, }, sheetZoomScrollContent: { flexGrow: 1, justifyContent: 'center', alignItems: 'center', }, sheetZoomImage: { width: '100%', height: '100%', }, sheetZoomVideo: { width: '100%', height: '100%', }, keyboardAvoid: { width: '100%', flex: 1, justifyContent: 'flex-end', }, separator: { height: 12, }, footer: { height: 24, }, photoRow: { flexDirection: 'row', gap: 8, }, photoPlaceholder: { opacity: 0.6, }, mediaPlaceholder: { opacity: 0.8, fontStyle: 'italic', }, videoPreview: { width: '100%', height: 200, borderRadius: 12, backgroundColor: '#1C1C1C', }, mediaStrip: { marginTop: 6, }, mediaChip: { width: 72, height: 72, borderRadius: 10, marginRight: 8, overflow: 'hidden', backgroundColor: '#E6E1D4', alignItems: 'center', justifyContent: 'center', }, mediaThumb: { width: '100%', height: '100%', }, removeBadge: { position: 'absolute', top: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)', width: 18, height: 18, borderRadius: 9, alignItems: 'center', justifyContent: 'center', }, removeBadgeText: { color: '#FFFFFF', fontSize: 12, lineHeight: 14, fontWeight: '700', }, videoThumb: { width: '100%', height: '100%', backgroundColor: '#1C1C1C', alignItems: 'center', justifyContent: 'center', }, videoThumbText: { color: '#FFFFFF', fontSize: 18, fontWeight: '700', }, });