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) ? (
) : (
setZoomUri(mediaUri)}>
console.log('[Fields] List image error:', mediaUri, error.nativeEvent)
}
onLoad={() => console.log('[Fields] List image loaded:', mediaUri)}
/>
)
) : null}
{item.notes ? {item.notes} : null}
confirmDelete(item.id)}
accessibilityLabel={t('fields.delete')}
variant="danger"
/>
{item.updated_at ? (
{formatDate(item.updated_at)}
) : null}
);
}}
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',
},
});