Nav apraksta

crops.tsx 32KB

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