Нема описа

observations.tsx 33KB

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