import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Image,
InteractionManager,
KeyboardAvoidingView,
Modal,
Pressable,
StyleSheet,
TextInput,
View,
Platform,
} from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker';
import { ResizeMode, Video } from 'expo-av';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
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 { ThemedButton } from '@/components/themed-button';
import { IconButton } from '@/components/icon-button';
import { ZoomImageModal } from '@/components/zoom-image-modal';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
type FieldRow = {
id: number;
name: string | null;
};
type CropRow = {
id: number;
field_id: number | null;
crop_name: string | null;
variety: string | null;
planting_date: string | null;
expected_harvest_date: string | null;
photo_uri: string | null;
field_name: string | null;
};
export default function CropsScreen() {
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 [crops, setCrops] = useState([]);
const [fields, setFields] = useState([]);
const [status, setStatus] = useState(t('crops.loading'));
const [selectedFieldId, setSelectedFieldId] = useState(null);
const [cropName, setCropName] = useState('');
const [variety, setVariety] = useState('');
const [plantingDate, setPlantingDate] = useState('');
const [harvestDate, setHarvestDate] = useState('');
const [photoUri, setPhotoUri] = useState(null);
const [showPlantingPicker, setShowPlantingPicker] = useState(false);
const [showHarvestPicker, setShowHarvestPicker] = useState(false);
const [newModalOpen, setNewModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editFieldId, setEditFieldId] = useState(null);
const [editName, setEditName] = useState('');
const [editVariety, setEditVariety] = useState('');
const [editPlantingDate, setEditPlantingDate] = useState('');
const [editHarvestDate, setEditHarvestDate] = useState('');
const [editPhotoUri, setEditPhotoUri] = useState(null);
const [zoomUri, setZoomUri] = useState(null);
const [pendingZoomUri, setPendingZoomUri] = useState(null);
const [showEditPlantingPicker, setShowEditPlantingPicker] = useState(false);
const [showEditHarvestPicker, setShowEditHarvestPicker] = useState(false);
const [fieldModalOpen, setFieldModalOpen] = useState(false);
const [fieldModalTarget, setFieldModalTarget] = useState<'new' | 'edit'>('new');
const [newErrors, setNewErrors] = useState<{ field?: string; name?: string }>({});
const [editErrors, setEditErrors] = useState<{ field?: string; name?: string }>({});
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
useEffect(() => {
if (!newModalOpen && !editModalOpen && pendingZoomUri) {
const uri = pendingZoomUri;
setPendingZoomUri(null);
InteractionManager.runAfterInteractions(() => {
setTimeout(() => setZoomUri(uri), 150);
});
}
}, [newModalOpen, editModalOpen, pendingZoomUri]);
const selectedField = useMemo(
() => fields.find((item) => item.id === selectedFieldId),
[fields, selectedFieldId]
);
const selectedEditField = useMemo(
() => fields.find((item) => item.id === editFieldId),
[fields, editFieldId]
);
useEffect(() => {
let isActive = true;
async function loadData() {
try {
await initCoreTables();
const db = await dbPromise;
const fieldRows = await db.getAllAsync(
'SELECT id, name FROM fields ORDER BY name ASC;'
);
if (!isActive) return;
setFields(fieldRows);
await fetchCropsPage(1, true, isActive);
} catch (error) {
if (isActive) setStatus(`Error: ${String(error)}`);
}
}
loadData();
return () => {
isActive = false;
};
}, [t]);
useFocusEffect(
useCallback(() => {
fetchCropsPage(1, true);
}, [])
);
async function fetchCropsPage(pageToLoad: number, replace: boolean, isActive = true) {
try {
const db = await dbPromise;
const cropRows = await db.getAllAsync(
`SELECT c.id, c.field_id, c.crop_name, c.variety, c.planting_date, c.expected_harvest_date, c.photo_uri,
f.name as field_name
FROM crops c
LEFT JOIN fields f ON f.id = c.field_id
ORDER BY c.id DESC
LIMIT ? OFFSET ?;`,
pageSize,
(pageToLoad - 1) * pageSize
);
if (!isActive) return;
setCrops((prev) => (replace ? cropRows : [...prev, ...cropRows]));
setHasMore(cropRows.length === pageSize);
setPage(pageToLoad);
if (replace) {
setStatus(cropRows.length === 0 ? t('crops.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 fetchCropsPage(nextPage, false);
}
async function handleSave() {
const trimmedName = cropName.trim();
const nextErrors: { field?: string; name?: string } = {};
if (!selectedFieldId) {
nextErrors.field = t('crops.fieldRequired');
}
if (!trimmedName) {
nextErrors.name = t('crops.nameRequired');
}
setNewErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) {
setStatus(nextErrors.field ?? nextErrors.name ?? t('crops.fieldRequired'));
return false;
}
try {
const db = await dbPromise;
await db.runAsync(
'INSERT INTO crops (field_id, crop_name, variety, planting_date, expected_harvest_date, photo_uri) VALUES (?, ?, ?, ?, ?, ?);',
selectedFieldId,
trimmedName,
variety.trim() || null,
plantingDate.trim() || null,
harvestDate.trim() || null,
photoUri
);
setCropName('');
setVariety('');
setPlantingDate('');
setHarvestDate('');
setPhotoUri(null);
setNewErrors({});
await fetchCropsPage(1, true);
setStatus(t('crops.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 crops WHERE id = ?;', id);
await fetchCropsPage(1, true);
} catch (error) {
setStatus(`Error: ${String(error)}`);
}
}
function confirmDelete(id: number) {
Alert.alert(
t('crops.deleteTitle'),
t('crops.deleteMessage'),
[
{ text: t('crops.cancel'), style: 'cancel' },
{ text: t('crops.delete'), style: 'destructive', onPress: () => handleDelete(id) },
]
);
}
function startEdit(crop: CropRow) {
router.push(`/logbook/crops/${crop.id}`);
}
function cancelEdit() {
setEditingId(null);
setEditFieldId(null);
setEditName('');
setEditVariety('');
setEditPlantingDate('');
setEditHarvestDate('');
setEditPhotoUri(null);
setEditErrors({});
setEditModalOpen(false);
}
async function handleUpdate() {
if (!editingId) return;
const trimmedName = editName.trim();
const nextErrors: { field?: string; name?: string } = {};
if (!editFieldId) {
nextErrors.field = t('crops.fieldRequired');
}
if (!trimmedName) {
nextErrors.name = t('crops.nameRequired');
}
setEditErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) {
setStatus(nextErrors.field ?? nextErrors.name ?? t('crops.fieldRequired'));
return;
}
try {
const db = await dbPromise;
await db.runAsync(
'UPDATE crops SET field_id = ?, crop_name = ?, variety = ?, planting_date = ?, expected_harvest_date = ?, photo_uri = ? WHERE id = ?;',
editFieldId,
trimmedName,
editVariety.trim() || null,
editPlantingDate.trim() || null,
editHarvestDate.trim() || null,
editPhotoUri,
editingId
);
setEditModalOpen(false);
setEditingId(null);
setEditErrors({});
await fetchCropsPage(1, true);
setStatus(t('crops.saved'));
} catch (error) {
setStatus(`Error: ${String(error)}`);
}
}
const inputStyle = [
styles.input,
{ borderColor: palette.border, backgroundColor: palette.input, color: palette.text },
];
return (
<>
String(item.id)}
renderItem={({ item }) => (
startEdit(item)}>
{item.crop_name || t('crops.untitled')}
{item.field_name || t('crops.noField')}
{item.variety ? {item.variety} : null}
{normalizeMediaUri(item.photo_uri) ? (
isVideoUri(normalizeMediaUri(item.photo_uri) as string) ? (
) : (
setZoomUri(normalizeMediaUri(item.photo_uri) as string)}>
)
) : null}
{item.planting_date ? (
{t('crops.plantingLabel')} {item.planting_date}
) : null}
{item.expected_harvest_date ? (
{t('crops.harvestLabel')} {item.expected_harvest_date}
) : null}
confirmDelete(item.id)}
accessibilityLabel={t('crops.delete')}
variant="danger"
/>
)}
ItemSeparatorComponent={() => }
onEndReached={handleLoadMore}
onEndReachedThreshold={0.4}
ListHeaderComponent={
{t('crops.title')}
{status ? (
{status}
) : null}
{
router.push('/logbook/crops/new');
}}>
{t('crops.new')}
}
ListFooterComponent={
{loadingMore ? : null}
}
/>
setFieldModalOpen(false)}>
{t('crops.selectField')}
String(item.id)}
renderItem={({ item }) => (
{
if (fieldModalTarget === 'new') {
setSelectedFieldId(item.id);
setNewErrors((prev) => ({ ...prev, field: undefined }));
setNewModalOpen(true);
} else {
setEditFieldId(item.id);
setEditErrors((prev) => ({ ...prev, field: undefined }));
setEditModalOpen(true);
}
setFieldModalOpen(false);
}}
style={styles.modalItem}>
{item.name || t('crops.untitled')}
)}
/>
setNewModalOpen(false)}>
setNewModalOpen(false)} />
item.key}
contentContainerStyle={styles.sheetListContent}
renderItem={() => (
{t('crops.new')}
{t('crops.field')}
*
{
setFieldModalTarget('new');
setNewModalOpen(false);
setFieldModalOpen(true);
}}
variant="secondary"
/>
{newErrors.field ? (
{newErrors.field}
) : null}
{t('crops.name')}
*
{
setCropName(value);
if (newErrors.name) {
setNewErrors((prev) => ({ ...prev, name: undefined }));
}
}}
placeholder={t('crops.namePlaceholder')}
style={inputStyle}
/>
{newErrors.name ? (
{newErrors.name}
) : null}
{t('crops.variety')}
{t('crops.addMedia')}
{normalizeMediaUri(photoUri) ? (
isVideoUri(normalizeMediaUri(photoUri) as string) ? (
) : (
{
setPendingZoomUri(normalizeMediaUri(photoUri) as string);
setNewModalOpen(false);
}}>
)
) : (
{t('crops.noPhoto')}
)}
handlePickPhoto(setPhotoUri)}
variant="secondary"
/>
handleTakePhoto(setPhotoUri, (code) =>
setStatus(
code === 'cameraDenied' ? t('tasks.cameraDenied') : t('tasks.cameraError')
)
)
}
variant="secondary"
/>
{t('crops.planting')}
setShowPlantingPicker(true)} style={styles.dateInput}>
{plantingDate ? formatDateLabel(plantingDate) : t('crops.plantingPlaceholder')}
{showPlantingPicker ? (
<>
{Platform.OS === 'ios' ? (
{
setPlantingDate(toDateOnly(new Date()));
setShowPlantingPicker(false);
}}
variant="secondary"
/>
setShowPlantingPicker(false)}
variant="secondary"
/>
) : null}
{
if (date) setPlantingDate(toDateOnly(date));
if (Platform.OS !== 'ios') {
setShowPlantingPicker(false);
}
}}
/>
>
) : null}
{t('crops.harvest')}
setShowHarvestPicker(true)} style={styles.dateInput}>
{harvestDate ? formatDateLabel(harvestDate) : t('crops.harvestPlaceholder')}
{showHarvestPicker ? (
<>
{Platform.OS === 'ios' ? (
{
setHarvestDate(toDateOnly(new Date()));
setShowHarvestPicker(false);
}}
variant="secondary"
/>
setShowHarvestPicker(false)}
variant="secondary"
/>
) : null}
{
if (date) setHarvestDate(toDateOnly(date));
if (Platform.OS !== 'ios') {
setShowHarvestPicker(false);
}
}}
/>
>
) : null}
setNewModalOpen(false)}
variant="secondary"
/>
{
const ok = await handleSave();
if (ok) setNewModalOpen(false);
}}
/>
)}
ListFooterComponent={}
/>
item.key}
contentContainerStyle={styles.sheetListContent}
renderItem={() => (
{t('crops.edit')}
{t('crops.field')}
*
{
setFieldModalTarget('edit');
setEditModalOpen(false);
setFieldModalOpen(true);
}}
variant="secondary"
/>
{editErrors.field ? (
{editErrors.field}
) : null}
{t('crops.name')}
*
{
setEditName(value);
if (editErrors.name) {
setEditErrors((prev) => ({ ...prev, name: undefined }));
}
}}
placeholder={t('crops.namePlaceholder')}
style={inputStyle}
/>
{editErrors.name ? (
{editErrors.name}
) : null}
{t('crops.variety')}
{t('crops.addMedia')}
{normalizeMediaUri(editPhotoUri) ? (
isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? (
) : (
{
setPendingZoomUri(normalizeMediaUri(editPhotoUri) as string);
setEditModalOpen(false);
}}>
)
) : (
{t('crops.noPhoto')}
)}
handlePickPhoto(setEditPhotoUri)}
variant="secondary"
/>
handleTakePhoto(setEditPhotoUri, (code) =>
setStatus(
code === 'cameraDenied' ? t('tasks.cameraDenied') : t('tasks.cameraError')
)
)
}
variant="secondary"
/>
{t('crops.planting')}
setShowEditPlantingPicker(true)} style={styles.dateInput}>
{editPlantingDate
? formatDateLabel(editPlantingDate)
: t('crops.plantingPlaceholder')}
{showEditPlantingPicker ? (
<>
{Platform.OS === 'ios' ? (
{
setEditPlantingDate(toDateOnly(new Date()));
setShowEditPlantingPicker(false);
}}
variant="secondary"
/>
setShowEditPlantingPicker(false)}
variant="secondary"
/>
) : null}
{
if (date) setEditPlantingDate(toDateOnly(date));
if (Platform.OS !== 'ios') {
setShowEditPlantingPicker(false);
}
}}
/>
>
) : null}
{t('crops.harvest')}
setShowEditHarvestPicker(true)} style={styles.dateInput}>
{editHarvestDate
? formatDateLabel(editHarvestDate)
: t('crops.harvestPlaceholder')}
{showEditHarvestPicker ? (
<>
{Platform.OS === 'ios' ? (
{
setEditHarvestDate(toDateOnly(new Date()));
setShowEditHarvestPicker(false);
}}
variant="secondary"
/>
setShowEditHarvestPicker(false)}
variant="secondary"
/>
) : null}
{
if (date) setEditHarvestDate(toDateOnly(date));
if (Platform.OS !== 'ios') {
setShowEditHarvestPicker(false);
}
}}
/>
>
) : null}
)}
ListFooterComponent={}
/>
setZoomUri(null)}
/>
>
);
}
function toDateOnly(date: Date) {
return date.toISOString().slice(0, 10);
}
function formatDateLabel(value: string) {
try {
return new Date(value).toLocaleDateString();
} catch {
return value;
}
}
async function handlePickPhoto(setter: (value: string | null) => void) {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: getMediaTypes(),
quality: 1,
});
if (result.canceled) return;
const asset = result.assets[0];
setter(asset.uri);
}
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];
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;
}
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,
},
card: {
borderRadius: 12,
borderWidth: 1,
borderColor: '#C6C6C6',
padding: 12,
marginHorizontal: 16,
gap: 6,
},
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: {
height: 200,
width: '100%',
borderRadius: 12,
},
videoPreview: {
width: '100%',
height: 200,
borderRadius: 12,
backgroundColor: '#1C1C1C',
},
photoRow: {
flexDirection: 'row',
gap: 8,
},
photoPlaceholder: {
opacity: 0.6,
},
dateInput: {
borderRadius: 10,
borderWidth: 1,
borderColor: '#B9B9B9',
paddingHorizontal: 12,
paddingVertical: 10,
},
dateValue: {
fontSize: 15,
opacity: 0.9,
},
pickerRow: {
flexDirection: 'row',
gap: 8,
},
buttonRow: {
alignSelf: 'flex-start',
},
separator: {
height: 12,
},
footer: {
height: 24,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.3)',
justifyContent: 'center',
padding: 16,
},
modalCard: {
borderRadius: 12,
borderWidth: 1,
borderColor: '#C6C6C6',
padding: 16,
backgroundColor: '#FFFFFF',
gap: 8,
maxHeight: '70%',
},
modalActions: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
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',
},
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,
},
sheetListContent: {
paddingBottom: 80,
},
sheetFooter: {
height: 24,
},
keyboardAvoid: {
width: '100%',
flex: 1,
justifyContent: 'flex-end',
},
modalItem: {
paddingVertical: 8,
},
});