Нема описа

index.tsx 39KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234
  1. import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
  2. import {
  3. ActivityIndicator,
  4. Alert,
  5. FlatList,
  6. Image,
  7. InteractionManager,
  8. KeyboardAvoidingView,
  9. Modal,
  10. Pressable,
  11. Platform,
  12. ScrollView,
  13. StyleSheet,
  14. TextInput,
  15. View,
  16. } from 'react-native';
  17. import * as ImagePicker from 'expo-image-picker';
  18. import { ResizeMode, Video } from 'expo-av';
  19. import { useLocalSearchParams, useRouter } from 'expo-router';
  20. import { useFocusEffect, useNavigation } from '@react-navigation/native';
  21. import { ThemedText } from '@/components/themed-text';
  22. import { ThemedView } from '@/components/themed-view';
  23. import { IconSymbol } from '@/components/ui/icon-symbol';
  24. import { ThemedButton } from '@/components/themed-button';
  25. import { IconButton } from '@/components/icon-button';
  26. import { Colors, Fonts } from '@/constants/theme';
  27. import { useTranslation } from '@/localization/i18n';
  28. import { dbPromise, initCoreTables } from '@/services/db';
  29. import { useColorScheme } from '@/hooks/use-color-scheme';
  30. import { ZoomImageModal } from '@/components/zoom-image-modal';
  31. type FieldRow = {
  32. id: number;
  33. name: string | null;
  34. area_ha: number | null;
  35. notes: string | null;
  36. photo_uri: string | null;
  37. created_at: string | null;
  38. updated_at: string | null;
  39. };
  40. type FieldMediaRow = {
  41. uri: string | null;
  42. };
  43. export default function FieldsScreen() {
  44. const { t } = useTranslation();
  45. const router = useRouter();
  46. const navigation = useNavigation();
  47. const { from } = useLocalSearchParams<{ from?: string }>();
  48. const theme = useColorScheme() ?? 'light';
  49. const palette = Colors[theme];
  50. useLayoutEffect(() => {
  51. if (from === 'home') {
  52. navigation.setOptions({
  53. headerLeft: () => (
  54. <Pressable onPress={() => router.replace('/')} hitSlop={10} style={{ paddingHorizontal: 8 }}>
  55. <IconSymbol size={20} name="chevron.left" color={palette.text} />
  56. </Pressable>
  57. ),
  58. });
  59. }
  60. }, [from, navigation, palette.text, router]);
  61. const pageSize = 12;
  62. const [fields, setFields] = useState<FieldRow[]>([]);
  63. const [status, setStatus] = useState(t('fields.loading'));
  64. const [name, setName] = useState('');
  65. const [areaHa, setAreaHa] = useState('');
  66. const [notes, setNotes] = useState('');
  67. const [photoUri, setPhotoUri] = useState<string | null>(null);
  68. const [mediaUris, setMediaUris] = useState<string[]>([]);
  69. const [newModalVisible, setNewModalVisible] = useState(false);
  70. const [editingId, setEditingId] = useState<number | null>(null);
  71. const [editModalVisible, setEditModalVisible] = useState(false);
  72. const [editName, setEditName] = useState('');
  73. const [editAreaHa, setEditAreaHa] = useState('');
  74. const [editNotes, setEditNotes] = useState('');
  75. const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
  76. const [editMediaUris, setEditMediaUris] = useState<string[]>([]);
  77. const [zoomUri, setZoomUri] = useState<string | null>(null);
  78. const [pendingZoomUri, setPendingZoomUri] = useState<string | null>(null);
  79. const [sheetZoomUri, setSheetZoomUri] = useState<string | null>(null);
  80. function closeSheetViewer() {
  81. setSheetZoomUri(null);
  82. setEditModalVisible(false);
  83. setNewModalVisible(false);
  84. setEditingId(null);
  85. setEditErrors({});
  86. setNewErrors({});
  87. }
  88. const [newErrors, setNewErrors] = useState<{ name?: string; area?: string }>({});
  89. const [editErrors, setEditErrors] = useState<{ name?: string; area?: string }>({});
  90. const [page, setPage] = useState(1);
  91. const [hasMore, setHasMore] = useState(true);
  92. const [loadingMore, setLoadingMore] = useState(false);
  93. useEffect(() => {
  94. let isActive = true;
  95. async function loadFields() {
  96. await fetchFieldsPage(1, true, isActive);
  97. }
  98. loadFields();
  99. return () => {
  100. isActive = false;
  101. };
  102. }, [t]);
  103. useFocusEffect(
  104. useCallback(() => {
  105. fetchFieldsPage(1, true);
  106. }, [])
  107. );
  108. useEffect(() => {
  109. if (!editModalVisible && !newModalVisible && pendingZoomUri) {
  110. const uri = pendingZoomUri;
  111. setPendingZoomUri(null);
  112. InteractionManager.runAfterInteractions(() => {
  113. setTimeout(() => setZoomUri(uri), 150);
  114. });
  115. }
  116. }, [editModalVisible, newModalVisible, pendingZoomUri]);
  117. async function fetchFieldsPage(pageToLoad: number, replace: boolean, isActive = true) {
  118. try {
  119. await initCoreTables();
  120. const db = await dbPromise;
  121. const rows = await db.getAllAsync<FieldRow>(
  122. 'SELECT id, name, area_ha, notes, photo_uri, created_at, updated_at FROM fields ORDER BY id DESC LIMIT ? OFFSET ?;',
  123. pageSize,
  124. (pageToLoad - 1) * pageSize
  125. );
  126. if (!isActive) return;
  127. setFields((prev) => (replace ? rows : [...prev, ...rows]));
  128. setHasMore(rows.length === pageSize);
  129. setPage(pageToLoad);
  130. if (replace) {
  131. setStatus(rows.length === 0 ? t('fields.empty') : '');
  132. }
  133. } catch (error) {
  134. if (isActive) setStatus(`Error: ${String(error)}`);
  135. } finally {
  136. if (isActive) setLoadingMore(false);
  137. }
  138. }
  139. async function handleLoadMore() {
  140. if (loadingMore || !hasMore) return;
  141. setLoadingMore(true);
  142. const nextPage = page + 1;
  143. await fetchFieldsPage(nextPage, false);
  144. }
  145. async function handleSave() {
  146. const trimmedName = name.trim();
  147. const area = areaHa.trim() ? Number(areaHa) : null;
  148. const nextErrors: { name?: string; area?: string } = {};
  149. if (!trimmedName) {
  150. nextErrors.name = t('fields.nameRequired');
  151. }
  152. if (areaHa.trim() && !Number.isFinite(area)) {
  153. nextErrors.area = t('fields.areaInvalid');
  154. }
  155. setNewErrors(nextErrors);
  156. if (Object.keys(nextErrors).length > 0) {
  157. setStatus(nextErrors.name ?? nextErrors.area ?? t('fields.nameRequired'));
  158. return false;
  159. }
  160. try {
  161. const db = await dbPromise;
  162. const now = new Date().toISOString();
  163. const primaryUri = mediaUris[0] ?? normalizeMediaUri(photoUri);
  164. const result = await db.runAsync(
  165. 'INSERT INTO fields (name, area_ha, notes, photo_uri, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?);',
  166. trimmedName,
  167. area,
  168. notes.trim() || null,
  169. primaryUri ?? null,
  170. now,
  171. now
  172. );
  173. const fieldId = Number(result.lastInsertRowId);
  174. if (fieldId) {
  175. const mediaToInsert = uniqueMediaUris([
  176. ...mediaUris,
  177. ...(normalizeMediaUri(photoUri) ? [normalizeMediaUri(photoUri) as string] : []),
  178. ]);
  179. for (const uri of mediaToInsert) {
  180. await db.runAsync(
  181. 'INSERT INTO field_media (field_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
  182. fieldId,
  183. uri,
  184. isVideoUri(uri) ? 'video' : 'image',
  185. now
  186. );
  187. }
  188. }
  189. setName('');
  190. setAreaHa('');
  191. setNotes('');
  192. setPhotoUri(null);
  193. setMediaUris([]);
  194. setNewErrors({});
  195. await fetchFieldsPage(1, true);
  196. setStatus(t('fields.saved'));
  197. return true;
  198. } catch (error) {
  199. setStatus(`Error: ${String(error)}`);
  200. return false;
  201. }
  202. }
  203. async function handleDelete(id: number) {
  204. try {
  205. const db = await dbPromise;
  206. await db.runAsync('DELETE FROM field_media WHERE field_id = ?;', id);
  207. await db.runAsync('DELETE FROM fields WHERE id = ?;', id);
  208. await fetchFieldsPage(1, true);
  209. } catch (error) {
  210. setStatus(`Error: ${String(error)}`);
  211. }
  212. }
  213. function confirmDelete(id: number) {
  214. Alert.alert(
  215. t('fields.deleteTitle'),
  216. t('fields.deleteMessage'),
  217. [
  218. { text: t('fields.cancel'), style: 'cancel' },
  219. { text: t('fields.delete'), style: 'destructive', onPress: () => handleDelete(id) },
  220. ]
  221. );
  222. }
  223. async function startEdit(field: FieldRow) {
  224. router.push(`/logbook/fields/${field.id}`);
  225. }
  226. function cancelEdit() {
  227. setEditingId(null);
  228. setEditName('');
  229. setEditAreaHa('');
  230. setEditNotes('');
  231. setEditPhotoUri(null);
  232. setEditMediaUris([]);
  233. setEditErrors({});
  234. setSheetZoomUri(null);
  235. setEditModalVisible(false);
  236. setStatus('');
  237. }
  238. async function handleUpdate() {
  239. if (!editingId) return;
  240. const trimmedName = editName.trim();
  241. const area = editAreaHa.trim() ? Number(editAreaHa) : null;
  242. const nextErrors: { name?: string; area?: string } = {};
  243. if (!trimmedName) {
  244. nextErrors.name = t('fields.nameRequired');
  245. }
  246. if (editAreaHa.trim() && !Number.isFinite(area)) {
  247. nextErrors.area = t('fields.areaInvalid');
  248. }
  249. setEditErrors(nextErrors);
  250. if (Object.keys(nextErrors).length > 0) {
  251. setStatus(nextErrors.name ?? nextErrors.area ?? t('fields.nameRequired'));
  252. return;
  253. }
  254. try {
  255. const db = await dbPromise;
  256. const now = new Date().toISOString();
  257. const primaryUri = editMediaUris[0] ?? normalizeMediaUri(editPhotoUri);
  258. await db.runAsync(
  259. 'UPDATE fields SET name = ?, area_ha = ?, notes = ?, photo_uri = ?, updated_at = ? WHERE id = ?;',
  260. trimmedName,
  261. area,
  262. editNotes.trim() || null,
  263. primaryUri ?? null,
  264. now,
  265. editingId
  266. );
  267. await db.runAsync('DELETE FROM field_media WHERE field_id = ?;', editingId);
  268. const mediaToInsert = uniqueMediaUris([
  269. ...editMediaUris,
  270. ...(normalizeMediaUri(editPhotoUri) ? [normalizeMediaUri(editPhotoUri) as string] : []),
  271. ]);
  272. for (const uri of mediaToInsert) {
  273. await db.runAsync(
  274. 'INSERT INTO field_media (field_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
  275. editingId,
  276. uri,
  277. isVideoUri(uri) ? 'video' : 'image',
  278. now
  279. );
  280. }
  281. setEditModalVisible(false);
  282. setEditingId(null);
  283. setEditErrors({});
  284. await fetchFieldsPage(1, true);
  285. setStatus(t('fields.saved'));
  286. } catch (error) {
  287. setStatus(`Error: ${String(error)}`);
  288. }
  289. }
  290. const inputStyle = [
  291. styles.input,
  292. {
  293. borderColor: palette.border,
  294. backgroundColor: palette.input,
  295. color: palette.text,
  296. },
  297. ];
  298. return (
  299. <>
  300. <FlatList
  301. data={fields}
  302. keyExtractor={(item) => String(item.id)}
  303. extraData={[photoUri, editModalVisible, editPhotoUri, name, areaHa, notes, status]}
  304. onEndReached={handleLoadMore}
  305. onEndReachedThreshold={0.4}
  306. renderItem={({ item }) => {
  307. const mediaUri = normalizeMediaUri(item.photo_uri);
  308. return (
  309. <Pressable onPress={() => startEdit(item)}>
  310. <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
  311. <ThemedText type="subtitle">{item.name || t('fields.unnamed')}</ThemedText>
  312. {item.area_ha !== null ? (
  313. <ThemedText style={styles.meta}>
  314. {t('fields.areaLabel')} {item.area_ha}
  315. </ThemedText>
  316. ) : null}
  317. {mediaUri ? (
  318. isVideoUri(mediaUri) ? (
  319. <Video
  320. source={{ uri: mediaUri }}
  321. style={styles.videoPreview}
  322. useNativeControls
  323. resizeMode={ResizeMode.CONTAIN}
  324. isMuted
  325. />
  326. ) : (
  327. <Pressable onPress={() => setZoomUri(mediaUri)}>
  328. <Image
  329. source={{ uri: mediaUri }}
  330. style={styles.photoPreview}
  331. resizeMode="contain"
  332. onError={(error) =>
  333. console.log('[Fields] List image error:', mediaUri, error.nativeEvent)
  334. }
  335. onLoad={() => console.log('[Fields] List image loaded:', mediaUri)}
  336. />
  337. </Pressable>
  338. )
  339. ) : null}
  340. {item.notes ? <ThemedText>{item.notes}</ThemedText> : null}
  341. <View style={styles.buttonRow}>
  342. <IconButton
  343. name="trash"
  344. onPress={() => confirmDelete(item.id)}
  345. accessibilityLabel={t('fields.delete')}
  346. variant="danger"
  347. />
  348. {item.updated_at ? (
  349. <ThemedText style={styles.metaEnd}>{formatDate(item.updated_at)}</ThemedText>
  350. ) : null}
  351. </View>
  352. </ThemedView>
  353. </Pressable>
  354. );
  355. }}
  356. ItemSeparatorComponent={() => <View style={styles.separator} />}
  357. ListHeaderComponent={
  358. <View>
  359. <ThemedView style={styles.hero}>
  360. <Image source={require('@/assets/images/fields.jpg')} style={styles.heroImage} />
  361. </ThemedView>
  362. <ThemedView style={styles.titleContainer}>
  363. <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
  364. {t('fields.title')}
  365. </ThemedText>
  366. </ThemedView>
  367. {status ? (
  368. <ThemedView style={styles.section}>
  369. <ThemedText>{status}</ThemedText>
  370. </ThemedView>
  371. ) : null}
  372. <ThemedView style={styles.section}>
  373. <Pressable
  374. style={styles.newButton}
  375. onPress={() => {
  376. setNewErrors({});
  377. setNewModalVisible(true);
  378. }}>
  379. <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
  380. <ThemedText style={styles.newButtonText}>{t('fields.new')}</ThemedText>
  381. </Pressable>
  382. </ThemedView>
  383. </View>
  384. }
  385. ListFooterComponent={
  386. <View style={styles.footer}>
  387. {loadingMore ? <ActivityIndicator /> : null}
  388. </View>
  389. }
  390. />
  391. <Modal
  392. visible={editModalVisible}
  393. animationType="slide"
  394. transparent
  395. onRequestClose={cancelEdit}>
  396. <View style={styles.sheetOverlay}>
  397. <Pressable
  398. style={styles.sheetBackdrop}
  399. pointerEvents={sheetZoomUri ? 'none' : 'auto'}
  400. onPress={() => {
  401. if (sheetZoomUri) {
  402. setSheetZoomUri(null);
  403. return;
  404. }
  405. cancelEdit();
  406. }}
  407. />
  408. <KeyboardAvoidingView
  409. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  410. keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
  411. style={styles.keyboardAvoid}
  412. pointerEvents={sheetZoomUri ? 'none' : 'auto'}>
  413. <Pressable
  414. style={[
  415. styles.sheet,
  416. { backgroundColor: palette.card, borderColor: palette.border, paddingBottom: 0 },
  417. ]}
  418. onPress={(event) => {
  419. event.stopPropagation();
  420. }}
  421. onStartShouldSetResponder={() => true}
  422. pointerEvents={sheetZoomUri ? 'none' : 'auto'}>
  423. <ScrollView
  424. keyboardShouldPersistTaps="handled"
  425. contentContainerStyle={styles.sheetContent}>
  426. <ThemedText type="subtitle">{t('fields.edit')}</ThemedText>
  427. <ThemedText>
  428. {t('fields.name')}
  429. <ThemedText style={styles.requiredMark}> *</ThemedText>
  430. </ThemedText>
  431. <TextInput
  432. value={editName}
  433. onChangeText={(value) => {
  434. setEditName(value);
  435. if (editErrors.name) {
  436. setEditErrors((prev) => ({ ...prev, name: undefined }));
  437. }
  438. }}
  439. placeholder={t('fields.name')}
  440. placeholderTextColor={palette.placeholder}
  441. style={inputStyle}
  442. />
  443. {editErrors.name ? <ThemedText style={styles.errorText}>{editErrors.name}</ThemedText> : null}
  444. <ThemedText>{t('fields.area')}</ThemedText>
  445. <TextInput
  446. value={editAreaHa}
  447. onChangeText={(value) => {
  448. setEditAreaHa(value);
  449. if (editErrors.area) {
  450. setEditErrors((prev) => ({ ...prev, area: undefined }));
  451. }
  452. }}
  453. placeholder={t('fields.areaPlaceholder')}
  454. placeholderTextColor={palette.placeholder}
  455. style={inputStyle}
  456. keyboardType="decimal-pad"
  457. />
  458. {editErrors.area ? <ThemedText style={styles.errorText}>{editErrors.area}</ThemedText> : null}
  459. <ThemedText>{t('fields.notes')}</ThemedText>
  460. <TextInput
  461. value={editNotes}
  462. onChangeText={setEditNotes}
  463. placeholder={t('fields.notesPlaceholder')}
  464. placeholderTextColor={palette.placeholder}
  465. style={inputStyle}
  466. multiline
  467. />
  468. <ThemedText>{t('fields.addMedia')}</ThemedText>
  469. {editMediaUris.length > 0 ? (
  470. isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? (
  471. <Pressable
  472. onPress={(event) => {
  473. event.stopPropagation();
  474. setSheetZoomUri(normalizeMediaUri(editPhotoUri) as string);
  475. }}>
  476. <Video
  477. source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
  478. style={styles.videoPreview}
  479. useNativeControls
  480. resizeMode={ResizeMode.CONTAIN}
  481. isMuted
  482. />
  483. </Pressable>
  484. ) : (
  485. <Pressable
  486. onPress={(event) => {
  487. event.stopPropagation();
  488. setSheetZoomUri(normalizeMediaUri(editPhotoUri) as string);
  489. }}>
  490. <Image
  491. key={normalizeMediaUri(editPhotoUri) as string}
  492. source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
  493. style={styles.photoPreview}
  494. resizeMode="contain"
  495. onError={(error) =>
  496. console.log(
  497. '[Fields] Edit image error:',
  498. normalizeMediaUri(editPhotoUri) as string,
  499. error.nativeEvent
  500. )
  501. }
  502. onLoad={() =>
  503. console.log(
  504. '[Fields] Edit image loaded:',
  505. normalizeMediaUri(editPhotoUri) as string
  506. )
  507. }
  508. />
  509. </Pressable>
  510. )
  511. ) : (
  512. <ThemedText style={styles.photoPlaceholder}>{t('fields.noPhoto')}</ThemedText>
  513. )}
  514. {editMediaUris.length > 0 ? (
  515. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
  516. {editMediaUris.map((uri) => (
  517. <Pressable
  518. key={uri}
  519. style={styles.mediaChip}
  520. onPress={(event) => {
  521. event.stopPropagation();
  522. setSheetZoomUri(uri);
  523. }}>
  524. {isVideoUri(uri) ? (
  525. <View style={styles.videoThumb}>
  526. <ThemedText style={styles.videoThumbText}>▶</ThemedText>
  527. </View>
  528. ) : (
  529. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  530. )}
  531. <Pressable
  532. style={styles.removeBadge}
  533. onPress={(event) => {
  534. event.stopPropagation();
  535. setEditMediaUris((prev) => {
  536. const next = prev.filter((item) => item !== uri);
  537. setEditPhotoUri((current) => (current === uri ? next[0] ?? null : current));
  538. return next;
  539. });
  540. }}>
  541. <ThemedText style={styles.removeBadgeText}>×</ThemedText>
  542. </Pressable>
  543. </Pressable>
  544. ))}
  545. </ScrollView>
  546. ) : null}
  547. <View style={styles.photoRow}>
  548. <ThemedButton
  549. title={t('fields.pickFromGallery')}
  550. onPress={() =>
  551. handlePickPhoto(
  552. (uri) => {
  553. if (!uri) return;
  554. setEditPhotoUri((prev) => prev ?? uri);
  555. },
  556. (uris) => {
  557. if (uris.length === 0) return;
  558. setEditMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  559. setEditPhotoUri((prev) => prev ?? uris[0]);
  560. }
  561. )
  562. }
  563. variant="secondary"
  564. />
  565. <ThemedButton
  566. title={t('fields.takeMedia')}
  567. onPress={() =>
  568. handleTakePhoto(
  569. (uri) => {
  570. if (!uri) return;
  571. setEditPhotoUri((prev) => prev ?? uri);
  572. setEditMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  573. },
  574. (code) =>
  575. setStatus(
  576. code === 'cameraDenied'
  577. ? t('tasks.cameraDenied')
  578. : t('tasks.cameraError')
  579. )
  580. )
  581. }
  582. variant="secondary"
  583. />
  584. </View>
  585. <View style={styles.modalActions}>
  586. <ThemedButton title={t('fields.cancel')} onPress={cancelEdit} variant="secondary" />
  587. <ThemedButton title={t('fields.update')} onPress={handleUpdate} />
  588. </View>
  589. </ScrollView>
  590. </Pressable>
  591. </KeyboardAvoidingView>
  592. {sheetZoomUri ? (
  593. <View style={styles.sheetZoomOverlay} pointerEvents="box-none">
  594. <Pressable
  595. style={styles.sheetZoomBackdrop}
  596. onPress={() => setSheetZoomUri(null)}
  597. />
  598. <Pressable
  599. style={styles.sheetZoomClose}
  600. onPress={closeSheetViewer}
  601. hitSlop={12}
  602. pointerEvents="auto">
  603. <ThemedText style={styles.sheetZoomCloseText}>{t('common.close')}</ThemedText>
  604. </Pressable>
  605. <View style={styles.sheetZoomContent} pointerEvents="auto">
  606. {isVideoUri(sheetZoomUri) ? (
  607. <Video
  608. source={{ uri: sheetZoomUri }}
  609. style={styles.sheetZoomVideo}
  610. useNativeControls
  611. resizeMode={ResizeMode.CONTAIN}
  612. />
  613. ) : (
  614. <ScrollView
  615. style={styles.sheetZoomScroll}
  616. contentContainerStyle={styles.sheetZoomScrollContent}
  617. minimumZoomScale={1}
  618. maximumZoomScale={3}
  619. centerContent
  620. showsHorizontalScrollIndicator={false}
  621. showsVerticalScrollIndicator={false}>
  622. <Image source={{ uri: sheetZoomUri }} style={styles.sheetZoomImage} resizeMode="contain" />
  623. </ScrollView>
  624. )}
  625. </View>
  626. </View>
  627. ) : null}
  628. </View>
  629. </Modal>
  630. <Modal
  631. visible={newModalVisible}
  632. animationType="slide"
  633. transparent
  634. onRequestClose={() => setNewModalVisible(false)}>
  635. <View style={styles.sheetOverlay}>
  636. <Pressable
  637. style={styles.sheetBackdrop}
  638. pointerEvents={sheetZoomUri ? 'none' : 'auto'}
  639. onPress={() => {
  640. if (sheetZoomUri) {
  641. setSheetZoomUri(null);
  642. return;
  643. }
  644. setNewModalVisible(false);
  645. }}
  646. />
  647. <KeyboardAvoidingView
  648. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  649. keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
  650. style={styles.keyboardAvoid}
  651. pointerEvents={sheetZoomUri ? 'none' : 'auto'}>
  652. <Pressable
  653. style={[
  654. styles.sheet,
  655. { backgroundColor: palette.card, borderColor: palette.border, paddingBottom: 0 },
  656. ]}
  657. onPress={(event) => {
  658. event.stopPropagation();
  659. }}
  660. onStartShouldSetResponder={() => true}
  661. pointerEvents={sheetZoomUri ? 'none' : 'auto'}>
  662. <ScrollView
  663. keyboardShouldPersistTaps="handled"
  664. contentContainerStyle={styles.sheetContent}>
  665. <ThemedText type="subtitle">{t('fields.new')}</ThemedText>
  666. <ThemedText>
  667. {t('fields.name')}
  668. <ThemedText style={styles.requiredMark}> *</ThemedText>
  669. </ThemedText>
  670. <TextInput
  671. value={name}
  672. onChangeText={(value) => {
  673. setName(value);
  674. if (newErrors.name) {
  675. setNewErrors((prev) => ({ ...prev, name: undefined }));
  676. }
  677. }}
  678. placeholder={t('fields.name')}
  679. placeholderTextColor={palette.placeholder}
  680. style={inputStyle}
  681. />
  682. {newErrors.name ? <ThemedText style={styles.errorText}>{newErrors.name}</ThemedText> : null}
  683. <ThemedText>{t('fields.area')}</ThemedText>
  684. <TextInput
  685. value={areaHa}
  686. onChangeText={(value) => {
  687. setAreaHa(value);
  688. if (newErrors.area) {
  689. setNewErrors((prev) => ({ ...prev, area: undefined }));
  690. }
  691. }}
  692. placeholder={t('fields.areaPlaceholder')}
  693. placeholderTextColor={palette.placeholder}
  694. style={inputStyle}
  695. keyboardType="decimal-pad"
  696. />
  697. {newErrors.area ? <ThemedText style={styles.errorText}>{newErrors.area}</ThemedText> : null}
  698. <ThemedText>{t('fields.notes')}</ThemedText>
  699. <TextInput
  700. value={notes}
  701. onChangeText={setNotes}
  702. placeholder={t('fields.notesPlaceholder')}
  703. placeholderTextColor={palette.placeholder}
  704. style={inputStyle}
  705. multiline
  706. />
  707. <ThemedText>{t('fields.addMedia')}</ThemedText>
  708. {mediaUris.length > 0 ? (
  709. isVideoUri(normalizeMediaUri(photoUri) as string) ? (
  710. <Pressable
  711. onPress={(event) => {
  712. event.stopPropagation();
  713. setSheetZoomUri(normalizeMediaUri(photoUri) as string);
  714. }}>
  715. <Video
  716. source={{ uri: normalizeMediaUri(photoUri) as string }}
  717. style={styles.videoPreview}
  718. useNativeControls
  719. resizeMode={ResizeMode.CONTAIN}
  720. isMuted
  721. />
  722. </Pressable>
  723. ) : (
  724. <Pressable
  725. onPress={(event) => {
  726. event.stopPropagation();
  727. setSheetZoomUri(normalizeMediaUri(photoUri) as string);
  728. }}>
  729. <Image
  730. key={normalizeMediaUri(photoUri) as string}
  731. source={{ uri: normalizeMediaUri(photoUri) as string }}
  732. style={styles.photoPreview}
  733. resizeMode="contain"
  734. onError={(error) =>
  735. console.log(
  736. '[Fields] New image error:',
  737. normalizeMediaUri(photoUri) as string,
  738. error.nativeEvent
  739. )
  740. }
  741. onLoad={() =>
  742. console.log(
  743. '[Fields] New image loaded:',
  744. normalizeMediaUri(photoUri) as string
  745. )
  746. }
  747. />
  748. </Pressable>
  749. )
  750. ) : (
  751. <ThemedText style={styles.photoPlaceholder}>{t('fields.noPhoto')}</ThemedText>
  752. )}
  753. {mediaUris.length > 0 ? (
  754. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
  755. {mediaUris.map((uri) => (
  756. <Pressable
  757. key={uri}
  758. style={styles.mediaChip}
  759. onPress={(event) => {
  760. event.stopPropagation();
  761. setSheetZoomUri(uri);
  762. }}>
  763. {isVideoUri(uri) ? (
  764. <View style={styles.videoThumb}>
  765. <ThemedText style={styles.videoThumbText}>▶</ThemedText>
  766. </View>
  767. ) : (
  768. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  769. )}
  770. <Pressable
  771. style={styles.removeBadge}
  772. onPress={(event) => {
  773. event.stopPropagation();
  774. setMediaUris((prev) => {
  775. const next = prev.filter((item) => item !== uri);
  776. setPhotoUri((current) => (current === uri ? next[0] ?? null : current));
  777. return next;
  778. });
  779. }}>
  780. <ThemedText style={styles.removeBadgeText}>×</ThemedText>
  781. </Pressable>
  782. </Pressable>
  783. ))}
  784. </ScrollView>
  785. ) : null}
  786. <View style={styles.photoRow}>
  787. <ThemedButton
  788. title={t('fields.pickFromGallery')}
  789. onPress={() =>
  790. handlePickPhoto(
  791. (uri) => {
  792. if (!uri) return;
  793. setPhotoUri((prev) => prev ?? uri);
  794. },
  795. (uris) => {
  796. if (uris.length === 0) return;
  797. setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  798. setPhotoUri((prev) => prev ?? uris[0]);
  799. }
  800. )
  801. }
  802. variant="secondary"
  803. />
  804. <ThemedButton
  805. title={t('fields.takeMedia')}
  806. onPress={() =>
  807. handleTakePhoto(
  808. (uri) => {
  809. if (!uri) return;
  810. setPhotoUri((prev) => prev ?? uri);
  811. setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  812. },
  813. (code) =>
  814. setStatus(
  815. code === 'cameraDenied'
  816. ? t('tasks.cameraDenied')
  817. : t('tasks.cameraError')
  818. )
  819. )
  820. }
  821. variant="secondary"
  822. />
  823. </View>
  824. <View style={styles.modalActions}>
  825. <ThemedButton
  826. title={t('fields.cancel')}
  827. onPress={() => setNewModalVisible(false)}
  828. variant="secondary"
  829. />
  830. <ThemedButton
  831. title={t('fields.save')}
  832. onPress={async () => {
  833. const ok = await handleSave();
  834. if (ok) setNewModalVisible(false);
  835. }}
  836. />
  837. </View>
  838. </ScrollView>
  839. </Pressable>
  840. </KeyboardAvoidingView>
  841. {sheetZoomUri ? (
  842. <View style={styles.sheetZoomOverlay} pointerEvents="box-none">
  843. <Pressable
  844. style={styles.sheetZoomBackdrop}
  845. onPress={() => setSheetZoomUri(null)}
  846. />
  847. <Pressable
  848. style={styles.sheetZoomClose}
  849. onPress={closeSheetViewer}
  850. hitSlop={12}
  851. pointerEvents="auto">
  852. <ThemedText style={styles.sheetZoomCloseText}>{t('common.close')}</ThemedText>
  853. </Pressable>
  854. <View style={styles.sheetZoomContent} pointerEvents="auto">
  855. {isVideoUri(sheetZoomUri) ? (
  856. <Video
  857. source={{ uri: sheetZoomUri }}
  858. style={styles.sheetZoomVideo}
  859. useNativeControls
  860. resizeMode={ResizeMode.CONTAIN}
  861. />
  862. ) : (
  863. <ScrollView
  864. style={styles.sheetZoomScroll}
  865. contentContainerStyle={styles.sheetZoomScrollContent}
  866. minimumZoomScale={1}
  867. maximumZoomScale={3}
  868. centerContent
  869. showsHorizontalScrollIndicator={false}
  870. showsVerticalScrollIndicator={false}>
  871. <Image source={{ uri: sheetZoomUri }} style={styles.sheetZoomImage} resizeMode="contain" />
  872. </ScrollView>
  873. )}
  874. </View>
  875. </View>
  876. ) : null}
  877. </View>
  878. </Modal>
  879. <ZoomImageModal
  880. uri={zoomUri}
  881. visible={Boolean(zoomUri)}
  882. onClose={() => setZoomUri(null)}
  883. />
  884. </>
  885. );
  886. }
  887. function formatDate(value: string) {
  888. try {
  889. return new Date(value).toLocaleString();
  890. } catch {
  891. return value;
  892. }
  893. }
  894. async function handlePickPhoto(
  895. setter: (value: string | null) => void,
  896. onAdd?: (uris: string[]) => void
  897. ) {
  898. const result = await ImagePicker.launchImageLibraryAsync({
  899. mediaTypes: getMediaTypes(),
  900. quality: 1,
  901. allowsMultipleSelection: true,
  902. selectionLimit: 0,
  903. });
  904. if (result.canceled) return;
  905. const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
  906. if (uris.length === 0) return;
  907. console.log('[Fields] Picked media:', uris);
  908. setter(uris[0]);
  909. onAdd?.(uris);
  910. }
  911. async function handleTakePhoto(
  912. setter: (value: string | null) => void,
  913. onError?: (msg: string) => void
  914. ) {
  915. try {
  916. const permission = await ImagePicker.requestCameraPermissionsAsync();
  917. if (!permission.granted) {
  918. onError?.('cameraDenied');
  919. return;
  920. }
  921. const result = await ImagePicker.launchCameraAsync({
  922. mediaTypes: getMediaTypes(),
  923. quality: 1,
  924. });
  925. if (result.canceled) return;
  926. const asset = result.assets[0];
  927. console.log('[Fields] Captured media:', asset.uri);
  928. setter(asset.uri);
  929. } catch {
  930. onError?.('cameraError');
  931. }
  932. }
  933. function getMediaTypes() {
  934. const mediaType = (ImagePicker as {
  935. MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
  936. }).MediaType;
  937. const imageType = mediaType?.Image ?? mediaType?.Images;
  938. const videoType = mediaType?.Video ?? mediaType?.Videos;
  939. if (imageType && videoType) {
  940. return [imageType, videoType];
  941. }
  942. return imageType ?? videoType ?? ['images', 'videos'];
  943. }
  944. function isVideoUri(uri: string) {
  945. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  946. }
  947. function normalizeMediaUri(uri?: string | null) {
  948. if (typeof uri !== 'string') return null;
  949. const trimmed = uri.trim();
  950. return trimmed ? trimmed : null;
  951. }
  952. function uniqueMediaUris(uris: string[]) {
  953. const seen = new Set<string>();
  954. const result: string[] = [];
  955. for (const uri of uris) {
  956. if (!uri || seen.has(uri)) continue;
  957. seen.add(uri);
  958. result.push(uri);
  959. }
  960. return result;
  961. }
  962. const styles = StyleSheet.create({
  963. hero: {
  964. backgroundColor: '#E8E6DA',
  965. aspectRatio: 16 / 9,
  966. width: '100%',
  967. },
  968. heroImage: {
  969. width: '100%',
  970. height: '100%',
  971. },
  972. titleContainer: {
  973. gap: 8,
  974. paddingHorizontal: 16,
  975. paddingVertical: 12,
  976. },
  977. section: {
  978. gap: 8,
  979. marginBottom: 16,
  980. paddingHorizontal: 16,
  981. },
  982. newButton: {
  983. flexDirection: 'row',
  984. alignItems: 'center',
  985. gap: 8,
  986. borderRadius: 10,
  987. borderWidth: 1,
  988. borderColor: '#B9B9B9',
  989. paddingHorizontal: 12,
  990. paddingVertical: 10,
  991. alignSelf: 'flex-start',
  992. },
  993. newButtonText: {
  994. fontSize: 15,
  995. fontWeight: '600',
  996. },
  997. card: {
  998. borderRadius: 12,
  999. borderWidth: 1,
  1000. borderColor: '#C6C6C6',
  1001. padding: 12,
  1002. marginHorizontal: 16,
  1003. gap: 6,
  1004. backgroundColor: '#FFFFFF',
  1005. },
  1006. meta: {
  1007. opacity: 0.7,
  1008. },
  1009. input: {
  1010. borderRadius: 10,
  1011. borderWidth: 1,
  1012. borderColor: '#B9B9B9',
  1013. paddingHorizontal: 12,
  1014. paddingVertical: 10,
  1015. fontSize: 15,
  1016. },
  1017. requiredMark: {
  1018. color: '#C0392B',
  1019. fontWeight: '700',
  1020. },
  1021. errorText: {
  1022. color: '#C0392B',
  1023. fontSize: 12,
  1024. },
  1025. photoPreview: {
  1026. width: '100%',
  1027. height: 200,
  1028. borderRadius: 12,
  1029. },
  1030. buttonRow: {
  1031. alignSelf: 'flex-start',
  1032. flexDirection: 'row',
  1033. gap: 8,
  1034. alignItems: 'center',
  1035. width: '100%',
  1036. },
  1037. metaEnd: {
  1038. marginLeft: 'auto',
  1039. opacity: 0.7,
  1040. fontSize: 12,
  1041. },
  1042. cancelRow: {
  1043. marginTop: 8,
  1044. },
  1045. modalActions: {
  1046. flexDirection: 'row',
  1047. justifyContent: 'space-between',
  1048. gap: 12,
  1049. },
  1050. sheetOverlay: {
  1051. flex: 1,
  1052. backgroundColor: 'rgba(0,0,0,0.3)',
  1053. justifyContent: 'flex-end',
  1054. },
  1055. sheetBackdrop: {
  1056. ...StyleSheet.absoluteFillObject,
  1057. },
  1058. sheet: {
  1059. borderTopLeftRadius: 16,
  1060. borderTopRightRadius: 16,
  1061. borderWidth: 1,
  1062. borderColor: '#C6C6C6',
  1063. padding: 16,
  1064. backgroundColor: '#FFFFFF',
  1065. gap: 10,
  1066. maxHeight: '85%',
  1067. },
  1068. sheetContent: {
  1069. gap: 10,
  1070. paddingBottom: 80,
  1071. },
  1072. sheetZoomOverlay: {
  1073. position: 'absolute',
  1074. top: 0,
  1075. left: 0,
  1076. right: 0,
  1077. bottom: 0,
  1078. backgroundColor: 'rgba(0,0,0,0.85)',
  1079. justifyContent: 'center',
  1080. alignItems: 'center',
  1081. zIndex: 10,
  1082. elevation: 10,
  1083. },
  1084. sheetZoomBackdrop: {
  1085. ...StyleSheet.absoluteFillObject,
  1086. zIndex: 0,
  1087. },
  1088. sheetZoomContent: {
  1089. width: '100%',
  1090. height: '100%',
  1091. paddingHorizontal: 16,
  1092. paddingTop: 24,
  1093. paddingBottom: 24,
  1094. justifyContent: 'center',
  1095. zIndex: 1,
  1096. },
  1097. sheetZoomClose: {
  1098. position: 'absolute',
  1099. top: 24,
  1100. right: 16,
  1101. zIndex: 2,
  1102. backgroundColor: 'rgba(0,0,0,0.6)',
  1103. paddingHorizontal: 12,
  1104. paddingVertical: 6,
  1105. borderRadius: 14,
  1106. },
  1107. sheetZoomCloseText: {
  1108. color: '#FFFFFF',
  1109. fontWeight: '600',
  1110. },
  1111. sheetZoomScroll: {
  1112. flex: 1,
  1113. },
  1114. sheetZoomScrollContent: {
  1115. flexGrow: 1,
  1116. justifyContent: 'center',
  1117. alignItems: 'center',
  1118. },
  1119. sheetZoomImage: {
  1120. width: '100%',
  1121. height: '100%',
  1122. },
  1123. sheetZoomVideo: {
  1124. width: '100%',
  1125. height: '100%',
  1126. },
  1127. keyboardAvoid: {
  1128. width: '100%',
  1129. flex: 1,
  1130. justifyContent: 'flex-end',
  1131. },
  1132. separator: {
  1133. height: 12,
  1134. },
  1135. footer: {
  1136. height: 24,
  1137. },
  1138. photoRow: {
  1139. flexDirection: 'row',
  1140. gap: 8,
  1141. },
  1142. photoPlaceholder: {
  1143. opacity: 0.6,
  1144. },
  1145. mediaPlaceholder: {
  1146. opacity: 0.8,
  1147. fontStyle: 'italic',
  1148. },
  1149. videoPreview: {
  1150. width: '100%',
  1151. height: 200,
  1152. borderRadius: 12,
  1153. backgroundColor: '#1C1C1C',
  1154. },
  1155. mediaStrip: {
  1156. marginTop: 6,
  1157. },
  1158. mediaChip: {
  1159. width: 72,
  1160. height: 72,
  1161. borderRadius: 10,
  1162. marginRight: 8,
  1163. overflow: 'hidden',
  1164. backgroundColor: '#E6E1D4',
  1165. alignItems: 'center',
  1166. justifyContent: 'center',
  1167. },
  1168. mediaThumb: {
  1169. width: '100%',
  1170. height: '100%',
  1171. },
  1172. removeBadge: {
  1173. position: 'absolute',
  1174. top: 4,
  1175. right: 4,
  1176. backgroundColor: 'rgba(0,0,0,0.6)',
  1177. width: 18,
  1178. height: 18,
  1179. borderRadius: 9,
  1180. alignItems: 'center',
  1181. justifyContent: 'center',
  1182. },
  1183. removeBadgeText: {
  1184. color: '#FFFFFF',
  1185. fontSize: 12,
  1186. lineHeight: 14,
  1187. fontWeight: '700',
  1188. },
  1189. videoThumb: {
  1190. width: '100%',
  1191. height: '100%',
  1192. backgroundColor: '#1C1C1C',
  1193. alignItems: 'center',
  1194. justifyContent: 'center',
  1195. },
  1196. videoThumbText: {
  1197. color: '#FFFFFF',
  1198. fontSize: 18,
  1199. fontWeight: '700',
  1200. },
  1201. });