No Description

new.tsx 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. import { useEffect, useMemo, useState } from 'react';
  2. import {
  3. Image,
  4. KeyboardAvoidingView,
  5. Modal,
  6. Platform,
  7. Pressable,
  8. ScrollView,
  9. StyleSheet,
  10. TextInput,
  11. View,
  12. } from 'react-native';
  13. import * as ImagePicker from 'expo-image-picker';
  14. import DateTimePicker from '@react-native-community/datetimepicker';
  15. import { ResizeMode, Video } from 'expo-av';
  16. import { useRouter } from 'expo-router';
  17. import { ThemedButton } from '@/components/themed-button';
  18. import { ThemedText } from '@/components/themed-text';
  19. import { ThemedView } from '@/components/themed-view';
  20. import { ZoomImageModal } from '@/components/zoom-image-modal';
  21. import { Colors } from '@/constants/theme';
  22. import { useColorScheme } from '@/hooks/use-color-scheme';
  23. import { useTranslation } from '@/localization/i18n';
  24. import { dbPromise, initCoreTables } from '@/services/db';
  25. type FieldRow = {
  26. id: number;
  27. name: string | null;
  28. };
  29. type CropRow = {
  30. id: number;
  31. crop_name: string | null;
  32. };
  33. export default function NewHarvestScreen() {
  34. const { t } = useTranslation();
  35. const router = useRouter();
  36. const theme = useColorScheme() ?? 'light';
  37. const palette = Colors[theme];
  38. const [status, setStatus] = useState('');
  39. const [fields, setFields] = useState<FieldRow[]>([]);
  40. const [crops, setCrops] = useState<CropRow[]>([]);
  41. const [fieldModalOpen, setFieldModalOpen] = useState(false);
  42. const [cropModalOpen, setCropModalOpen] = useState(false);
  43. const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
  44. const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
  45. const [harvestDate, setHarvestDate] = useState('');
  46. const [showHarvestPicker, setShowHarvestPicker] = useState(false);
  47. const [quantity, setQuantity] = useState('');
  48. const [unit, setUnit] = useState('');
  49. const [notes, setNotes] = useState('');
  50. const [mediaUris, setMediaUris] = useState<string[]>([]);
  51. const [activeUri, setActiveUri] = useState<string | null>(null);
  52. const [errors, setErrors] = useState<{ field?: string; crop?: string; quantity?: string }>({});
  53. const [zoomUri, setZoomUri] = useState<string | null>(null);
  54. const [saving, setSaving] = useState(false);
  55. const unitPresets = [
  56. { key: 'kg', label: 'kg' },
  57. { key: 'g', label: 'g' },
  58. { key: 'ton', label: 'ton' },
  59. { key: 'pcs', label: 'pcs' },
  60. ];
  61. useEffect(() => {
  62. let isActive = true;
  63. async function loadData() {
  64. try {
  65. await initCoreTables();
  66. const db = await dbPromise;
  67. const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
  68. const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
  69. if (!isActive) return;
  70. setFields(fieldRows);
  71. setCrops(cropRows);
  72. } catch (error) {
  73. if (isActive) setStatus(`Error: ${String(error)}`);
  74. }
  75. }
  76. loadData();
  77. return () => {
  78. isActive = false;
  79. };
  80. }, [t]);
  81. const selectedField = useMemo(
  82. () => fields.find((item) => item.id === selectedFieldId),
  83. [fields, selectedFieldId]
  84. );
  85. const selectedCrop = useMemo(
  86. () => crops.find((item) => item.id === selectedCropId),
  87. [crops, selectedCropId]
  88. );
  89. const inputStyle = [
  90. styles.input,
  91. {
  92. borderColor: palette.border,
  93. backgroundColor: palette.input,
  94. color: palette.text,
  95. },
  96. ];
  97. async function handleSave() {
  98. const parsedQuantity = quantity.trim() ? Number(quantity) : null;
  99. const nextErrors: { field?: string; crop?: string; quantity?: string } = {};
  100. if (!selectedFieldId) nextErrors.field = t('harvests.fieldRequired');
  101. if (!selectedCropId) nextErrors.crop = t('harvests.cropRequired');
  102. if (quantity.trim() && !Number.isFinite(parsedQuantity)) {
  103. nextErrors.quantity = t('harvests.quantityInvalid');
  104. }
  105. setErrors(nextErrors);
  106. if (Object.keys(nextErrors).length > 0) return;
  107. try {
  108. setSaving(true);
  109. const db = await dbPromise;
  110. const now = new Date().toISOString();
  111. const primaryUri = mediaUris[0] ?? normalizeMediaUri(activeUri);
  112. const result = await db.runAsync(
  113. 'INSERT INTO harvests (field_id, crop_id, harvested_at, quantity, unit, notes, photo_uri, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?);',
  114. selectedFieldId,
  115. selectedCropId,
  116. harvestDate || null,
  117. parsedQuantity,
  118. unit.trim() || null,
  119. notes.trim() || null,
  120. primaryUri ?? null,
  121. now
  122. );
  123. const harvestId = Number(result.lastInsertRowId);
  124. if (harvestId) {
  125. const mediaToInsert = uniqueMediaUris([
  126. ...mediaUris,
  127. ...(normalizeMediaUri(activeUri) ? [normalizeMediaUri(activeUri) as string] : []),
  128. ]);
  129. for (const uri of mediaToInsert) {
  130. await db.runAsync(
  131. 'INSERT INTO harvest_media (harvest_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
  132. harvestId,
  133. uri,
  134. isVideoUri(uri) ? 'video' : 'image',
  135. now
  136. );
  137. }
  138. }
  139. setStatus(t('harvests.saved'));
  140. router.back();
  141. } catch (error) {
  142. setStatus(`Error: ${String(error)}`);
  143. } finally {
  144. setSaving(false);
  145. }
  146. }
  147. return (
  148. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  149. <KeyboardAvoidingView
  150. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  151. style={styles.keyboardAvoid}>
  152. <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
  153. <ThemedText type="title">{t('harvests.new')}</ThemedText>
  154. {status ? <ThemedText>{status}</ThemedText> : null}
  155. <ThemedText>{t('harvests.field')}</ThemedText>
  156. <ThemedButton
  157. title={selectedField?.name || t('harvests.selectField')}
  158. onPress={() => setFieldModalOpen(true)}
  159. variant="secondary"
  160. />
  161. {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
  162. <ThemedText>{t('harvests.crop')}</ThemedText>
  163. <ThemedButton
  164. title={selectedCrop?.crop_name || t('harvests.selectCrop')}
  165. onPress={() => setCropModalOpen(true)}
  166. variant="secondary"
  167. />
  168. {errors.crop ? <ThemedText style={styles.errorText}>{errors.crop}</ThemedText> : null}
  169. <ThemedText>{t('harvests.date')}</ThemedText>
  170. <Pressable onPress={() => setShowHarvestPicker(true)} style={styles.dateInput}>
  171. <ThemedText style={styles.dateValue}>
  172. {harvestDate || t('harvests.datePlaceholder')}
  173. </ThemedText>
  174. </Pressable>
  175. {showHarvestPicker ? (
  176. <DateTimePicker
  177. value={harvestDate ? new Date(harvestDate) : new Date()}
  178. mode="date"
  179. onChange={(event, date) => {
  180. setShowHarvestPicker(false);
  181. if (date) setHarvestDate(toDateOnly(date));
  182. }}
  183. />
  184. ) : null}
  185. <ThemedText>{t('harvests.quantity')}</ThemedText>
  186. <TextInput
  187. value={quantity}
  188. onChangeText={(value) => {
  189. setQuantity(value);
  190. if (errors.quantity) setErrors((prev) => ({ ...prev, quantity: undefined }));
  191. }}
  192. placeholder={t('harvests.quantityPlaceholder')}
  193. placeholderTextColor={palette.placeholder}
  194. style={inputStyle}
  195. keyboardType="decimal-pad"
  196. />
  197. {errors.quantity ? <ThemedText style={styles.errorText}>{errors.quantity}</ThemedText> : null}
  198. <ThemedText>{t('harvests.unit')}</ThemedText>
  199. <View style={styles.unitRow}>
  200. {unitPresets.map((preset) => {
  201. const label = t(`units.${preset.key}`);
  202. const isActive = unit === label || unit === preset.key;
  203. return (
  204. <Pressable
  205. key={`unit-${preset.key}`}
  206. onPress={() => setUnit(label)}
  207. style={[styles.unitChip, isActive ? styles.unitChipActive : null]}>
  208. <ThemedText style={isActive ? styles.unitTextActive : styles.unitText}>{label}</ThemedText>
  209. </Pressable>
  210. );
  211. })}
  212. </View>
  213. <TextInput
  214. value={unit}
  215. onChangeText={setUnit}
  216. placeholder={t('harvests.unitPlaceholder')}
  217. placeholderTextColor={palette.placeholder}
  218. style={inputStyle}
  219. />
  220. <ThemedText>{t('harvests.notes')}</ThemedText>
  221. <TextInput
  222. value={notes}
  223. onChangeText={setNotes}
  224. placeholder={t('harvests.notesPlaceholder')}
  225. placeholderTextColor={palette.placeholder}
  226. style={inputStyle}
  227. multiline
  228. />
  229. <ThemedText>{t('harvests.addMedia')}</ThemedText>
  230. {normalizeMediaUri(activeUri) ? (
  231. isVideoUri(normalizeMediaUri(activeUri) as string) ? (
  232. <Video
  233. source={{ uri: normalizeMediaUri(activeUri) as string }}
  234. style={styles.mediaPreview}
  235. useNativeControls
  236. resizeMode={ResizeMode.CONTAIN}
  237. />
  238. ) : (
  239. <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
  240. <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
  241. </Pressable>
  242. )
  243. ) : (
  244. <ThemedText style={styles.photoPlaceholder}>{t('harvests.noPhoto')}</ThemedText>
  245. )}
  246. {mediaUris.length > 0 ? (
  247. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
  248. {mediaUris.map((uri) => (
  249. <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
  250. {isVideoUri(uri) ? (
  251. <View style={styles.videoThumb}>
  252. <ThemedText style={styles.videoThumbText}>▶</ThemedText>
  253. </View>
  254. ) : (
  255. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  256. )}
  257. <Pressable
  258. style={styles.mediaRemove}
  259. onPress={(event) => {
  260. event.stopPropagation();
  261. setMediaUris((prev) => {
  262. const next = prev.filter((item) => item !== uri);
  263. setActiveUri((current) => (current === uri ? next[0] ?? null : current));
  264. return next;
  265. });
  266. }}>
  267. <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
  268. </Pressable>
  269. </Pressable>
  270. ))}
  271. </ScrollView>
  272. ) : null}
  273. <View style={styles.photoRow}>
  274. <ThemedButton
  275. title={t('harvests.pickFromGallery')}
  276. onPress={() =>
  277. handlePickMedia((uris) => {
  278. if (uris.length === 0) return;
  279. setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  280. setActiveUri((prev) => prev ?? uris[0]);
  281. })
  282. }
  283. variant="secondary"
  284. />
  285. <ThemedButton
  286. title={t('harvests.takeMedia')}
  287. onPress={() =>
  288. handleTakeMedia((uri) => {
  289. if (!uri) return;
  290. setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  291. setActiveUri((prev) => prev ?? uri);
  292. })
  293. }
  294. variant="secondary"
  295. />
  296. </View>
  297. <View style={styles.actions}>
  298. <ThemedButton
  299. title={saving ? t('harvests.saving') : t('harvests.save')}
  300. onPress={handleSave}
  301. disabled={saving}
  302. />
  303. </View>
  304. </ScrollView>
  305. </KeyboardAvoidingView>
  306. <Modal transparent visible={fieldModalOpen} animationType="fade">
  307. <Pressable style={styles.modalBackdrop} onPress={() => setFieldModalOpen(false)}>
  308. <View style={styles.modalCard}>
  309. <ThemedText type="subtitle">{t('harvests.selectField')}</ThemedText>
  310. <ScrollView style={styles.modalList}>
  311. {fields.map((item) => (
  312. <Pressable
  313. key={item.id}
  314. style={styles.modalItem}
  315. onPress={() => {
  316. setSelectedFieldId(item.id);
  317. setFieldModalOpen(false);
  318. }}>
  319. <ThemedText>{item.name || t('harvests.noField')}</ThemedText>
  320. </Pressable>
  321. ))}
  322. </ScrollView>
  323. </View>
  324. </Pressable>
  325. </Modal>
  326. <Modal transparent visible={cropModalOpen} animationType="fade">
  327. <Pressable style={styles.modalBackdrop} onPress={() => setCropModalOpen(false)}>
  328. <View style={styles.modalCard}>
  329. <ThemedText type="subtitle">{t('harvests.selectCrop')}</ThemedText>
  330. <ScrollView style={styles.modalList}>
  331. {crops.map((item) => (
  332. <Pressable
  333. key={item.id}
  334. style={styles.modalItem}
  335. onPress={() => {
  336. setSelectedCropId(item.id);
  337. setCropModalOpen(false);
  338. }}>
  339. <ThemedText>{item.crop_name || t('harvests.noCrop')}</ThemedText>
  340. </Pressable>
  341. ))}
  342. </ScrollView>
  343. </View>
  344. </Pressable>
  345. </Modal>
  346. <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
  347. </ThemedView>
  348. );
  349. }
  350. async function handlePickMedia(onAdd: (uris: string[]) => void) {
  351. const result = await ImagePicker.launchImageLibraryAsync({
  352. mediaTypes: getMediaTypes(),
  353. quality: 1,
  354. allowsMultipleSelection: true,
  355. selectionLimit: 0,
  356. });
  357. if (result.canceled) return;
  358. const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
  359. if (uris.length === 0) return;
  360. onAdd(uris);
  361. }
  362. async function handleTakeMedia(onAdd: (uri: string | null) => void) {
  363. const permission = await ImagePicker.requestCameraPermissionsAsync();
  364. if (!permission.granted) {
  365. return;
  366. }
  367. const result = await ImagePicker.launchCameraAsync({
  368. mediaTypes: getMediaTypes(),
  369. quality: 1,
  370. });
  371. if (result.canceled) return;
  372. const asset = result.assets[0];
  373. onAdd(asset.uri);
  374. }
  375. function getMediaTypes() {
  376. const mediaType = (ImagePicker as {
  377. MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
  378. }).MediaType;
  379. const imageType = mediaType?.Image ?? mediaType?.Images;
  380. const videoType = mediaType?.Video ?? mediaType?.Videos;
  381. if (imageType && videoType) {
  382. return [imageType, videoType];
  383. }
  384. return imageType ?? videoType ?? ['images', 'videos'];
  385. }
  386. function isVideoUri(uri: string) {
  387. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  388. }
  389. function normalizeMediaUri(uri?: string | null) {
  390. if (typeof uri !== 'string') return null;
  391. const trimmed = uri.trim();
  392. return trimmed ? trimmed : null;
  393. }
  394. function uniqueMediaUris(uris: string[]) {
  395. const seen = new Set<string>();
  396. const result: string[] = [];
  397. for (const uri of uris) {
  398. if (!uri || seen.has(uri)) continue;
  399. seen.add(uri);
  400. result.push(uri);
  401. }
  402. return result;
  403. }
  404. function toDateOnly(date: Date) {
  405. return date.toISOString().slice(0, 10);
  406. }
  407. const styles = StyleSheet.create({
  408. container: {
  409. flex: 1,
  410. },
  411. keyboardAvoid: {
  412. flex: 1,
  413. },
  414. content: {
  415. padding: 16,
  416. gap: 10,
  417. paddingBottom: 40,
  418. },
  419. input: {
  420. borderRadius: 10,
  421. borderWidth: 1,
  422. paddingHorizontal: 12,
  423. paddingVertical: 10,
  424. fontSize: 15,
  425. },
  426. errorText: {
  427. color: '#C0392B',
  428. fontSize: 12,
  429. },
  430. dateInput: {
  431. borderRadius: 10,
  432. borderWidth: 1,
  433. borderColor: '#B9B9B9',
  434. paddingHorizontal: 12,
  435. paddingVertical: 10,
  436. },
  437. dateValue: {
  438. opacity: 0.7,
  439. },
  440. mediaPreview: {
  441. width: '100%',
  442. height: 220,
  443. borderRadius: 12,
  444. backgroundColor: '#1C1C1C',
  445. },
  446. photoRow: {
  447. flexDirection: 'row',
  448. gap: 8,
  449. },
  450. actions: {
  451. marginTop: 12,
  452. gap: 10,
  453. },
  454. photoPlaceholder: {
  455. opacity: 0.6,
  456. },
  457. unitRow: {
  458. flexDirection: 'row',
  459. flexWrap: 'wrap',
  460. gap: 8,
  461. marginBottom: 8,
  462. },
  463. unitChip: {
  464. borderRadius: 999,
  465. borderWidth: 1,
  466. borderColor: '#C6C6C6',
  467. paddingHorizontal: 10,
  468. paddingVertical: 4,
  469. },
  470. unitChipActive: {
  471. borderColor: '#2F7D4F',
  472. backgroundColor: '#E7F3EA',
  473. },
  474. unitText: {
  475. fontSize: 12,
  476. },
  477. unitTextActive: {
  478. fontSize: 12,
  479. color: '#2F7D4F',
  480. fontWeight: '600',
  481. },
  482. mediaStrip: {
  483. marginTop: 6,
  484. },
  485. mediaChip: {
  486. width: 72,
  487. height: 72,
  488. borderRadius: 10,
  489. marginRight: 8,
  490. overflow: 'hidden',
  491. backgroundColor: '#E6E1D4',
  492. alignItems: 'center',
  493. justifyContent: 'center',
  494. },
  495. mediaThumb: {
  496. width: '100%',
  497. height: '100%',
  498. },
  499. videoThumb: {
  500. width: '100%',
  501. height: '100%',
  502. backgroundColor: '#1C1C1C',
  503. alignItems: 'center',
  504. justifyContent: 'center',
  505. },
  506. videoThumbText: {
  507. color: '#FFFFFF',
  508. fontSize: 18,
  509. fontWeight: '700',
  510. },
  511. mediaRemove: {
  512. position: 'absolute',
  513. top: 4,
  514. right: 4,
  515. width: 18,
  516. height: 18,
  517. borderRadius: 9,
  518. backgroundColor: 'rgba(0,0,0,0.6)',
  519. alignItems: 'center',
  520. justifyContent: 'center',
  521. },
  522. mediaRemoveText: {
  523. color: '#FFFFFF',
  524. fontSize: 12,
  525. lineHeight: 14,
  526. fontWeight: '700',
  527. },
  528. modalBackdrop: {
  529. flex: 1,
  530. backgroundColor: 'rgba(0,0,0,0.4)',
  531. justifyContent: 'center',
  532. padding: 24,
  533. },
  534. modalCard: {
  535. borderRadius: 14,
  536. backgroundColor: '#FFFFFF',
  537. padding: 16,
  538. gap: 10,
  539. maxHeight: '80%',
  540. },
  541. modalList: {
  542. maxHeight: 300,
  543. },
  544. modalItem: {
  545. paddingVertical: 10,
  546. },
  547. });