Нет описания

harvests.tsx 37KB

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