import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
Image,
InteractionManager,
KeyboardAvoidingView,
Modal,
Pressable,
Platform,
StyleSheet,
TextInput,
View,
} from 'react-native';
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;
};
type ObservationRow = {
id: number;
field_id: number | null;
crop_id: number | null;
obs_type: string | null;
note: string | null;
severity: number | null;
observed_at: string | null;
field_name: string | null;
crop_name: string | null;
image_uri: string | null;
};
export default function ObservationsScreen() {
const { t } = useTranslation();
const router = useRouter();
const navigation = useNavigation();
const params = useLocalSearchParams<{ from?: string | string[] }>();
const theme = useColorScheme() ?? 'light';
const palette = Colors[theme];
const fromParam = Array.isArray(params.from) ? params.from[0] : params.from;
const presetTypes = [
{ key: 'scouting', value: 'Scouting' },
{ key: 'pest', value: 'Pest' },
{ key: 'disease', value: 'Disease' },
{ key: 'irrigation', value: 'Irrigation' },
{ key: 'weeds', value: 'Weeds' },
{ key: 'nutrients', value: 'Nutrients' },
];
useEffect(() => {
navigation.setOptions({
headerLeft: () => (
{
if (fromParam === 'logbook') {
router.replace('/logbook');
return;
}
if (fromParam === 'home') {
router.replace('/');
return;
}
router.back();
}}
hitSlop={10}
style={{ paddingHorizontal: 8 }}>
),
});
}, [fromParam, navigation, palette.text, router]);
const pageSize = 12;
const [observations, setObservations] = useState([]);
const [fields, setFields] = useState([]);
const [crops, setCrops] = useState([]);
const [status, setStatus] = useState(t('observations.loading'));
const [selectedFieldId, setSelectedFieldId] = useState(null);
const [selectedCropId, setSelectedCropId] = useState(null);
const [type, setType] = useState('scouting');
const [severity, setSeverity] = useState('');
const [note, setNote] = useState('');
const [photoUri, setPhotoUri] = useState(null);
const [fieldModalOpen, setFieldModalOpen] = useState(false);
const [cropModalOpen, setCropModalOpen] = useState(false);
const [newModalOpen, setNewModalOpen] = useState(false);
const [reopenSheetAfterSelect, setReopenSheetAfterSelect] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editFieldId, setEditFieldId] = useState(null);
const [editCropId, setEditCropId] = useState(null);
const [editType, setEditType] = useState('');
const [editSeverity, setEditSeverity] = useState('');
const [editNote, setEditNote] = useState('');
const [editPhotoUri, setEditPhotoUri] = useState(null);
const [zoomUri, setZoomUri] = useState(null);
const [pendingZoomUri, setPendingZoomUri] = useState(null);
const [editReopenAfterSelect, setEditReopenAfterSelect] = useState(false);
const [newErrors, setNewErrors] = useState<{ field?: string; severity?: string }>({});
const [editErrors, setEditErrors] = useState<{ field?: string; severity?: 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 selectedCrop = useMemo(
() => crops.find((item) => item.id === selectedCropId),
[crops, selectedCropId]
);
const selectedEditField = useMemo(
() => fields.find((item) => item.id === editFieldId),
[fields, editFieldId]
);
const selectedEditCrop = useMemo(
() => crops.find((item) => item.id === editCropId),
[crops, editCropId]
);
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;'
);
const cropRows = await db.getAllAsync(
'SELECT id, field_id, crop_name FROM crops ORDER BY id DESC;'
);
if (!isActive) return;
setFields(fieldRows);
setCrops(cropRows);
await fetchObservationsPage(1, true, isActive);
} catch (error) {
if (isActive) setStatus(`Error: ${String(error)}`);
}
}
loadData();
return () => {
isActive = false;
};
}, [t]);
useFocusEffect(
useCallback(() => {
fetchObservationsPage(1, true);
}, [])
);
async function fetchObservationsPage(
pageToLoad: number,
replace: boolean,
isActive = true
) {
try {
const db = await dbPromise;
const observationRows = await db.getAllAsync(
observationQuery,
pageSize,
(pageToLoad - 1) * pageSize
);
if (!isActive) return;
setObservations((prev) =>
replace ? observationRows : [...prev, ...observationRows]
);
setHasMore(observationRows.length === pageSize);
setPage(pageToLoad);
if (replace) {
setStatus(observationRows.length === 0 ? t('observations.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 fetchObservationsPage(nextPage, false);
}
const observationQuery = `
SELECT o.id, o.field_id, o.crop_id, o.obs_type, o.note, o.severity, o.observed_at,
f.name as field_name, c.crop_name as crop_name,
i.uri as image_uri
FROM observations o
LEFT JOIN fields f ON f.id = o.field_id
LEFT JOIN crops c ON c.id = o.crop_id
LEFT JOIN images i ON i.id = (
SELECT id FROM images WHERE observation_id = o.id ORDER BY created_at DESC LIMIT 1
)
ORDER BY o.observed_at DESC
LIMIT ? OFFSET ?;`;
async function handleSave() {
const parsedSeverity = severity.trim() ? Number(severity) : null;
const nextErrors: { field?: string; severity?: string } = {};
if (!selectedFieldId) {
nextErrors.field = t('observations.fieldRequired');
}
if (severity.trim() && !Number.isFinite(parsedSeverity)) {
nextErrors.severity = t('observations.severityInvalid');
}
setNewErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) {
setStatus(nextErrors.field ?? nextErrors.severity ?? t('observations.fieldRequired'));
return false;
}
try {
const db = await dbPromise;
const now = new Date().toISOString();
const result = await db.runAsync(
'INSERT INTO observations (field_id, crop_id, obs_type, note, severity, observed_at) VALUES (?, ?, ?, ?, ?, ?);',
selectedFieldId,
selectedCropId,
type.trim() || null,
note.trim() || null,
parsedSeverity,
now
);
const newId = result.lastInsertRowId;
if (photoUri && newId) {
await db.runAsync(
'INSERT INTO images (observation_id, uri, created_at) VALUES (?, ?, ?);',
newId,
photoUri,
now
);
}
setNote('');
setSeverity('');
setPhotoUri(null);
setNewErrors({});
setStatus(t('observations.saved'));
await fetchObservationsPage(1, true);
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 images WHERE observation_id = ?;', id);
await db.runAsync('DELETE FROM observations WHERE id = ?;', id);
await fetchObservationsPage(1, true);
} catch (error) {
setStatus(`Error: ${String(error)}`);
}
}
function startEdit(obs: ObservationRow) {
router.push(`/observations/${obs.id}`);
}
function cancelEdit() {
setEditingId(null);
setEditFieldId(null);
setEditCropId(null);
setEditType('');
setEditSeverity('');
setEditNote('');
setEditPhotoUri(null);
setEditErrors({});
setEditModalOpen(false);
}
async function handleUpdate() {
if (!editingId) return;
const parsedSeverity = editSeverity.trim() ? Number(editSeverity) : null;
const nextErrors: { field?: string; severity?: string } = {};
if (!editFieldId) {
nextErrors.field = t('observations.fieldRequired');
}
if (editSeverity.trim() && !Number.isFinite(parsedSeverity)) {
nextErrors.severity = t('observations.severityInvalid');
}
setEditErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) {
setStatus(nextErrors.field ?? nextErrors.severity ?? t('observations.fieldRequired'));
return;
}
try {
const db = await dbPromise;
await db.runAsync(
'UPDATE observations SET field_id = ?, crop_id = ?, obs_type = ?, note = ?, severity = ? WHERE id = ?;',
editFieldId,
editCropId,
editType.trim() || null,
editNote.trim() || null,
parsedSeverity,
editingId
);
await db.runAsync('DELETE FROM images WHERE observation_id = ?;', editingId);
if (editPhotoUri) {
await db.runAsync(
'INSERT INTO images (observation_id, uri, created_at) VALUES (?, ?, ?);',
editingId,
editPhotoUri,
new Date().toISOString()
);
}
setEditModalOpen(false);
setEditingId(null);
setEditErrors({});
setStatus(t('observations.saved'));
await fetchObservationsPage(1, true);
} catch (error) {
setStatus(`Error: ${String(error)}`);
}
}
const inputStyle = [
styles.input,
{ borderColor: palette.border, backgroundColor: palette.input, color: palette.text },
];
return (
<>
String(item.id)}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.4}
renderItem={({ item }) => (
startEdit(item)}>
{item.obs_type || t('observations.untitled')}
{item.field_name || t('observations.noField')} ยท{' '}
{item.crop_name || t('observations.noCrop')}
{item.severity !== null ? (
{t('observations.severityLabel')} {item.severity}
) : null}
{item.note ? {item.note} : null}
{normalizeMediaUri(item.image_uri) ? (
isVideoUri(normalizeMediaUri(item.image_uri) as string) ? (
) : (
setZoomUri(normalizeMediaUri(item.image_uri) as string)}>
)
) : null}
{item.observed_at ? (
{formatDate(item.observed_at)}
) : null}
handleDelete(item.id)}
accessibilityLabel={t('observations.delete')}
variant="danger"
/>
)}
ItemSeparatorComponent={() => }
ListHeaderComponent={
{t('observations.title')}
{status ? (
{status}
) : null}
{
router.push('/observations/new');
}}>
{t('observations.new')}
}
ListFooterComponent={
{loadingMore ? : null}
}
/>
{
setFieldModalOpen(false);
setReopenSheetAfterSelect(false);
setEditReopenAfterSelect(false);
}}>
{t('observations.selectField')}
String(item.id)}
renderItem={({ item }) => (
{
if (editReopenAfterSelect) {
setEditFieldId(item.id);
setEditCropId(null);
setEditErrors((prev) => ({ ...prev, field: undefined }));
setEditModalOpen(true);
setEditReopenAfterSelect(false);
} else {
setSelectedFieldId(item.id);
setSelectedCropId(null);
setNewErrors((prev) => ({ ...prev, field: undefined }));
setNewModalOpen(true);
setReopenSheetAfterSelect(false);
}
setFieldModalOpen(false);
}}
style={styles.modalItem}>
{item.name || t('observations.untitled')}
)}
/>
{
setCropModalOpen(false);
setReopenSheetAfterSelect(false);
setEditReopenAfterSelect(false);
}}>
{t('observations.selectCrop')}
!selectedFieldId || item.field_id === selectedFieldId)}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => (
{
if (editReopenAfterSelect) {
setEditCropId(item.id);
setEditModalOpen(true);
setEditReopenAfterSelect(false);
} else {
setSelectedCropId(item.id);
setNewModalOpen(true);
setReopenSheetAfterSelect(false);
}
setCropModalOpen(false);
}}
style={styles.modalItem}>
{item.crop_name || t('observations.untitled')}
)}
ListEmptyComponent={
{t('observations.noCrop')}
}
/>
setNewModalOpen(false)} />
item.key}
contentContainerStyle={styles.sheetListContent}
renderItem={() => (
{t('observations.new')}
{t('observations.field')}
*
{
setReopenSheetAfterSelect(true);
setNewModalOpen(false);
setFieldModalOpen(true);
}}
variant="secondary"
/>
{newErrors.field ? (
{newErrors.field}
) : null}
{t('observations.crop')}
{
setReopenSheetAfterSelect(true);
setNewModalOpen(false);
setCropModalOpen(true);
}}
variant="secondary"
/>
{t('observations.type')}
{presetTypes.map((preset) => {
const label = t(`observations.type.${preset.key}`);
const normalized = type.trim().toLowerCase();
const isActive =
label.toLowerCase() === normalized ||
preset.value.toLowerCase() === normalized;
return (
setType(label)}
style={[styles.chip, isActive && styles.chipActive]}>
{label}
);
})}
{t('observations.severity')}
{
setSeverity(value);
if (newErrors.severity) {
setNewErrors((prev) => ({ ...prev, severity: undefined }));
}
}}
placeholder={t('observations.severityPlaceholder')}
style={inputStyle}
keyboardType="numeric"
/>
{newErrors.severity ? (
{newErrors.severity}
) : null}
{t('observations.note')}
{t('observations.addMedia')}
{normalizeMediaUri(photoUri) ? (
isVideoUri(normalizeMediaUri(photoUri) as string) ? (
) : (
{
setPendingZoomUri(normalizeMediaUri(photoUri) as string);
setNewModalOpen(false);
}}>
)
) : (
{t('observations.noPhoto')}
)}
handlePickPhoto(setPhotoUri)}
variant="secondary"
/>
handleTakePhoto(setPhotoUri, (code) =>
setStatus(
code === 'cameraDenied'
? t('tasks.cameraDenied')
: t('tasks.cameraError')
)
)
}
variant="secondary"
/>
setNewModalOpen(false)}
variant="secondary"
/>
{
const ok = await handleSave();
if (ok) setNewModalOpen(false);
}}
/>
)}
/>
item.key}
contentContainerStyle={styles.sheetListContent}
renderItem={() => (
{t('observations.edit')}
{t('observations.field')}
*
{
setEditReopenAfterSelect(true);
setEditModalOpen(false);
setFieldModalOpen(true);
}}
variant="secondary"
/>
{editErrors.field ? (
{editErrors.field}
) : null}
{t('observations.crop')}
{
setEditReopenAfterSelect(true);
setEditModalOpen(false);
setCropModalOpen(true);
}}
variant="secondary"
/>
{t('observations.type')}
{presetTypes.map((preset) => {
const label = t(`observations.type.${preset.key}`);
const normalized = editType.trim().toLowerCase();
const isActive =
label.toLowerCase() === normalized ||
preset.value.toLowerCase() === normalized;
return (
setEditType(label)}
style={[styles.chip, isActive && styles.chipActive]}>
{label}
);
})}
{t('observations.severity')}
{
setEditSeverity(value);
if (editErrors.severity) {
setEditErrors((prev) => ({ ...prev, severity: undefined }));
}
}}
placeholder={t('observations.severityPlaceholder')}
style={inputStyle}
keyboardType="numeric"
/>
{editErrors.severity ? (
{editErrors.severity}
) : null}
{t('observations.note')}
{t('observations.addMedia')}
{normalizeMediaUri(editPhotoUri) ? (
isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? (
) : (
{
setPendingZoomUri(normalizeMediaUri(editPhotoUri) as string);
setEditModalOpen(false);
}}>
)
) : (
{t('observations.noPhoto')}
)}
handlePickPhoto(setEditPhotoUri)}
variant="secondary"
/>
handleTakePhoto(setEditPhotoUri, (code) =>
setStatus(
code === 'cameraDenied'
? t('tasks.cameraDenied')
: t('tasks.cameraError')
)
)
}
variant="secondary"
/>
)}
/>
setZoomUri(null)}
/>
>
);
}
function formatDate(value: string) {
try {
return new Date(value).toLocaleString();
} 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];
console.log('[Observations] Picked photo:', asset.uri);
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];
console.log('[Observations] Captured photo:', 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;
}
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,
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,
},
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%',
},
modalItem: {
paddingVertical: 8,
},
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',
},
modalActions: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
chip: {
borderRadius: 999,
borderWidth: 1,
borderColor: '#C6C6C6',
paddingHorizontal: 12,
paddingVertical: 6,
},
chipActive: {
borderColor: '#2F7D4F',
backgroundColor: '#E7F3EA',
},
chipText: {
fontSize: 13,
},
chipTextActive: {
fontSize: 13,
color: '#2F7D4F',
fontWeight: '600',
},
photoRow: {
flexDirection: 'row',
gap: 12,
},
photoPlaceholder: {
opacity: 0.6,
},
photoPreview: {
width: '100%',
height: 200,
borderRadius: 12,
},
videoPreview: {
width: '100%',
height: 200,
borderRadius: 12,
backgroundColor: '#1C1C1C',
},
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: 8,
},
sheetListContent: {
paddingBottom: 80,
},
sheetFooter: {
height: 24,
},
keyboardAvoid: {
width: '100%',
flex: 1,
justifyContent: 'flex-end',
},
});