Няма описание

index.tsx 42KB

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