Geen omschrijving

index.tsx 37KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import {
  3. ActivityIndicator,
  4. FlatList,
  5. Image,
  6. InteractionManager,
  7. KeyboardAvoidingView,
  8. Modal,
  9. Pressable,
  10. Platform,
  11. StyleSheet,
  12. TextInput,
  13. View,
  14. } from 'react-native';
  15. import * as ImagePicker from 'expo-image-picker';
  16. import { ResizeMode, Video } from 'expo-av';
  17. import { ThemedText } from '@/components/themed-text';
  18. import { ThemedView } from '@/components/themed-view';
  19. import { IconSymbol } from '@/components/ui/icon-symbol';
  20. import { Colors, Fonts } from '@/constants/theme';
  21. import { useTranslation } from '@/localization/i18n';
  22. import { dbPromise, initCoreTables } from '@/services/db';
  23. import { useColorScheme } from '@/hooks/use-color-scheme';
  24. import { ThemedButton } from '@/components/themed-button';
  25. import { IconButton } from '@/components/icon-button';
  26. import { ZoomImageModal } from '@/components/zoom-image-modal';
  27. import { useLocalSearchParams, useRouter } from 'expo-router';
  28. import { useFocusEffect, useNavigation } from '@react-navigation/native';
  29. type FieldRow = {
  30. id: number;
  31. name: string | null;
  32. };
  33. type CropRow = {
  34. id: number;
  35. field_id: number | null;
  36. crop_name: string | null;
  37. };
  38. type ObservationRow = {
  39. id: number;
  40. field_id: number | null;
  41. crop_id: number | null;
  42. obs_type: string | null;
  43. note: string | null;
  44. severity: number | null;
  45. observed_at: string | null;
  46. field_name: string | null;
  47. crop_name: string | null;
  48. image_uri: string | null;
  49. };
  50. export default function ObservationsScreen() {
  51. const { t } = useTranslation();
  52. const router = useRouter();
  53. const navigation = useNavigation();
  54. const params = useLocalSearchParams<{ from?: string | string[] }>();
  55. const theme = useColorScheme() ?? 'light';
  56. const palette = Colors[theme];
  57. const fromParam = Array.isArray(params.from) ? params.from[0] : params.from;
  58. const presetTypes = [
  59. { key: 'scouting', value: 'Scouting' },
  60. { key: 'pest', value: 'Pest' },
  61. { key: 'disease', value: 'Disease' },
  62. { key: 'irrigation', value: 'Irrigation' },
  63. { key: 'weeds', value: 'Weeds' },
  64. { key: 'nutrients', value: 'Nutrients' },
  65. ];
  66. useEffect(() => {
  67. navigation.setOptions({
  68. headerLeft: () => (
  69. <Pressable
  70. onPress={() => {
  71. if (fromParam === 'logbook') {
  72. router.replace('/logbook');
  73. return;
  74. }
  75. if (fromParam === 'home') {
  76. router.replace('/');
  77. return;
  78. }
  79. router.back();
  80. }}
  81. hitSlop={10}
  82. style={{ paddingHorizontal: 8 }}>
  83. <IconSymbol name="chevron.left" size={20} color={palette.text} />
  84. </Pressable>
  85. ),
  86. });
  87. }, [fromParam, navigation, palette.text, router]);
  88. const pageSize = 12;
  89. const [observations, setObservations] = useState<ObservationRow[]>([]);
  90. const [fields, setFields] = useState<FieldRow[]>([]);
  91. const [crops, setCrops] = useState<CropRow[]>([]);
  92. const [status, setStatus] = useState(t('observations.loading'));
  93. const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
  94. const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
  95. const [type, setType] = useState('scouting');
  96. const [severity, setSeverity] = useState('');
  97. const [note, setNote] = useState('');
  98. const [photoUri, setPhotoUri] = useState<string | null>(null);
  99. const [fieldModalOpen, setFieldModalOpen] = useState(false);
  100. const [cropModalOpen, setCropModalOpen] = useState(false);
  101. const [newModalOpen, setNewModalOpen] = useState(false);
  102. const [reopenSheetAfterSelect, setReopenSheetAfterSelect] = useState(false);
  103. const [editModalOpen, setEditModalOpen] = useState(false);
  104. const [editingId, setEditingId] = useState<number | null>(null);
  105. const [editFieldId, setEditFieldId] = useState<number | null>(null);
  106. const [editCropId, setEditCropId] = useState<number | null>(null);
  107. const [editType, setEditType] = useState('');
  108. const [editSeverity, setEditSeverity] = useState('');
  109. const [editNote, setEditNote] = useState('');
  110. const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
  111. const [zoomUri, setZoomUri] = useState<string | null>(null);
  112. const [pendingZoomUri, setPendingZoomUri] = useState<string | null>(null);
  113. const [editReopenAfterSelect, setEditReopenAfterSelect] = useState(false);
  114. const [newErrors, setNewErrors] = useState<{ field?: string; severity?: string }>({});
  115. const [editErrors, setEditErrors] = useState<{ field?: string; severity?: string }>({});
  116. const [page, setPage] = useState(1);
  117. const [hasMore, setHasMore] = useState(true);
  118. const [loadingMore, setLoadingMore] = useState(false);
  119. useEffect(() => {
  120. if (!newModalOpen && !editModalOpen && pendingZoomUri) {
  121. const uri = pendingZoomUri;
  122. setPendingZoomUri(null);
  123. InteractionManager.runAfterInteractions(() => {
  124. setTimeout(() => setZoomUri(uri), 150);
  125. });
  126. }
  127. }, [newModalOpen, editModalOpen, pendingZoomUri]);
  128. const selectedField = useMemo(
  129. () => fields.find((item) => item.id === selectedFieldId),
  130. [fields, selectedFieldId]
  131. );
  132. const selectedCrop = useMemo(
  133. () => crops.find((item) => item.id === selectedCropId),
  134. [crops, selectedCropId]
  135. );
  136. const selectedEditField = useMemo(
  137. () => fields.find((item) => item.id === editFieldId),
  138. [fields, editFieldId]
  139. );
  140. const selectedEditCrop = useMemo(
  141. () => crops.find((item) => item.id === editCropId),
  142. [crops, editCropId]
  143. );
  144. useEffect(() => {
  145. let isActive = true;
  146. async function loadData() {
  147. try {
  148. await initCoreTables();
  149. const db = await dbPromise;
  150. const fieldRows = await db.getAllAsync<FieldRow>(
  151. 'SELECT id, name FROM fields ORDER BY name ASC;'
  152. );
  153. const cropRows = await db.getAllAsync<CropRow>(
  154. 'SELECT id, field_id, crop_name FROM crops ORDER BY id DESC;'
  155. );
  156. if (!isActive) return;
  157. setFields(fieldRows);
  158. setCrops(cropRows);
  159. await fetchObservationsPage(1, true, isActive);
  160. } catch (error) {
  161. if (isActive) setStatus(`Error: ${String(error)}`);
  162. }
  163. }
  164. loadData();
  165. return () => {
  166. isActive = false;
  167. };
  168. }, [t]);
  169. useFocusEffect(
  170. useCallback(() => {
  171. fetchObservationsPage(1, true);
  172. }, [])
  173. );
  174. async function fetchObservationsPage(
  175. pageToLoad: number,
  176. replace: boolean,
  177. isActive = true
  178. ) {
  179. try {
  180. const db = await dbPromise;
  181. const observationRows = await db.getAllAsync<ObservationRow>(
  182. observationQuery,
  183. pageSize,
  184. (pageToLoad - 1) * pageSize
  185. );
  186. if (!isActive) return;
  187. setObservations((prev) =>
  188. replace ? observationRows : [...prev, ...observationRows]
  189. );
  190. setHasMore(observationRows.length === pageSize);
  191. setPage(pageToLoad);
  192. if (replace) {
  193. setStatus(observationRows.length === 0 ? t('observations.empty') : '');
  194. }
  195. } catch (error) {
  196. if (isActive) setStatus(`Error: ${String(error)}`);
  197. } finally {
  198. if (isActive) setLoadingMore(false);
  199. }
  200. }
  201. async function handleLoadMore() {
  202. if (loadingMore || !hasMore) return;
  203. setLoadingMore(true);
  204. const nextPage = page + 1;
  205. await fetchObservationsPage(nextPage, false);
  206. }
  207. const observationQuery = `
  208. SELECT o.id, o.field_id, o.crop_id, o.obs_type, o.note, o.severity, o.observed_at,
  209. f.name as field_name, c.crop_name as crop_name,
  210. i.uri as image_uri
  211. FROM observations o
  212. LEFT JOIN fields f ON f.id = o.field_id
  213. LEFT JOIN crops c ON c.id = o.crop_id
  214. LEFT JOIN images i ON i.id = (
  215. SELECT id FROM images WHERE observation_id = o.id ORDER BY created_at DESC LIMIT 1
  216. )
  217. ORDER BY o.observed_at DESC
  218. LIMIT ? OFFSET ?;`;
  219. async function handleSave() {
  220. const parsedSeverity = severity.trim() ? Number(severity) : null;
  221. const nextErrors: { field?: string; severity?: string } = {};
  222. if (!selectedFieldId) {
  223. nextErrors.field = t('observations.fieldRequired');
  224. }
  225. if (severity.trim() && !Number.isFinite(parsedSeverity)) {
  226. nextErrors.severity = t('observations.severityInvalid');
  227. }
  228. setNewErrors(nextErrors);
  229. if (Object.keys(nextErrors).length > 0) {
  230. setStatus(nextErrors.field ?? nextErrors.severity ?? t('observations.fieldRequired'));
  231. return false;
  232. }
  233. try {
  234. const db = await dbPromise;
  235. const now = new Date().toISOString();
  236. const result = await db.runAsync(
  237. 'INSERT INTO observations (field_id, crop_id, obs_type, note, severity, observed_at) VALUES (?, ?, ?, ?, ?, ?);',
  238. selectedFieldId,
  239. selectedCropId,
  240. type.trim() || null,
  241. note.trim() || null,
  242. parsedSeverity,
  243. now
  244. );
  245. const newId = result.lastInsertRowId;
  246. if (photoUri && newId) {
  247. await db.runAsync(
  248. 'INSERT INTO images (observation_id, uri, created_at) VALUES (?, ?, ?);',
  249. newId,
  250. photoUri,
  251. now
  252. );
  253. }
  254. setNote('');
  255. setSeverity('');
  256. setPhotoUri(null);
  257. setNewErrors({});
  258. setStatus(t('observations.saved'));
  259. await fetchObservationsPage(1, true);
  260. return true;
  261. } catch (error) {
  262. setStatus(`Error: ${String(error)}`);
  263. return false;
  264. }
  265. }
  266. async function handleDelete(id: number) {
  267. try {
  268. const db = await dbPromise;
  269. await db.runAsync('DELETE FROM images WHERE observation_id = ?;', id);
  270. await db.runAsync('DELETE FROM observations WHERE id = ?;', id);
  271. await fetchObservationsPage(1, true);
  272. } catch (error) {
  273. setStatus(`Error: ${String(error)}`);
  274. }
  275. }
  276. function startEdit(obs: ObservationRow) {
  277. router.push(`/observations/${obs.id}`);
  278. }
  279. function cancelEdit() {
  280. setEditingId(null);
  281. setEditFieldId(null);
  282. setEditCropId(null);
  283. setEditType('');
  284. setEditSeverity('');
  285. setEditNote('');
  286. setEditPhotoUri(null);
  287. setEditErrors({});
  288. setEditModalOpen(false);
  289. }
  290. async function handleUpdate() {
  291. if (!editingId) return;
  292. const parsedSeverity = editSeverity.trim() ? Number(editSeverity) : null;
  293. const nextErrors: { field?: string; severity?: string } = {};
  294. if (!editFieldId) {
  295. nextErrors.field = t('observations.fieldRequired');
  296. }
  297. if (editSeverity.trim() && !Number.isFinite(parsedSeverity)) {
  298. nextErrors.severity = t('observations.severityInvalid');
  299. }
  300. setEditErrors(nextErrors);
  301. if (Object.keys(nextErrors).length > 0) {
  302. setStatus(nextErrors.field ?? nextErrors.severity ?? t('observations.fieldRequired'));
  303. return;
  304. }
  305. try {
  306. const db = await dbPromise;
  307. await db.runAsync(
  308. 'UPDATE observations SET field_id = ?, crop_id = ?, obs_type = ?, note = ?, severity = ? WHERE id = ?;',
  309. editFieldId,
  310. editCropId,
  311. editType.trim() || null,
  312. editNote.trim() || null,
  313. parsedSeverity,
  314. editingId
  315. );
  316. await db.runAsync('DELETE FROM images WHERE observation_id = ?;', editingId);
  317. if (editPhotoUri) {
  318. await db.runAsync(
  319. 'INSERT INTO images (observation_id, uri, created_at) VALUES (?, ?, ?);',
  320. editingId,
  321. editPhotoUri,
  322. new Date().toISOString()
  323. );
  324. }
  325. setEditModalOpen(false);
  326. setEditingId(null);
  327. setEditErrors({});
  328. setStatus(t('observations.saved'));
  329. await fetchObservationsPage(1, true);
  330. } catch (error) {
  331. setStatus(`Error: ${String(error)}`);
  332. }
  333. }
  334. const inputStyle = [
  335. styles.input,
  336. { borderColor: palette.border, backgroundColor: palette.input, color: palette.text },
  337. ];
  338. return (
  339. <>
  340. <FlatList
  341. data={observations}
  342. keyExtractor={(item) => String(item.id)}
  343. onEndReached={handleLoadMore}
  344. onEndReachedThreshold={0.4}
  345. renderItem={({ item }) => (
  346. <Pressable onPress={() => startEdit(item)}>
  347. <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
  348. <ThemedText type="subtitle">{item.obs_type || t('observations.untitled')}</ThemedText>
  349. <ThemedText style={styles.meta}>
  350. {item.field_name || t('observations.noField')} ·{' '}
  351. {item.crop_name || t('observations.noCrop')}
  352. </ThemedText>
  353. {item.severity !== null ? (
  354. <ThemedText style={styles.meta}>
  355. {t('observations.severityLabel')} {item.severity}
  356. </ThemedText>
  357. ) : null}
  358. {item.note ? <ThemedText>{item.note}</ThemedText> : null}
  359. {normalizeMediaUri(item.image_uri) ? (
  360. isVideoUri(normalizeMediaUri(item.image_uri) as string) ? (
  361. <Video
  362. source={{ uri: normalizeMediaUri(item.image_uri) as string }}
  363. style={styles.videoPreview}
  364. useNativeControls
  365. resizeMode={ResizeMode.CONTAIN}
  366. isMuted
  367. />
  368. ) : (
  369. <Pressable onPress={() => setZoomUri(normalizeMediaUri(item.image_uri) as string)}>
  370. <Image
  371. source={{ uri: normalizeMediaUri(item.image_uri) as string }}
  372. style={styles.photoPreview}
  373. resizeMode="contain"
  374. />
  375. </Pressable>
  376. )
  377. ) : null}
  378. {item.observed_at ? (
  379. <ThemedText style={styles.meta}>{formatDate(item.observed_at)}</ThemedText>
  380. ) : null}
  381. <View style={styles.buttonRow}>
  382. <IconButton
  383. name="trash"
  384. onPress={() => handleDelete(item.id)}
  385. accessibilityLabel={t('observations.delete')}
  386. variant="danger"
  387. />
  388. </View>
  389. </ThemedView>
  390. </Pressable>
  391. )}
  392. ItemSeparatorComponent={() => <View style={styles.separator} />}
  393. ListHeaderComponent={
  394. <View>
  395. <ThemedView style={styles.hero}>
  396. <Image source={require('@/assets/images/observations.jpg')} style={styles.heroImage} />
  397. </ThemedView>
  398. <ThemedView style={styles.titleContainer}>
  399. <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
  400. {t('observations.title')}
  401. </ThemedText>
  402. </ThemedView>
  403. {status ? (
  404. <ThemedView style={styles.section}>
  405. <ThemedText>{status}</ThemedText>
  406. </ThemedView>
  407. ) : null}
  408. <ThemedView style={styles.section}>
  409. <Pressable
  410. style={styles.newButton}
  411. onPress={() => {
  412. router.push('/observations/new');
  413. }}>
  414. <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
  415. <ThemedText style={styles.newButtonText}>{t('observations.new')}</ThemedText>
  416. </Pressable>
  417. </ThemedView>
  418. </View>
  419. }
  420. ListFooterComponent={
  421. <View style={styles.footer}>
  422. {loadingMore ? <ActivityIndicator /> : null}
  423. </View>
  424. }
  425. />
  426. <Modal transparent visible={fieldModalOpen} animationType="fade">
  427. <Pressable
  428. style={styles.modalOverlay}
  429. onPress={() => {
  430. setFieldModalOpen(false);
  431. setReopenSheetAfterSelect(false);
  432. setEditReopenAfterSelect(false);
  433. }}>
  434. <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
  435. <ThemedText type="subtitle">{t('observations.selectField')}</ThemedText>
  436. <FlatList
  437. data={fields}
  438. keyExtractor={(item) => String(item.id)}
  439. renderItem={({ item }) => (
  440. <Pressable
  441. onPress={() => {
  442. if (editReopenAfterSelect) {
  443. setEditFieldId(item.id);
  444. setEditCropId(null);
  445. setEditErrors((prev) => ({ ...prev, field: undefined }));
  446. setEditModalOpen(true);
  447. setEditReopenAfterSelect(false);
  448. } else {
  449. setSelectedFieldId(item.id);
  450. setSelectedCropId(null);
  451. setNewErrors((prev) => ({ ...prev, field: undefined }));
  452. setNewModalOpen(true);
  453. setReopenSheetAfterSelect(false);
  454. }
  455. setFieldModalOpen(false);
  456. }}
  457. style={styles.modalItem}>
  458. <ThemedText>{item.name || t('observations.untitled')}</ThemedText>
  459. </Pressable>
  460. )}
  461. />
  462. </View>
  463. </Pressable>
  464. </Modal>
  465. <Modal transparent visible={cropModalOpen} animationType="fade">
  466. <Pressable
  467. style={styles.modalOverlay}
  468. onPress={() => {
  469. setCropModalOpen(false);
  470. setReopenSheetAfterSelect(false);
  471. setEditReopenAfterSelect(false);
  472. }}>
  473. <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
  474. <ThemedText type="subtitle">{t('observations.selectCrop')}</ThemedText>
  475. <FlatList
  476. data={crops.filter((item) => !selectedFieldId || item.field_id === selectedFieldId)}
  477. keyExtractor={(item) => String(item.id)}
  478. renderItem={({ item }) => (
  479. <Pressable
  480. onPress={() => {
  481. if (editReopenAfterSelect) {
  482. setEditCropId(item.id);
  483. setEditModalOpen(true);
  484. setEditReopenAfterSelect(false);
  485. } else {
  486. setSelectedCropId(item.id);
  487. setNewModalOpen(true);
  488. setReopenSheetAfterSelect(false);
  489. }
  490. setCropModalOpen(false);
  491. }}
  492. style={styles.modalItem}>
  493. <ThemedText>{item.crop_name || t('observations.untitled')}</ThemedText>
  494. </Pressable>
  495. )}
  496. ListEmptyComponent={
  497. <ThemedText style={styles.meta}>{t('observations.noCrop')}</ThemedText>
  498. }
  499. />
  500. </View>
  501. </Pressable>
  502. </Modal>
  503. <Modal transparent visible={newModalOpen} animationType="slide">
  504. <View style={styles.sheetOverlay}>
  505. <Pressable style={styles.sheetBackdrop} onPress={() => setNewModalOpen(false)} />
  506. <KeyboardAvoidingView
  507. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  508. keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
  509. style={styles.keyboardAvoid}>
  510. <View style={[styles.sheet, { backgroundColor: palette.card, borderColor: palette.border }]}>
  511. <FlatList
  512. data={[{ key: 'new' }]}
  513. keyExtractor={(item) => item.key}
  514. contentContainerStyle={styles.sheetListContent}
  515. renderItem={() => (
  516. <View style={styles.sheetContent}>
  517. <ThemedText type="subtitle">{t('observations.new')}</ThemedText>
  518. <ThemedText>
  519. {t('observations.field')}
  520. <ThemedText style={styles.requiredMark}> *</ThemedText>
  521. </ThemedText>
  522. <ThemedButton
  523. title={selectedField?.name || t('observations.selectField')}
  524. onPress={() => {
  525. setReopenSheetAfterSelect(true);
  526. setNewModalOpen(false);
  527. setFieldModalOpen(true);
  528. }}
  529. variant="secondary"
  530. />
  531. {newErrors.field ? (
  532. <ThemedText style={styles.errorText}>{newErrors.field}</ThemedText>
  533. ) : null}
  534. <ThemedText>{t('observations.crop')}</ThemedText>
  535. <ThemedButton
  536. title={selectedCrop?.crop_name || t('observations.selectCrop')}
  537. onPress={() => {
  538. setReopenSheetAfterSelect(true);
  539. setNewModalOpen(false);
  540. setCropModalOpen(true);
  541. }}
  542. variant="secondary"
  543. />
  544. <ThemedText>{t('observations.type')}</ThemedText>
  545. <View style={styles.chipRow}>
  546. {presetTypes.map((preset) => {
  547. const label = t(`observations.type.${preset.key}`);
  548. const normalized = type.trim().toLowerCase();
  549. const isActive =
  550. label.toLowerCase() === normalized ||
  551. preset.value.toLowerCase() === normalized;
  552. return (
  553. <Pressable
  554. key={preset.key}
  555. onPress={() => setType(label)}
  556. style={[styles.chip, isActive && styles.chipActive]}>
  557. <ThemedText style={isActive ? styles.chipTextActive : styles.chipText}>
  558. {label}
  559. </ThemedText>
  560. </Pressable>
  561. );
  562. })}
  563. </View>
  564. <TextInput
  565. value={type}
  566. onChangeText={setType}
  567. placeholder={t('observations.typePlaceholder')}
  568. style={inputStyle}
  569. />
  570. <ThemedText>{t('observations.severity')}</ThemedText>
  571. <TextInput
  572. value={severity}
  573. onChangeText={(value) => {
  574. setSeverity(value);
  575. if (newErrors.severity) {
  576. setNewErrors((prev) => ({ ...prev, severity: undefined }));
  577. }
  578. }}
  579. placeholder={t('observations.severityPlaceholder')}
  580. style={inputStyle}
  581. keyboardType="numeric"
  582. />
  583. {newErrors.severity ? (
  584. <ThemedText style={styles.errorText}>{newErrors.severity}</ThemedText>
  585. ) : null}
  586. <ThemedText>{t('observations.note')}</ThemedText>
  587. <TextInput
  588. value={note}
  589. onChangeText={setNote}
  590. placeholder={t('observations.notePlaceholder')}
  591. style={inputStyle}
  592. multiline
  593. />
  594. <ThemedText>{t('observations.addMedia')}</ThemedText>
  595. {normalizeMediaUri(photoUri) ? (
  596. isVideoUri(normalizeMediaUri(photoUri) as string) ? (
  597. <Video
  598. source={{ uri: normalizeMediaUri(photoUri) as string }}
  599. style={styles.videoPreview}
  600. useNativeControls
  601. resizeMode={ResizeMode.CONTAIN}
  602. isMuted
  603. />
  604. ) : (
  605. <Pressable
  606. onPress={() => {
  607. setPendingZoomUri(normalizeMediaUri(photoUri) as string);
  608. setNewModalOpen(false);
  609. }}>
  610. <Image
  611. source={{ uri: normalizeMediaUri(photoUri) as string }}
  612. style={styles.photoPreview}
  613. resizeMode="contain"
  614. />
  615. </Pressable>
  616. )
  617. ) : (
  618. <ThemedText style={styles.photoPlaceholder}>{t('observations.noPhoto')}</ThemedText>
  619. )}
  620. <View style={styles.photoRow}>
  621. <ThemedButton
  622. title={t('observations.pickFromGallery')}
  623. onPress={() => handlePickPhoto(setPhotoUri)}
  624. variant="secondary"
  625. />
  626. <ThemedButton
  627. title={t('observations.takeMedia')}
  628. onPress={() =>
  629. handleTakePhoto(setPhotoUri, (code) =>
  630. setStatus(
  631. code === 'cameraDenied'
  632. ? t('tasks.cameraDenied')
  633. : t('tasks.cameraError')
  634. )
  635. )
  636. }
  637. variant="secondary"
  638. />
  639. </View>
  640. <View style={styles.modalActions}>
  641. <ThemedButton
  642. title={t('observations.cancel')}
  643. onPress={() => setNewModalOpen(false)}
  644. variant="secondary"
  645. />
  646. <ThemedButton
  647. title={t('observations.save')}
  648. onPress={async () => {
  649. const ok = await handleSave();
  650. if (ok) setNewModalOpen(false);
  651. }}
  652. />
  653. </View>
  654. <View style={styles.sheetFooter} />
  655. </View>
  656. )}
  657. />
  658. </View>
  659. </KeyboardAvoidingView>
  660. </View>
  661. </Modal>
  662. <Modal transparent visible={editModalOpen} animationType="slide">
  663. <View style={styles.sheetOverlay}>
  664. <Pressable style={styles.sheetBackdrop} onPress={cancelEdit} />
  665. <KeyboardAvoidingView
  666. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  667. keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
  668. style={styles.keyboardAvoid}>
  669. <View style={[styles.sheet, { backgroundColor: palette.card, borderColor: palette.border }]}>
  670. <FlatList
  671. data={[{ key: 'edit' }]}
  672. keyExtractor={(item) => item.key}
  673. contentContainerStyle={styles.sheetListContent}
  674. renderItem={() => (
  675. <View style={styles.sheetContent}>
  676. <ThemedText type="subtitle">{t('observations.edit')}</ThemedText>
  677. <ThemedText>
  678. {t('observations.field')}
  679. <ThemedText style={styles.requiredMark}> *</ThemedText>
  680. </ThemedText>
  681. <ThemedButton
  682. title={selectedEditField?.name || t('observations.selectField')}
  683. onPress={() => {
  684. setEditReopenAfterSelect(true);
  685. setEditModalOpen(false);
  686. setFieldModalOpen(true);
  687. }}
  688. variant="secondary"
  689. />
  690. {editErrors.field ? (
  691. <ThemedText style={styles.errorText}>{editErrors.field}</ThemedText>
  692. ) : null}
  693. <ThemedText>{t('observations.crop')}</ThemedText>
  694. <ThemedButton
  695. title={selectedEditCrop?.crop_name || t('observations.selectCrop')}
  696. onPress={() => {
  697. setEditReopenAfterSelect(true);
  698. setEditModalOpen(false);
  699. setCropModalOpen(true);
  700. }}
  701. variant="secondary"
  702. />
  703. <ThemedText>{t('observations.type')}</ThemedText>
  704. <View style={styles.chipRow}>
  705. {presetTypes.map((preset) => {
  706. const label = t(`observations.type.${preset.key}`);
  707. const normalized = editType.trim().toLowerCase();
  708. const isActive =
  709. label.toLowerCase() === normalized ||
  710. preset.value.toLowerCase() === normalized;
  711. return (
  712. <Pressable
  713. key={`edit-${preset.key}`}
  714. onPress={() => setEditType(label)}
  715. style={[styles.chip, isActive && styles.chipActive]}>
  716. <ThemedText style={isActive ? styles.chipTextActive : styles.chipText}>
  717. {label}
  718. </ThemedText>
  719. </Pressable>
  720. );
  721. })}
  722. </View>
  723. <TextInput
  724. value={editType}
  725. onChangeText={setEditType}
  726. placeholder={t('observations.typePlaceholder')}
  727. style={inputStyle}
  728. />
  729. <ThemedText>{t('observations.severity')}</ThemedText>
  730. <TextInput
  731. value={editSeverity}
  732. onChangeText={(value) => {
  733. setEditSeverity(value);
  734. if (editErrors.severity) {
  735. setEditErrors((prev) => ({ ...prev, severity: undefined }));
  736. }
  737. }}
  738. placeholder={t('observations.severityPlaceholder')}
  739. style={inputStyle}
  740. keyboardType="numeric"
  741. />
  742. {editErrors.severity ? (
  743. <ThemedText style={styles.errorText}>{editErrors.severity}</ThemedText>
  744. ) : null}
  745. <ThemedText>{t('observations.note')}</ThemedText>
  746. <TextInput
  747. value={editNote}
  748. onChangeText={setEditNote}
  749. placeholder={t('observations.notePlaceholder')}
  750. style={inputStyle}
  751. multiline
  752. />
  753. <ThemedText>{t('observations.addMedia')}</ThemedText>
  754. {normalizeMediaUri(editPhotoUri) ? (
  755. isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? (
  756. <Video
  757. source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
  758. style={styles.videoPreview}
  759. useNativeControls
  760. resizeMode={ResizeMode.CONTAIN}
  761. isMuted
  762. />
  763. ) : (
  764. <Pressable
  765. onPress={() => {
  766. setPendingZoomUri(normalizeMediaUri(editPhotoUri) as string);
  767. setEditModalOpen(false);
  768. }}>
  769. <Image
  770. source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
  771. style={styles.photoPreview}
  772. resizeMode="contain"
  773. />
  774. </Pressable>
  775. )
  776. ) : (
  777. <ThemedText style={styles.photoPlaceholder}>
  778. {t('observations.noPhoto')}
  779. </ThemedText>
  780. )}
  781. <View style={styles.photoRow}>
  782. <ThemedButton
  783. title={t('observations.pickFromGallery')}
  784. onPress={() => handlePickPhoto(setEditPhotoUri)}
  785. variant="secondary"
  786. />
  787. <ThemedButton
  788. title={t('observations.takeMedia')}
  789. onPress={() =>
  790. handleTakePhoto(setEditPhotoUri, (code) =>
  791. setStatus(
  792. code === 'cameraDenied'
  793. ? t('tasks.cameraDenied')
  794. : t('tasks.cameraError')
  795. )
  796. )
  797. }
  798. variant="secondary"
  799. />
  800. </View>
  801. <View style={styles.modalActions}>
  802. <ThemedButton
  803. title={t('observations.cancel')}
  804. onPress={cancelEdit}
  805. variant="secondary"
  806. />
  807. <ThemedButton title={t('observations.update')} onPress={handleUpdate} />
  808. </View>
  809. <View style={styles.sheetFooter} />
  810. </View>
  811. )}
  812. />
  813. </View>
  814. </KeyboardAvoidingView>
  815. </View>
  816. </Modal>
  817. <ZoomImageModal
  818. uri={zoomUri}
  819. visible={Boolean(zoomUri)}
  820. onClose={() => setZoomUri(null)}
  821. />
  822. </>
  823. );
  824. }
  825. function formatDate(value: string) {
  826. try {
  827. return new Date(value).toLocaleString();
  828. } catch {
  829. return value;
  830. }
  831. }
  832. async function handlePickPhoto(setter: (value: string | null) => void) {
  833. const result = await ImagePicker.launchImageLibraryAsync({
  834. mediaTypes: getMediaTypes(),
  835. quality: 1,
  836. });
  837. if (result.canceled) return;
  838. const asset = result.assets[0];
  839. console.log('[Observations] Picked photo:', asset.uri);
  840. setter(asset.uri);
  841. }
  842. async function handleTakePhoto(
  843. setter: (value: string | null) => void,
  844. onError?: (msg: string) => void
  845. ) {
  846. try {
  847. const permission = await ImagePicker.requestCameraPermissionsAsync();
  848. if (!permission.granted) {
  849. onError?.('cameraDenied');
  850. return;
  851. }
  852. const result = await ImagePicker.launchCameraAsync({
  853. mediaTypes: getMediaTypes(),
  854. quality: 1,
  855. });
  856. if (result.canceled) return;
  857. const asset = result.assets[0];
  858. console.log('[Observations] Captured photo:', asset.uri);
  859. setter(asset.uri);
  860. } catch {
  861. onError?.('cameraError');
  862. }
  863. }
  864. function getMediaTypes() {
  865. const mediaType = (ImagePicker as {
  866. MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
  867. }).MediaType;
  868. const imageType = mediaType?.Image ?? mediaType?.Images;
  869. const videoType = mediaType?.Video ?? mediaType?.Videos;
  870. if (imageType && videoType) {
  871. return [imageType, videoType];
  872. }
  873. return imageType ?? videoType ?? ['images', 'videos'];
  874. }
  875. function isVideoUri(uri: string) {
  876. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  877. }
  878. function normalizeMediaUri(uri?: string | null) {
  879. if (typeof uri !== 'string') return null;
  880. const trimmed = uri.trim();
  881. return trimmed ? trimmed : null;
  882. }
  883. const styles = StyleSheet.create({
  884. hero: {
  885. backgroundColor: '#E8E6DA',
  886. aspectRatio: 16 / 9,
  887. width: '100%',
  888. },
  889. heroImage: {
  890. width: '100%',
  891. height: '100%',
  892. },
  893. titleContainer: {
  894. gap: 8,
  895. paddingHorizontal: 16,
  896. paddingVertical: 12,
  897. },
  898. section: {
  899. gap: 8,
  900. marginBottom: 16,
  901. paddingHorizontal: 16,
  902. },
  903. card: {
  904. borderRadius: 12,
  905. borderWidth: 1,
  906. borderColor: '#C6C6C6',
  907. padding: 12,
  908. marginHorizontal: 16,
  909. gap: 6,
  910. backgroundColor: '#FFFFFF',
  911. },
  912. meta: {
  913. opacity: 0.7,
  914. },
  915. input: {
  916. borderRadius: 10,
  917. borderWidth: 1,
  918. borderColor: '#B9B9B9',
  919. paddingHorizontal: 12,
  920. paddingVertical: 10,
  921. fontSize: 15,
  922. },
  923. requiredMark: {
  924. color: '#C0392B',
  925. fontWeight: '700',
  926. },
  927. errorText: {
  928. color: '#C0392B',
  929. fontSize: 12,
  930. },
  931. buttonRow: {
  932. alignSelf: 'flex-start',
  933. },
  934. separator: {
  935. height: 12,
  936. },
  937. footer: {
  938. height: 24,
  939. },
  940. modalOverlay: {
  941. flex: 1,
  942. backgroundColor: 'rgba(0,0,0,0.3)',
  943. justifyContent: 'center',
  944. padding: 16,
  945. },
  946. modalCard: {
  947. borderRadius: 12,
  948. borderWidth: 1,
  949. borderColor: '#C6C6C6',
  950. padding: 16,
  951. backgroundColor: '#FFFFFF',
  952. gap: 8,
  953. maxHeight: '70%',
  954. },
  955. modalItem: {
  956. paddingVertical: 8,
  957. },
  958. newButton: {
  959. flexDirection: 'row',
  960. alignItems: 'center',
  961. gap: 8,
  962. borderRadius: 10,
  963. borderWidth: 1,
  964. borderColor: '#B9B9B9',
  965. paddingHorizontal: 12,
  966. paddingVertical: 10,
  967. alignSelf: 'flex-start',
  968. },
  969. newButtonText: {
  970. fontSize: 15,
  971. fontWeight: '600',
  972. },
  973. modalActions: {
  974. flexDirection: 'row',
  975. justifyContent: 'space-between',
  976. gap: 12,
  977. },
  978. chipRow: {
  979. flexDirection: 'row',
  980. flexWrap: 'wrap',
  981. gap: 8,
  982. },
  983. chip: {
  984. borderRadius: 999,
  985. borderWidth: 1,
  986. borderColor: '#C6C6C6',
  987. paddingHorizontal: 12,
  988. paddingVertical: 6,
  989. },
  990. chipActive: {
  991. borderColor: '#2F7D4F',
  992. backgroundColor: '#E7F3EA',
  993. },
  994. chipText: {
  995. fontSize: 13,
  996. },
  997. chipTextActive: {
  998. fontSize: 13,
  999. color: '#2F7D4F',
  1000. fontWeight: '600',
  1001. },
  1002. photoRow: {
  1003. flexDirection: 'row',
  1004. gap: 12,
  1005. },
  1006. photoPlaceholder: {
  1007. opacity: 0.6,
  1008. },
  1009. photoPreview: {
  1010. width: '100%',
  1011. height: 200,
  1012. borderRadius: 12,
  1013. },
  1014. videoPreview: {
  1015. width: '100%',
  1016. height: 200,
  1017. borderRadius: 12,
  1018. backgroundColor: '#1C1C1C',
  1019. },
  1020. sheetOverlay: {
  1021. flex: 1,
  1022. backgroundColor: 'rgba(0,0,0,0.3)',
  1023. justifyContent: 'flex-end',
  1024. },
  1025. sheetBackdrop: {
  1026. ...StyleSheet.absoluteFillObject,
  1027. },
  1028. sheet: {
  1029. borderTopLeftRadius: 16,
  1030. borderTopRightRadius: 16,
  1031. borderWidth: 1,
  1032. borderColor: '#C6C6C6',
  1033. padding: 16,
  1034. backgroundColor: '#FFFFFF',
  1035. gap: 10,
  1036. maxHeight: '85%',
  1037. },
  1038. sheetContent: {
  1039. gap: 10,
  1040. paddingBottom: 8,
  1041. },
  1042. sheetListContent: {
  1043. paddingBottom: 80,
  1044. },
  1045. sheetFooter: {
  1046. height: 24,
  1047. },
  1048. keyboardAvoid: {
  1049. width: '100%',
  1050. flex: 1,
  1051. justifyContent: 'flex-end',
  1052. },
  1053. });