Ei kuvausta

index.tsx 36KB

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