Açıklama Yok

new.tsx 15KB

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