Bez popisu

new.tsx 16KB

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