9 ol-359"> 359
+          )}
360
+          {mediaUris.length > 0 ? (
361
+            <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaRow}>
362
+              {mediaUris.map((uri) => (
363
+                <Pressable
364
+                  key={uri}
365
+                  style={styles.mediaThumbWrap}
366
+                  onPress={() => setActiveUri(uri)}>
367
+                  {isVideoUri(uri) ? (
368
+                    <Video
369
+                      source={{ uri }}
370
+                      style={styles.mediaThumb}
371
+                      resizeMode={ResizeMode.COVER}
372
+                      isMuted
373
+                    />
374
+                  ) : (
375
+                    <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
376
+                  )}
377
+                  <Pressable
378
+                    style={styles.mediaRemove}
379
+                    onPress={() => {
380
+                      setMediaUris((prev) => {
381
+                        const next = prev.filter((item) => item !== uri);
382
+                        if (activeUri === uri) setActiveUri(next[0] ?? null);
383
+                        return next;
384
+                      });
385
+                    }}>
386
+                    <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
387
+                  </Pressable>
388
+                </Pressable>
389
+              ))}
390
+            </ScrollView>
391
+          ) : null}
392
+
393
+          <View style={styles.mediaButtons}>
394
+            <ThemedButton
395
+              title={t('costs.pickFromGallery')}
396
+              onPress={() =>
397
+                handlePickMedia((uris) => {
398
+                  setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
399
+                  if (!activeUri && uris[0]) setActiveUri(uris[0]);
400
+                })
401
+              }
402
+              variant="secondary"
403
+            />
404
+            <ThemedButton
405
+              title={t('costs.takeMedia')}
406
+              onPress={() =>
407
+                handleTakeMedia((uri) => {
408
+                  if (!uri) return;
409
+                  setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
410
+                  if (!activeUri) setActiveUri(uri);
411
+                })
412
+              }
413
+              variant="secondary"
414
+            />
415
+          </View>
416
+
417
+          <View style={styles.actions}>
418
+            <IconButton
419
+              name="trash"
420
+              onPress={handleDelete}
421
+              accessibilityLabel={t('costs.delete')}
422
+              variant="danger"
423
+            />
424
+            <View style={styles.updateGroup}>
425
+              {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('costs.saved')}</ThemedText> : null}
426
+              <ThemedButton
427
+                title={saving ? t('costs.saving') : t('costs.update')}
428
+                onPress={handleUpdate}
429
+              />
430
+            </View>
431
+          </View>
432
+        </ScrollView>
433
+      </KeyboardAvoidingView>
434
+
435
+      <Modal transparent visible={fieldModalOpen} animationType="fade">
436
+        <Pressable style={styles.modalOverlay} onPress={() => setFieldModalOpen(false)}>
437
+          <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
438
+            <ThemedText type="subtitle">{t('costs.selectField')}</ThemedText>
439
+            <ScrollView>
440
+              {fields.map((item) => (
441
+                <Pressable
442
+                  key={item.id}
443
+                  onPress={() => {
444
+                    setSelectedFieldId(item.id);
445
+                    setErrors((prev) => ({ ...prev, field: undefined }));
446
+                    setFieldModalOpen(false);
447
+                  }}
448
+                  style={styles.modalItem}>
449
+                  <ThemedText>{item.name || t('costs.untitled')}</ThemedText>
450
+                </Pressable>
451
+              ))}
452
+            </ScrollView>
453
+          </View>
454
+        </Pressable>
455
+      </Modal>
456
+
457
+      <Modal transparent visible={cropModalOpen} animationType="fade">
458
+        <Pressable style={styles.modalOverlay} onPress={() => setCropModalOpen(false)}>
459
+          <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
460
+            <ThemedText type="subtitle">{t('costs.selectCrop')}</ThemedText>
461
+            <ScrollView>
462
+              {crops.map((item) => (
463
+                <Pressable
464
+                  key={item.id}
465
+                  onPress={() => {
466
+                    setSelectedCropId(item.id);
467
+                    setCropModalOpen(false);
468
+                  }}
469
+                  style={styles.modalItem}>
470
+                  <ThemedText>{item.crop_name || t('costs.untitled')}</ThemedText>
471
+                </Pressable>
472
+              ))}
473
+            </ScrollView>
474
+          </View>
475
+        </Pressable>
476
+      </Modal>
477
+
478
+      <ZoomImageModal visible={Boolean(zoomUri)} uri={zoomUri} onClose={() => setZoomUri(null)} />
479
+    </ThemedView>
480
+  );
481
+}
482
+
483
+async function handlePickMedia(onAdd: (uris: string[]) => void) {
484
+  const pickerTypes =
485
+    (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
486
+    (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
487
+    undefined;
488
+  const mediaTypes =
489
+    pickerTypes?.Images && pickerTypes?.Videos
490
+      ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
491
+      : ['images', 'videos'];
492
+  const result = await ImagePicker.launchImageLibraryAsync({
493
+    mediaTypes,
494
+    quality: 1,
495
+    allowsMultipleSelection: true,
496
+  });
497
+  if (result.canceled) return;
498
+  const uris = result.assets.map((asset) => asset.uri).filter(Boolean);
499
+  if (uris.length > 0) onAdd(uris);
500
+}
501
+
502
+async function handleTakeMedia(onAdd: (uri: string | null) => void) {
503
+  const pickerTypes =
504
+    (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
505
+    (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
506
+    undefined;
507
+  const mediaTypes =
508
+    pickerTypes?.Images && pickerTypes?.Videos
509
+      ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
510
+      : ['images', 'videos'];
511
+  const result = await ImagePicker.launchCameraAsync({
512
+    mediaTypes,
513
+    quality: 1,
514
+  });
515
+  if (result.canceled) return;
516
+  onAdd(result.assets[0]?.uri ?? null);
517
+}
518
+
519
+function isVideoUri(uri: string) {
520
+  const cleaned = uri.split('?')[0]?.toLowerCase() ?? '';
521
+  return ['.mp4', '.mov', '.m4v', '.webm', '.mkv', '.avi'].some((ext) => cleaned.endsWith(ext));
522
+}
523
+
524
+function normalizeMediaUri(uri?: string | null) {
525
+  if (!uri) return null;
526
+  if (uri.startsWith('file://') || uri.startsWith('http://') || uri.startsWith('https://')) return uri;
527
+  return `file://${uri}`;
528
+}
529
+
530
+function uniqueMediaUris(uris: string[]) {
531
+  return Array.from(new Set(uris.filter(Boolean)));
532
+}
533
+
534
+function toDateOnly(date: Date) {
535
+  const year = date.getFullYear();
536
+  const month = String(date.getMonth() + 1).padStart(2, '0');
537
+  const day = String(date.getDate()).padStart(2, '0');
538
+  return `${year}-${month}-${day}`;
539
+}
540
+
541
+const styles = StyleSheet.create({
542
+  container: { flex: 1 },
543
+  keyboardAvoid: { flex: 1 },
544
+  content: { padding: 16, paddingBottom: 40 },
545
+  input: {
546
+    borderWidth: 1,
547
+    borderRadius: 12,
548
+    paddingHorizontal: 12,
549
+    paddingVertical: 10,
550
+    marginTop: 6,
551
+    marginBottom: 12,
552
+    fontSize: 16,
553
+  },
554
+  multiline: { minHeight: 90, textAlignVertical: 'top' },
555
+  errorText: { color: '#C0392B', marginBottom: 8 },
556
+  requiredMark: { color: '#C0392B' },
557
+  dateInput: {
558
+    borderWidth: 1,
559
+    borderRadius: 12,
560
+    paddingHorizontal: 12,
561
+    paddingVertical: 12,
562
+    marginTop: 6,
563
+    marginBottom: 12,
564
+  },
565
+  dateValue: { fontSize: 16 },
566
+  chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 8 },
567
+  chip: {
568
+    paddingHorizontal: 12,
569
+    paddingVertical: 6,
570
+    borderRadius: 999,
571
+    borderWidth: 1,
572
+    borderColor: '#C9D2C4',
573
+    backgroundColor: '#F8F6F0',
574
+  },
575
+  chipActive: { backgroundColor: '#DDE8DA', borderColor: '#88A68F' },
576
+  chipText: { fontSize: 13 },
577
+  mediaPreview: {
578
+    width: '100%',
579
+    height: 200,
580
+    borderRadius: 16,
581
+    backgroundColor: '#F1F1F1',
582
+    marginBottom: 12,
583
+  },
584
+  mediaPlaceholder: {
585
+    width: '100%',
586
+    height: 200,
587
+    borderRadius: 16,
588
+    borderWidth: 1,
589
+    justifyContent: 'center',
590
+    alignItems: 'center',
591
+    marginBottom: 12,
592
+  },
593
+  mediaPlaceholderText: { opacity: 0.7 },
594
+  mediaRow: { marginBottom: 12 },
595
+  mediaThumbWrap: { marginRight: 8 },
596
+  mediaThumb: { width: 72, height: 72, borderRadius: 12 },
597
+  mediaRemove: {
598
+    position: 'absolute',
599
+    top: -6,
600
+    right: -6,
601
+    width: 22,
602
+    height: 22,
603
+    borderRadius: 11,
604
+    backgroundColor: '#2C2C2C',
605
+    justifyContent: 'center',
606
+    alignItems: 'center',
607
+  },
608
+  mediaRemoveText: { color: '#fff', fontSize: 14, lineHeight: 16 },
609
+  updateGroup: {
610
+    flexDirection: 'row',
611
+    alignItems: 'center',
612
+    gap: 8,
613
+  },
614
+  inlineToastText: {
615
+    fontWeight: '700',
616
+    fontSize: 12,
617
+  },
618
+  mediaButtons: { flexDirection: 'row', gap: 12, flexWrap: 'wrap', marginBottom: 16 },
619
+  actions: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8 },
620
+  modalOverlay: {
621
+    flex: 1,
622
+    backgroundColor: 'rgba(0,0,0,0.4)',
623
+    justifyContent: 'center',
624
+    padding: 20,
625
+  },
626
+  modalCard: {
627
+    borderRadius: 16,
628
+    borderWidth: 1,
629
+    padding: 16,
630
+    maxHeight: '70%',
631
+  },
632
+  modalItem: { paddingVertical: 10 },
633
+});

+ 43 - 0
app/(tabs)/costs/_layout.tsx

@@ -0,0 +1,43 @@
1
+import { Stack, useRouter } from 'expo-router';
2
+import { Pressable } from 'react-native';
3
+
4
+import { IconSymbol } from '@/components/ui/icon-symbol';
5
+import { Colors } from '@/constants/theme';
6
+import { useColorScheme } from '@/hooks/use-color-scheme';
7
+import { useTranslation } from '@/localization/i18n';
8
+
9
+export default function CostsLayout() {
10
+  const { t } = useTranslation();
11
+  const router = useRouter();
12
+  const colorScheme = useColorScheme();
13
+  const palette = Colors[colorScheme ?? 'light'];
14
+
15
+  return (
16
+    <Stack
17
+      screenOptions={{
18
+        headerBackTitleVisible: false,
19
+        headerBackTitle: '',
20
+        headerBackTitleStyle: { display: 'none' },
21
+        headerLeft: ({ canGoBack }) =>
22
+          canGoBack ? (
23
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
24
+              <IconSymbol size={20} name="chevron.left" color={palette.text} />
25
+            </Pressable>
26
+          ) : null,
27
+      }}>
28
+      <Stack.Screen
29
+        name="index"
30
+        options={{
31
+          title: t('costs.title'),
32
+          headerLeft: () => (
33
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
34
+              <IconSymbol size={20} name="chevron.left" color={palette.text} />
35
+            </Pressable>
36
+          ),
37
+        }}
38
+      />
39
+      <Stack.Screen name="new" options={{ title: t('costs.new') }} />
40
+      <Stack.Screen name="[id]" options={{ title: t('costs.edit') }} />
41
+    </Stack>
42
+  );
43
+}

+ 181 - 33
app/costs.tsx

@@ -1,8 +1,9 @@
1
-import { useEffect, useMemo, useState } from 'react';
1
+import { useCallback, useEffect, useMemo, useState } from 'react';
2 2
 import {
3 3
   Alert,
4 4
   FlatList,
5 5
   Image,
6
+  InteractionManager,
6 7
   KeyboardAvoidingView,
7 8
   Modal,
8 9
   Pressable,
@@ -13,6 +14,7 @@ import {
13 14
 } from 'react-native';
14 15
 import DateTimePicker from '@react-native-community/datetimepicker';
15 16
 import * as ImagePicker from 'expo-image-picker';
17
+import { ResizeMode, Video } from 'expo-av';
16 18
 
17 19
 import { ThemedText } from '@/components/themed-text';
18 20
 import { ThemedView } from '@/components/themed-view';
@@ -22,6 +24,9 @@ import { IconSymbol } from '@/components/ui/icon-symbol';
22 24
 import { Colors, Fonts } from '@/constants/theme';
23 25
 import { useTranslation } from '@/localization/i18n';
24 26
 import { dbPromise, initCoreTables } from '@/services/db';
27
+import { ZoomImageModal } from '@/components/zoom-image-modal';
28
+import { useLocalSearchParams, useRouter } from 'expo-router';
29
+import { useFocusEffect, useNavigation } from '@react-navigation/native';
25 30
 import { useColorScheme } from '@/hooks/use-color-scheme';
26 31
 
27 32
 type FieldRow = {
@@ -52,8 +57,12 @@ type CostRow = {
52 57
 
53 58
 export default function CostsScreen() {
54 59
   const { t } = useTranslation();
60
+  const router = useRouter();
61
+  const navigation = useNavigation();
62
+  const params = useLocalSearchParams<{ from?: string | string[] }>();
55 63
   const theme = useColorScheme() ?? 'light';
56 64
   const palette = Colors[theme];
65
+  const fromParam = Array.isArray(params.from) ? params.from[0] : params.from;
57 66
   const categoryPresets = ['seed', 'fertilizer', 'labor', 'fuel', 'equipment', 'transport', 'misc'];
58 67
   const [currency, setCurrency] = useState('THB');
59 68
   const [costs, setCosts] = useState<CostRow[]>([]);
@@ -71,6 +80,29 @@ export default function CostsScreen() {
71 80
 
72 81
   const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
73 82
   const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
83
+
84
+  useEffect(() => {
85
+    navigation.setOptions({
86
+      headerLeft: () => (
87
+        <Pressable
88
+          onPress={() => {
89
+            if (fromParam === 'logbook') {
90
+              router.replace('/logbook');
91
+              return;
92
+            }
93
+            if (fromParam === 'home') {
94
+              router.replace('/');
95
+              return;
96
+            }
97
+            router.back();
98
+          }}
99
+          hitSlop={10}
100
+          style={{ paddingHorizontal: 8 }}>
101
+          <IconSymbol name="chevron.left" size={20} color={palette.text} />
102
+        </Pressable>
103
+      ),
104
+    });
105
+  }, [fromParam, navigation, palette.text, router]);
74 106
   const [category, setCategory] = useState('');
75 107
   const [amount, setAmount] = useState('');
76 108
   const [vendor, setVendor] = useState('');
@@ -89,6 +121,8 @@ export default function CostsScreen() {
89 121
   const [editSpentDate, setEditSpentDate] = useState('');
90 122
   const [showEditSpentPicker, setShowEditSpentPicker] = useState(false);
91 123
   const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
124
+  const [zoomUri, setZoomUri] = useState<string | null>(null);
125
+  const [pendingZoomUri, setPendingZoomUri] = useState<string | null>(null);
92 126
   const [editErrors, setEditErrors] = useState<{ field?: string; amount?: string }>({});
93 127
 
94 128
   const selectedField = useMemo(
@@ -149,6 +183,44 @@ export default function CostsScreen() {
149 183
     };
150 184
   }, [t]);
151 185
 
186
+  const fetchCostsPage = useCallback(async () => {
187
+    try {
188
+      const db = await dbPromise;
189
+      const profileRow = await db.getFirstAsync<{ currency: string | null }>(
190
+        'SELECT currency FROM user_profile WHERE id = 1;'
191
+      );
192
+      const costRows = await db.getAllAsync<CostRow>(
193
+        `SELECT c.id, c.field_id, c.crop_id, c.category, c.amount, c.currency, c.vendor, c.notes,
194
+                c.spent_at, c.photo_uri, f.name as field_name, cr.crop_name as crop_name
195
+         FROM costs c
196
+         LEFT JOIN fields f ON f.id = c.field_id
197
+         LEFT JOIN crops cr ON cr.id = c.crop_id
198
+         ORDER BY c.spent_at DESC;`
199
+      );
200
+      setCurrency(profileRow?.currency ?? 'THB');
201
+      setCosts(costRows);
202
+      setStatus(costRows.length === 0 ? t('costs.empty') : '');
203
+    } catch (error) {
204
+      setStatus(`Error: ${String(error)}`);
205
+    }
206
+  }, [t]);
207
+
208
+  useFocusEffect(
209
+    useCallback(() => {
210
+      fetchCostsPage();
211
+    }, [fetchCostsPage])
212
+  );
213
+
214
+  useEffect(() => {
215
+    if (!newModalOpen && !editModalOpen && pendingZoomUri) {
216
+      const uri = pendingZoomUri;
217
+      setPendingZoomUri(null);
218
+      InteractionManager.runAfterInteractions(() => {
219
+        setTimeout(() => setZoomUri(uri), 150);
220
+      });
221
+    }
222
+  }, [newModalOpen, editModalOpen, pendingZoomUri]);
223
+
152 224
   async function handleSave() {
153 225
     const parsedAmount = amount.trim() ? Number(amount) : null;
154 226
     const nextErrors: { field?: string; amount?: string } = {};
@@ -237,17 +309,7 @@ export default function CostsScreen() {
237 309
   }
238 310
 
239 311
   function startEdit(item: CostRow) {
240
-    setEditingId(item.id);
241
-    setEditFieldId(item.field_id ?? null);
242
-    setEditCropId(item.crop_id ?? null);
243
-    setEditCategory(item.category ?? '');
244
-    setEditAmount(item.amount !== null ? String(item.amount) : '');
245
-    setEditVendor(item.vendor ?? '');
246
-    setEditNotes(item.notes ?? '');
247
-    setEditSpentDate(item.spent_at ?? '');
248
-    setEditPhotoUri(item.photo_uri ?? null);
249
-    setEditErrors({});
250
-    setEditModalOpen(true);
312
+    router.push(`/costs/${item.id}`);
251 313
   }
252 314
 
253 315
   function cancelEdit() {
@@ -338,8 +400,24 @@ export default function CostsScreen() {
338 400
                   {item.amount} {item.currency || currency}
339 401
                 </ThemedText>
340 402
               ) : null}
341
-              {item.photo_uri ? (
342
-                <Image source={{ uri: item.photo_uri }} style={styles.listPhoto} />
403
+              {normalizeMediaUri(item.photo_uri) ? (
404
+                isVideoUri(normalizeMediaUri(item.photo_uri) as string) ? (
405
+                  <Video
406
+                    source={{ uri: normalizeMediaUri(item.photo_uri) as string }}
407
+                    style={styles.videoPreview}
408
+                    useNativeControls
409
+                    resizeMode={ResizeMode.CONTAIN}
410
+                    isMuted
411
+                  />
412
+                ) : (
413
+                  <Pressable onPress={() => setZoomUri(normalizeMediaUri(item.photo_uri) as string)}>
414
+                    <Image
415
+                      source={{ uri: normalizeMediaUri(item.photo_uri) as string }}
416
+                      style={styles.listPhoto}
417
+                      resizeMode="contain"
418
+                    />
419
+                  </Pressable>
420
+                )
343 421
               ) : null}
344 422
               {item.vendor ? <ThemedText>{item.vendor}</ThemedText> : null}
345 423
               {item.notes ? <ThemedText>{item.notes}</ThemedText> : null}
@@ -363,7 +441,7 @@ export default function CostsScreen() {
363 441
               </ThemedView>
364 442
             ) : null}
365 443
             <ThemedView style={styles.section}>
366
-              <Pressable style={styles.newButton} onPress={() => setNewModalOpen(true)}>
444
+              <Pressable style={styles.newButton} onPress={() => router.push('/costs/new')}>
367 445
                 <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
368 446
                 <ThemedText style={styles.newButtonText}>{t('costs.new')}</ThemedText>
369 447
               </Pressable>
@@ -584,20 +662,40 @@ export default function CostsScreen() {
584 662
                     style={inputStyle}
585 663
                     multiline
586 664
                   />
587
-                  <ThemedText>{t('costs.photo')}</ThemedText>
588
-                  {photoUri ? (
589
-                    <Image source={{ uri: photoUri }} style={styles.photoPreview} />
665
+                  <ThemedText>{t('costs.addMedia')}</ThemedText>
666
+                  {normalizeMediaUri(photoUri) ? (
667
+                    isVideoUri(normalizeMediaUri(photoUri) as string) ? (
668
+                      <Video
669
+                        source={{ uri: normalizeMediaUri(photoUri) as string }}
670
+                        style={styles.videoPreview}
671
+                        useNativeControls
672
+                        resizeMode={ResizeMode.CONTAIN}
673
+                        isMuted
674
+                      />
675
+                    ) : (
676
+                      <Pressable
677
+                        onPress={() => {
678
+                          setPendingZoomUri(normalizeMediaUri(photoUri) as string);
679
+                          setNewModalOpen(false);
680
+                        }}>
681
+                        <Image
682
+                          source={{ uri: normalizeMediaUri(photoUri) as string }}
683
+                          style={styles.photoPreview}
684
+                          resizeMode="contain"
685
+                        />
686
+                      </Pressable>
687
+                    )
590 688
                   ) : (
591 689
                     <ThemedText style={styles.photoPlaceholder}>{t('costs.noPhoto')}</ThemedText>
592 690
                   )}
593 691
                   <View style={styles.photoRow}>
594 692
                     <ThemedButton
595
-                      title={t('costs.pickPhoto')}
693
+                      title={t('costs.pickFromGallery')}
596 694
                       onPress={() => handlePickPhoto(setPhotoUri)}
597 695
                       variant="secondary"
598 696
                     />
599 697
                     <ThemedButton
600
-                      title={t('costs.takePhoto')}
698
+                      title={t('costs.takeMedia')}
601 699
                       onPress={() =>
602 700
                         handleTakePhoto(setPhotoUri, (code) =>
603 701
                           setStatus(
@@ -770,20 +868,40 @@ export default function CostsScreen() {
770 868
                     style={inputStyle}
771 869
                     multiline
772 870
                   />
773
-                  <ThemedText>{t('costs.photo')}</ThemedText>
774
-                  {editPhotoUri ? (
775
-                    <Image source={{ uri: editPhotoUri }} style={styles.photoPreview} />
871
+                  <ThemedText>{t('costs.addMedia')}</ThemedText>
872
+                  {normalizeMediaUri(editPhotoUri) ? (
873
+                    isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? (
874
+                      <Video
875
+                        source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
876
+                        style={styles.videoPreview}
877
+                        useNativeControls
878
+                        resizeMode={ResizeMode.CONTAIN}
879
+                        isMuted
880
+                      />
881
+                    ) : (
882
+                      <Pressable
883
+                        onPress={() => {
884
+                          setPendingZoomUri(normalizeMediaUri(editPhotoUri) as string);
885
+                          setEditModalOpen(false);
886
+                        }}>
887
+                        <Image
888
+                          source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
889
+                          style={styles.photoPreview}
890
+                          resizeMode="contain"
891
+                        />
892
+                      </Pressable>
893
+                    )
776 894
                   ) : (
777 895
                     <ThemedText style={styles.photoPlaceholder}>{t('costs.noPhoto')}</ThemedText>
778 896
                   )}
779 897
                   <View style={styles.photoRow}>
780 898
                     <ThemedButton
781
-                      title={t('costs.pickPhoto')}
899
+                      title={t('costs.pickFromGallery')}
782 900
                       onPress={() => handlePickPhoto(setEditPhotoUri)}
783 901
                       variant="secondary"
784 902
                     />
785 903
                     <ThemedButton
786
-                      title={t('costs.takePhoto')}
904
+                      title={t('costs.takeMedia')}
787 905
                       onPress={() =>
788 906
                         handleTakePhoto(setEditPhotoUri, (code) =>
789 907
                           setStatus(
@@ -812,6 +930,11 @@ export default function CostsScreen() {
812 930
           </KeyboardAvoidingView>
813 931
         </View>
814 932
       </Modal>
933
+      <ZoomImageModal
934
+        uri={zoomUri}
935
+        visible={Boolean(zoomUri)}
936
+        onClose={() => setZoomUri(null)}
937
+      />
815 938
     </>
816 939
   );
817 940
 }
@@ -838,7 +961,7 @@ function toDateOnly(date: Date) {
838 961
 
839 962
 async function handlePickPhoto(setter: (value: string | null) => void) {
840 963
   const result = await ImagePicker.launchImageLibraryAsync({
841
-    mediaTypes: getImageMediaTypes(),
964
+    mediaTypes: getMediaTypes(),
842 965
     quality: 1,
843 966
   });
844 967
   if (result.canceled) return;
@@ -856,7 +979,10 @@ async function handleTakePhoto(
856 979
       onError?.('cameraDenied');
857 980
       return;
858 981
     }
859
-    const result = await ImagePicker.launchCameraAsync({ quality: 1 });
982
+    const result = await ImagePicker.launchCameraAsync({
983
+      mediaTypes: getMediaTypes(),
984
+      quality: 1,
985
+    });
860 986
     if (result.canceled) return;
861 987
     const asset = result.assets[0];
862 988
     setter(asset.uri);
@@ -865,10 +991,26 @@ async function handleTakePhoto(
865 991
   }
866 992
 }
867 993
 
868
-function getImageMediaTypes() {
869
-  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
870
-    .MediaType;
871
-  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
994
+function getMediaTypes() {
995
+  const mediaType = (ImagePicker as {
996
+    MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
997
+  }).MediaType;
998
+  const imageType = mediaType?.Image ?? mediaType?.Images;
999
+  const videoType = mediaType?.Video ?? mediaType?.Videos;
1000
+  if (imageType && videoType) {
1001
+    return [imageType, videoType];
1002
+  }
1003
+  return imageType ?? videoType ?? ['images', 'videos'];
1004
+}
1005
+
1006
+function isVideoUri(uri: string) {
1007
+  return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
1008
+}
1009
+
1010
+function normalizeMediaUri(uri?: string | null) {
1011
+  if (typeof uri !== 'string') return null;
1012
+  const trimmed = uri.trim();
1013
+  return trimmed ? trimmed : null;
872 1014
 }
873 1015
 
874 1016
 const styles = StyleSheet.create({
@@ -999,14 +1141,20 @@ const styles = StyleSheet.create({
999 1141
   },
1000 1142
   photoPreview: {
1001 1143
     width: '100%',
1002
-    height: 160,
1144
+    height: 200,
1003 1145
     borderRadius: 12,
1004 1146
   },
1005 1147
   listPhoto: {
1006 1148
     width: '100%',
1007
-    height: 120,
1149
+    height: 160,
1008 1150
     borderRadius: 10,
1009 1151
   },
1152
+  videoPreview: {
1153
+    width: '100%',
1154
+    height: 200,
1155
+    borderRadius: 12,
1156
+    backgroundColor: '#1C1C1C',
1157
+  },
1010 1158
   photoPlaceholder: {
1011 1159
     opacity: 0.6,
1012 1160
   },

+ 535 - 0
app/(tabs)/costs/new.tsx

@@ -0,0 +1,535 @@
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
+
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
+
27
+const CATEGORY_PRESETS = ['seed', 'fertilizer', 'labor', 'fuel', 'equipment', 'transport', 'misc'];
28
+
29
+type FieldRow = {
30
+  id: number;
31
+  name: string | null;
32
+};
33
+
34
+type CropRow = {
35
+  id: number;
36
+  crop_name: string | null;
37
+};
38
+
39
+export default function NewCostScreen() {
40
+  const { t } = useTranslation();
41
+  const router = useRouter();
42
+  const theme = useColorScheme() ?? 'light';
43
+  const palette = Colors[theme];
44
+
45
+  const [status, setStatus] = useState('');
46
+  const [fields, setFields] = useState<FieldRow[]>([]);
47
+  const [crops, setCrops] = useState<CropRow[]>([]);
48
+  const [currency, setCurrency] = useState('THB');
49
+  const [fieldModalOpen, setFieldModalOpen] = useState(false);
50
+  const [cropModalOpen, setCropModalOpen] = useState(false);
51
+  const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
52
+  const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
53
+  const [category, setCategory] = useState('');
54
+  const [amount, setAmount] = useState('');
55
+  const [vendor, setVendor] = useState('');
56
+  const [notes, setNotes] = useState('');
57
+  const [spentDate, setSpentDate] = useState('');
58
+  const [showSpentPicker, setShowSpentPicker] = useState(false);
59
+  const [mediaUris, setMediaUris] = useState<string[]>([]);
60
+  const [activeUri, setActiveUri] = useState<string | null>(null);
61
+  const [errors, setErrors] = useState<{ field?: string; amount?: string }>({});
62
+  const [zoomUri, setZoomUri] = useState<string | null>(null);
63
+  const [saving, setSaving] = useState(false);
64
+
65
+  useEffect(() => {
66
+    let isActive = true;
67
+
68
+    async function loadData() {
69
+      try {
70
+        await initCoreTables();
71
+        const db = await dbPromise;
72
+        const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
73
+        const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
74
+        const profileRow = await db.getFirstAsync<{ currency: string | null }>(
75
+          'SELECT currency FROM user_profile WHERE id = 1;'
76
+        );
77
+        if (!isActive) return;
78
+        setFields(fieldRows);
79
+        setCrops(cropRows);
80
+        setCurrency(profileRow?.currency ?? 'THB');
81
+      } catch (error) {
82
+        if (isActive) setStatus(`Error: ${String(error)}`);
83
+      }
84
+    }
85
+
86
+    loadData();
87
+    return () => {
88
+      isActive = false;
89
+    };
90
+  }, [t]);
91
+
92
+  const selectedField = useMemo(
93
+    () => fields.find((item) => item.id === selectedFieldId),
94
+    [fields, selectedFieldId]
95
+  );
96
+  const selectedCrop = useMemo(
97
+    () => crops.find((item) => item.id === selectedCropId),
98
+    [crops, selectedCropId]
99
+  );
100
+
101
+  const inputStyle = [
102
+    styles.input,
103
+    {
104
+      borderColor: palette.border,
105
+      backgroundColor: palette.input,
106
+      color: palette.text,
107
+    },
108
+  ];
109
+
110
+  async function handleSave() {
111
+    const parsedAmount = amount.trim() ? Number(amount) : null;
112
+    const nextErrors: { field?: string; amount?: string } = {};
113
+    if (!selectedFieldId) nextErrors.field = t('costs.fieldRequired');
114
+    if (!parsedAmount || !Number.isFinite(parsedAmount)) nextErrors.amount = t('costs.amountInvalid');
115
+    setErrors(nextErrors);
116
+    if (Object.keys(nextErrors).length > 0) return;
117
+    try {
118
+      setSaving(true);
119
+      const db = await dbPromise;
120
+      const now = new Date().toISOString();
121
+      const primaryUri = mediaUris[0] ?? normalizeMediaUri(activeUri);
122
+      const result = await db.runAsync(
123
+        'INSERT INTO costs (field_id, crop_id, category, amount, currency, vendor, notes, spent_at, photo_uri, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
124
+        selectedFieldId,
125
+        selectedCropId,
126
+        category.trim() || null,
127
+        parsedAmount,
128
+        currency,
129
+        vendor.trim() || null,
130
+        notes.trim() || null,
131
+        spentDate || null,
132
+        primaryUri ?? null,
133
+        now
134
+      );
135
+      const costId = Number(result.lastInsertRowId);
136
+      if (costId) {
137
+        const mediaToInsert = uniqueMediaUris([
138
+          ...mediaUris,
139
+          ...(normalizeMediaUri(activeUri) ? [normalizeMediaUri(activeUri) as string] : []),
140
+        ]);
141
+        for (const uri of mediaToInsert) {
142
+          await db.runAsync(
143
+            'INSERT INTO cost_media (cost_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
144
+            costId,
145
+            uri,
146
+            isVideoUri(uri) ? 'video' : 'image',
147
+            now
148
+          );
149
+        }
150
+      }
151
+      setStatus(t('costs.saved'));
152
+      router.back();
153
+    } catch (error) {
154
+      setStatus(`Error: ${String(error)}`);
155
+    } finally {
156
+      setSaving(false);
157
+    }
158
+  }
159
+
160
+  return (
161
+    <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
162
+      <KeyboardAvoidingView
163
+        behavior={Platform.OS === 'ios' ? 'padding' : undefined}
164
+        style={styles.keyboardAvoid}>
165
+        <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
166
+          <ThemedText type="title">{t('costs.new')}</ThemedText>
167
+          {status ? <ThemedText>{status}</ThemedText> : null}
168
+
169
+          <ThemedText>
170
+            {t('costs.field')}
171
+            <ThemedText style={styles.requiredMark}> *</ThemedText>
172
+          </ThemedText>
173
+          <ThemedButton
174
+            title={selectedField?.name || t('costs.selectField')}
175
+            onPress={() => setFieldModalOpen(true)}
176
+            variant="secondary"
177
+          />
178
+          {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
179
+
180
+          <ThemedText>{t('costs.crop')}</ThemedText>
181
+          <ThemedButton
182
+            title={selectedCrop?.crop_name || t('costs.selectCrop')}
183
+            onPress={() => setCropModalOpen(true)}
184
+            variant="secondary"
185
+          />
186
+
187
+          <ThemedText>{t('costs.category')}</ThemedText>
188
+          <View style={styles.chipRow}>
189
+            {CATEGORY_PRESETS.map((preset) => {
190
+              const label = t(`costs.category.${preset}`);
191
+              const isActive = label === category || preset === category;
192
+              return (
193
+                <Pressable
194
+                  key={preset}
195
+                  style={[styles.chip, isActive ? styles.chipActive : null]}
196
+                  onPress={() => setCategory(label)}>
197
+                  <ThemedText style={styles.chipText}>{label}</ThemedText>
198
+                </Pressable>
199
+              );
200
+            })}
201
+          </View>
202
+          <TextInput
203
+            value={category}
204
+            onChangeText={setCategory}
205
+            placeholder={t('costs.categoryPlaceholder')}
206
+            placeholderTextColor={palette.placeholder}
207
+            style={inputStyle}
208
+          />
209
+
210
+          <ThemedText>
211
+            {t('costs.amount')} ({currency})
212
+            <ThemedText style={styles.requiredMark}> *</ThemedText>
213
+          </ThemedText>
214
+          <TextInput
215
+            value={amount}
216
+            onChangeText={(value) => {
217
+              setAmount(value);
218
+              if (errors.amount) setErrors((prev) => ({ ...prev, amount: undefined }));
219
+            }}
220
+            placeholder={t('costs.amountPlaceholder')}
221
+            placeholderTextColor={palette.placeholder}
222
+            style={inputStyle}
223
+            keyboardType="decimal-pad"
224
+          />
225
+          {errors.amount ? <ThemedText style={styles.errorText}>{errors.amount}</ThemedText> : null}
226
+
227
+          <ThemedText>{t('costs.vendor')}</ThemedText>
228
+          <TextInput
229
+            value={vendor}
230
+            onChangeText={setVendor}
231
+            placeholder={t('costs.vendorPlaceholder')}
232
+            placeholderTextColor={palette.placeholder}
233
+            style={inputStyle}
234
+          />
235
+
236
+          <ThemedText>{t('costs.date')}</ThemedText>
237
+          <Pressable onPress={() => setShowSpentPicker(true)} style={styles.dateInput}>
238
+            <ThemedText style={styles.dateValue}>
239
+              {spentDate || t('costs.datePlaceholder')}
240
+            </ThemedText>
241
+          </Pressable>
242
+          {showSpentPicker ? (
243
+            <DateTimePicker
244
+              value={spentDate ? new Date(spentDate) : new Date()}
245
+              mode="date"
246
+              onChange={(event, date) => {
247
+                setShowSpentPicker(false);
248
+                if (date) setSpentDate(toDateOnly(date));
249
+              }}
250
+            />
251
+          ) : null}
252
+
253
+          <ThemedText>{t('costs.notes')}</ThemedText>
254
+          <TextInput
255
+            value={notes}
256
+            onChangeText={setNotes}
257
+            placeholder={t('costs.notesPlaceholder')}
258
+            placeholderTextColor={palette.placeholder}
259
+            style={[inputStyle, styles.multiline]}
260
+            multiline
261
+          />
262
+
263
+          <ThemedText>{t('costs.addMedia')}</ThemedText>
264
+          {normalizeMediaUri(activeUri) ? (
265
+            isVideoUri(normalizeMediaUri(activeUri) as string) ? (
266
+              <Video
267
+                source={{ uri: normalizeMediaUri(activeUri) as string }}
268
+                style={styles.mediaPreview}
269
+                useNativeControls
270
+                resizeMode={ResizeMode.CONTAIN}
271
+              />
272
+            ) : (
273
+              <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
274
+                <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
275
+              </Pressable>
276
+            )
277
+          ) : (
278
+            <ThemedView style={[styles.mediaPlaceholder, { borderColor: palette.border }]}>
279
+              <ThemedText style={styles.mediaPlaceholderText}>{t('costs.noPhoto')}</ThemedText>
280
+            </ThemedView>
281
+          )}
282
+          {mediaUris.length > 0 ? (
283
+            <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaRow}>
284
+              {mediaUris.map((uri) => (
285
+                <Pressable
286
+                  key={uri}
287
+                  style={styles.mediaThumbWrap}
288
+                  onPress={() => setActiveUri(uri)}>
289
+                  {isVideoUri(uri) ? (
290
+                    <Video
291
+                      source={{ uri }}
292
+                      style={styles.mediaThumb}
293
+                      resizeMode={ResizeMode.COVER}
294
+                      isMuted
295
+                    />
296
+                  ) : (
297
+                    <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
298
+                  )}
299
+                  <Pressable
300
+                    style={styles.mediaRemove}
301
+                    onPress={() => {
302
+                      setMediaUris((prev) => {
303
+                        const next = prev.filter((item) => item !== uri);
304
+                        if (activeUri === uri) setActiveUri(next[0] ?? null);
305
+                        return next;
306
+                      });
307
+                    }}>
308
+                    <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
309
+                  </Pressable>
310
+                </Pressable>
311
+              ))}
312
+            </ScrollView>
313
+          ) : null}
314
+
315
+          <View style={styles.mediaButtons}>
316
+            <ThemedButton
317
+              title={t('costs.pickFromGallery')}
318
+              onPress={() =>
319
+                handlePickMedia((uris) => {
320
+                  setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
321
+                  if (!activeUri && uris[0]) setActiveUri(uris[0]);
322
+                })
323
+              }
324
+              variant="secondary"
325
+            />
326
+            <ThemedButton
327
+              title={t('costs.takeMedia')}
328
+              onPress={() =>
329
+                handleTakeMedia((uri) => {
330
+                  if (!uri) return;
331
+                  setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
332
+                  if (!activeUri) setActiveUri(uri);
333
+                })
334
+              }
335
+              variant="secondary"
336
+            />
337
+          </View>
338
+
339
+          <View style={styles.actions}>
340
+            <ThemedButton title={t('costs.cancel')} onPress={() => router.back()} variant="ghost" />
341
+            <ThemedButton title={saving ? t('costs.saving') : t('costs.save')} onPress={handleSave} />
342
+          </View>
343
+        </ScrollView>
344
+      </KeyboardAvoidingView>
345
+
346
+      <Modal transparent visible={fieldModalOpen} animationType="fade">
347
+        <Pressable style={styles.modalOverlay} onPress={() => setFieldModalOpen(false)}>
348
+          <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
349
+            <ThemedText type="subtitle">{t('costs.selectField')}</ThemedText>
350
+            <ScrollView>
351
+              {fields.map((item) => (
352
+                <Pressable
353
+                  key={item.id}
354
+                  onPress={() => {
355
+                    setSelectedFieldId(item.id);
356
+                    setErrors((prev) => ({ ...prev, field: undefined }));
357
+                    setFieldModalOpen(false);
358
+                  }}
359
+                  style={styles.modalItem}>
360
+                  <ThemedText>{item.name || t('costs.untitled')}</ThemedText>
361
+                </Pressable>
362
+              ))}
363
+            </ScrollView>
364
+          </View>
365
+        </Pressable>
366
+      </Modal>
367
+
368
+      <Modal transparent visible={cropModalOpen} animationType="fade">
369
+        <Pressable style={styles.modalOverlay} onPress={() => setCropModalOpen(false)}>
370
+          <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
371
+            <ThemedText type="subtitle">{t('costs.selectCrop')}</ThemedText>
372
+            <ScrollView>
373
+              {crops.map((item) => (
374
+                <Pressable
375
+                  key={item.id}
376
+                  onPress={() => {
377
+                    setSelectedCropId(item.id);
378
+                    setCropModalOpen(false);
379
+                  }}
380
+                  style={styles.modalItem}>
381
+                  <ThemedText>{item.crop_name || t('costs.untitled')}</ThemedText>
382
+                </Pressable>
383
+              ))}
384
+            </ScrollView>
385
+          </View>
386
+        </Pressable>
387
+      </Modal>
388
+
389
+      <ZoomImageModal visible={Boolean(zoomUri)} uri={zoomUri} onClose={() => setZoomUri(null)} />
390
+    </ThemedView>
391
+  );
392
+}
393
+
394
+async function handlePickMedia(onAdd: (uris: string[]) => void) {
395
+  const pickerTypes =
396
+    (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
397
+    (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
398
+    undefined;
399
+  const mediaTypes =
400
+    pickerTypes?.Images && pickerTypes?.Videos
401
+      ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
402
+      : ['images', 'videos'];
403
+  const result = await ImagePicker.launchImageLibraryAsync({
404
+    mediaTypes,
405
+    quality: 1,
406
+    allowsMultipleSelection: true,
407
+  });
408
+  if (result.canceled) return;
409
+  const uris = result.assets.map((asset) => asset.uri).filter(Boolean);
410
+  if (uris.length > 0) onAdd(uris);
411
+}
412
+
413
+async function handleTakeMedia(onAdd: (uri: string | null) => void) {
414
+  const pickerTypes =
415
+    (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
416
+    (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
417
+    undefined;
418
+  const mediaTypes =
419
+    pickerTypes?.Images && pickerTypes?.Videos
420
+      ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
421
+      : ['images', 'videos'];
422
+  const result = await ImagePicker.launchCameraAsync({
423
+    mediaTypes,
424
+    quality: 1,
425
+  });
426
+  if (result.canceled) return;
427
+  onAdd(result.assets[0]?.uri ?? null);
428
+}
429
+
430
+function isVideoUri(uri: string) {
431
+  const cleaned = uri.split('?')[0]?.toLowerCase() ?? '';
432
+  return ['.mp4', '.mov', '.m4v', '.webm', '.mkv', '.avi'].some((ext) => cleaned.endsWith(ext));
433
+}
434
+
435
+function normalizeMediaUri(uri?: string | null) {
436
+  if (!uri) return null;
437
+  if (uri.startsWith('file://') || uri.startsWith('http://') || uri.startsWith('https://')) return uri;
438
+  return `file://${uri}`;
439
+}
440
+
441
+function uniqueMediaUris(uris: string[]) {
442
+  return Array.from(new Set(uris.filter(Boolean)));
443
+}
444
+
445
+function toDateOnly(date: Date) {
446
+  const year = date.getFullYear();
447
+  const month = String(date.getMonth() + 1).padStart(2, '0');
448
+  const day = String(date.getDate()).padStart(2, '0');
449
+  return `${year}-${month}-${day}`;
450
+}
451
+
452
+const styles = StyleSheet.create({
453
+  container: { flex: 1 },
454
+  keyboardAvoid: { flex: 1 },
455
+  content: { padding: 16, paddingBottom: 40 },
456
+  input: {
457
+    borderWidth: 1,
458
+    borderRadius: 12,
459
+    paddingHorizontal: 12,
460
+    paddingVertical: 10,
461
+    marginTop: 6,
462
+    marginBottom: 12,
463
+    fontSize: 16,
464
+  },
465
+  multiline: { minHeight: 90, textAlignVertical: 'top' },
466
+  errorText: { color: '#C0392B', marginBottom: 8 },
467
+  requiredMark: { color: '#C0392B' },
468
+  dateInput: {
469
+    borderWidth: 1,
470
+    borderRadius: 12,
471
+    paddingHorizontal: 12,
472
+    paddingVertical: 12,
473
+    marginTop: 6,
474
+    marginBottom: 12,
475
+  },
476
+  dateValue: { fontSize: 16 },
477
+  chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 8 },
478
+  chip: {
479
+    paddingHorizontal: 12,
480
+    paddingVertical: 6,
481
+    borderRadius: 999,
482
+    borderWidth: 1,
483
+    borderColor: '#C9D2C4',
484
+    backgroundColor: '#F8F6F0',
485
+  },
486
+  chipActive: { backgroundColor: '#DDE8DA', borderColor: '#88A68F' },
487
+  chipText: { fontSize: 13 },
488
+  mediaPreview: {
489
+    width: '100%',
490
+    height: 200,
491
+    borderRadius: 16,
492
+    backgroundColor: '#F1F1F1',
493
+    marginBottom: 12,
494
+  },
495
+  mediaPlaceholder: {
496
+    width: '100%',
497
+    height: 200,
498
+    borderRadius: 16,
499
+    borderWidth: 1,
500
+    justifyContent: 'center',
501
+    alignItems: 'center',
502
+    marginBottom: 12,
503
+  },
504
+  mediaPlaceholderText: { opacity: 0.7 },
505
+  mediaRow: { marginBottom: 12 },
506
+  mediaThumbWrap: { marginRight: 8 },
507
+  mediaThumb: { width: 72, height: 72, borderRadius: 12 },
508
+  mediaRemove: {
509
+    position: 'absolute',
510
+    top: -6,
511
+    right: -6,
512
+    width: 22,
513
+    height: 22,
514
+    borderRadius: 11,
515
+    backgroundColor: '#2C2C2C',
516
+    justifyContent: 'center',
517
+    alignItems: 'center',
518
+  },
519
+  mediaRemoveText: { color: '#fff', fontSize: 14, lineHeight: 16 },
520
+  mediaButtons: { flexDirection: 'row', gap: 12, flexWrap: 'wrap', marginBottom: 16 },
521
+  actions: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8 },
522
+  modalOverlay: {
523
+    flex: 1,
524
+    backgroundColor: 'rgba(0,0,0,0.4)',
525
+    justifyContent: 'center',
526
+    padding: 20,
527
+  },
528
+  modalCard: {
529
+    borderRadius: 16,
530
+    borderWidth: 1,
531
+    padding: 16,
532
+    maxHeight: '70%',
533
+  },
534
+  modalItem: { paddingVertical: 10 },
535
+});

+ 696 - 0
app/(tabs)/harvests/[id].tsx

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

+ 43 - 0
app/(tabs)/harvests/_layout.tsx

@@ -0,0 +1,43 @@
1
+import { Stack, useRouter } from 'expo-router';
2
+import { Pressable } from 'react-native';
3
+
4
+import { IconSymbol } from '@/components/ui/icon-symbol';
5
+import { Colors } from '@/constants/theme';
6
+import { useColorScheme } from '@/hooks/use-color-scheme';
7
+import { useTranslation } from '@/localization/i18n';
8
+
9
+export default function HarvestsLayout() {
10
+  const { t } = useTranslation();
11
+  const router = useRouter();
12
+  const colorScheme = useColorScheme();
13
+  const palette = Colors[colorScheme ?? 'light'];
14
+
15
+  return (
16
+    <Stack
17
+      screenOptions={{
18
+        headerBackTitleVisible: false,
19
+        headerBackTitle: '',
20
+        headerBackTitleStyle: { display: 'none' },
21
+        headerLeft: ({ canGoBack }) =>
22
+          canGoBack ? (
23
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
24
+              <IconSymbol size={20} name="chevron.left" color={palette.text} />
25
+            </Pressable>
26
+          ) : null,
27
+      }}>
28
+      <Stack.Screen
29
+        name="index"
30
+        options={{
31
+          title: t('harvests.title'),
32
+          headerLeft: () => (
33
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
34
+              <IconSymbol size={20} name="chevron.left" color={palette.text} />
35
+            </Pressable>
36
+          ),
37
+        }}
38
+      />
39
+      <Stack.Screen name="new" options={{ title: t('harvests.new') }} />
40
+      <Stack.Screen name="[id]" options={{ title: t('harvests.edit') }} />
41
+    </Stack>
42
+  );
43
+}

+ 176 - 31
app/harvests.tsx

@@ -1,8 +1,9 @@
1
-import { useEffect, useMemo, useState } from 'react';
1
+import { useCallback, useEffect, useMemo, useState } from 'react';
2 2
 import {
3 3
   Alert,
4 4
   FlatList,
5 5
   Image,
6
+  InteractionManager,
6 7
   KeyboardAvoidingView,
7 8
   Modal,
8 9
   Pressable,
@@ -13,6 +14,7 @@ import {
13 14
 } from 'react-native';
14 15
 import DateTimePicker from '@react-native-community/datetimepicker';
15 16
 import * as ImagePicker from 'expo-image-picker';
17
+import { ResizeMode, Video } from 'expo-av';
16 18
 
17 19
 import { ThemedText } from '@/components/themed-text';
18 20
 import { ThemedView } from '@/components/themed-view';
@@ -22,6 +24,9 @@ import { IconSymbol } from '@/components/ui/icon-symbol';
22 24
 import { Colors, Fonts } from '@/constants/theme';
23 25
 import { useTranslation } from '@/localization/i18n';
24 26
 import { dbPromise, initCoreTables } from '@/services/db';
27
+import { ZoomImageModal } from '@/components/zoom-image-modal';
28
+import { useLocalSearchParams, useRouter } from 'expo-router';
29
+import { useFocusEffect, useNavigation } from '@react-navigation/native';
25 30
 import { useColorScheme } from '@/hooks/use-color-scheme';
26 31
 
27 32
 type FieldRow = {
@@ -50,8 +55,12 @@ type HarvestRow = {
50 55
 
51 56
 export default function HarvestsScreen() {
52 57
   const { t } = useTranslation();
58
+  const router = useRouter();
59
+  const navigation = useNavigation();
60
+  const params = useLocalSearchParams<{ from?: string | string[] }>();
53 61
   const theme = useColorScheme() ?? 'light';
54 62
   const palette = Colors[theme];
63
+  const fromParam = Array.isArray(params.from) ? params.from[0] : params.from;
55 64
   const unitPresets = [
56 65
     { key: 'kg', value: 'kg' },
57 66
     { key: 'g', value: 'g' },
@@ -72,6 +81,29 @@ export default function HarvestsScreen() {
72 81
 
73 82
   const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
74 83
   const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
84
+
85
+  useEffect(() => {
86
+    navigation.setOptions({
87
+      headerLeft: () => (
88
+        <Pressable
89
+          onPress={() => {
90
+            if (fromParam === 'logbook') {
91
+              router.replace('/logbook');
92
+              return;
93
+            }
94
+            if (fromParam === 'home') {
95
+              router.replace('/');
96
+              return;
97
+            }
98
+            router.back();
99
+          }}
100
+          hitSlop={10}
101
+          style={{ paddingHorizontal: 8 }}>
102
+          <IconSymbol name="chevron.left" size={20} color={palette.text} />
103
+        </Pressable>
104
+      ),
105
+    });
106
+  }, [fromParam, navigation, palette.text, router]);
75 107
   const [harvestDate, setHarvestDate] = useState('');
76 108
   const [showHarvestPicker, setShowHarvestPicker] = useState(false);
77 109
   const [quantity, setQuantity] = useState('');
@@ -87,6 +119,8 @@ export default function HarvestsScreen() {
87 119
   const [editUnit, setEditUnit] = useState('kg');
88 120
   const [editNotes, setEditNotes] = useState('');
89 121
   const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
122
+  const [zoomUri, setZoomUri] = useState<string | null>(null);
123
+  const [pendingZoomUri, setPendingZoomUri] = useState<string | null>(null);
90 124
   const [editErrors, setEditErrors] = useState<{ field?: string; crop?: string; quantity?: string }>(
91 125
     {}
92 126
   );
@@ -145,6 +179,40 @@ export default function HarvestsScreen() {
145 179
     };
146 180
   }, [t]);
147 181
 
182
+  async function fetchHarvestsPage() {
183
+    try {
184
+      const db = await dbPromise;
185
+      const harvestRows = await db.getAllAsync<HarvestRow>(
186
+        `SELECT h.id, h.field_id, h.crop_id, h.harvested_at, h.quantity, h.unit, h.notes, h.photo_uri,
187
+                f.name as field_name, c.crop_name as crop_name
188
+         FROM harvests h
189
+         LEFT JOIN fields f ON f.id = h.field_id
190
+         LEFT JOIN crops c ON c.id = h.crop_id
191
+         ORDER BY h.harvested_at DESC;`
192
+      );
193
+      setHarvests(harvestRows);
194
+      setStatus(harvestRows.length === 0 ? t('harvests.empty') : '');
195
+    } catch (error) {
196
+      setStatus(`Error: ${String(error)}`);
197
+    }
198
+  }
199
+
200
+  useFocusEffect(
201
+    useCallback(() => {
202
+      fetchHarvestsPage();
203
+    }, [t])
204
+  );
205
+
206
+  useEffect(() => {
207
+    if (!newModalOpen && !editModalOpen && pendingZoomUri) {
208
+      const uri = pendingZoomUri;
209
+      setPendingZoomUri(null);
210
+      InteractionManager.runAfterInteractions(() => {
211
+        setTimeout(() => setZoomUri(uri), 150);
212
+      });
213
+    }
214
+  }, [newModalOpen, editModalOpen, pendingZoomUri]);
215
+
148 216
   async function handleSave() {
149 217
     const parsedQty = quantity.trim() ? Number(quantity) : null;
150 218
     const nextErrors: { field?: string; crop?: string; quantity?: string } = {};
@@ -196,16 +264,7 @@ export default function HarvestsScreen() {
196 264
   }
197 265
 
198 266
   function startEdit(item: HarvestRow) {
199
-    setEditingId(item.id);
200
-    setEditFieldId(item.field_id ?? null);
201
-    setEditCropId(item.crop_id ?? null);
202
-    setEditHarvestDate(item.harvested_at ?? '');
203
-    setEditQuantity(item.quantity !== null ? String(item.quantity) : '');
204
-    setEditUnit(item.unit ?? 'kg');
205
-    setEditNotes(item.notes ?? '');
206
-    setEditPhotoUri(item.photo_uri ?? null);
207
-    setEditErrors({});
208
-    setEditModalOpen(true);
267
+    router.push(`/harvests/${item.id}`);
209 268
   }
210 269
 
211 270
   function cancelEdit() {
@@ -325,8 +384,24 @@ export default function HarvestsScreen() {
325 384
                 {item.quantity} {item.unit || t('harvests.unitPlaceholder')}
326 385
               </ThemedText>
327 386
             ) : null}
328
-            {item.photo_uri ? (
329
-              <Image source={{ uri: item.photo_uri }} style={styles.photoPreview} />
387
+            {normalizeMediaUri(item.photo_uri) ? (
388
+              isVideoUri(normalizeMediaUri(item.photo_uri) as string) ? (
389
+                <Video
390
+                  source={{ uri: normalizeMediaUri(item.photo_uri) as string }}
391
+                  style={styles.videoPreview}
392
+                  useNativeControls
393
+                  resizeMode={ResizeMode.CONTAIN}
394
+                  isMuted
395
+                />
396
+              ) : (
397
+                <Pressable onPress={() => setZoomUri(normalizeMediaUri(item.photo_uri) as string)}>
398
+                  <Image
399
+                    source={{ uri: normalizeMediaUri(item.photo_uri) as string }}
400
+                    style={styles.photoPreview}
401
+                    resizeMode="contain"
402
+                  />
403
+                </Pressable>
404
+              )
330 405
             ) : null}
331 406
             {item.notes ? <ThemedText>{item.notes}</ThemedText> : null}
332 407
             </ThemedView>
@@ -349,7 +424,7 @@ export default function HarvestsScreen() {
349 424
               </ThemedView>
350 425
             ) : null}
351 426
             <ThemedView style={styles.section}>
352
-              <Pressable style={styles.newButton} onPress={() => setNewModalOpen(true)}>
427
+              <Pressable style={styles.newButton} onPress={() => router.push('/harvests/new')}>
353 428
                 <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
354 429
                 <ThemedText style={styles.newButtonText}>{t('harvests.new')}</ThemedText>
355 430
               </Pressable>
@@ -563,9 +638,29 @@ export default function HarvestsScreen() {
563 638
                     style={inputStyle}
564 639
                     multiline
565 640
                   />
566
-                  <ThemedText>{t('harvests.photo')}</ThemedText>
567
-                  {photoUri ? (
568
-                    <Image source={{ uri: photoUri }} style={styles.photoPreview} />
641
+                  <ThemedText>{t('harvests.addMedia')}</ThemedText>
642
+                  {normalizeMediaUri(photoUri) ? (
643
+                    isVideoUri(normalizeMediaUri(photoUri) as string) ? (
644
+                      <Video
645
+                        source={{ uri: normalizeMediaUri(photoUri) as string }}
646
+                        style={styles.videoPreview}
647
+                        useNativeControls
648
+                        resizeMode={ResizeMode.CONTAIN}
649
+                        isMuted
650
+                      />
651
+                    ) : (
652
+                      <Pressable
653
+                        onPress={() => {
654
+                          setPendingZoomUri(normalizeMediaUri(photoUri) as string);
655
+                          setNewModalOpen(false);
656
+                        }}>
657
+                        <Image
658
+                          source={{ uri: normalizeMediaUri(photoUri) as string }}
659
+                          style={styles.photoPreview}
660
+                          resizeMode="contain"
661
+                        />
662
+                      </Pressable>
663
+                    )
569 664
                   ) : (
570 665
                     <ThemedText style={styles.photoPlaceholder}>
571 666
                       {t('harvests.noPhoto')}
@@ -573,12 +668,12 @@ export default function HarvestsScreen() {
573 668
                   )}
574 669
                   <View style={styles.photoRow}>
575 670
                     <ThemedButton
576
-                      title={t('harvests.pickPhoto')}
671
+                      title={t('harvests.pickFromGallery')}
577 672
                       onPress={() => handlePickPhoto(setPhotoUri)}
578 673
                       variant="secondary"
579 674
                     />
580 675
                     <ThemedButton
581
-                      title={t('harvests.takePhoto')}
676
+                      title={t('harvests.takeMedia')}
582 677
                       onPress={() =>
583 678
                         handleTakePhoto(setPhotoUri, (code) =>
584 679
                           setStatus(
@@ -752,9 +847,29 @@ export default function HarvestsScreen() {
752 847
                     style={inputStyle}
753 848
                     multiline
754 849
                   />
755
-                  <ThemedText>{t('harvests.photo')}</ThemedText>
756
-                  {editPhotoUri ? (
757
-                    <Image source={{ uri: editPhotoUri }} style={styles.photoPreview} />
850
+                  <ThemedText>{t('harvests.addMedia')}</ThemedText>
851
+                  {normalizeMediaUri(editPhotoUri) ? (
852
+                    isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? (
853
+                      <Video
854
+                        source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
855
+                        style={styles.videoPreview}
856
+                        useNativeControls
857
+                        resizeMode={ResizeMode.CONTAIN}
858
+                        isMuted
859
+                      />
860
+                    ) : (
861
+                      <Pressable
862
+                        onPress={() => {
863
+                          setPendingZoomUri(normalizeMediaUri(editPhotoUri) as string);
864
+                          setEditModalOpen(false);
865
+                        }}>
866
+                        <Image
867
+                          source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
868
+                          style={styles.photoPreview}
869
+                          resizeMode="contain"
870
+                        />
871
+                      </Pressable>
872
+                    )
758 873
                   ) : (
759 874
                     <ThemedText style={styles.photoPlaceholder}>
760 875
                       {t('harvests.noPhoto')}
@@ -762,12 +877,12 @@ export default function HarvestsScreen() {
762 877
                   )}
763 878
                   <View style={styles.photoRow}>
764 879
                     <ThemedButton
765
-                      title={t('harvests.pickPhoto')}
880
+                      title={t('harvests.pickFromGallery')}
766 881
                       onPress={() => handlePickPhoto(setEditPhotoUri)}
767 882
                       variant="secondary"
768 883
                     />
769 884
                     <ThemedButton
770
-                      title={t('harvests.takePhoto')}
885
+                      title={t('harvests.takeMedia')}
771 886
                       onPress={() =>
772 887
                         handleTakePhoto(setEditPhotoUri, (code) =>
773 888
                           setStatus(
@@ -796,6 +911,11 @@ export default function HarvestsScreen() {
796 911
           </KeyboardAvoidingView>
797 912
         </View>
798 913
       </Modal>
914
+      <ZoomImageModal
915
+        uri={zoomUri}
916
+        visible={Boolean(zoomUri)}
917
+        onClose={() => setZoomUri(null)}
918
+      />
799 919
     </>
800 920
   );
801 921
 }
@@ -822,7 +942,7 @@ function toDateOnly(date: Date) {
822 942
 
823 943
 async function handlePickPhoto(setter: (value: string | null) => void) {
824 944
   const result = await ImagePicker.launchImageLibraryAsync({
825
-    mediaTypes: getImageMediaTypes(),
945
+    mediaTypes: getMediaTypes(),
826 946
     quality: 1,
827 947
   });
828 948
   if (result.canceled) return;
@@ -840,7 +960,10 @@ async function handleTakePhoto(
840 960
       onError?.('cameraDenied');
841 961
       return;
842 962
     }
843
-    const result = await ImagePicker.launchCameraAsync({ quality: 1 });
963
+    const result = await ImagePicker.launchCameraAsync({
964
+      mediaTypes: getMediaTypes(),
965
+      quality: 1,
966
+    });
844 967
     if (result.canceled) return;
845 968
     const asset = result.assets[0];
846 969
     setter(asset.uri);
@@ -849,10 +972,26 @@ async function handleTakePhoto(
849 972
   }
850 973
 }
851 974
 
852
-function getImageMediaTypes() {
853
-  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
854
-    .MediaType;
855
-  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
975
+function getMediaTypes() {
976
+  const mediaType = (ImagePicker as {
977
+    MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
978
+  }).MediaType;
979
+  const imageType = mediaType?.Image ?? mediaType?.Images;
980
+  const videoType = mediaType?.Video ?? mediaType?.Videos;
981
+  if (imageType && videoType) {
982
+    return [imageType, videoType];
983
+  }
984
+  return imageType ?? videoType ?? ['images', 'videos'];
985
+}
986
+
987
+function isVideoUri(uri: string) {
988
+  return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
989
+}
990
+
991
+function normalizeMediaUri(uri?: string | null) {
992
+  if (typeof uri !== 'string') return null;
993
+  const trimmed = uri.trim();
994
+  return trimmed ? trimmed : null;
856 995
 }
857 996
 
858 997
 const styles = StyleSheet.create({
@@ -998,8 +1137,14 @@ const styles = StyleSheet.create({
998 1137
   },
999 1138
   photoPreview: {
1000 1139
     width: '100%',
1001
-    height: 160,
1140
+    height: 200,
1141
+    borderRadius: 12,
1142
+  },
1143
+  videoPreview: {
1144
+    width: '100%',
1145
+    height: 200,
1002 1146
     borderRadius: 12,
1147
+    backgroundColor: '#1C1C1C',
1003 1148
   },
1004 1149
   photoPlaceholder: {
1005 1150
     opacity: 0.6,

+ 580 - 0
app/(tabs)/harvests/new.tsx

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

+ 136 - 271
app/(tabs)/index.tsx

@@ -1,6 +1,6 @@
1 1
 import { Image } from 'expo-image';
2
-import { Pressable, StyleSheet, View } from 'react-native';
3
-import { useCallback, useState } from 'react';
2
+import { FlatList, Pressable, StyleSheet, View } from 'react-native';
3
+import { useCallback, useMemo, useState } from 'react';
4 4
 import { Link, useFocusEffect } from 'expo-router';
5 5
 
6 6
 import ParallaxScrollView from '@/components/parallax-scroll-view';
@@ -13,7 +13,6 @@ import { useTranslation } from '@/localization/i18n';
13 13
 import { dbPromise, initCoreTables } from '@/services/db';
14 14
 import { useColorScheme } from '@/hooks/use-color-scheme';
15 15
 
16
-
17 16
 export default function HomeScreen() {
18 17
   const { t } = useTranslation();
19 18
   const theme = useColorScheme() ?? 'light';
@@ -30,6 +29,68 @@ export default function HomeScreen() {
30 29
     costs: 0,
31 30
   });
32 31
 
32
+  const quickActions = useMemo(
33
+    () => [
34
+      {
35
+        key: 'fields',
36
+        title: t('home.fields'),
37
+        hint: t('home.fieldsHint'),
38
+        icon: 'map.fill' as const,
39
+        href: { pathname: '/logbook/fields', params: { from: 'home' } },
40
+      },
41
+      {
42
+        key: 'crops',
43
+        title: t('home.crops'),
44
+        hint: t('home.cropsHint'),
45
+        icon: 'leaf.circle.fill' as const,
46
+        href: { pathname: '/logbook/crops', params: { from: 'home' } },
47
+      },
48
+      {
49
+        key: 'observations',
50
+        title: t('home.observations'),
51
+        hint: t('home.observationsHint'),
52
+        icon: 'eye.fill' as const,
53
+        href: { pathname: '/observations', params: { from: 'home' } },
54
+      },
55
+      {
56
+        key: 'harvests',
57
+        title: t('home.harvests'),
58
+        hint: t('home.harvestsHint'),
59
+        icon: 'tray.full.fill' as const,
60
+        href: { pathname: '/harvests', params: { from: 'home' } },
61
+      },
62
+      {
63
+        key: 'sales',
64
+        title: t('home.sales'),
65
+        hint: t('home.salesHint'),
66
+        icon: 'cart.fill' as const,
67
+        href: { pathname: '/sales', params: { from: 'home' } },
68
+      },
69
+      {
70
+        key: 'costs',
71
+        title: t('home.costs'),
72
+        hint: t('home.costsHint'),
73
+        icon: 'dollarsign.circle.fill' as const,
74
+        href: { pathname: '/costs', params: { from: 'home' } },
75
+      },
76
+      {
77
+        key: 'blogs',
78
+        title: t('home.blogs'),
79
+        hint: t('home.blogsHint'),
80
+        icon: 'doc.text.image' as const,
81
+        href: '/blog',
82
+      },
83
+      {
84
+        key: 'profile',
85
+        title: t('home.profile'),
86
+        hint: t('home.profileHint'),
87
+        icon: 'person.crop.circle' as const,
88
+        href: '/setup',
89
+      },
90
+    ],
91
+    [t]
92
+  );
93
+
33 94
   useFocusEffect(
34 95
     useCallback(() => {
35 96
       let isActive = true;
@@ -96,26 +157,20 @@ export default function HomeScreen() {
96 157
           contentFit="cover"
97 158
         />
98 159
       }>
99
-      <ThemedView style={styles.heroCopy}>
100
-        <View
101
-          style={[
102
-            styles.heroBadge,
103
-            { borderColor: palette.border, backgroundColor: palette.surface },
104
-          ]}>
160
+      <ThemedView style={styles.heroCard}>
161
+        <View style={[styles.badge, { borderColor: palette.border, backgroundColor: palette.card }]}>
105 162
           <IconSymbol name="leaf.fill" size={16} color={iconColor} />
106
-          <ThemedText style={styles.heroBadgeText}>{t('home.badge')}</ThemedText>
163
+          <ThemedText style={styles.badgeText}>{t('home.badge')}</ThemedText>
107 164
         </View>
108 165
         <ThemedText type="title" style={styles.heroTitle}>
109 166
           {t('home.title')}
110 167
         </ThemedText>
111
-        <ThemedText style={styles.heroSubtitle}>
112
-          {t('home.subtitle')}
113
-        </ThemedText>
168
+        <ThemedText style={styles.heroSubtitle}>{t('home.subtitle')}</ThemedText>
114 169
         <View style={styles.heroActions}>
115 170
           <Link href="/logbook" asChild>
116 171
             <ThemedButton title={t('home.openLogbook')} />
117 172
           </Link>
118
-          <Link href="/tasks" asChild>
173
+          <Link href={{ pathname: '/tasks', params: { from: 'home' } }} asChild>
119 174
             <ThemedButton title={t('home.todayTasks')} variant="secondary" />
120 175
           </Link>
121 176
         </View>
@@ -123,225 +178,59 @@ export default function HomeScreen() {
123 178
 
124 179
       <ThemedView style={styles.section}>
125 180
         <ThemedText type="subtitle">{t('home.quickActions')}</ThemedText>
126
-        <View style={styles.actionGrid}>
127
-          <Link href="/fields" asChild>
128
-            <Pressable
129
-              style={[
130
-                styles.actionCard,
131
-                { borderColor: palette.border, backgroundColor: palette.card },
132
-                theme === 'light' ? styles.cardLightShadow : null,
133
-              ]}>
134
-              <IconSymbol name="map.fill" size={22} color={iconColor} />
135
-              <View style={styles.actionHeader}>
136
-                <ThemedText style={styles.actionTitle}>{t('home.fields')}</ThemedText>
137
-                <View
138
-                  style={[
139
-                    styles.countBadge,
140
-                    { borderColor: palette.border, backgroundColor: palette.surface },
141
-                  ]}>
142
-                  <ThemedText style={styles.countText}>{counts.fields}</ThemedText>
143
-                </View>
144
-              </View>
145
-              <ThemedText style={styles.actionHint}>{t('home.fieldsHint')}</ThemedText>
146
-            </Pressable>
147
-          </Link>
148
-          <Link href="/crops" asChild>
149
-            <Pressable
150
-              style={[
151
-                styles.actionCard,
152
-                { borderColor: palette.border, backgroundColor: palette.card },
153
-                theme === 'light' ? styles.cardLightShadow : null,
154
-              ]}>
155
-              <IconSymbol name="leaf.circle.fill" size={22} color={iconColor} />
156
-              <View style={styles.actionHeader}>
157
-                <ThemedText style={styles.actionTitle}>{t('home.crops')}</ThemedText>
158
-                <View
159
-                  style={[
160
-                    styles.countBadge,
161
-                    { borderColor: palette.border, backgroundColor: palette.surface },
162
-                  ]}>
163
-                  <ThemedText style={styles.countText}>{counts.crops}</ThemedText>
164
-                </View>
165
-              </View>
166
-              <ThemedText style={styles.actionHint}>{t('home.cropsHint')}</ThemedText>
167
-            </Pressable>
168
-          </Link>
169
-          <Link href="/observations" asChild>
170
-            <Pressable
171
-              style={[
172
-                styles.actionCard,
173
-                { borderColor: palette.border, backgroundColor: palette.card },
174
-                theme === 'light' ? styles.cardLightShadow : null,
175
-              ]}>
176
-              <IconSymbol name="eye.fill" size={22} color={iconColor} />
177
-              <View style={styles.actionHeader}>
178
-                <ThemedText style={styles.actionTitle}>{t('home.observations')}</ThemedText>
179
-                <View
180
-                  style={[
181
-                    styles.countBadge,
182
-                    { borderColor: palette.border, backgroundColor: palette.surface },
183
-                  ]}>
184
-                  <ThemedText style={styles.countText}>{counts.observations}</ThemedText>
185
-                </View>
186
-              </View>
187
-              <ThemedText style={styles.actionHint}>{t('home.observationsHint')}</ThemedText>
188
-            </Pressable>
189
-          </Link>
190
-          <Link href="/onnx" asChild>
191
-            <Pressable
192
-              style={[
193
-                styles.actionCard,
194
-                { borderColor: palette.border, backgroundColor: palette.card },
195
-                theme === 'light' ? styles.cardLightShadow : null,
196
-              ]}>
197
-              <IconSymbol name="sparkles" size={22} color={iconColor} />
198
-              <ThemedText style={styles.actionTitle}>{t('home.onnx')}</ThemedText>
199
-              <ThemedText style={styles.actionHint}>{t('home.onnxHint')}</ThemedText>
200
-            </Pressable>
201
-          </Link>
202
-          <Link href="/harvests" asChild>
203
-            <Pressable
204
-              style={[
205
-                styles.actionCard,
206
-                { borderColor: palette.border, backgroundColor: palette.card },
207
-                theme === 'light' ? styles.cardLightShadow : null,
208
-              ]}>
209
-              <IconSymbol name="tray.full.fill" size={22} color={iconColor} />
210
-              <View style={styles.actionHeader}>
211
-                <ThemedText style={styles.actionTitle}>{t('home.harvests')}</ThemedText>
212
-                <View
213
-                  style={[
214
-                    styles.countBadge,
215
-                    { borderColor: palette.border, backgroundColor: palette.surface },
216
-                  ]}>
217
-                  <ThemedText style={styles.countText}>{counts.harvests}</ThemedText>
218
-                </View>
219
-              </View>
220
-              <ThemedText style={styles.actionHint}>{t('home.harvestsHint')}</ThemedText>
221
-            </Pressable>
222
-          </Link>
223
-          <Link href="/sales" asChild>
224
-            <Pressable
225
-              style={[
226
-                styles.actionCard,
227
-                { borderColor: palette.border, backgroundColor: palette.card },
228
-                theme === 'light' ? styles.cardLightShadow : null,
229
-              ]}>
230
-              <IconSymbol name="cart.fill" size={22} color={iconColor} />
231
-              <View style={styles.actionHeader}>
232
-                <ThemedText style={styles.actionTitle}>{t('home.sales')}</ThemedText>
233
-                <View
234
-                  style={[
235
-                    styles.countBadge,
236
-                    { borderColor: palette.border, backgroundColor: palette.surface },
237
-                  ]}>
238
-                  <ThemedText style={styles.countText}>{counts.sales}</ThemedText>
239
-                </View>
240
-              </View>
241
-              <ThemedText style={styles.actionHint}>{t('home.salesHint')}</ThemedText>
242
-            </Pressable>
243
-          </Link>
244
-          <Link href="/costs" asChild>
245
-            <Pressable
246
-              style={[
247
-                styles.actionCard,
248
-                { borderColor: palette.border, backgroundColor: palette.card },
249
-                theme === 'light' ? styles.cardLightShadow : null,
250
-              ]}>
251
-              <IconSymbol name="dollarsign.circle.fill" size={22} color={iconColor} />
252
-              <View style={styles.actionHeader}>
253
-                <ThemedText style={styles.actionTitle}>{t('home.costs')}</ThemedText>
254
-                <View
255
-                  style={[
256
-                    styles.countBadge,
257
-                    { borderColor: palette.border, backgroundColor: palette.surface },
258
-                  ]}>
259
-                  <ThemedText style={styles.countText}>{counts.costs}</ThemedText>
181
+        <FlatList
182
+          data={quickActions}
183
+          keyExtractor={(item) => item.key}
184
+          horizontal
185
+          showsHorizontalScrollIndicator={false}
186
+          contentContainerStyle={styles.actionRow}
187
+          renderItem={({ item }) => (
188
+            <Link href={item.href} asChild>
189
+              <Pressable
190
+                style={[
191
+                  styles.actionCard,
192
+                  { borderColor: palette.border, backgroundColor: palette.card },
193
+                  theme === 'light' ? styles.cardShadow : null,
194
+                ]}>
195
+                <View style={styles.actionIconWrap}>
196
+                <IconSymbol name={item.icon} size={26} color={iconColor} />
260 197
                 </View>
261
-              </View>
262
-              <ThemedText style={styles.actionHint}>{t('home.costsHint')}</ThemedText>
263
-            </Pressable>
264
-          </Link>
265
-        </View>
198
+                <ThemedText style={styles.actionTitle}>{item.title}</ThemedText>
199
+                <ThemedText style={styles.actionHint}>{item.hint}</ThemedText>
200
+              </Pressable>
201
+            </Link>
202
+          )}
203
+        />
266 204
       </ThemedView>
267 205
 
268 206
       <ThemedView style={styles.section}>
269 207
         <ThemedText type="subtitle">{t('home.todayTitle')}</ThemedText>
270
-        <View
271
-          style={[
272
-            styles.highlightCard,
273
-            { borderColor: palette.border, backgroundColor: palette.surface },
274
-          ]}>
275
-          <View style={styles.highlightHeader}>
276
-            <ThemedText style={styles.highlightTitle}>{t('home.todayCardTitle')}</ThemedText>
277
-            <View style={styles.highlightCounts}>
278
-              <View
279
-                style={[
280
-                  styles.countBadge,
281
-                  { borderColor: palette.border, backgroundColor: palette.card },
282
-                ]}>
283
-                <ThemedText style={styles.countText}>
284
-                  {t('home.count.tasks')} {counts.tasks}
285
-                </ThemedText>
286
-              </View>
287
-              <View
288
-                style={[
289
-                  styles.countBadge,
290
-                  { borderColor: palette.border, backgroundColor: palette.card },
291
-                ]}>
292
-                <ThemedText style={styles.countText}>
293
-                  {t('home.count.history')} {counts.history}
294
-                </ThemedText>
295
-              </View>
208
+        <View style={[styles.card, { borderColor: palette.border, backgroundColor: palette.surface }]}>
209
+          <ThemedText style={styles.cardTitle}>{t('home.todayCardTitle')}</ThemedText>
210
+          <View style={styles.countRow}>
211
+            <View style={[styles.countBadge, { borderColor: palette.border, backgroundColor: palette.card }]}>
212
+              <ThemedText style={styles.countText}>
213
+                {t('home.count.tasks')} {counts.tasks}
214
+              </ThemedText>
215
+            </View>
216
+            <View style={[styles.countBadge, { borderColor: palette.border, backgroundColor: palette.card }]}>
217
+              <ThemedText style={styles.countText}>
218
+                {t('home.count.history')} {counts.history}
219
+              </ThemedText>
296 220
             </View>
297 221
           </View>
298
-          <ThemedText style={styles.highlightBody}>
299
-            {t('home.todayCardBody')}
300
-          </ThemedText>
301
-          <View style={styles.highlightActions}>
302
-            <Link href="/tasks" asChild>
222
+          <ThemedText style={styles.cardBody}>{t('home.todayCardBody')}</ThemedText>
223
+          <View style={styles.cardActions}>
224
+            <Link href={{ pathname: '/tasks', params: { from: 'home' } }} asChild>
303 225
               <ThemedButton title={t('home.openTasks')} />
304 226
             </Link>
305
-            <Link href="/task-history" asChild>
227
+            <Link href={{ pathname: '/task-history', params: { from: 'home' } }} asChild>
306 228
               <ThemedButton title={t('home.taskHistory')} variant="secondary" />
307 229
             </Link>
308 230
           </View>
309 231
         </View>
310 232
       </ThemedView>
311 233
 
312
-      <ThemedView style={styles.section}>
313
-        <ThemedText type="subtitle">{t('home.learnAnalyze')}</ThemedText>
314
-        <View style={styles.actionGrid}>
315
-          <Link href="/blog" asChild>
316
-            <Pressable
317
-              style={[
318
-                styles.actionCardWide,
319
-                { borderColor: palette.border, backgroundColor: palette.card },
320
-                theme === 'light' ? styles.cardLightShadow : null,
321
-              ]}>
322
-              <IconSymbol name="doc.text.image" size={22} color={iconColor} />
323
-              <View style={styles.actionCopy}>
324
-                <ThemedText style={styles.actionTitle}>{t('home.blogs')}</ThemedText>
325
-                <ThemedText style={styles.actionHint}>{t('home.blogsHint')}</ThemedText>
326
-              </View>
327
-            </Pressable>
328
-          </Link>
329
-          <Link href="/setup" asChild>
330
-            <Pressable
331
-              style={[
332
-                styles.actionCardWide,
333
-                { borderColor: palette.border, backgroundColor: palette.card },
334
-                theme === 'light' ? styles.cardLightShadow : null,
335
-              ]}>
336
-              <IconSymbol name="person.crop.circle" size={22} color={iconColor} />
337
-              <View style={styles.actionCopy}>
338
-                <ThemedText style={styles.actionTitle}>{t('home.profile')}</ThemedText>
339
-                <ThemedText style={styles.actionHint}>{t('home.profileHint')}</ThemedText>
340
-              </View>
341
-            </Pressable>
342
-          </Link>
343
-        </View>
344
-      </ThemedView>
345 234
     </ParallaxScrollView>
346 235
   );
347 236
 }
@@ -352,11 +241,11 @@ const styles = StyleSheet.create({
352 241
     width: '100%',
353 242
     position: 'absolute',
354 243
   },
355
-  heroCopy: {
244
+  heroCard: {
356 245
     gap: 12,
357 246
     paddingHorizontal: 4,
358 247
   },
359
-  heroBadge: {
248
+  badge: {
360 249
     alignSelf: 'flex-start',
361 250
     flexDirection: 'row',
362 251
     gap: 6,
@@ -364,10 +253,8 @@ const styles = StyleSheet.create({
364 253
     paddingVertical: 6,
365 254
     borderRadius: 999,
366 255
     borderWidth: 1,
367
-    borderColor: '#D2C9B8',
368
-    backgroundColor: '#F6F1E8',
369 256
   },
370
-  heroBadgeText: {
257
+  badgeText: {
371 258
     fontSize: 12,
372 259
     fontWeight: '600',
373 260
   },
@@ -388,87 +275,60 @@ const styles = StyleSheet.create({
388 275
   section: {
389 276
     gap: 12,
390 277
   },
391
-  actionGrid: {
392
-    flexDirection: 'row',
393
-    flexWrap: 'wrap',
394
-    columnGap: 20,
395
-    rowGap: 16,
278
+  actionRow: {
279
+    gap: 18,
280
+    paddingRight: 4,
396 281
   },
397 282
   actionCard: {
398
-    flexBasis: '49%',
399
-    flexGrow: 1,
400
-    gap: 6,
283
+    width: 180,
284
+    minWidth: 180,
285
+    maxWidth: 180,
401 286
     borderRadius: 14,
402 287
     borderWidth: 1,
403
-    borderColor: '#D9D1C2',
404
-    padding: 12,
405
-    backgroundColor: '#FBF8F1',
406
-    alignItems: 'center',
407
-  },
408
-  actionCardWide: {
409
-    flexDirection: 'row',
288
+    padding: 14,
289
+    gap: 10,
410 290
     alignItems: 'center',
411
-    gap: 12,
412
-    borderRadius: 14,
413
-    borderWidth: 1,
414
-    borderColor: '#D9D1C2',
415
-    padding: 12,
416
-    backgroundColor: '#FBF8F1',
417
-    flexBasis: '100%',
418 291
   },
419
-  actionCopy: {
420
-    gap: 4,
421
-  },
422
-  actionHeader: {
423
-    flexDirection: 'row',
292
+  actionIconWrap: {
293
+    width: '100%',
424 294
     alignItems: 'center',
425
-    justifyContent: 'space-between',
426
-    gap: 8,
295
+    justifyContent: 'center',
427 296
   },
428 297
   actionTitle: {
429 298
     fontSize: 16,
430 299
     fontWeight: '600',
300
+    textAlign: 'center',
301
+    width: '100%',
431 302
   },
432 303
   actionHint: {
433 304
     fontSize: 12,
434 305
     opacity: 0.7,
306
+    textAlign: 'center',
307
+    width: '100%',
435 308
   },
436
-  highlightCard: {
309
+  card: {
437 310
     borderRadius: 16,
438 311
     borderWidth: 1,
439
-    borderColor: '#D6CCB9',
440
-    backgroundColor: '#EFE7D6',
441 312
     padding: 16,
442 313
     gap: 10,
443 314
   },
444
-  highlightHeader: {
445
-    gap: 8,
446
-  },
447
-  highlightCounts: {
448
-    flexDirection: 'row',
449
-    flexWrap: 'wrap',
450
-    gap: 8,
451
-  },
452
-  highlightTitle: {
315
+  cardTitle: {
453 316
     fontSize: 18,
454 317
     fontWeight: '700',
455 318
   },
456
-  highlightBody: {
319
+  cardBody: {
457 320
     fontSize: 14,
458 321
     lineHeight: 20,
459 322
     opacity: 0.8,
460 323
   },
461
-  highlightActions: {
324
+  countRow: {
462 325
     flexDirection: 'row',
463 326
     flexWrap: 'wrap',
464
-    gap: 12,
327
+    gap: 8,
465 328
   },
466 329
   countBadge: {
467
-    minWidth: 28,
468 330
     borderRadius: 999,
469 331
     borderWidth: 1,
470
-    borderColor: '#D9D1C2',
471
-    backgroundColor: '#F6F1E8',
472 332
     alignItems: 'center',
473 333
     justifyContent: 'center',
474 334
     paddingHorizontal: 8,
@@ -478,7 +338,12 @@ const styles = StyleSheet.create({
478 338
     fontSize: 11,
479 339
     fontWeight: '600',
480 340
   },
481
-  cardLightShadow: {
341
+  cardActions: {
342
+    flexDirection: 'row',
343
+    flexWrap: 'wrap',
344
+    gap: 12,
345
+  },
346
+  cardShadow: {
482 347
     shadowColor: '#1E1A12',
483 348
     shadowOpacity: 0.08,
484 349
     shadowRadius: 10,

+ 35 - 0
app/(tabs)/logbook/_layout.tsx

@@ -0,0 +1,35 @@
1
+import { Stack, useRouter } from 'expo-router';
2
+import { Pressable } from 'react-native';
3
+
4
+import { IconSymbol } from '@/components/ui/icon-symbol';
5
+import { Colors } from '@/constants/theme';
6
+import { useColorScheme } from '@/hooks/use-color-scheme';
7
+import { useTranslation } from '@/localization/i18n';
8
+
9
+export default function LogbookLayout() {
10
+  const { t } = useTranslation();
11
+  const router = useRouter();
12
+  const colorScheme = useColorScheme();
13
+
14
+  return (
15
+    <Stack
16
+      screenOptions={{
17
+        headerBackTitleVisible: false,
18
+        headerBackTitle: '',
19
+        headerBackTitleStyle: { display: 'none' },
20
+        headerLeft: ({ canGoBack }) =>
21
+          canGoBack ? (
22
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
23
+              <IconSymbol size={20} name="chevron.left" color={Colors[colorScheme ?? 'light'].text} />
24
+            </Pressable>
25
+          ) : null,
26
+      }}>
27
+      <Stack.Screen name="index" options={{ title: t('logbook.title') }} />
28
+      <Stack.Screen name="fields" options={{ title: t('fields.title') }} />
29
+      <Stack.Screen name="fields/[id]" options={{ title: t('fields.edit') }} />
30
+      <Stack.Screen name="crops" options={{ title: t('crops.title') }} />
31
+      <Stack.Screen name="crops/new" options={{ title: t('crops.new') }} />
32
+      <Stack.Screen name="crops/[id]" options={{ title: t('crops.edit') }} />
33
+    </Stack>
34
+  );
35
+}

+ 601 - 0
app/(tabs)/logbook/crops/[id].tsx

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

+ 146 - 36
app/crops.tsx

@@ -1,9 +1,10 @@
1
-import { useEffect, useMemo, useState } from 'react';
1
+import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
2 2
 import {
3 3
   ActivityIndicator,
4 4
   Alert,
5 5
   FlatList,
6 6
   Image,
7
+  InteractionManager,
7 8
   KeyboardAvoidingView,
8 9
   Modal,
9 10
   Pressable,
@@ -14,6 +15,7 @@ import {
14 15
 } from 'react-native';
15 16
 import DateTimePicker from '@react-native-community/datetimepicker';
16 17
 import * as ImagePicker from 'expo-image-picker';
18
+import { ResizeMode, Video } from 'expo-av';
17 19
 
18 20
 import { ThemedText } from '@/components/themed-text';
19 21
 import { ThemedView } from '@/components/themed-view';
@@ -24,6 +26,9 @@ import { dbPromise, initCoreTables } from '@/services/db';
24 26
 import { useColorScheme } from '@/hooks/use-color-scheme';
25 27
 import { ThemedButton } from '@/components/themed-button';
26 28
 import { IconButton } from '@/components/icon-button';
29
+import { ZoomImageModal } from '@/components/zoom-image-modal';
30
+import { useLocalSearchParams, useRouter } from 'expo-router';
31
+import { useFocusEffect, useNavigation } from '@react-navigation/native';
27 32
 
28 33
 type FieldRow = {
29 34
   id: number;
@@ -43,8 +48,23 @@ type CropRow = {
43 48
 
44 49
 export default function CropsScreen() {
45 50
   const { t } = useTranslation();
51
+  const router = useRouter();
52
+  const navigation = useNavigation();
53
+  const { from } = useLocalSearchParams<{ from?: string }>();
46 54
   const theme = useColorScheme() ?? 'light';
47 55
   const palette = Colors[theme];
56
+
57
+  useLayoutEffect(() => {
58
+    if (from === 'home') {
59
+      navigation.setOptions({
60
+        headerLeft: () => (
61
+          <Pressable onPress={() => router.replace('/')} hitSlop={10} style={{ paddingHorizontal: 8 }}>
62
+            <IconSymbol size={20} name="chevron.left" color={palette.text} />
63
+          </Pressable>
64
+        ),
65
+      });
66
+    }
67
+  }, [from, navigation, palette.text, router]);
48 68
   const pageSize = 12;
49 69
   const [crops, setCrops] = useState<CropRow[]>([]);
50 70
   const [fields, setFields] = useState<FieldRow[]>([]);
@@ -66,6 +86,8 @@ export default function CropsScreen() {
66 86
   const [editPlantingDate, setEditPlantingDate] = useState('');
67 87
   const [editHarvestDate, setEditHarvestDate] = useState('');
68 88
   const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
89
+  const [zoomUri, setZoomUri] = useState<string | null>(null);
90
+  const [pendingZoomUri, setPendingZoomUri] = useState<string | null>(null);
69 91
   const [showEditPlantingPicker, setShowEditPlantingPicker] = useState(false);
70 92
   const [showEditHarvestPicker, setShowEditHarvestPicker] = useState(false);
71 93
   const [fieldModalOpen, setFieldModalOpen] = useState(false);
@@ -76,6 +98,16 @@ export default function CropsScreen() {
76 98
   const [hasMore, setHasMore] = useState(true);
77 99
   const [loadingMore, setLoadingMore] = useState(false);
78 100
 
101
+  useEffect(() => {
102
+    if (!newModalOpen && !editModalOpen && pendingZoomUri) {
103
+      const uri = pendingZoomUri;
104
+      setPendingZoomUri(null);
105
+      InteractionManager.runAfterInteractions(() => {
106
+        setTimeout(() => setZoomUri(uri), 150);
107
+      });
108
+    }
109
+  }, [newModalOpen, editModalOpen, pendingZoomUri]);
110
+
79 111
   const selectedField = useMemo(
80 112
     () => fields.find((item) => item.id === selectedFieldId),
81 113
     [fields, selectedFieldId]
@@ -109,6 +141,12 @@ export default function CropsScreen() {
109 141
     };
110 142
   }, [t]);
111 143
 
144
+  useFocusEffect(
145
+    useCallback(() => {
146
+      fetchCropsPage(1, true);
147
+    }, [])
148
+  );
149
+
112 150
   async function fetchCropsPage(pageToLoad: number, replace: boolean, isActive = true) {
113 151
     try {
114 152
       const db = await dbPromise;
@@ -205,15 +243,7 @@ export default function CropsScreen() {
205 243
   }
206 244
 
207 245
   function startEdit(crop: CropRow) {
208
-    setEditingId(crop.id);
209
-    setEditFieldId(crop.field_id ?? null);
210
-    setEditName(crop.crop_name ?? '');
211
-    setEditVariety(crop.variety ?? '');
212
-    setEditPlantingDate(crop.planting_date ?? '');
213
-    setEditHarvestDate(crop.expected_harvest_date ?? '');
214
-    setEditPhotoUri(crop.photo_uri ?? null);
215
-    setEditErrors({});
216
-    setEditModalOpen(true);
246
+    router.push(`/logbook/crops/${crop.id}`);
217 247
   }
218 248
 
219 249
   function cancelEdit() {
@@ -283,8 +313,24 @@ export default function CropsScreen() {
283 313
                 {item.field_name || t('crops.noField')}
284 314
               </ThemedText>
285 315
             {item.variety ? <ThemedText>{item.variety}</ThemedText> : null}
286
-            {item.photo_uri ? (
287
-              <Image source={{ uri: item.photo_uri }} style={styles.photoPreview} resizeMode="cover" />
316
+            {normalizeMediaUri(item.photo_uri) ? (
317
+              isVideoUri(normalizeMediaUri(item.photo_uri) as string) ? (
318
+                <Video
319
+                  source={{ uri: normalizeMediaUri(item.photo_uri) as string }}
320
+                  style={styles.videoPreview}
321
+                  useNativeControls
322
+                  resizeMode={ResizeMode.CONTAIN}
323
+                  isMuted
324
+                />
325
+              ) : (
326
+                <Pressable onPress={() => setZoomUri(normalizeMediaUri(item.photo_uri) as string)}>
327
+                  <Image
328
+                    source={{ uri: normalizeMediaUri(item.photo_uri) as string }}
329
+                    style={styles.photoPreview}
330
+                    resizeMode="contain"
331
+                  />
332
+                </Pressable>
333
+              )
288 334
             ) : null}
289 335
             {item.planting_date ? (
290 336
                 <ThemedText style={styles.meta}>
@@ -331,8 +377,7 @@ export default function CropsScreen() {
331 377
               <Pressable
332 378
                 style={styles.newButton}
333 379
                 onPress={() => {
334
-                  setNewErrors({});
335
-                  setNewModalOpen(true);
380
+                  router.push('/logbook/crops/new');
336 381
                 }}>
337 382
                 <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
338 383
                 <ThemedText style={styles.newButtonText}>{t('crops.new')}</ThemedText>
@@ -439,20 +484,40 @@ export default function CropsScreen() {
439 484
                     placeholder={t('crops.varietyPlaceholder')}
440 485
                     style={inputStyle}
441 486
                   />
442
-                  <ThemedText>{t('crops.photo')}</ThemedText>
443
-                  {photoUri ? (
444
-                    <Image source={{ uri: photoUri }} style={styles.photoPreview} resizeMode="cover" />
487
+                  <ThemedText>{t('crops.addMedia')}</ThemedText>
488
+                  {normalizeMediaUri(photoUri) ? (
489
+                    isVideoUri(normalizeMediaUri(photoUri) as string) ? (
490
+                      <Video
491
+                        source={{ uri: normalizeMediaUri(photoUri) as string }}
492
+                        style={styles.videoPreview}
493
+                        useNativeControls
494
+                        resizeMode={ResizeMode.CONTAIN}
495
+                        isMuted
496
+                      />
497
+                    ) : (
498
+                      <Pressable
499
+                        onPress={() => {
500
+                          setPendingZoomUri(normalizeMediaUri(photoUri) as string);
501
+                          setNewModalOpen(false);
502
+                        }}>
503
+                        <Image
504
+                          source={{ uri: normalizeMediaUri(photoUri) as string }}
505
+                          style={styles.photoPreview}
506
+                          resizeMode="contain"
507
+                        />
508
+                      </Pressable>
509
+                    )
445 510
                   ) : (
446 511
                     <ThemedText style={styles.photoPlaceholder}>{t('crops.noPhoto')}</ThemedText>
447 512
                   )}
448 513
                   <View style={styles.photoRow}>
449 514
                     <ThemedButton
450
-                      title={t('crops.pickPhoto')}
515
+                      title={t('crops.pickFromGallery')}
451 516
                       onPress={() => handlePickPhoto(setPhotoUri)}
452 517
                       variant="secondary"
453 518
                     />
454 519
                     <ThemedButton
455
-                      title={t('crops.takePhoto')}
520
+                      title={t('crops.takeMedia')}
456 521
                       onPress={() =>
457 522
                         handleTakePhoto(setPhotoUri, (code) =>
458 523
                           setStatus(
@@ -618,24 +683,40 @@ export default function CropsScreen() {
618 683
                     placeholder={t('crops.varietyPlaceholder')}
619 684
                     style={inputStyle}
620 685
                   />
621
-                  <ThemedText>{t('crops.photo')}</ThemedText>
622
-                  {editPhotoUri ? (
623
-                    <Image
624
-                      source={{ uri: editPhotoUri }}
625
-                      style={styles.photoPreview}
626
-                      resizeMode="cover"
627
-                    />
686
+                  <ThemedText>{t('crops.addMedia')}</ThemedText>
687
+                  {normalizeMediaUri(editPhotoUri) ? (
688
+                    isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? (
689
+                      <Video
690
+                        source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
691
+                        style={styles.videoPreview}
692
+                        useNativeControls
693
+                        resizeMode={ResizeMode.CONTAIN}
694
+                        isMuted
695
+                      />
696
+                    ) : (
697
+                      <Pressable
698
+                        onPress={() => {
699
+                          setPendingZoomUri(normalizeMediaUri(editPhotoUri) as string);
700
+                          setEditModalOpen(false);
701
+                        }}>
702
+                        <Image
703
+                          source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
704
+                          style={styles.photoPreview}
705
+                          resizeMode="contain"
706
+                        />
707
+                      </Pressable>
708
+                    )
628 709
                   ) : (
629 710
                     <ThemedText style={styles.photoPlaceholder}>{t('crops.noPhoto')}</ThemedText>
630 711
                   )}
631 712
                   <View style={styles.photoRow}>
632 713
                     <ThemedButton
633
-                      title={t('crops.pickPhoto')}
714
+                      title={t('crops.pickFromGallery')}
634 715
                       onPress={() => handlePickPhoto(setEditPhotoUri)}
635 716
                       variant="secondary"
636 717
                     />
637 718
                     <ThemedButton
638
-                      title={t('crops.takePhoto')}
719
+                      title={t('crops.takeMedia')}
639 720
                       onPress={() =>
640 721
                         handleTakePhoto(setEditPhotoUri, (code) =>
641 722
                           setStatus(
@@ -738,7 +819,11 @@ export default function CropsScreen() {
738 819
           </KeyboardAvoidingView>
739 820
         </View>
740 821
       </Modal>
741
-
822
+      <ZoomImageModal
823
+        uri={zoomUri}
824
+        visible={Boolean(zoomUri)}
825
+        onClose={() => setZoomUri(null)}
826
+      />
742 827
     </>
743 828
   );
744 829
 }
@@ -757,7 +842,7 @@ function formatDateLabel(value: string) {
757 842
 
758 843
 async function handlePickPhoto(setter: (value: string | null) => void) {
759 844
   const result = await ImagePicker.launchImageLibraryAsync({
760
-    mediaTypes: getImageMediaTypes(),
845
+    mediaTypes: getMediaTypes(),
761 846
     quality: 1,
762 847
   });
763 848
   if (result.canceled) return;
@@ -775,7 +860,10 @@ async function handleTakePhoto(
775 860
       onError?.('cameraDenied');
776 861
       return;
777 862
     }
778
-    const result = await ImagePicker.launchCameraAsync({ quality: 1 });
863
+    const result = await ImagePicker.launchCameraAsync({
864
+      mediaTypes: getMediaTypes(),
865
+      quality: 1,
866
+    });
779 867
     if (result.canceled) return;
780 868
     const asset = result.assets[0];
781 869
     setter(asset.uri);
@@ -784,10 +872,26 @@ async function handleTakePhoto(
784 872
   }
785 873
 }
786 874
 
787
-function getImageMediaTypes() {
788
-  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
789
-    .MediaType;
790
-  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
875
+function getMediaTypes() {
876
+  const mediaType = (ImagePicker as {
877
+    MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
878
+  }).MediaType;
879
+  const imageType = mediaType?.Image ?? mediaType?.Images;
880
+  const videoType = mediaType?.Video ?? mediaType?.Videos;
881
+  if (imageType && videoType) {
882
+    return [imageType, videoType];
883
+  }
884
+  return imageType ?? videoType ?? ['images', 'videos'];
885
+}
886
+
887
+function isVideoUri(uri: string) {
888
+  return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
889
+}
890
+
891
+function normalizeMediaUri(uri?: string | null) {
892
+  if (typeof uri !== 'string') return null;
893
+  const trimmed = uri.trim();
894
+  return trimmed ? trimmed : null;
791 895
 }
792 896
 
793 897
 const styles = StyleSheet.create({
@@ -838,9 +942,15 @@ const styles = StyleSheet.create({
838 942
     fontSize: 12,
839 943
   },
840 944
   photoPreview: {
841
-    height: 160,
945
+    height: 200,
946
+    width: '100%',
947
+    borderRadius: 12,
948
+  },
949
+  videoPreview: {
842 950
     width: '100%',
951
+    height: 200,
843 952
     borderRadius: 12,
953
+    backgroundColor: '#1C1C1C',
844 954
   },
845 955
   photoRow: {
846 956
     flexDirection: 'row',

+ 502 - 0
app/(tabs)/logbook/crops/new.tsx

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

+ 503 - 0
app/(tabs)/logbook/fields/[id].tsx

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

File diff suppressed because it is too large
+ 1233 - 0
app/(tabs)/logbook/fields/index.tsx


+ 8 - 8
app/(tabs)/logbook.tsx

@@ -98,7 +98,7 @@ export default function LogbookScreen() {
98 98
         </ThemedView>
99 99
 
100 100
         <View style={styles.grid}>
101
-          <Link href="/fields" asChild>
101
+          <Link href="/logbook/fields" asChild>
102 102
             <Pressable
103 103
               style={[
104 104
                 styles.card,
@@ -127,7 +127,7 @@ export default function LogbookScreen() {
127 127
               <ThemedText style={styles.cardHint}>{t('logbook.fieldsHint')}</ThemedText>
128 128
             </Pressable>
129 129
           </Link>
130
-          <Link href="/crops" asChild>
130
+          <Link href="/logbook/crops" asChild>
131 131
             <Pressable
132 132
               style={[
133 133
                 styles.card,
@@ -156,7 +156,7 @@ export default function LogbookScreen() {
156 156
               <ThemedText style={styles.cardHint}>{t('logbook.cropsHint')}</ThemedText>
157 157
             </Pressable>
158 158
           </Link>
159
-          <Link href="/observations" asChild>
159
+          <Link href={{ pathname: '/observations', params: { from: 'logbook' } }} asChild>
160 160
             <Pressable
161 161
               style={[
162 162
                 styles.card,
@@ -185,7 +185,7 @@ export default function LogbookScreen() {
185 185
               <ThemedText style={styles.cardHint}>{t('logbook.observationsHint')}</ThemedText>
186 186
             </Pressable>
187 187
           </Link>
188
-          <Link href="/tasks" asChild>
188
+          <Link href={{ pathname: '/tasks', params: { from: 'logbook' } }} asChild>
189 189
             <Pressable
190 190
               style={[
191 191
                 styles.card,
@@ -214,7 +214,7 @@ export default function LogbookScreen() {
214 214
               <ThemedText style={styles.cardHint}>{t('logbook.tasksHint')}</ThemedText>
215 215
             </Pressable>
216 216
           </Link>
217
-          <Link href="/task-history" asChild>
217
+          <Link href={{ pathname: '/task-history', params: { from: 'logbook' } }} asChild>
218 218
             <Pressable
219 219
               style={[
220 220
                 styles.card,
@@ -243,7 +243,7 @@ export default function LogbookScreen() {
243 243
               <ThemedText style={styles.cardHint}>{t('logbook.historyHint')}</ThemedText>
244 244
             </Pressable>
245 245
           </Link>
246
-          <Link href="/harvests" asChild>
246
+          <Link href={{ pathname: '/harvests', params: { from: 'logbook' } }} asChild>
247 247
             <Pressable
248 248
               style={[
249 249
                 styles.card,
@@ -272,7 +272,7 @@ export default function LogbookScreen() {
272 272
               <ThemedText style={styles.cardHint}>{t('logbook.harvestsHint')}</ThemedText>
273 273
             </Pressable>
274 274
           </Link>
275
-          <Link href="/sales" asChild>
275
+          <Link href={{ pathname: '/sales', params: { from: 'logbook' } }} asChild>
276 276
             <Pressable
277 277
               style={[
278 278
                 styles.card,
@@ -301,7 +301,7 @@ export default function LogbookScreen() {
301 301
               <ThemedText style={styles.cardHint}>{t('logbook.salesHint')}</ThemedText>
302 302
             </Pressable>
303 303
           </Link>
304
-          <Link href="/costs" asChild>
304
+          <Link href={{ pathname: '/costs', params: { from: 'logbook' } }} asChild>
305 305
             <Pressable
306 306
               style={[
307 307
                 styles.card,

+ 624 - 0
app/(tabs)/observations/[id].tsx

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

+ 43 - 0
app/(tabs)/observations/_layout.tsx

@@ -0,0 +1,43 @@
1
+import { Stack, useRouter } from 'expo-router';
2
+import { Pressable } from 'react-native';
3
+
4
+import { IconSymbol } from '@/components/ui/icon-symbol';
5
+import { Colors } from '@/constants/theme';
6
+import { useColorScheme } from '@/hooks/use-color-scheme';
7
+import { useTranslation } from '@/localization/i18n';
8
+
9
+export default function ObservationsLayout() {
10
+  const { t } = useTranslation();
11
+  const router = useRouter();
12
+  const colorScheme = useColorScheme();
13
+  const palette = Colors[colorScheme ?? 'light'];
14
+
15
+  return (
16
+    <Stack
17
+      screenOptions={{
18
+        headerBackTitleVisible: false,
19
+        headerBackTitle: '',
20
+        headerBackTitleStyle: { display: 'none' },
21
+        headerLeft: ({ canGoBack }) =>
22
+          canGoBack ? (
23
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
24
+              <IconSymbol size={20} name="chevron.left" color={palette.text} />
25
+            </Pressable>
26
+          ) : null,
27
+      }}>
28
+      <Stack.Screen
29
+        name="index"
30
+        options={{
31
+          title: t('observations.title'),
32
+          headerLeft: () => (
33
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
34
+              <IconSymbol size={20} name="chevron.left" color={palette.text} />
35
+            </Pressable>
36
+          ),
37
+        }}
38
+      />
39
+      <Stack.Screen name="new" options={{ title: t('observations.new') }} />
40
+      <Stack.Screen name="[id]" options={{ title: t('observations.edit') }} />
41
+    </Stack>
42
+  );
43
+}

+ 157 - 39
app/observations.tsx

@@ -1,8 +1,9 @@
1
-import { useEffect, useMemo, useState } from 'react';
1
+import { useCallback, useEffect, useMemo, useState } from 'react';
2 2
 import {
3 3
   ActivityIndicator,
4 4
   FlatList,
5 5
   Image,
6
+  InteractionManager,
6 7
   KeyboardAvoidingView,
7 8
   Modal,
8 9
   Pressable,
@@ -12,6 +13,7 @@ import {
12 13
   View,
13 14
 } from 'react-native';
14 15
 import * as ImagePicker from 'expo-image-picker';
16
+import { ResizeMode, Video } from 'expo-av';
15 17
 
16 18
 import { ThemedText } from '@/components/themed-text';
17 19
 import { ThemedView } from '@/components/themed-view';
@@ -22,6 +24,9 @@ import { dbPromise, initCoreTables } from '@/services/db';
22 24
 import { useColorScheme } from '@/hooks/use-color-scheme';
23 25
 import { ThemedButton } from '@/components/themed-button';
24 26
 import { IconButton } from '@/components/icon-button';
27
+import { ZoomImageModal } from '@/components/zoom-image-modal';
28
+import { useLocalSearchParams, useRouter } from 'expo-router';
29
+import { useFocusEffect, useNavigation } from '@react-navigation/native';
25 30
 
26 31
 type FieldRow = {
27 32
   id: number;
@@ -49,8 +54,12 @@ type ObservationRow = {
49 54
 
50 55
 export default function ObservationsScreen() {
51 56
   const { t } = useTranslation();
57
+  const router = useRouter();
58
+  const navigation = useNavigation();
59
+  const params = useLocalSearchParams<{ from?: string | string[] }>();
52 60
   const theme = useColorScheme() ?? 'light';
53 61
   const palette = Colors[theme];
62
+  const fromParam = Array.isArray(params.from) ? params.from[0] : params.from;
54 63
   const presetTypes = [
55 64
     { key: 'scouting', value: 'Scouting' },
56 65
     { key: 'pest', value: 'Pest' },
@@ -59,6 +68,28 @@ export default function ObservationsScreen() {
59 68
     { key: 'weeds', value: 'Weeds' },
60 69
     { key: 'nutrients', value: 'Nutrients' },
61 70
   ];
71
+  useEffect(() => {
72
+    navigation.setOptions({
73
+      headerLeft: () => (
74
+        <Pressable
75
+          onPress={() => {
76
+            if (fromParam === 'logbook') {
77
+              router.replace('/logbook');
78
+              return;
79
+            }
80
+            if (fromParam === 'home') {
81
+              router.replace('/');
82
+              return;
83
+            }
84
+            router.back();
85
+          }}
86
+          hitSlop={10}
87
+          style={{ paddingHorizontal: 8 }}>
88
+          <IconSymbol name="chevron.left" size={20} color={palette.text} />
89
+        </Pressable>
90
+      ),
91
+    });
92
+  }, [fromParam, navigation, palette.text, router]);
62 93
   const pageSize = 12;
63 94
   const [observations, setObservations] = useState<ObservationRow[]>([]);
64 95
   const [fields, setFields] = useState<FieldRow[]>([]);
@@ -82,6 +113,8 @@ export default function ObservationsScreen() {
82 113
   const [editSeverity, setEditSeverity] = useState('');
83 114
   const [editNote, setEditNote] = useState('');
84 115
   const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
116
+  const [zoomUri, setZoomUri] = useState<string | null>(null);
117
+  const [pendingZoomUri, setPendingZoomUri] = useState<string | null>(null);
85 118
   const [editReopenAfterSelect, setEditReopenAfterSelect] = useState(false);
86 119
   const [newErrors, setNewErrors] = useState<{ field?: string; severity?: string }>({});
87 120
   const [editErrors, setEditErrors] = useState<{ field?: string; severity?: string }>({});
@@ -89,6 +122,16 @@ export default function ObservationsScreen() {
89 122
   const [hasMore, setHasMore] = useState(true);
90 123
   const [loadingMore, setLoadingMore] = useState(false);
91 124
 
125
+  useEffect(() => {
126
+    if (!newModalOpen && !editModalOpen && pendingZoomUri) {
127
+      const uri = pendingZoomUri;
128
+      setPendingZoomUri(null);
129
+      InteractionManager.runAfterInteractions(() => {
130
+        setTimeout(() => setZoomUri(uri), 150);
131
+      });
132
+    }
133
+  }, [newModalOpen, editModalOpen, pendingZoomUri]);
134
+
92 135
   const selectedField = useMemo(
93 136
     () => fields.find((item) => item.id === selectedFieldId),
94 137
     [fields, selectedFieldId]
@@ -134,6 +177,12 @@ export default function ObservationsScreen() {
134 177
     };
135 178
   }, [t]);
136 179
 
180
+  useFocusEffect(
181
+    useCallback(() => {
182
+      fetchObservationsPage(1, true);
183
+    }, [])
184
+  );
185
+
137 186
   async function fetchObservationsPage(
138 187
     pageToLoad: number,
139 188
     replace: boolean,
@@ -242,15 +291,7 @@ export default function ObservationsScreen() {
242 291
   }
243 292
 
244 293
   function startEdit(obs: ObservationRow) {
245
-    setEditingId(obs.id);
246
-    setEditFieldId(obs.field_id ?? null);
247
-    setEditCropId(obs.crop_id ?? null);
248
-    setEditType(obs.obs_type ?? '');
249
-    setEditSeverity(obs.severity !== null ? String(obs.severity) : '');
250
-    setEditNote(obs.note ?? '');
251
-    setEditPhotoUri(obs.image_uri ?? null);
252
-    setEditErrors({});
253
-    setEditModalOpen(true);
294
+    router.push(`/observations/${obs.id}`);
254 295
   }
255 296
 
256 297
   function cancelEdit() {
@@ -336,12 +377,24 @@ export default function ObservationsScreen() {
336 377
                 </ThemedText>
337 378
               ) : null}
338 379
               {item.note ? <ThemedText>{item.note}</ThemedText> : null}
339
-              {item.image_uri ? (
340
-                <Image
341
-                  source={{ uri: item.image_uri }}
342
-                  style={styles.photoPreview}
343
-                  resizeMode="cover"
344
-                />
380
+              {normalizeMediaUri(item.image_uri) ? (
381
+                isVideoUri(normalizeMediaUri(item.image_uri) as string) ? (
382
+                  <Video
383
+                    source={{ uri: normalizeMediaUri(item.image_uri) as string }}
384
+                    style={styles.videoPreview}
385
+                    useNativeControls
386
+                    resizeMode={ResizeMode.CONTAIN}
387
+                    isMuted
388
+                  />
389
+                ) : (
390
+                  <Pressable onPress={() => setZoomUri(normalizeMediaUri(item.image_uri) as string)}>
391
+                    <Image
392
+                      source={{ uri: normalizeMediaUri(item.image_uri) as string }}
393
+                      style={styles.photoPreview}
394
+                      resizeMode="contain"
395
+                    />
396
+                  </Pressable>
397
+                )
345 398
               ) : null}
346 399
               {item.observed_at ? (
347 400
                 <ThemedText style={styles.meta}>{formatDate(item.observed_at)}</ThemedText>
@@ -378,8 +431,7 @@ export default function ObservationsScreen() {
378 431
               <Pressable
379 432
                 style={styles.newButton}
380 433
                 onPress={() => {
381
-                  setNewErrors({});
382
-                  setNewModalOpen(true);
434
+                  router.push('/observations/new');
383 435
                 }}>
384 436
                 <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
385 437
                 <ThemedText style={styles.newButtonText}>{t('observations.new')}</ThemedText>
@@ -564,20 +616,40 @@ export default function ObservationsScreen() {
564 616
                     style={inputStyle}
565 617
                     multiline
566 618
                   />
567
-                  <ThemedText>{t('observations.photo')}</ThemedText>
568
-                  {photoUri ? (
569
-                    <Image source={{ uri: photoUri }} style={styles.photoPreview} resizeMode="cover" />
619
+                  <ThemedText>{t('observations.addMedia')}</ThemedText>
620
+                  {normalizeMediaUri(photoUri) ? (
621
+                    isVideoUri(normalizeMediaUri(photoUri) as string) ? (
622
+                      <Video
623
+                        source={{ uri: normalizeMediaUri(photoUri) as string }}
624
+                        style={styles.videoPreview}
625
+                        useNativeControls
626
+                        resizeMode={ResizeMode.CONTAIN}
627
+                        isMuted
628
+                      />
629
+                    ) : (
630
+                      <Pressable
631
+                        onPress={() => {
632
+                          setPendingZoomUri(normalizeMediaUri(photoUri) as string);
633
+                          setNewModalOpen(false);
634
+                        }}>
635
+                        <Image
636
+                          source={{ uri: normalizeMediaUri(photoUri) as string }}
637
+                          style={styles.photoPreview}
638
+                          resizeMode="contain"
639
+                        />
640
+                      </Pressable>
641
+                    )
570 642
                   ) : (
571 643
                     <ThemedText style={styles.photoPlaceholder}>{t('observations.noPhoto')}</ThemedText>
572 644
                   )}
573 645
                   <View style={styles.photoRow}>
574 646
                     <ThemedButton
575
-                      title={t('observations.pickPhoto')}
647
+                      title={t('observations.pickFromGallery')}
576 648
                       onPress={() => handlePickPhoto(setPhotoUri)}
577 649
                       variant="secondary"
578 650
                     />
579 651
                     <ThemedButton
580
-                      title={t('observations.takePhoto')}
652
+                      title={t('observations.takeMedia')}
581 653
                       onPress={() =>
582 654
                         handleTakePhoto(setPhotoUri, (code) =>
583 655
                           setStatus(
@@ -704,13 +776,29 @@ export default function ObservationsScreen() {
704 776
                     style={inputStyle}
705 777
                     multiline
706 778
                   />
707
-                  <ThemedText>{t('observations.photo')}</ThemedText>
708
-                  {editPhotoUri ? (
709
-                    <Image
710
-                      source={{ uri: editPhotoUri }}
711
-                      style={styles.photoPreview}
712
-                      resizeMode="cover"
713
-                    />
779
+                  <ThemedText>{t('observations.addMedia')}</ThemedText>
780
+                  {normalizeMediaUri(editPhotoUri) ? (
781
+                    isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? (
782
+                      <Video
783
+                        source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
784
+                        style={styles.videoPreview}
785
+                        useNativeControls
786
+                        resizeMode={ResizeMode.CONTAIN}
787
+                        isMuted
788
+                      />
789
+                    ) : (
790
+                      <Pressable
791
+                        onPress={() => {
792
+                          setPendingZoomUri(normalizeMediaUri(editPhotoUri) as string);
793
+                          setEditModalOpen(false);
794
+                        }}>
795
+                        <Image
796
+                          source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
797
+                          style={styles.photoPreview}
798
+                          resizeMode="contain"
799
+                        />
800
+                      </Pressable>
801
+                    )
714 802
                   ) : (
715 803
                     <ThemedText style={styles.photoPlaceholder}>
716 804
                       {t('observations.noPhoto')}
@@ -718,12 +806,12 @@ export default function ObservationsScreen() {
718 806
                   )}
719 807
                   <View style={styles.photoRow}>
720 808
                     <ThemedButton
721
-                      title={t('observations.pickPhoto')}
809
+                      title={t('observations.pickFromGallery')}
722 810
                       onPress={() => handlePickPhoto(setEditPhotoUri)}
723 811
                       variant="secondary"
724 812
                     />
725 813
                     <ThemedButton
726
-                      title={t('observations.takePhoto')}
814
+                      title={t('observations.takeMedia')}
727 815
                       onPress={() =>
728 816
                         handleTakePhoto(setEditPhotoUri, (code) =>
729 817
                           setStatus(
@@ -752,6 +840,11 @@ export default function ObservationsScreen() {
752 840
           </KeyboardAvoidingView>
753 841
         </View>
754 842
       </Modal>
843
+      <ZoomImageModal
844
+        uri={zoomUri}
845
+        visible={Boolean(zoomUri)}
846
+        onClose={() => setZoomUri(null)}
847
+      />
755 848
     </>
756 849
   );
757 850
 }
@@ -766,7 +859,7 @@ function formatDate(value: string) {
766 859
 
767 860
 async function handlePickPhoto(setter: (value: string | null) => void) {
768 861
   const result = await ImagePicker.launchImageLibraryAsync({
769
-    mediaTypes: getImageMediaTypes(),
862
+    mediaTypes: getMediaTypes(),
770 863
     quality: 1,
771 864
   });
772 865
   if (result.canceled) return;
@@ -785,7 +878,10 @@ async function handleTakePhoto(
785 878
       onError?.('cameraDenied');
786 879
       return;
787 880
     }
788
-    const result = await ImagePicker.launchCameraAsync({ quality: 1 });
881
+    const result = await ImagePicker.launchCameraAsync({
882
+      mediaTypes: getMediaTypes(),
883
+      quality: 1,
884
+    });
789 885
     if (result.canceled) return;
790 886
     const asset = result.assets[0];
791 887
     console.log('[Observations] Captured photo:', asset.uri);
@@ -795,10 +891,26 @@ async function handleTakePhoto(
795 891
   }
796 892
 }
797 893
 
798
-function getImageMediaTypes() {
799
-  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
800
-    .MediaType;
801
-  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
894
+function getMediaTypes() {
895
+  const mediaType = (ImagePicker as {
896
+    MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
897
+  }).MediaType;
898
+  const imageType = mediaType?.Image ?? mediaType?.Images;
899
+  const videoType = mediaType?.Video ?? mediaType?.Videos;
900
+  if (imageType && videoType) {
901
+    return [imageType, videoType];
902
+  }
903
+  return imageType ?? videoType ?? ['images', 'videos'];
904
+}
905
+
906
+function isVideoUri(uri: string) {
907
+  return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
908
+}
909
+
910
+function normalizeMediaUri(uri?: string | null) {
911
+  if (typeof uri !== 'string') return null;
912
+  const trimmed = uri.trim();
913
+  return trimmed ? trimmed : null;
802 914
 }
803 915
 
804 916
 const styles = StyleSheet.create({
@@ -929,8 +1041,14 @@ const styles = StyleSheet.create({
929 1041
   },
930 1042
   photoPreview: {
931 1043
     width: '100%',
932
-    height: 160,
1044
+    height: 200,
1045
+    borderRadius: 12,
1046
+  },
1047
+  videoPreview: {
1048
+    width: '100%',
1049
+    height: 200,
933 1050
     borderRadius: 12,
1051
+    backgroundColor: '#1C1C1C',
934 1052
   },
935 1053
   sheetOverlay: {
936 1054
     flex: 1,

+ 529 - 0
app/(tabs)/observations/new.tsx

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

+ 761 - 0
app/(tabs)/sales/[id].tsx

@@ -0,0 +1,761 @@
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 { useLocalSearchParams, useRouter } from 'expo-router';
18
+
19
+import { ThemedButton } from '@/components/themed-button';
20
+import { IconButton } from '@/components/icon-button';
21
+import { ThemedText } from '@/components/themed-text';
22
+import { ThemedView } from '@/components/themed-view';
23
+import { ZoomImageModal } from '@/components/zoom-image-modal';
24
+import { Colors } from '@/constants/theme';
25
+import { useColorScheme } from '@/hooks/use-color-scheme';
26
+import { useTranslation } from '@/localization/i18n';
27
+import { dbPromise, initCoreTables } from '@/services/db';
28
+
29
+type FieldRow = {
30
+  id: number;
31
+  name: string | null;
32
+};
33
+
34
+type CropRow = {
35
+  id: number;
36
+  crop_name: string | null;
37
+};
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
+  field_name: string | null;
47
+  crop_name: string | null;
48
+};
49
+
50
+type SaleRow = {
51
+  id: number;
52
+  field_id: number | null;
53
+  crop_id: number | null;
54
+  harvest_id: number | null;
55
+  sold_at: string | null;
56
+  quantity: number | null;
57
+  unit: string | null;
58
+  price: number | null;
59
+  buyer: string | null;
60
+  notes: string | null;
61
+};
62
+
63
+type MediaRow = {
64
+  uri: string | null;
65
+};
66
+
67
+export default function SaleDetailScreen() {
68
+  const { t } = useTranslation();
69
+  const router = useRouter();
70
+  const { id } = useLocalSearchParams<{ id?: string | string[] }>();
71
+  const saleId = Number(Array.isArray(id) ? id[0] : id);
72
+  const theme = useColorScheme() ?? 'light';
73
+  const palette = Colors[theme];
74
+
75
+  const [loading, setLoading] = useState(true);
76
+  const [status, setStatus] = useState('');
77
+  const [fields, setFields] = useState<FieldRow[]>([]);
78
+  const [crops, setCrops] = useState<CropRow[]>([]);
79
+  const [harvests, setHarvests] = useState<HarvestRow[]>([]);
80
+  const [fieldModalOpen, setFieldModalOpen] = useState(false);
81
+  const [cropModalOpen, setCropModalOpen] = useState(false);
82
+  const [harvestModalOpen, setHarvestModalOpen] = useState(false);
83
+  const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
84
+  const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
85
+  const [selectedHarvestId, setSelectedHarvestId] = useState<number | null>(null);
86
+  const [saleDate, setSaleDate] = useState('');
87
+  const [showSalePicker, setShowSalePicker] = useState(false);
88
+  const [quantity, setQuantity] = useState('');
89
+  const [unit, setUnit] = useState('');
90
+  const [currency, setCurrency] = useState('THB');
91
+  const [price, setPrice] = useState('');
92
+  const [buyer, setBuyer] = useState('');
93
+  const [notes, setNotes] = useState('');
94
+  const [mediaUris, setMediaUris] = useState<string[]>([]);
95
+  const [activeUri, setActiveUri] = useState<string | null>(null);
96
+  const [errors, setErrors] = useState<{ field?: string; crop?: string; quantity?: string }>({});
97
+  const [zoomUri, setZoomUri] = useState<string | null>(null);
98
+  const [saving, setSaving] = useState(false);
99
+  const [showSaved, setShowSaved] = useState(false);
100
+
101
+  useEffect(() => {
102
+    let isActive = true;
103
+
104
+    async function loadSale() {
105
+      try {
106
+        await initCoreTables();
107
+        const db = await dbPromise;
108
+        const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
109
+        const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
110
+        const profileRow = await db.getFirstAsync<{ currency: string | null }>(
111
+          'SELECT currency FROM user_profile WHERE id = 1;'
112
+        );
113
+        const harvestRows = await db.getAllAsync<HarvestRow>(
114
+          `SELECT h.id, h.field_id, h.crop_id, h.harvested_at, h.quantity, h.unit,
115
+                  f.name as field_name, c.crop_name as crop_name
116
+           FROM harvests h
117
+           LEFT JOIN fields f ON f.id = h.field_id
118
+           LEFT JOIN crops c ON c.id = h.crop_id
119
+           ORDER BY h.harvested_at DESC;`
120
+        );
121
+        const rows = await db.getAllAsync<SaleRow>(
122
+          `SELECT id, field_id, crop_id, harvest_id, sold_at, quantity, unit, price, buyer, notes
123
+           FROM sales WHERE id = ? LIMIT 1;`,
124
+          saleId
125
+        );
126
+        if (!isActive) return;
127
+        setFields(fieldRows);
128
+        setCrops(cropRows);
129
+        setCurrency(profileRow?.currency ?? 'THB');
130
+        setHarvests(harvestRows);
131
+        const sale = rows[0];
132
+        if (!sale) {
133
+          setStatus(t('sales.empty'));
134
+          setLoading(false);
135
+          return;
136
+        }
137
+        setSelectedFieldId(sale.field_id ?? null);
138
+        setSelectedCropId(sale.crop_id ?? null);
139
+        setSelectedHarvestId(sale.harvest_id ?? null);
140
+        setSaleDate(sale.sold_at ?? '');
141
+        setQuantity(sale.quantity !== null ? String(sale.quantity) : '');
142
+        setUnit(sale.unit ?? '');
143
+        setPrice(sale.price !== null ? String(sale.price) : '');
144
+        setBuyer(sale.buyer ?? '');
145
+        setNotes(sale.notes ?? '');
146
+        const mediaRows = await db.getAllAsync<MediaRow>(
147
+          'SELECT uri FROM sale_media WHERE sale_id = ? ORDER BY created_at ASC;',
148
+          saleId
149
+        );
150
+        const media = uniqueMediaUris(mediaRows.map((row) => row.uri).filter(Boolean) as string[]);
151
+        setMediaUris(media);
152
+        setActiveUri(media[0] ?? null);
153
+      } catch (error) {
154
+        if (isActive) setStatus(`Error: ${String(error)}`);
155
+      } finally {
156
+        if (isActive) setLoading(false);
157
+      }
158
+    }
159
+
160
+    loadSale();
161
+    return () => {
162
+      isActive = false;
163
+    };
164
+  }, [saleId, t]);
165
+
166
+  const selectedField = useMemo(
167
+    () => fields.find((item) => item.id === selectedFieldId),
168
+    [fields, selectedFieldId]
169
+  );
170
+  const selectedCrop = useMemo(
171
+    () => crops.find((item) => item.id === selectedCropId),
172
+    [crops, selectedCropId]
173
+  );
174
+  const selectedHarvest = useMemo(
175
+    () => harvests.find((item) => item.id === selectedHarvestId),
176
+    [harvests, selectedHarvestId]
177
+  );
178
+
179
+  const inputStyle = [
180
+    styles.input,
181
+    {
182
+      borderColor: palette.border,
183
+      backgroundColor: palette.input,
184
+      color: palette.text,
185
+    },
186
+  ];
187
+  const unitPresets = ['kg', 'g', 'ton', 'pcs'];
188
+
189
+  async function handleUpdate() {
190
+    const parsedQuantity = quantity.trim() ? Number(quantity) : null;
191
+    const nextErrors: { field?: string; crop?: string; quantity?: string } = {};
192
+    if (!selectedFieldId) nextErrors.field = t('sales.fieldRequired');
193
+    if (!selectedCropId) nextErrors.crop = t('sales.cropRequired');
194
+    if (quantity.trim() && !Number.isFinite(parsedQuantity)) {
195
+      nextErrors.quantity = t('sales.quantityInvalid');
196
+    }
197
+    setErrors(nextErrors);
198
+    if (Object.keys(nextErrors).length > 0) return;
199
+    try {
200
+      setSaving(true);
201
+      const db = await dbPromise;
202
+      const now = new Date().toISOString();
203
+      await db.runAsync(
204
+        'UPDATE sales SET field_id = ?, crop_id = ?, harvest_id = ?, sold_at = ?, quantity = ?, unit = ?, price = ?, buyer = ?, notes = ? WHERE id = ?;',
205
+        selectedFieldId,
206
+        selectedCropId,
207
+        selectedHarvestId,
208
+        saleDate || null,
209
+        parsedQuantity,
210
+        unit.trim() || null,
211
+        price.trim() ? Number(price) : null,
212
+        buyer.trim() || null,
213
+        notes.trim() || null,
214
+        saleId
215
+      );
216
+      await db.runAsync('DELETE FROM sale_media WHERE sale_id = ?;', saleId);
217
+      const mediaToInsert = uniqueMediaUris(mediaUris);
218
+      for (const uri of mediaToInsert) {
219
+        await db.runAsync(
220
+          'INSERT INTO sale_media (sale_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
221
+          saleId,
222
+          uri,
223
+          isVideoUri(uri) ? 'video' : 'image',
224
+          now
225
+        );
226
+      }
227
+      setStatus(t('sales.saved'));
228
+      setShowSaved(true);
229
+      setTimeout(() => {
230
+        setShowSaved(false);
231
+        setStatus('');
232
+      }, 1800);
233
+    } catch (error) {
234
+      setStatus(`Error: ${String(error)}`);
235
+    } finally {
236
+      setSaving(false);
237
+    }
238
+  }
239
+
240
+  function confirmDelete() {
241
+    Alert.alert(
242
+      t('sales.deleteTitle'),
243
+      t('sales.deleteMessage'),
244
+      [
245
+        { text: t('sales.cancel'), style: 'cancel' },
246
+        {
247
+          text: t('sales.delete'),
248
+          style: 'destructive',
249
+          onPress: async () => {
250
+            const db = await dbPromise;
251
+            await db.runAsync('DELETE FROM sale_media WHERE sale_id = ?;', saleId);
252
+            await db.runAsync('DELETE FROM sales WHERE id = ?;', saleId);
253
+            router.back();
254
+          },
255
+        },
256
+      ]
257
+    );
258
+  }
259
+
260
+  const harvestLabel = selectedHarvest
261
+    ? `${selectedHarvest.field_name || ''} ${selectedHarvest.crop_name || ''}, ${selectedHarvest.quantity ?? ''} ${selectedHarvest.unit ?? ''}, ${selectedHarvest.harvested_at ?? ''}`.trim()
262
+    : t('sales.selectHarvest');
263
+
264
+  if (loading) {
265
+    return (
266
+      <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
267
+        <ThemedText>{t('sales.loading')}</ThemedText>
268
+      </ThemedView>
269
+    );
270
+  }
271
+
272
+  return (
273
+    <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
274
+      <KeyboardAvoidingView
275
+        behavior={Platform.OS === 'ios' ? 'padding' : undefined}
276
+        style={styles.keyboardAvoid}>
277
+        <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
278
+          <ThemedText type="title">{t('sales.edit')}</ThemedText>
279
+          {status && !showSaved ? <ThemedText>{status}</ThemedText> : null}
280
+
281
+          <ThemedText>{t('sales.field')}</ThemedText>
282
+          <ThemedButton
283
+            title={selectedField?.name || t('sales.selectField')}
284
+            onPress={() => setFieldModalOpen(true)}
285
+            variant="secondary"
286
+          />
287
+          {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
288
+
289
+          <ThemedText>{t('sales.crop')}</ThemedText>
290
+          <ThemedButton
291
+            title={selectedCrop?.crop_name || t('sales.selectCrop')}
292
+            onPress={() => setCropModalOpen(true)}
293
+            variant="secondary"
294
+          />
295
+          {errors.crop ? <ThemedText style={styles.errorText}>{errors.crop}</ThemedText> : null}
296
+
297
+          <ThemedText>{t('sales.harvest')}</ThemedText>
298
+          <ThemedButton
299
+            title={harvestLabel || t('sales.selectHarvest')}
300
+            onPress={() => setHarvestModalOpen(true)}
301
+            variant="secondary"
302
+          />
303
+
304
+          <ThemedText>{t('sales.date')}</ThemedText>
305
+          <Pressable onPress={() => setShowSalePicker(true)} style={styles.dateInput}>
306
+            <ThemedText style={styles.dateValue}>
307
+              {saleDate || t('sales.datePlaceholder')}
308
+            </ThemedText>
309
+          </Pressable>
310
+          {showSalePicker ? (
311
+            <DateTimePicker
312
+              value={saleDate ? new Date(saleDate) : new Date()}
313
+              mode="date"
314
+              onChange={(event, date) => {
315
+                setShowSalePicker(false);
316
+                if (date) setSaleDate(toDateOnly(date));
317
+              }}
318
+            />
319
+          ) : null}
320
+
321
+          <ThemedText>{t('sales.quantity')}</ThemedText>
322
+          <TextInput
323
+            value={quantity}
324
+            onChangeText={(value) => {
325
+              setQuantity(value);
326
+              if (errors.quantity) setErrors((prev) => ({ ...prev, quantity: undefined }));
327
+            }}
328
+            placeholder={t('sales.quantityPlaceholder')}
329
+            placeholderTextColor={palette.placeholder}
330
+            style={inputStyle}
331
+            keyboardType="decimal-pad"
332
+          />
333
+          {errors.quantity ? <ThemedText style={styles.errorText}>{errors.quantity}</ThemedText> : null}
334
+
335
+          <ThemedText>{t('sales.unit')}</ThemedText>
336
+          <View style={styles.chipRow}>
337
+            {unitPresets.map((preset) => {
338
+              const label = t(`units.${preset}`);
339
+              const isActive = unit === label || unit === preset;
340
+              return (
341
+                <Pressable
342
+                  key={preset}
343
+                  style={[styles.chip, isActive ? styles.chipActive : null]}
344
+                  onPress={() => setUnit(label)}>
345
+                  <ThemedText style={styles.chipText}>{label}</ThemedText>
346
+                </Pressable>
347
+              );
348
+            })}
349
+          </View>
350
+          <TextInput
351
+            value={unit}
352
+            onChangeText={setUnit}
353
+            placeholder={t('sales.unitPlaceholder')}
354
+            placeholderTextColor={palette.placeholder}
355
+            style={inputStyle}
356
+          />
357
+
358
+          <ThemedText>{t('sales.price')} ({currency})</ThemedText>
359
+          <TextInput
360
+            value={price}
361
+            onChangeText={setPrice}
362
+            placeholder={t('sales.pricePlaceholder')}
363
+            placeholderTextColor={palette.placeholder}
364
+            style={inputStyle}
365
+            keyboardType="decimal-pad"
366
+          />
367
+
368
+          <ThemedText>{t('sales.buyer')}</ThemedText>
369
+          <TextInput
370
+            value={buyer}
371
+            onChangeText={setBuyer}
372
+            placeholder={t('sales.buyerPlaceholder')}
373
+            placeholderTextColor={palette.placeholder}
374
+            style={inputStyle}
375
+          />
376
+
377
+          <ThemedText>{t('sales.notes')}</ThemedText>
378
+          <TextInput
379
+            value={notes}
380
+            onChangeText={setNotes}
381
+            placeholder={t('sales.notesPlaceholder')}
382
+            placeholderTextColor={palette.placeholder}
383
+            style={inputStyle}
384
+            multiline
385
+          />
386
+
387
+          <ThemedText>{t('sales.addMedia')}</ThemedText>
388
+          {normalizeMediaUri(activeUri) ? (
389
+            isVideoUri(normalizeMediaUri(activeUri) as string) ? (
390
+              <Video
391
+                source={{ uri: normalizeMediaUri(activeUri) as string }}
392
+                style={styles.mediaPreview}
393
+                useNativeControls
394
+                resizeMode={ResizeMode.CONTAIN}
395
+              />
396
+            ) : (
397
+              <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
398
+                <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
399
+              </Pressable>
400
+            )
401
+          ) : (
402
+            <ThemedText style={styles.photoPlaceholder}>{t('sales.noPhoto')}</ThemedText>
403
+          )}
404
+
405
+          {mediaUris.length > 0 ? (
406
+            <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
407
+              {mediaUris.map((uri) => (
408
+                <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
409
+                  {isVideoUri(uri) ? (
410
+                    <View style={styles.videoThumb}>
411
+                      <ThemedText style={styles.videoThumbText}>▶</ThemedText>
412
+                    </View>
413
+                  ) : (
414
+                    <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
415
+                  )}
416
+                  <Pressable
417
+                    style={styles.mediaRemove}
418
+                    onPress={(event) => {
419
+                      event.stopPropagation();
420
+                      setMediaUris((prev) => {
421
+                        const next = prev.filter((item) => item !== uri);
422
+                        setActiveUri((current) => (current === uri ? next[0] ?? null : current));
423
+                        return next;
424
+                      });
425
+                    }}>
426
+                    <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
427
+                  </Pressable>
428
+                </Pressable>
429
+              ))}
430
+            </ScrollView>
431
+          ) : null}
432
+
433
+          <View style={styles.photoRow}>
434
+            <ThemedButton
435
+              title={t('sales.pickFromGallery')}
436
+              onPress={() =>
437
+                handlePickMedia((uris) => {
438
+                  if (uris.length === 0) return;
439
+                  setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
440
+                  setActiveUri((prev) => prev ?? uris[0]);
441
+                })
442
+              }
443
+              variant="secondary"
444
+            />
445
+            <ThemedButton
446
+              title={t('sales.takeMedia')}
447
+              onPress={() =>
448
+                handleTakeMedia((uri) => {
449
+                  if (!uri) return;
450
+                  setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
451
+                  setActiveUri((prev) => prev ?? uri);
452
+                })
453
+              }
454
+              variant="secondary"
455
+            />
456
+          </View>
457
+
458
+          <View style={styles.actions}>
459
+            <IconButton
460
+              name="trash"
461
+              onPress={confirmDelete}
462
+              accessibilityLabel={t('sales.delete')}
463
+              variant="danger"
464
+            />
465
+            <View style={styles.updateGroup}>
466
+              {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('sales.saved')}</ThemedText> : null}
467
+              <ThemedButton
468
+                title={saving ? t('sales.saving') : t('sales.update')}
469
+                onPress={handleUpdate}
470
+                disabled={saving}
471
+              />
472
+            </View>
473
+          </View>
474
+        </ScrollView>
475
+      </KeyboardAvoidingView>
476
+
477
+      <Modal transparent visible={fieldModalOpen} animationType="fade">
478
+        <Pressable style={styles.modalBackdrop} onPress={() => setFieldModalOpen(false)}>
479
+          <View style={styles.modalCard}>
480
+            <ThemedText type="subtitle">{t('sales.selectField')}</ThemedText>
481
+            <ScrollView style={styles.modalList}>
482
+              {fields.map((item) => (
483
+                <Pressable
484
+                  key={item.id}
485
+                  style={styles.modalItem}
486
+                  onPress={() => {
487
+                    setSelectedFieldId(item.id);
488
+                    setFieldModalOpen(false);
489
+                  }}>
490
+                  <ThemedText>{item.name || t('sales.noField')}</ThemedText>
491
+                </Pressable>
492
+              ))}
493
+            </ScrollView>
494
+          </View>
495
+        </Pressable>
496
+      </Modal>
497
+
498
+      <Modal transparent visible={cropModalOpen} animationType="fade">
499
+        <Pressable style={styles.modalBackdrop} onPress={() => setCropModalOpen(false)}>
500
+          <View style={styles.modalCard}>
501
+            <ThemedText type="subtitle">{t('sales.selectCrop')}</ThemedText>
502
+            <ScrollView style={styles.modalList}>
503
+              {crops.map((item) => (
504
+                <Pressable
505
+                  key={item.id}
506
+                  style={styles.modalItem}
507
+                  onPress={() => {
508
+                    setSelectedCropId(item.id);
509
+                    setCropModalOpen(false);
510
+                  }}>
511
+                  <ThemedText>{item.crop_name || t('sales.noCrop')}</ThemedText>
512
+                </Pressable>
513
+              ))}
514
+            </ScrollView>
515
+          </View>
516
+        </Pressable>
517
+      </Modal>
518
+
519
+      <Modal transparent visible={harvestModalOpen} animationType="fade">
520
+        <Pressable style={styles.modalBackdrop} onPress={() => setHarvestModalOpen(false)}>
521
+          <View style={styles.modalCard}>
522
+            <ThemedText type="subtitle">{t('sales.selectHarvest')}</ThemedText>
523
+            <ScrollView style={styles.modalList}>
524
+              {harvests.map((item) => (
525
+                <Pressable
526
+                  key={item.id}
527
+                  style={styles.modalItem}
528
+                  onPress={() => {
529
+                    setSelectedHarvestId(item.id);
530
+                    setSelectedFieldId(item.field_id ?? null);
531
+                    setSelectedCropId(item.crop_id ?? null);
532
+                    setHarvestModalOpen(false);
533
+                  }}>
534
+                  <ThemedText>
535
+                    {item.field_name || ''} {item.crop_name || ''}, {item.quantity ?? ''} {item.unit ?? ''}, {item.harvested_at ?? ''}
536
+                  </ThemedText>
537
+                </Pressable>
538
+              ))}
539
+            </ScrollView>
540
+          </View>
541
+        </Pressable>
542
+      </Modal>
543
+
544
+      <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
545
+    </ThemedView>
546
+  );
547
+}
548
+
549
+async function handlePickMedia(onAdd: (uris: string[]) => void) {
550
+  const result = await ImagePicker.launchImageLibraryAsync({
551
+    mediaTypes: getMediaTypes(),
552
+    quality: 1,
553
+    allowsMultipleSelection: true,
554
+    selectionLimit: 0,
555
+  });
556
+  if (result.canceled) return;
557
+  const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
558
+  if (uris.length === 0) return;
559
+  onAdd(uris);
560
+}
561
+
562
+async function handleTakeMedia(onAdd: (uri: string | null) => void) {
563
+  const permission = await ImagePicker.requestCameraPermissionsAsync();
564
+  if (!permission.granted) {
565
+    return;
566
+  }
567
+  const result = await ImagePicker.launchCameraAsync({
568
+    mediaTypes: getMediaTypes(),
569
+    quality: 1,
570
+  });
571
+  if (result.canceled) return;
572
+  const asset = result.assets[0];
573
+  onAdd(asset.uri);
574
+}
575
+
576
+function getMediaTypes() {
577
+  const mediaType = (ImagePicker as {
578
+    MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
579
+  }).MediaType;
580
+  const imageType = mediaType?.Image ?? mediaType?.Images;
581
+  const videoType = mediaType?.Video ?? mediaType?.Videos;
582
+  if (imageType && videoType) {
583
+    return [imageType, videoType];
584
+  }
585
+  return imageType ?? videoType ?? ['images', 'videos'];
586
+}
587
+
588
+function isVideoUri(uri: string) {
589
+  return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
590
+}
591
+
592
+function normalizeMediaUri(uri?: string | null) {
593
+  if (typeof uri !== 'string') return null;
594
+  const trimmed = uri.trim();
595
+  return trimmed ? trimmed : null;
596
+}
597
+
598
+function uniqueMediaUris(uris: string[]) {
599
+  const seen = new Set<string>();
600
+  const result: string[] = [];
601
+  for (const uri of uris) {
602
+    if (!uri || seen.has(uri)) continue;
603
+    seen.add(uri);
604
+    result.push(uri);
605
+  }
606
+  return result;
607
+}
608
+
609
+function toDateOnly(date: Date) {
610
+  return date.toISOString().slice(0, 10);
611
+}
612
+
613
+const styles = StyleSheet.create({
614
+  container: {
615
+    flex: 1,
616
+  },
617
+  keyboardAvoid: {
618
+    flex: 1,
619
+  },
620
+  content: {
621
+    padding: 16,
622
+    gap: 10,
623
+    paddingBottom: 40,
624
+  },
625
+  input: {
626
+    borderRadius: 10,
627
+    borderWidth: 1,
628
+    paddingHorizontal: 12,
629
+    paddingVertical: 10,
630
+    fontSize: 15,
631
+  },
632
+  errorText: {
633
+    color: '#C0392B',
634
+    fontSize: 12,
635
+  },
636
+  dateInput: {
637
+    borderRadius: 10,
638
+    borderWidth: 1,
639
+    borderColor: '#B9B9B9',
640
+    paddingHorizontal: 12,
641
+    paddingVertical: 10,
642
+  },
643
+  dateValue: {
644
+    opacity: 0.7,
645
+  },
646
+  mediaPreview: {
647
+    width: '100%',
648
+    height: 220,
649
+    borderRadius: 12,
650
+    backgroundColor: '#1C1C1C',
651
+  },
652
+  photoRow: {
653
+    flexDirection: 'row',
654
+    gap: 8,
655
+  },
656
+  actions: {
657
+    marginTop: 12,
658
+    flexDirection: 'row',
659
+    justifyContent: 'space-between',
660
+    alignItems: 'center',
661
+    gap: 10,
662
+  },
663
+  photoPlaceholder: {
664
+    opacity: 0.6,
665
+  },
666
+  mediaStrip: {
667
+    marginTop: 6,
668
+  },
669
+  mediaChip: {
670
+    width: 72,
671
+    height: 72,
672
+    borderRadius: 10,
673
+    marginRight: 8,
674
+    overflow: 'hidden',
675
+    backgroundColor: '#E6E1D4',
676
+    alignItems: 'center',
677
+    justifyContent: 'center',
678
+  },
679
+  mediaThumb: {
680
+    width: '100%',
681
+    height: '100%',
682
+  },
683
+  videoThumb: {
684
+    width: '100%',
685
+    height: '100%',
686
+    backgroundColor: '#1C1C1C',
687
+    alignItems: 'center',
688
+    justifyContent: 'center',
689
+  },
690
+  videoThumbText: {
691
+    color: '#FFFFFF',
692
+    fontSize: 18,
693
+    fontWeight: '700',
694
+  },
695
+  mediaRemove: {
696
+    position: 'absolute',
697
+    top: 4,
698
+    right: 4,
699
+    width: 18,
700
+    height: 18,
701
+    borderRadius: 9,
702
+    backgroundColor: 'rgba(0,0,0,0.6)',
703
+    alignItems: 'center',
704
+    justifyContent: 'center',
705
+  },
706
+  mediaRemoveText: {
707
+    color: '#FFFFFF',
708
+    fontSize: 12,
709
+    lineHeight: 14,
710
+    fontWeight: '700',
711
+  },
712
+  updateGroup: {
713
+    flexDirection: 'row',
714
+    alignItems: 'center',
715
+    gap: 8,
716
+  },
717
+  inlineToastText: {
718
+    fontWeight: '700',
719
+    fontSize: 12,
720
+  },
721
+  chipRow: {
722
+    flexDirection: 'row',
723
+    flexWrap: 'wrap',
724
+    gap: 8,
725
+    marginBottom: 8,
726
+  },
727
+  chip: {
728
+    paddingHorizontal: 12,
729
+    paddingVertical: 6,
730
+    borderRadius: 999,
731
+    borderWidth: 1,
732
+    borderColor: '#D9D1C2',
733
+    backgroundColor: '#F8F6F0',
734
+  },
735
+  chipActive: {
736
+    backgroundColor: '#DDE8DA',
737
+    borderColor: '#88A68F',
738
+  },
739
+  chipText: {
740
+    fontSize: 13,
741
+  },
742
+  modalBackdrop: {
743
+    flex: 1,
744
+    backgroundColor: 'rgba(0,0,0,0.4)',
745
+    justifyContent: 'center',
746
+    padding: 24,
747
+  },
748
+  modalCard: {
749
+    borderRadius: 14,
750
+    backgroundColor: '#FFFFFF',
751
+    padding: 16,
752
+    gap: 10,
753
+    maxHeight: '80%',
754
+  },
755
+  modalList: {
756
+    maxHeight: 300,
757
+  },
758
+  modalItem: {
759
+    paddingVertical: 10,
760
+  },
761
+});

+ 43 - 0
app/(tabs)/sales/_layout.tsx

@@ -0,0 +1,43 @@
1
+import { Stack, useRouter } from 'expo-router';
2
+import { Pressable } from 'react-native';
3
+
4
+import { IconSymbol } from '@/components/ui/icon-symbol';
5
+import { Colors } from '@/constants/theme';
6
+import { useColorScheme } from '@/hooks/use-color-scheme';
7
+import { useTranslation } from '@/localization/i18n';
8
+
9
+export default function SalesLayout() {
10
+  const { t } = useTranslation();
11
+  const router = useRouter();
12
+  const colorScheme = useColorScheme();
13
+  const palette = Colors[colorScheme ?? 'light'];
14
+
15
+  return (
16
+    <Stack
17
+      screenOptions={{
18
+        headerBackTitleVisible: false,
19
+        headerBackTitle: '',
20
+        headerBackTitleStyle: { display: 'none' },
21
+        headerLeft: ({ canGoBack }) =>
22
+          canGoBack ? (
23
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
24
+              <IconSymbol size={20} name="chevron.left" color={palette.text} />
25
+            </Pressable>
26
+          ) : null,
27
+      }}>
28
+      <Stack.Screen
29
+        name="index"
30
+        options={{
31
+          title: t('sales.title'),
32
+          headerLeft: () => (
33
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
34
+              <IconSymbol size={20} name="chevron.left" color={palette.text} />
35
+            </Pressable>
36
+          ),
37
+        }}
38
+      />
39
+      <Stack.Screen name="new" options={{ title: t('sales.new') }} />
40
+      <Stack.Screen name="[id]" options={{ title: t('sales.edit') }} />
41
+    </Stack>
42
+  );
43
+}

+ 60 - 14
app/sales.tsx

@@ -1,4 +1,4 @@
1
-import { useEffect, useMemo, useState } from 'react';
1
+import { useCallback, useEffect, useMemo, useState } from 'react';
2 2
 import {
3 3
   Alert,
4 4
   FlatList,
@@ -22,6 +22,8 @@ import { Colors, Fonts } from '@/constants/theme';
22 22
 import { useTranslation } from '@/localization/i18n';
23 23
 import { dbPromise, initCoreTables } from '@/services/db';
24 24
 import { useColorScheme } from '@/hooks/use-color-scheme';
25
+import { useLocalSearchParams, useRouter } from 'expo-router';
26
+import { useFocusEffect, useNavigation } from '@react-navigation/native';
25 27
 
26 28
 type FieldRow = {
27 29
   id: number;
@@ -62,8 +64,12 @@ type SaleRow = {
62 64
 
63 65
 export default function SalesScreen() {
64 66
   const { t } = useTranslation();
67
+  const router = useRouter();
68
+  const navigation = useNavigation();
69
+  const params = useLocalSearchParams<{ from?: string | string[] }>();
65 70
   const theme = useColorScheme() ?? 'light';
66 71
   const palette = Colors[theme];
72
+  const fromParam = Array.isArray(params.from) ? params.from[0] : params.from;
67 73
   const unitPresets = [
68 74
     { key: 'kg', value: 'kg' },
69 75
     { key: 'g', value: 'g' },
@@ -90,6 +96,29 @@ export default function SalesScreen() {
90 96
   const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
91 97
   const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
92 98
   const [selectedHarvestId, setSelectedHarvestId] = useState<number | null>(null);
99
+
100
+  useEffect(() => {
101
+    navigation.setOptions({
102
+      headerLeft: () => (
103
+        <Pressable
104
+          onPress={() => {
105
+            if (fromParam === 'logbook') {
106
+              router.replace('/logbook');
107
+              return;
108
+            }
109
+            if (fromParam === 'home') {
110
+              router.replace('/');
111
+              return;
112
+            }
113
+            router.back();
114
+          }}
115
+          hitSlop={10}
116
+          style={{ paddingHorizontal: 8 }}>
117
+          <IconSymbol name="chevron.left" size={20} color={palette.text} />
118
+        </Pressable>
119
+      ),
120
+    });
121
+  }, [fromParam, navigation, palette.text, router]);
93 122
   const [soldDate, setSoldDate] = useState('');
94 123
   const [showSoldPicker, setShowSoldPicker] = useState(false);
95 124
   const [quantity, setQuantity] = useState('');
@@ -187,6 +216,34 @@ export default function SalesScreen() {
187 216
     };
188 217
   }, [t]);
189 218
 
219
+  async function fetchSalesPage() {
220
+    try {
221
+      const db = await dbPromise;
222
+      const profileRow = await db.getFirstAsync<{ currency: string | null }>(
223
+        'SELECT currency FROM user_profile WHERE id = 1;'
224
+      );
225
+      const saleRows = await db.getAllAsync<SaleRow>(
226
+        `SELECT s.id, s.field_id, s.crop_id, s.harvest_id, s.sold_at, s.quantity, s.unit, s.price,
227
+                s.buyer, s.notes, f.name as field_name, c.crop_name as crop_name
228
+         FROM sales s
229
+         LEFT JOIN fields f ON f.id = s.field_id
230
+         LEFT JOIN crops c ON c.id = s.crop_id
231
+         ORDER BY s.sold_at DESC;`
232
+      );
233
+      setCurrency(profileRow?.currency ?? 'THB');
234
+      setSales(saleRows);
235
+      setStatus(saleRows.length === 0 ? t('sales.empty') : '');
236
+    } catch (error) {
237
+      setStatus(`Error: ${String(error)}`);
238
+    }
239
+  }
240
+
241
+  useFocusEffect(
242
+    useCallback(() => {
243
+      fetchSalesPage();
244
+    }, [t])
245
+  );
246
+
190 247
   async function handleSave() {
191 248
     const parsedQty = quantity.trim() ? Number(quantity) : null;
192 249
     const parsedPrice = price.trim() ? Number(price) : null;
@@ -243,18 +300,7 @@ export default function SalesScreen() {
243 300
   }
244 301
 
245 302
   function startEdit(item: SaleRow) {
246
-    setEditingId(item.id);
247
-    setEditFieldId(item.field_id ?? null);
248
-    setEditCropId(item.crop_id ?? null);
249
-    setEditHarvestId(item.harvest_id ?? null);
250
-    setEditSoldDate(item.sold_at ?? '');
251
-    setEditQuantity(item.quantity !== null ? String(item.quantity) : '');
252
-    setEditUnit(item.unit ?? 'kg');
253
-    setEditPrice(item.price !== null ? String(item.price) : '');
254
-    setEditBuyer(item.buyer ?? '');
255
-    setEditNotes(item.notes ?? '');
256
-    setEditErrors({});
257
-    setEditModalOpen(true);
303
+    router.push(`/sales/${item.id}`);
258 304
   }
259 305
 
260 306
   function cancelEdit() {
@@ -404,7 +450,7 @@ export default function SalesScreen() {
404 450
               </ThemedView>
405 451
             ) : null}
406 452
             <ThemedView style={styles.section}>
407
-              <Pressable style={styles.newButton} onPress={() => setNewModalOpen(true)}>
453
+              <Pressable style={styles.newButton} onPress={() => router.push('/sales/new')}>
408 454
                 <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
409 455
                 <ThemedText style={styles.newButtonText}>{t('sales.new')}</ThemedText>
410 456
               </Pressable>

+ 658 - 0
app/(tabs)/sales/new.tsx

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

+ 116 - 86
app/(tabs)/setup.tsx

@@ -10,7 +10,7 @@ import { ThemedText } from '@/components/themed-text';
10 10
 import { ThemedView } from '@/components/themed-view';
11 11
 import { IconSymbol } from '@/components/ui/icon-symbol';
12 12
 import { Colors, Fonts } from '@/constants/theme';
13
-import { useTranslation } from '@/localization/i18n';
13
+import { createTranslator, useTranslation } from '@/localization/i18n';
14 14
 import { dbPromise, initCoreTables } from '@/services/db';
15 15
 import { ThemedButton } from '@/components/themed-button';
16 16
 import { useColorScheme } from '@/hooks/use-color-scheme';
@@ -20,7 +20,7 @@ type Profile = {
20 20
   farmName: string;
21 21
   location: string;
22 22
   photoUri: string;
23
-  language: 'en' | 'th';
23
+  language: 'en' | 'th' | 'ja';
24 24
   currency: string;
25 25
 };
26 26
 
@@ -186,7 +186,9 @@ export default function SetupScreen() {
186 186
 
187 187
       if (row) {
188 188
         const storedLanguage =
189
-          row.language === 'th' || row.language === 'en' ? row.language : language;
189
+          row.language === 'th' || row.language === 'en' || row.language === 'ja'
190
+            ? row.language
191
+            : language;
190 192
         if (storedLanguage !== language) {
191 193
           setLanguage(storedLanguage);
192 194
         }
@@ -257,47 +259,63 @@ export default function SetupScreen() {
257 259
       const nowIso = now.toISOString();
258 260
       const day = 24 * 60 * 60 * 1000;
259 261
       const toDateOnly = (date: Date) => date.toISOString().slice(0, 10);
262
+      const demoT = createTranslator(profile.language);
263
+      const truncateText = (value: string, max = 160) => {
264
+        if (value.length <= max) return value;
265
+        return `${value.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
266
+      };
260 267
 
261 268
       const [fieldImageUri, cropImageUri, observationImageUri] = await loadDemoImageUris();
262 269
       const fieldRows = [
263 270
         {
264
-          name: t('demo.field.north'),
271
+          name: demoT('demo.field.north'),
265 272
           area: 2.4,
266
-          notes: t('demo.field.northNote'),
273
+          notes: demoT('demo.field.northNote'),
267 274
           photoUri: fieldImageUri,
268 275
         },
269 276
         {
270
-          name: t('demo.field.river'),
277
+          name: demoT('demo.field.river'),
271 278
           area: 1.2,
272
-          notes: t('demo.field.riverNote'),
279
+          notes: demoT('demo.field.riverNote'),
273 280
           photoUri: fieldImageUri,
274 281
         },
275 282
         {
276
-          name: t('demo.field.greenhouse'),
283
+          name: demoT('demo.field.greenhouse'),
277 284
           area: 0.4,
278
-          notes: t('demo.field.greenhouseNote'),
285
+          notes: demoT('demo.field.greenhouseNote'),
279 286
           photoUri: fieldImageUri,
280 287
         },
281 288
         {
282
-          name: t('demo.field.orchard'),
289
+          name: demoT('demo.field.orchard'),
283 290
           area: 1.8,
284
-          notes: t('demo.field.orchardNote'),
291
+          notes: demoT('demo.field.orchardNote'),
285 292
           photoUri: fieldImageUri,
286 293
         },
287 294
         {
288
-          name: t('demo.field.terrace'),
295
+          name: demoT('demo.field.terrace'),
289 296
           area: 0.9,
290
-          notes: t('demo.field.terraceNote'),
297
+          notes: demoT('demo.field.terraceNote'),
291 298
           photoUri: fieldImageUri,
292 299
         },
293 300
       ];
294 301
       const extraFieldCount = 100;
302
+      const fieldSuffixes = [
303
+        demoT('demo.field.northNote'),
304
+        demoT('demo.field.riverNote'),
305
+        demoT('demo.field.greenhouseNote'),
306
+        demoT('demo.field.orchardNote'),
307
+        demoT('demo.field.terraceNote'),
308
+        demoT('demo.observation.scoutingNote'),
309
+        demoT('demo.observation.irrigationNote'),
310
+        demoT('demo.observation.pestNote'),
311
+        demoT('demo.observation.nutrientNote'),
312
+      ];
295 313
       for (let i = 0; i < extraFieldCount; i += 1) {
296 314
         const seed = fieldRows[i % fieldRows.length];
297 315
         fieldRows.push({
298
-          name: `${seed.name} ${i + 2}`,
316
+          name: truncateText(`${seed.name} ${fieldSuffixes[i % fieldSuffixes.length]}`),
299 317
           area: Number((seed.area + (i % 3) * 0.2).toFixed(2)),
300
-          notes: seed.notes,
318
+          notes: truncateText(seed.notes),
301 319
           photoUri: fieldImageUri,
302 320
         });
303 321
       }
@@ -318,24 +336,24 @@ export default function SetupScreen() {
318 336
 
319 337
       const cropTemplates = [
320 338
         {
321
-          name: t('demo.crop.tomato'),
322
-          variety: t('demo.crop.tomatoVariety'),
339
+          name: demoT('demo.crop.tomato'),
340
+          variety: demoT('demo.crop.tomatoVariety'),
323 341
         },
324 342
         {
325
-          name: t('demo.crop.rice'),
326
-          variety: t('demo.crop.riceVariety'),
343
+          name: demoT('demo.crop.rice'),
344
+          variety: demoT('demo.crop.riceVariety'),
327 345
         },
328 346
         {
329
-          name: t('demo.crop.lettuce'),
330
-          variety: t('demo.crop.lettuceVariety'),
347
+          name: demoT('demo.crop.lettuce'),
348
+          variety: demoT('demo.crop.lettuceVariety'),
331 349
         },
332 350
         {
333
-          name: t('demo.crop.chili'),
334
-          variety: t('demo.crop.chiliVariety'),
351
+          name: demoT('demo.crop.chili'),
352
+          variety: demoT('demo.crop.chiliVariety'),
335 353
         },
336 354
         {
337
-          name: t('demo.crop.cabbage'),
338
-          variety: t('demo.crop.cabbageVariety'),
355
+          name: demoT('demo.crop.cabbage'),
356
+          variety: demoT('demo.crop.cabbageVariety'),
339 357
         },
340 358
       ];
341 359
       const cropRows = fieldIds.slice(0, cropTemplates.length).map((fieldId, index) => {
@@ -350,12 +368,19 @@ export default function SetupScreen() {
350 368
         };
351 369
       });
352 370
       const extraCropCount = 120;
371
+      const cropSuffixes = [
372
+        demoT('demo.observation.scoutingNote'),
373
+        demoT('demo.observation.diseaseNote'),
374
+        demoT('demo.observation.irrigationNote'),
375
+        demoT('demo.observation.pestNote'),
376
+        demoT('demo.observation.nutrientNote'),
377
+      ];
353 378
       for (let i = 0; i < extraCropCount; i += 1) {
354 379
         const template = cropTemplates[i % cropTemplates.length];
355 380
         cropRows.push({
356 381
           fieldId: fieldIds[i % fieldIds.length],
357
-          name: `${template.name} ${i + 2}`,
358
-          variety: template.variety,
382
+          name: truncateText(`${template.name} ${cropSuffixes[i % cropSuffixes.length]}`),
383
+          variety: truncateText(template.variety),
359 384
           planting: toDateOnly(new Date(now.getTime() - (10 + i * 3) * day)),
360 385
           harvest: toDateOnly(new Date(now.getTime() + (20 + i * 4) * day)),
361 386
           photoUri: cropImageUri,
@@ -378,57 +403,57 @@ export default function SetupScreen() {
378 403
       }
379 404
 
380 405
       const observationNotes = [
381
-        t('demo.observation.scoutingNote'),
382
-        t('demo.observation.diseaseNote'),
383
-        t('demo.observation.irrigationNote'),
384
-        t('demo.observation.pestNote'),
385
-        t('demo.observation.nutrientNote'),
406
+        demoT('demo.observation.scoutingNote'),
407
+        demoT('demo.observation.diseaseNote'),
408
+        demoT('demo.observation.irrigationNote'),
409
+        demoT('demo.observation.pestNote'),
410
+        demoT('demo.observation.nutrientNote'),
386 411
       ];
387 412
       const observationTypes = [
388
-        t('observations.type.scouting'),
389
-        t('observations.type.disease'),
390
-        t('observations.type.irrigation'),
391
-        t('observations.type.pest'),
392
-        t('observations.type.nutrients'),
413
+        demoT('observations.type.scouting'),
414
+        demoT('observations.type.disease'),
415
+        demoT('observations.type.irrigation'),
416
+        demoT('observations.type.pest'),
417
+        demoT('observations.type.nutrients'),
393 418
       ];
394 419
       const observationRows = [
395 420
         {
396 421
           fieldId: fieldIds[0],
397 422
           cropId: cropIds[0],
398
-          type: t('observations.type.scouting'),
399
-          note: t('demo.observation.scoutingNote'),
423
+          type: demoT('observations.type.scouting'),
424
+          note: demoT('demo.observation.scoutingNote'),
400 425
           severity: 2,
401 426
           observedAt: new Date(now.getTime() - 2 * day).toISOString(),
402 427
         },
403 428
         {
404 429
           fieldId: fieldIds[1],
405 430
           cropId: cropIds[1],
406
-          type: t('observations.type.disease'),
407
-          note: t('demo.observation.diseaseNote'),
431
+          type: demoT('observations.type.disease'),
432
+          note: demoT('demo.observation.diseaseNote'),
408 433
           severity: 5,
409 434
           observedAt: new Date(now.getTime() - day).toISOString(),
410 435
         },
411 436
         {
412 437
           fieldId: fieldIds[2],
413 438
           cropId: cropIds[2],
414
-          type: t('observations.type.irrigation'),
415
-          note: t('demo.observation.irrigationNote'),
439
+          type: demoT('observations.type.irrigation'),
440
+          note: demoT('demo.observation.irrigationNote'),
416 441
           severity: null,
417 442
           observedAt: nowIso,
418 443
         },
419 444
         {
420 445
           fieldId: fieldIds[3],
421 446
           cropId: cropIds[3],
422
-          type: t('observations.type.pest'),
423
-          note: t('demo.observation.pestNote'),
447
+          type: demoT('observations.type.pest'),
448
+          note: demoT('demo.observation.pestNote'),
424 449
           severity: 3,
425 450
           observedAt: new Date(now.getTime() - 3 * day).toISOString(),
426 451
         },
427 452
         {
428 453
           fieldId: fieldIds[4],
429 454
           cropId: cropIds[4],
430
-          type: t('observations.type.nutrients'),
431
-          note: t('demo.observation.nutrientNote'),
455
+          type: demoT('observations.type.nutrients'),
456
+          note: demoT('demo.observation.nutrientNote'),
432 457
           severity: 4,
433 458
           observedAt: new Date(now.getTime() - 4 * day).toISOString(),
434 459
         },
@@ -471,9 +496,9 @@ export default function SetupScreen() {
471 496
       );
472 497
       if (!taskCount?.count) {
473 498
         const defaultTasks = [
474
-          { name: t('tasks.default.fieldCheck'), description: t('tasks.default.fieldCheckDesc'), time: '08:00' },
475
-          { name: t('tasks.default.scouting'), description: t('tasks.default.scoutingDesc'), time: '10:00' },
476
-          { name: t('tasks.default.sensors'), description: t('tasks.default.sensorsDesc'), time: '15:00' },
499
+          { name: demoT('tasks.default.fieldCheck'), description: demoT('tasks.default.fieldCheckDesc'), time: '08:00' },
500
+          { name: demoT('tasks.default.scouting'), description: demoT('tasks.default.scoutingDesc'), time: '10:00' },
501
+          { name: demoT('tasks.default.sensors'), description: demoT('tasks.default.sensorsDesc'), time: '15:00' },
477 502
         ];
478 503
         for (const task of defaultTasks) {
479 504
           await db.runAsync(
@@ -489,7 +514,7 @@ export default function SetupScreen() {
489 514
       const taskRows = await db.getAllAsync<{ id: number; name: string }>(
490 515
         'SELECT id, name FROM daily_tasks WHERE is_active = 1 ORDER BY id ASC LIMIT 3;'
491 516
       );
492
-      const taskNotes = [t('demo.task.note'), t('demo.task.note2'), t('demo.task.note3')];
517
+      const taskNotes = [demoT('demo.task.note'), demoT('demo.task.note2'), demoT('demo.task.note3')];
493 518
       let entryIndex = 0;
494 519
       for (let dayOffset = 0; dayOffset < 120; dayOffset += 1) {
495 520
         for (let i = 0; i < taskRows.length; i += 1) {
@@ -515,7 +540,7 @@ export default function SetupScreen() {
515 540
           date: new Date(now.getTime() - 5 * day).toISOString(),
516 541
           qty: 120,
517 542
           unit: 'kg',
518
-          notes: t('demo.harvest.note1'),
543
+          notes: demoT('demo.harvest.note1'),
519 544
         },
520 545
         {
521 546
           fieldId: fieldIds[1],
@@ -523,7 +548,7 @@ export default function SetupScreen() {
523 548
           date: new Date(now.getTime() - 3 * day).toISOString(),
524 549
           qty: 260,
525 550
           unit: 'kg',
526
-          notes: t('demo.harvest.note2'),
551
+          notes: demoT('demo.harvest.note2'),
527 552
         },
528 553
         {
529 554
           fieldId: fieldIds[2],
@@ -531,7 +556,7 @@ export default function SetupScreen() {
531 556
           date: new Date(now.getTime() - 2 * day).toISOString(),
532 557
           qty: 40,
533 558
           unit: 'kg',
534
-          notes: t('demo.harvest.note3'),
559
+          notes: demoT('demo.harvest.note3'),
535 560
         },
536 561
       ];
537 562
       const extraHarvestCount = 100;
@@ -542,7 +567,7 @@ export default function SetupScreen() {
542 567
           date: new Date(now.getTime() - (7 + i) * day).toISOString(),
543 568
           qty: 30 + i * 12,
544 569
           unit: 'kg',
545
-          notes: [t('demo.harvest.note1'), t('demo.harvest.note2'), t('demo.harvest.note3')][
570
+          notes: [demoT('demo.harvest.note1'), demoT('demo.harvest.note2'), demoT('demo.harvest.note3')][
546 571
             i % 3
547 572
           ],
548 573
         });
@@ -573,8 +598,8 @@ export default function SetupScreen() {
573 598
           qty: 80,
574 599
           unit: 'kg',
575 600
           price: 35,
576
-          buyer: t('demo.sale.buyer1'),
577
-          notes: t('demo.sale.note1'),
601
+          buyer: demoT('demo.sale.buyer1'),
602
+          notes: demoT('demo.sale.note1'),
578 603
         },
579 604
         {
580 605
           harvestId: harvestIds[1] ?? null,
@@ -584,8 +609,8 @@ export default function SetupScreen() {
584 609
           qty: 150,
585 610
           unit: 'kg',
586 611
           price: 28,
587
-          buyer: t('demo.sale.buyer2'),
588
-          notes: t('demo.sale.note2'),
612
+          buyer: demoT('demo.sale.buyer2'),
613
+          notes: demoT('demo.sale.note2'),
589 614
         },
590 615
         {
591 616
           harvestId: harvestIds[2] ?? null,
@@ -595,8 +620,8 @@ export default function SetupScreen() {
595 620
           qty: 25,
596 621
           unit: 'kg',
597 622
           price: 40,
598
-          buyer: t('demo.sale.buyer3'),
599
-          notes: t('demo.sale.note3'),
623
+          buyer: demoT('demo.sale.buyer3'),
624
+          notes: demoT('demo.sale.note3'),
600 625
         },
601 626
       ];
602 627
       const extraSaleCount = Math.min(harvestIds.length, 120);
@@ -609,8 +634,8 @@ export default function SetupScreen() {
609 634
           qty: 20 + i * 8,
610 635
           unit: 'kg',
611 636
           price: 25 + i * 2,
612
-          buyer: [t('demo.sale.buyer1'), t('demo.sale.buyer2'), t('demo.sale.buyer3')][i % 3],
613
-          notes: [t('demo.sale.note1'), t('demo.sale.note2'), t('demo.sale.note3')][i % 3],
637
+          buyer: [demoT('demo.sale.buyer1'), demoT('demo.sale.buyer2'), demoT('demo.sale.buyer3')][i % 3],
638
+          notes: [demoT('demo.sale.note1'), demoT('demo.sale.note2'), demoT('demo.sale.note3')][i % 3],
614 639
         });
615 640
       }
616 641
 
@@ -635,40 +660,40 @@ export default function SetupScreen() {
635 660
         {
636 661
           fieldId: fieldIds[0],
637 662
           cropId: cropIds[0],
638
-          category: t('costs.category.seed'),
663
+          category: demoT('costs.category.seed'),
639 664
           amount: 1200,
640
-          vendor: t('demo.cost.vendor1'),
641
-          notes: t('demo.cost.note1'),
665
+          vendor: demoT('demo.cost.vendor1'),
666
+          notes: demoT('demo.cost.note1'),
642 667
           date: new Date(now.getTime() - 6 * day).toISOString(),
643 668
         },
644 669
         {
645 670
           fieldId: fieldIds[1],
646 671
           cropId: cropIds[1],
647
-          category: t('costs.category.fertilizer'),
672
+          category: demoT('costs.category.fertilizer'),
648 673
           amount: 950,
649
-          vendor: t('demo.cost.vendor2'),
650
-          notes: t('demo.cost.note2'),
674
+          vendor: demoT('demo.cost.vendor2'),
675
+          notes: demoT('demo.cost.note2'),
651 676
           date: new Date(now.getTime() - 4 * day).toISOString(),
652 677
         },
653 678
         {
654 679
           fieldId: fieldIds[2],
655 680
           cropId: cropIds[2],
656
-          category: t('costs.category.labor'),
681
+          category: demoT('costs.category.labor'),
657 682
           amount: 600,
658
-          vendor: t('demo.cost.vendor3'),
659
-          notes: t('demo.cost.note3'),
683
+          vendor: demoT('demo.cost.vendor3'),
684
+          notes: demoT('demo.cost.note3'),
660 685
           date: new Date(now.getTime() - 2 * day).toISOString(),
661 686
         },
662 687
       ];
663 688
       const extraCostCount = 140;
664 689
       const costCategories = [
665
-        t('costs.category.seed'),
666
-        t('costs.category.fertilizer'),
667
-        t('costs.category.labor'),
668
-        t('costs.category.fuel'),
669
-        t('costs.category.equipment'),
670
-        t('costs.category.transport'),
671
-        t('costs.category.misc'),
690
+        demoT('costs.category.seed'),
691
+        demoT('costs.category.fertilizer'),
692
+        demoT('costs.category.labor'),
693
+        demoT('costs.category.fuel'),
694
+        demoT('costs.category.equipment'),
695
+        demoT('costs.category.transport'),
696
+        demoT('costs.category.misc'),
672 697
       ];
673 698
       for (let i = 0; i < extraCostCount; i += 1) {
674 699
         costRows.push({
@@ -676,8 +701,8 @@ export default function SetupScreen() {
676 701
           cropId: cropIds[i % cropIds.length],
677 702
           category: costCategories[i % costCategories.length],
678 703
           amount: 250 + i * 75,
679
-          vendor: [t('demo.cost.vendor1'), t('demo.cost.vendor2'), t('demo.cost.vendor3')][i % 3],
680
-          notes: [t('demo.cost.note1'), t('demo.cost.note2'), t('demo.cost.note3')][i % 3],
704
+          vendor: [demoT('demo.cost.vendor1'), demoT('demo.cost.vendor2'), demoT('demo.cost.vendor3')][i % 3],
705
+          notes: [demoT('demo.cost.note1'), demoT('demo.cost.note2'), demoT('demo.cost.note3')][i % 3],
681 706
           date: new Date(now.getTime() - (8 + i) * day).toISOString(),
682 707
         });
683 708
       }
@@ -745,16 +770,16 @@ export default function SetupScreen() {
745 770
       await initCoreTables();
746 771
       const db = await dbPromise;
747 772
       const backup = await fetchDemoBackup(db);
748
-      const payload = buildCsvExport(backup);
773
+      const payload = JSON.stringify(backup, null, 2);
749 774
       const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
750
-      const fileUri = `${FileSystem.documentDirectory}smartfarming-export-${timestamp}.csv`;
775
+      const fileUri = `${FileSystem.documentDirectory}smartfarming-export-${timestamp}.json`;
751 776
       await FileSystem.writeAsStringAsync(fileUri, payload);
752 777
       const canShare = await Sharing.isAvailableAsync();
753 778
       if (canShare) {
754 779
         await Sharing.shareAsync(fileUri, {
755
-          mimeType: 'text/csv',
780
+          mimeType: 'application/json',
756 781
           dialogTitle: t('setup.exportTitle'),
757
-          UTI: 'public.comma-separated-values-text',
782
+          UTI: 'public.json',
758 783
         });
759 784
         showSnackbar(t('setup.exported'));
760 785
       } else {
@@ -765,7 +790,7 @@ export default function SetupScreen() {
765 790
     }
766 791
   }
767 792
 
768
-  function updateLanguage(nextLanguage: 'en' | 'th') {
793
+  function updateLanguage(nextLanguage: 'en' | 'th' | 'ja') {
769 794
     setLanguage(nextLanguage);
770 795
     setProfile((prev) => ({ ...prev, language: nextLanguage }));
771 796
   }
@@ -890,6 +915,11 @@ export default function SetupScreen() {
890 915
               onPress={() => updateLanguage('th')}
891 916
               variant={profile.language === 'th' ? 'primary' : 'secondary'}
892 917
             />
918
+            <ThemedButton
919
+              title={t('setup.lang.ja')}
920
+              onPress={() => updateLanguage('ja')}
921
+              variant={profile.language === 'ja' ? 'primary' : 'secondary'}
922
+            />
893 923
         </View>
894 924
       </ThemedView>
895 925
 

+ 31 - 1
app/task-history.tsx

@@ -1,9 +1,12 @@
1 1
 import { useEffect, useState } from 'react';
2
-import { ActivityIndicator, FlatList, StyleSheet, View } from 'react-native';
2
+import { ActivityIndicator, FlatList, Pressable, StyleSheet, View } from 'react-native';
3 3
 import { Image } from 'expo-image';
4
+import { useLocalSearchParams, useRouter } from 'expo-router';
5
+import { useNavigation } from '@react-navigation/native';
4 6
 
5 7
 import { ThemedText } from '@/components/themed-text';
6 8
 import { ThemedView } from '@/components/themed-view';
9
+import { IconSymbol } from '@/components/ui/icon-symbol';
7 10
 import { Colors, Fonts } from '@/constants/theme';
8 11
 import { useTranslation } from '@/localization/i18n';
9 12
 import { dbPromise, initCoreTables } from '@/services/db';
@@ -19,8 +22,12 @@ type EntryRow = {
19 22
 
20 23
 export default function TaskHistoryScreen() {
21 24
   const { t } = useTranslation();
25
+  const router = useRouter();
26
+  const navigation = useNavigation();
27
+  const params = useLocalSearchParams<{ from?: string | string[] }>();
22 28
   const theme = useColorScheme() ?? 'light';
23 29
   const palette = Colors[theme];
30
+  const fromParam = Array.isArray(params.from) ? params.from[0] : params.from;
24 31
   const pageSize = 12;
25 32
   const [entries, setEntries] = useState<EntryRow[]>([]);
26 33
   const [status, setStatus] = useState(t('tasks.loading'));
@@ -29,6 +36,29 @@ export default function TaskHistoryScreen() {
29 36
   const [loadingMore, setLoadingMore] = useState(false);
30 37
 
31 38
   useEffect(() => {
39
+    navigation.setOptions({
40
+      headerLeft: () => (
41
+        <Pressable
42
+          onPress={() => {
43
+            if (fromParam === 'logbook') {
44
+              router.replace('/logbook');
45
+              return;
46
+            }
47
+            if (fromParam === 'home') {
48
+              router.replace('/');
49
+              return;
50
+            }
51
+            router.back();
52
+          }}
53
+          hitSlop={10}
54
+          style={{ paddingHorizontal: 8 }}>
55
+          <IconSymbol name="chevron.left" size={20} color={palette.text} />
56
+        </Pressable>
57
+      ),
58
+    });
59
+  }, [fromParam, navigation, palette.text, router]);
60
+
61
+  useEffect(() => {
32 62
     let isActive = true;
33 63
 
34 64
     async function loadEntries() {

+ 469 - 0
app/(tabs)/tasks/[id].tsx

@@ -0,0 +1,469 @@
1
+import { useEffect, useMemo, useState } from 'react';
2
+import {
3
+  Image,
4
+  KeyboardAvoidingView,
5
+  Platform,
6
+  Pressable,
7
+  ScrollView,
8
+  StyleSheet,
9
+  Switch,
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 { useLocalSearchParams } from 'expo-router';
16
+
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
+
26
+type TaskRow = {
27
+  id: number;
28
+  name: string;
29
+  description: string | null;
30
+};
31
+
32
+type EntryRow = {
33
+  id: number;
34
+  status: string | null;
35
+  notes: string | null;
36
+  meta_json: string | null;
37
+  completed_at: string | null;
38
+};
39
+
40
+type MediaRow = {
41
+  uri: string | null;
42
+};
43
+
44
+export default function TaskDetailScreen() {
45
+  const { t } = useTranslation();
46
+  const { id } = useLocalSearchParams<{ id?: string | string[] }>();
47
+  const taskId = Number(Array.isArray(id) ? id[0] : id);
48
+  const theme = useColorScheme() ?? 'light';
49
+  const palette = Colors[theme];
50
+  const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []);
51
+
52
+  const [task, setTask] = useState<TaskRow | null>(null);
53
+  const [entryId, setEntryId] = useState<number | null>(null);
54
+  const [status, setStatus] = useState('');
55
+  const [isDone, setIsDone] = useState(false);
56
+  const [notes, setNotes] = useState('');
57
+  const [mediaUris, setMediaUris] = useState<string[]>([]);
58
+  const [activeUri, setActiveUri] = useState<string | null>(null);
59
+  const [zoomUri, setZoomUri] = useState<string | null>(null);
60
+  const [saving, setSaving] = useState(false);
61
+  const [showSaved, setShowSaved] = useState(false);
62
+
63
+  useEffect(() => {
64
+    let isActive = true;
65
+
66
+    async function loadTask() {
67
+      try {
68
+        await initCoreTables();
69
+        const db = await dbPromise;
70
+        const taskRows = await db.getAllAsync<TaskRow>(
71
+          'SELECT id, name, description FROM daily_tasks WHERE id = ? LIMIT 1;',
72
+          taskId
73
+        );
74
+        const entryRows = await db.getAllAsync<EntryRow>(
75
+          `SELECT id, status, notes, meta_json, completed_at
76
+           FROM daily_task_entries
77
+           WHERE task_id = ? AND substr(completed_at, 1, 10) = ?
78
+           LIMIT 1;`,
79
+          taskId,
80
+          todayKey
81
+        );
82
+        if (!isActive) return;
83
+        setTask(taskRows[0] ?? null);
84
+        const entry = entryRows[0];
85
+        if (entry) {
86
+          setEntryId(entry.id);
87
+          setNotes(entry.notes ?? '');
88
+        const entryStatus = entry.status ?? '';
89
+        setStatus(entryStatus);
90
+        setIsDone(entryStatus === 'done');
91
+          const mediaRows = await db.getAllAsync<MediaRow>(
92
+            'SELECT uri FROM task_entry_media WHERE entry_id = ? ORDER BY created_at ASC;',
93
+            entry.id
94
+          );
95
+          const media = uniqueMediaUris(mediaRows.map((row) => row.uri).filter(Boolean) as string[]);
96
+          const fallback = parseTaskMeta(entry.meta_json)?.photoUri;
97
+          const merged = uniqueMediaUris([
98
+            ...media,
99
+            ...(normalizeMediaUri(fallback) ? [normalizeMediaUri(fallback) as string] : []),
100
+          ]);
101
+          setMediaUris(merged);
102
+          setActiveUri(merged[0] ?? null);
103
+        }
104
+      } catch (error) {
105
+        if (isActive) setStatus(`Error: ${String(error)}`);
106
+      }
107
+    }
108
+
109
+    loadTask();
110
+    return () => {
111
+      isActive = false;
112
+    };
113
+  }, [taskId, todayKey]);
114
+
115
+  const inputStyle = [
116
+    styles.input,
117
+    {
118
+      borderColor: palette.border,
119
+      backgroundColor: palette.input,
120
+      color: palette.text,
121
+    },
122
+  ];
123
+
124
+  async function handleSave(nextStatus?: string) {
125
+    if (!task) return;
126
+    try {
127
+      setSaving(true);
128
+      const db = await dbPromise;
129
+      const now = new Date().toISOString();
130
+      const statusValue = nextStatus ?? (isDone ? 'done' : 'open');
131
+      let currentEntryId = entryId;
132
+      if (!currentEntryId) {
133
+        const result = await db.runAsync(
134
+          'INSERT INTO daily_task_entries (task_id, field_id, notes, status, completed_at, created_at, meta_json) VALUES (?, NULL, ?, ?, ?, ?, ?);',
135
+          task.id,
136
+          notes.trim() || null,
137
+          statusValue,
138
+          now,
139
+          now,
140
+          serializeTaskMeta({ photoUri: mediaUris[0] })
141
+        );
142
+        currentEntryId = Number(result.lastInsertRowId);
143
+        setEntryId(currentEntryId);
144
+      } else {
145
+        await db.runAsync(
146
+          'UPDATE daily_task_entries SET notes = ?, status = ?, completed_at = ?, meta_json = ? WHERE id = ?;',
147
+          notes.trim() || null,
148
+          statusValue,
149
+          now,
150
+          serializeTaskMeta({ photoUri: mediaUris[0] }),
151
+          currentEntryId
152
+        );
153
+      }
154
+      if (currentEntryId) {
155
+        await db.runAsync('DELETE FROM task_entry_media WHERE entry_id = ?;', currentEntryId);
156
+        for (const uri of uniqueMediaUris(mediaUris)) {
157
+          await db.runAsync(
158
+            'INSERT INTO task_entry_media (entry_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
159
+            currentEntryId,
160
+            uri,
161
+            isVideoUri(uri) ? 'video' : 'image',
162
+            now
163
+          );
164
+        }
165
+      }
166
+      setStatus(statusValue === 'done' ? t('tasks.done') : t('tasks.open'));
167
+      setShowSaved(true);
168
+      setTimeout(() => {
169
+        setShowSaved(false);
170
+        setStatus('');
171
+      }, 1800);
172
+    } catch (error) {
173
+      setStatus(`Error: ${String(error)}`);
174
+    } finally {
175
+      setSaving(false);
176
+    }
177
+  }
178
+
179
+  return (
180
+    <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
181
+      <KeyboardAvoidingView
182
+        behavior={Platform.OS === 'ios' ? 'padding' : undefined}
183
+        style={styles.keyboardAvoid}>
184
+        <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
185
+          <ThemedText type="title">{task?.name ?? t('tasks.title')}</ThemedText>
186
+          {task?.description ? <ThemedText>{task.description}</ThemedText> : null}
187
+
188
+          <View style={styles.statusRow}>
189
+            <ThemedText>{t('tasks.done')}</ThemedText>
190
+            <Switch
191
+              value={isDone}
192
+              onValueChange={(value) => setIsDone(value)}
193
+              trackColor={{ false: palette.border, true: palette.tint }}
194
+              thumbColor={palette.card}
195
+            />
196
+          </View>
197
+
198
+          <ThemedText>{t('tasks.notePlaceholder')}</ThemedText>
199
+          <TextInput
200
+            value={notes}
201
+            onChangeText={setNotes}
202
+            placeholder={t('tasks.notePlaceholder')}
203
+            placeholderTextColor={palette.placeholder}
204
+            style={inputStyle}
205
+            multiline
206
+          />
207
+
208
+          <ThemedText>{t('tasks.addMedia')}</ThemedText>
209
+          {normalizeMediaUri(activeUri) ? (
210
+            isVideoUri(normalizeMediaUri(activeUri) as string) ? (
211
+              <Video
212
+                source={{ uri: normalizeMediaUri(activeUri) as string }}
213
+                style={styles.mediaPreview}
214
+                useNativeControls
215
+                resizeMode={ResizeMode.CONTAIN}
216
+              />
217
+            ) : (
218
+              <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
219
+                <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
220
+              </Pressable>
221
+            )
222
+          ) : (
223
+            <ThemedText style={styles.photoPlaceholder}>{t('tasks.photo')}</ThemedText>
224
+          )}
225
+
226
+          {mediaUris.length > 0 ? (
227
+            <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
228
+              {mediaUris.map((uri) => (
229
+                <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
230
+                  {isVideoUri(uri) ? (
231
+                    <View style={styles.videoThumb}>
232
+                      <ThemedText style={styles.videoThumbText}>▶</ThemedText>
233
+                    </View>
234
+                  ) : (
235
+                    <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
236
+                  )}
237
+                  <Pressable
238
+                    style={styles.mediaRemove}
239
+                    onPress={(event) => {
240
+                      event.stopPropagation();
241
+                      setMediaUris((prev) => {
242
+                        const next = prev.filter((item) => item !== uri);
243
+                        setActiveUri((current) => (current === uri ? next[0] ?? null : current));
244
+                        return next;
245
+                      });
246
+                    }}>
247
+                    <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
248
+                  </Pressable>
249
+                </Pressable>
250
+              ))}
251
+            </ScrollView>
252
+          ) : null}
253
+
254
+          <View style={styles.photoRow}>
255
+            <ThemedButton
256
+              title={t('tasks.pickFromGallery')}
257
+              onPress={() =>
258
+                handlePickMedia((uris) => {
259
+                  if (uris.length === 0) return;
260
+                  setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
261
+                  setActiveUri((prev) => prev ?? uris[0]);
262
+                })
263
+              }
264
+              variant="secondary"
265
+            />
266
+            <ThemedButton
267
+              title={t('tasks.takeMedia')}
268
+              onPress={() =>
269
+                handleTakeMedia((uri) => {
270
+                  if (!uri) return;
271
+                  setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
272
+                  setActiveUri((prev) => prev ?? uri);
273
+                })
274
+              }
275
+              variant="secondary"
276
+            />
277
+          </View>
278
+
279
+          <View style={styles.actions}>
280
+            <View style={styles.updateGroup}>
281
+              {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('tasks.saved')}</ThemedText> : null}
282
+              <ThemedButton
283
+                title={saving ? t('tasks.saving') : t('tasks.save')}
284
+                onPress={() => handleSave()}
285
+                disabled={saving}
286
+              />
287
+            </View>
288
+          </View>
289
+        </ScrollView>
290
+      </KeyboardAvoidingView>
291
+
292
+      <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
293
+    </ThemedView>
294
+  );
295
+}
296
+
297
+async function handlePickMedia(onAdd: (uris: string[]) => void) {
298
+  const result = await ImagePicker.launchImageLibraryAsync({
299
+    mediaTypes: getMediaTypes(),
300
+    quality: 1,
301
+    allowsMultipleSelection: true,
302
+    selectionLimit: 0,
303
+  });
304
+  if (result.canceled) return;
305
+  const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
306
+  if (uris.length === 0) return;
307
+  onAdd(uris);
308
+}
309
+
310
+async function handleTakeMedia(onAdd: (uri: string | null) => void) {
311
+  const permission = await ImagePicker.requestCameraPermissionsAsync();
312
+  if (!permission.granted) return;
313
+  const result = await ImagePicker.launchCameraAsync({
314
+    mediaTypes: getMediaTypes(),
315
+    quality: 1,
316
+  });
317
+  if (result.canceled) return;
318
+  const asset = result.assets[0];
319
+  onAdd(asset.uri);
320
+}
321
+
322
+function getMediaTypes() {
323
+  const mediaType = (ImagePicker as {
324
+    MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
325
+  }).MediaType;
326
+  const imageType = mediaType?.Image ?? mediaType?.Images;
327
+  const videoType = mediaType?.Video ?? mediaType?.Videos;
328
+  if (imageType && videoType) {
329
+    return [imageType, videoType];
330
+  }
331
+  return imageType ?? videoType ?? ['images', 'videos'];
332
+}
333
+
334
+function isVideoUri(uri: string) {
335
+  return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
336
+}
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
+
344
+function uniqueMediaUris(uris: string[]) {
345
+  const seen = new Set<string>();
346
+  const result: string[] = [];
347
+  for (const uri of uris) {
348
+    if (!uri || seen.has(uri)) continue;
349
+    seen.add(uri);
350
+    result.push(uri);
351
+  }
352
+  return result;
353
+}
354
+
355
+function parseTaskMeta(raw: string | null) {
356
+  if (!raw) return {} as { photoUri?: string };
357
+  try {
358
+    return JSON.parse(raw) as { photoUri?: string };
359
+  } catch {
360
+    return {} as { photoUri?: string };
361
+  }
362
+}
363
+
364
+function serializeTaskMeta(meta: { photoUri?: string }) {
365
+  if (!meta.photoUri) return null;
366
+  return JSON.stringify(meta);
367
+}
368
+
369
+const styles = StyleSheet.create({
370
+  container: {
371
+    flex: 1,
372
+  },
373
+  keyboardAvoid: {
374
+    flex: 1,
375
+  },
376
+  content: {
377
+    padding: 16,
378
+    gap: 10,
379
+    paddingBottom: 40,
380
+  },
381
+  input: {
382
+    borderRadius: 10,
383
+    borderWidth: 1,
384
+    paddingHorizontal: 12,
385
+    paddingVertical: 10,
386
+    fontSize: 15,
387
+  },
388
+  mediaPreview: {
389
+    width: '100%',
390
+    height: 220,
391
+    borderRadius: 12,
392
+    backgroundColor: '#1C1C1C',
393
+  },
394
+  photoRow: {
395
+    flexDirection: 'row',
396
+    gap: 8,
397
+  },
398
+  actions: {
399
+    marginTop: 12,
400
+    flexDirection: 'row',
401
+    justifyContent: 'flex-end',
402
+    alignItems: 'center',
403
+    gap: 10,
404
+  },
405
+  photoPlaceholder: {
406
+    opacity: 0.6,
407
+  },
408
+  mediaStrip: {
409
+    marginTop: 6,
410
+  },
411
+  mediaChip: {
412
+    width: 72,
413
+    height: 72,
414
+    borderRadius: 10,
415
+    marginRight: 8,
416
+    overflow: 'hidden',
417
+    backgroundColor: '#E6E1D4',
418
+    alignItems: 'center',
419
+    justifyContent: 'center',
420
+  },
421
+  mediaThumb: {
422
+    width: '100%',
423
+    height: '100%',
424
+  },
425
+  videoThumb: {
426
+    width: '100%',
427
+    height: '100%',
428
+    backgroundColor: '#1C1C1C',
429
+    alignItems: 'center',
430
+    justifyContent: 'center',
431
+  },
432
+  videoThumbText: {
433
+    color: '#FFFFFF',
434
+    fontSize: 18,
435
+    fontWeight: '700',
436
+  },
437
+  mediaRemove: {
438
+    position: 'absolute',
439
+    top: 4,
440
+    right: 4,
441
+    width: 18,
442
+    height: 18,
443
+    borderRadius: 9,
444
+    backgroundColor: 'rgba(0,0,0,0.6)',
445
+    alignItems: 'center',
446
+    justifyContent: 'center',
447
+  },
448
+  mediaRemoveText: {
449
+    color: '#FFFFFF',
450
+    fontSize: 12,
451
+    lineHeight: 14,
452
+    fontWeight: '700',
453
+  },
454
+  updateGroup: {
455
+    flexDirection: 'row',
456
+    alignItems: 'center',
457
+    gap: 8,
458
+  },
459
+  inlineToastText: {
460
+    fontWeight: '700',
461
+    fontSize: 12,
462
+  },
463
+  statusRow: {
464
+    flexDirection: 'row',
465
+    alignItems: 'center',
466
+    justifyContent: 'space-between',
467
+    marginBottom: 12,
468
+  },
469
+});

+ 43 - 0
app/(tabs)/tasks/_layout.tsx

@@ -0,0 +1,43 @@
1
+import { Stack, useRouter } from 'expo-router';
2
+import { Pressable } from 'react-native';
3
+
4
+import { IconSymbol } from '@/components/ui/icon-symbol';
5
+import { Colors } from '@/constants/theme';
6
+import { useColorScheme } from '@/hooks/use-color-scheme';
7
+import { useTranslation } from '@/localization/i18n';
8
+
9
+export default function TasksLayout() {
10
+  const i18n = useTranslation();
11
+  const title = typeof i18n?.t === 'function' ? i18n.t('tasks.title') : 'Tasks';
12
+  const router = useRouter();
13
+  const colorScheme = useColorScheme();
14
+  const palette = Colors[colorScheme ?? 'light'];
15
+
16
+  return (
17
+    <Stack
18
+      screenOptions={{
19
+        headerBackTitleVisible: false,
20
+        headerBackTitle: '',
21
+        headerBackTitleStyle: { display: 'none' },
22
+        headerLeft: ({ canGoBack }) =>
23
+          canGoBack ? (
24
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
25
+              <IconSymbol size={20} name="chevron.left" color={palette.text} />
26
+            </Pressable>
27
+          ) : null,
28
+      }}>
29
+      <Stack.Screen
30
+        name="index"
31
+        options={{
32
+          title,
33
+          headerLeft: () => (
34
+            <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
35
+              <IconSymbol size={20} name="chevron.left" color={palette.text} />
36
+            </Pressable>
37
+          ),
38
+        }}
39
+      />
40
+      <Stack.Screen name="[id]" options={{ title }} />
41
+    </Stack>
42
+  );
43
+}

+ 158 - 166
app/tasks.tsx

@@ -1,17 +1,17 @@
1
-import { useEffect, useMemo, useState } from 'react';
1
+import { useCallback, useEffect, useMemo, useState } from 'react';
2 2
 import {
3 3
   ActivityIndicator,
4 4
   FlatList,
5 5
   KeyboardAvoidingView,
6 6
   Platform,
7
+  Pressable,
7 8
   StyleSheet,
8
-  TextInput,
9 9
   View,
10 10
 } from 'react-native';
11 11
 import { Image } from 'expo-image';
12
-import * as ImageManipulator from 'expo-image-manipulator';
13
-import * as ImagePicker from 'expo-image-picker';
14
-import { Link } from 'expo-router';
12
+import { ResizeMode, Video } from 'expo-av';
13
+import { Link, useLocalSearchParams, useRouter } from 'expo-router';
14
+import { useFocusEffect, useNavigation } from '@react-navigation/native';
15 15
 
16 16
 import { ThemedText } from '@/components/themed-text';
17 17
 import { ThemedView } from '@/components/themed-view';
@@ -20,6 +20,8 @@ import { ThemedButton } from '@/components/themed-button';
20 20
 import { useTranslation } from '@/localization/i18n';
21 21
 import { dbPromise, initCoreTables } from '@/services/db';
22 22
 import { useColorScheme } from '@/hooks/use-color-scheme';
23
+import { ZoomImageModal } from '@/components/zoom-image-modal';
24
+import { IconSymbol } from '@/components/ui/icon-symbol';
23 25
 
24 26
 type TaskRow = {
25 27
   id: number;
@@ -29,12 +31,17 @@ type TaskRow = {
29 31
   status: string | null;
30 32
   notes: string | null;
31 33
   meta_json: string | null;
34
+  media_uri?: string | null;
32 35
 };
33 36
 
34 37
 export default function DailyTasksScreen() {
35 38
   const { t } = useTranslation();
39
+  const router = useRouter();
40
+  const navigation = useNavigation();
41
+  const params = useLocalSearchParams<{ from?: string | string[] }>();
36 42
   const theme = useColorScheme() ?? 'light';
37 43
   const palette = Colors[theme];
44
+  const fromParam = Array.isArray(params.from) ? params.from[0] : params.from;
38 45
   const defaultTasks = [
39 46
     {
40 47
       name: t('tasks.default.fieldCheck'),
@@ -55,12 +62,44 @@ export default function DailyTasksScreen() {
55 62
   const pageSize = 10;
56 63
   const [tasks, setTasks] = useState<TaskRow[]>([]);
57 64
   const [status, setStatus] = useState(t('tasks.loading'));
58
-  const [notesByTaskId, setNotesByTaskId] = useState<Record<number, string>>({});
65
+
66
+  useEffect(() => {
67
+    navigation.setOptions({
68
+      headerLeft: () => (
69
+        <Pressable
70
+          onPress={() => {
71
+            if (fromParam === 'logbook') {
72
+              router.replace('/logbook');
73
+              return;
74
+            }
75
+            if (fromParam === 'home') {
76
+              router.replace('/');
77
+              return;
78
+            }
79
+            router.back();
80
+          }}
81
+          hitSlop={10}
82
+          style={{ paddingHorizontal: 8 }}>
83
+          <IconSymbol name="chevron.left" size={20} color={palette.text} />
84
+        </Pressable>
85
+      ),
86
+    });
87
+  }, [fromParam, navigation, palette.text, router]);
59 88
   const [photoByTaskId, setPhotoByTaskId] = useState<Record<number, string>>({});
89
+  const [zoomUri, setZoomUri] = useState<string | null>(null);
90
+  const [pendingZoomUri, setPendingZoomUri] = useState<string | null>(null);
60 91
   const [page, setPage] = useState(1);
61 92
   const [hasMore, setHasMore] = useState(true);
62 93
   const [loadingMore, setLoadingMore] = useState(false);
63 94
 
95
+  useEffect(() => {
96
+    if (pendingZoomUri) {
97
+      const uri = pendingZoomUri;
98
+      setPendingZoomUri(null);
99
+      setTimeout(() => setZoomUri(uri), 150);
100
+    }
101
+  }, [pendingZoomUri]);
102
+
64 103
   const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []);
65 104
 
66 105
   useEffect(() => {
@@ -83,12 +122,19 @@ export default function DailyTasksScreen() {
83 122
     };
84 123
   }, [t, todayKey]);
85 124
 
125
+  useFocusEffect(
126
+    useCallback(() => {
127
+      fetchTasksPage(1, true);
128
+    }, [])
129
+  );
130
+
86 131
   async function fetchTasksPage(pageToLoad: number, replace: boolean, isActive = true) {
87 132
     try {
88 133
       const db = await dbPromise;
89 134
       const rows = await db.getAllAsync<TaskRow>(
90 135
         `SELECT t.id, t.name, t.description,
91
-                e.id as entry_id, e.status, e.notes, e.meta_json
136
+                e.id as entry_id, e.status, e.notes, e.meta_json,
137
+                (SELECT uri FROM task_entry_media m WHERE m.entry_id = e.id ORDER BY created_at DESC LIMIT 1) as media_uri
92 138
          FROM daily_tasks t
93 139
          LEFT JOIN daily_task_entries e
94 140
            ON e.task_id = t.id
@@ -107,18 +153,12 @@ export default function DailyTasksScreen() {
107 153
       if (replace) {
108 154
         setStatus(rows.length === 0 ? t('tasks.empty') : '');
109 155
       }
110
-      setNotesByTaskId((prev) => {
111
-        const next = replace ? {} : { ...prev };
112
-        rows.forEach((row) => {
113
-          if (row.notes) next[row.id] = row.notes;
114
-        });
115
-        return next;
116
-      });
117 156
       setPhotoByTaskId((prev) => {
118 157
         const next = replace ? {} : { ...prev };
119 158
         rows.forEach((row) => {
120 159
           const meta = parseTaskMeta(row.meta_json);
121
-          if (meta.photoUri) next[row.id] = meta.photoUri;
160
+          const uri = normalizeMediaUri(row.media_uri) ?? normalizeMediaUri(meta.photoUri);
161
+          if (uri) next[row.id] = uri;
122 162
         });
123 163
         return next;
124 164
       });
@@ -136,67 +176,6 @@ export default function DailyTasksScreen() {
136 176
     await fetchTasksPage(nextPage, false);
137 177
   }
138 178
 
139
-  async function handleComplete(task: TaskRow) {
140
-    try {
141
-      const db = await dbPromise;
142
-      const now = new Date().toISOString();
143
-      const notes = (notesByTaskId[task.id] ?? '').trim();
144
-      const metaJson = serializeTaskMeta({
145
-        photoUri: photoByTaskId[task.id],
146
-      });
147
-      if (task.entry_id) {
148
-        await db.runAsync('DELETE FROM daily_task_entries WHERE id = ?;', task.entry_id);
149
-      } else {
150
-        await db.runAsync(
151
-          'INSERT INTO daily_task_entries (task_id, field_id, notes, status, completed_at, created_at, meta_json) VALUES (?, NULL, ?, ?, ?, ?, ?);',
152
-          task.id,
153
-          notes,
154
-          'done',
155
-          now,
156
-          now,
157
-          metaJson
158
-        );
159
-      }
160
-      await fetchTasksPage(1, true);
161
-    } catch (error) {
162
-      setStatus(`Error: ${String(error)}`);
163
-    }
164
-  }
165
-
166
-  async function handlePickPhoto(task: TaskRow) {
167
-    const result = await ImagePicker.launchImageLibraryAsync({
168
-      mediaTypes: getImageMediaTypes(),
169
-      quality: 1,
170
-    });
171
-    if (result.canceled) return;
172
-    const asset = result.assets[0];
173
-    console.log('[Tasks] Picked photo:', asset.uri);
174
-    const normalized = await normalizeImageUri(asset.uri);
175
-    console.log('[Tasks] Normalized photo:', normalized);
176
-    await persistTaskPhoto(task, normalized, setPhotoByTaskId);
177
-  }
178
-
179
-  async function handleTakePhoto(task: TaskRow) {
180
-    try {
181
-      const permission = await ImagePicker.requestCameraPermissionsAsync();
182
-      if (!permission.granted) {
183
-        setStatus(t('tasks.cameraDenied'));
184
-        return;
185
-      }
186
-      const result = await ImagePicker.launchCameraAsync({
187
-        quality: 1,
188
-      });
189
-      if (result.canceled) return;
190
-      const asset = result.assets[0];
191
-      console.log('[Tasks] Captured photo:', asset.uri);
192
-      const normalized = await normalizeImageUri(asset.uri);
193
-      console.log('[Tasks] Normalized photo:', normalized);
194
-      await persistTaskPhoto(task, normalized, setPhotoByTaskId);
195
-    } catch (error) {
196
-      setStatus(t('tasks.cameraError'));
197
-    }
198
-  }
199
-
200 179
   return (
201 180
     <KeyboardAvoidingView
202 181
       behavior={Platform.OS === 'ios' ? 'padding' : undefined}
@@ -209,62 +188,54 @@ export default function DailyTasksScreen() {
209 188
         keyboardDismissMode="on-drag"
210 189
         contentContainerStyle={styles.listContent}
211 190
         renderItem={({ item }) => {
212
-        const isDone = Boolean(item.entry_id);
213
-        return (
214
-          <ThemedView
215
-            style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
216
-            <ThemedView style={styles.cardHeader}>
217
-              <ThemedText type="subtitle" style={[styles.taskTitle, { color: palette.tint }]}>
218
-                {item.name}
219
-              </ThemedText>
220
-              <ThemedText style={styles.badge}>
221
-                {isDone ? t('tasks.done') : t('tasks.pending')}
222
-              </ThemedText>
223
-            </ThemedView>
224
-            {item.description ? (
225
-              <ThemedText style={styles.description}>{item.description}</ThemedText>
226
-            ) : null}
227
-            <ThemedText style={styles.photoLabel}>{t('tasks.photo')}</ThemedText>
228
-            {photoByTaskId[item.id] ? (
229
-              <Image
230
-                source={{ uri: photoByTaskId[item.id] }}
231
-                style={styles.photoPreview}
232
-                contentFit="cover"
233
-              />
234
-            ) : null}
235
-            <View style={styles.photoRow}>
236
-              <ThemedButton
237
-                title={t('tasks.pickPhoto')}
238
-                onPress={() => handlePickPhoto(item)}
239
-                variant="secondary"
240
-              />
241
-              <ThemedButton
242
-                title={t('tasks.takePhoto')}
243
-                onPress={() => handleTakePhoto(item)}
244
-                variant="secondary"
245
-              />
246
-            </View>
247
-            <TextInput
248
-              value={notesByTaskId[item.id] ?? ''}
249
-              onChangeText={(value) =>
250
-                setNotesByTaskId((prev) => ({ ...prev, [item.id]: value }))
251
-              }
252
-              placeholder={t('tasks.notePlaceholder')}
253
-              style={[
254
-                styles.input,
255
-                { borderColor: palette.border, backgroundColor: palette.input, color: palette.text },
256
-              ]}
257
-              multiline
258
-            />
259
-            <View style={styles.buttonRow}>
260
-              <ThemedButton
261
-                title={isDone ? t('tasks.undo') : t('tasks.complete')}
262
-                onPress={() => handleComplete(item)}
263
-              />
264
-            </View>
265
-          </ThemedView>
266
-        );
267
-      }}
191
+          const isDone = (item.status ?? '').toLowerCase() === 'done';
192
+          return (
193
+            <Pressable onPress={() => router.push(`/tasks/${item.id}`)}>
194
+              <ThemedView
195
+                style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
196
+                <View style={styles.cardHeader}>
197
+                  <ThemedText type="subtitle" style={[styles.taskTitle, { color: palette.tint }]}>
198
+                    {item.name}
199
+                  </ThemedText>
200
+                  <View style={[styles.statusBadge, isDone ? styles.statusDone : styles.statusPending]}>
201
+                    <View style={[styles.statusDot, isDone ? styles.statusDotDone : styles.statusDotPending]} />
202
+                    <ThemedText style={[styles.statusText, isDone ? styles.statusTextDone : styles.statusTextPending]}>
203
+                      {isDone ? t('tasks.done') : t('tasks.pending')}
204
+                    </ThemedText>
205
+                  </View>
206
+                </View>
207
+                {item.description ? (
208
+                  <ThemedText style={styles.description}>{item.description}</ThemedText>
209
+                ) : null}
210
+                {item.notes ? (
211
+                  <ThemedText style={styles.description}>{item.notes}</ThemedText>
212
+                ) : null}
213
+                {normalizeMediaUri(photoByTaskId[item.id]) ? (
214
+                  isVideoUri(normalizeMediaUri(photoByTaskId[item.id]) as string) ? (
215
+                    <Video
216
+                      source={{ uri: normalizeMediaUri(photoByTaskId[item.id]) as string }}
217
+                      style={styles.videoPreview}
218
+                      useNativeControls
219
+                      resizeMode={ResizeMode.CONTAIN}
220
+                      isMuted
221
+                    />
222
+                  ) : (
223
+                    <Pressable
224
+                      onPress={() => {
225
+                        setPendingZoomUri(normalizeMediaUri(photoByTaskId[item.id]) as string);
226
+                      }}>
227
+                      <Image
228
+                        source={{ uri: normalizeMediaUri(photoByTaskId[item.id]) as string }}
229
+                        style={styles.photoPreview}
230
+                        contentFit="contain"
231
+                      />
232
+                    </Pressable>
233
+                  )
234
+                ) : null}
235
+              </ThemedView>
236
+            </Pressable>
237
+          );
238
+        }}
268 239
         ItemSeparatorComponent={() => <View style={styles.separator} />}
269 240
         ListHeaderComponent={
270 241
         <View>
@@ -296,6 +267,11 @@ export default function DailyTasksScreen() {
296 267
           </View>
297 268
         }
298 269
       />
270
+      <ZoomImageModal
271
+        uri={zoomUri}
272
+        visible={Boolean(zoomUri)}
273
+        onClose={() => setZoomUri(null)}
274
+      />
299 275
     </KeyboardAvoidingView>
300 276
   );
301 277
 }
@@ -320,22 +296,6 @@ async function ensureDefaultTasks(
320 296
   }
321 297
 }
322 298
 
323
-async function persistTaskPhoto(
324
-  task: TaskRow,
325
-  uri: string,
326
-  setPhotoByTaskId: (photos: Record<number, string>) => void
327
-) {
328
-  setPhotoByTaskId((prev) => ({ ...prev, [task.id]: uri }));
329
-  if (!task.entry_id) return;
330
-  const db = await dbPromise;
331
-  const metaJson = serializeTaskMeta({ photoUri: uri });
332
-  await db.runAsync(
333
-    'UPDATE daily_task_entries SET meta_json = ? WHERE id = ?;',
334
-    metaJson,
335
-    task.entry_id
336
-  );
337
-}
338
-
339 299
 function parseTaskMeta(raw: string | null) {
340 300
   if (!raw) return {} as { photoUri?: string };
341 301
   try {
@@ -350,23 +310,14 @@ function serializeTaskMeta(meta: { photoUri?: string }) {
350 310
   return JSON.stringify(meta);
351 311
 }
352 312
 
353
-function getImageMediaTypes() {
354
-  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
355
-    .MediaType;
356
-  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
313
+function isVideoUri(uri: string) {
314
+  return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
357 315
 }
358 316
 
359
-async function normalizeImageUri(uri: string) {
360
-  try {
361
-    const result = await ImageManipulator.manipulateAsync(
362
-      uri,
363
-      [],
364
-      { compress: 1, format: ImageManipulator.SaveFormat.JPEG }
365
-    );
366
-    return result.uri;
367
-  } catch {
368
-    return uri;
369
-  }
317
+function normalizeMediaUri(uri?: string | null) {
318
+  if (typeof uri !== 'string') return null;
319
+  const trimmed = uri.trim();
320
+  return trimmed ? trimmed : null;
370 321
 }
371 322
 
372 323
 const styles = StyleSheet.create({
@@ -405,6 +356,7 @@ const styles = StyleSheet.create({
405 356
   cardHeader: {
406 357
     flexDirection: 'row',
407 358
     justifyContent: 'space-between',
359
+    alignItems: 'center',
408 360
     gap: 8,
409 361
   },
410 362
   description: {
@@ -414,17 +366,57 @@ const styles = StyleSheet.create({
414 366
     opacity: 0.7,
415 367
   },
416 368
   photoPreview: {
417
-    height: 160,
369
+    height: 200,
370
+    width: '100%',
371
+    borderRadius: 12,
372
+  },
373
+  videoPreview: {
418 374
     width: '100%',
375
+    height: 200,
419 376
     borderRadius: 12,
377
+    backgroundColor: '#1C1C1C',
420 378
   },
421 379
   photoRow: {
422 380
     flexDirection: 'row',
423 381
     gap: 8,
424 382
   },
425
-  badge: {
383
+  statusBadge: {
384
+    flexDirection: 'row',
385
+    alignItems: 'center',
386
+    gap: 6,
387
+    paddingHorizontal: 10,
388
+    paddingVertical: 4,
389
+    borderRadius: 999,
390
+    borderWidth: 1,
391
+  },
392
+  statusDone: {
393
+    backgroundColor: '#E6F4EA',
394
+    borderColor: '#B7E1C1',
395
+  },
396
+  statusPending: {
397
+    backgroundColor: '#F1ECE0',
398
+    borderColor: '#D6CCB9',
399
+  },
400
+  statusDot: {
401
+    width: 6,
402
+    height: 6,
403
+    borderRadius: 3,
404
+  },
405
+  statusDotDone: {
406
+    backgroundColor: '#2F7D4F',
407
+  },
408
+  statusDotPending: {
409
+    backgroundColor: '#8A7A5B',
410
+  },
411
+  statusText: {
426 412
     fontSize: 12,
427
-    opacity: 0.7,
413
+    fontWeight: '600',
414
+  },
415
+  statusTextDone: {
416
+    color: '#2F7D4F',
417
+  },
418
+  statusTextPending: {
419
+    color: '#7B6A4A',
428 420
   },
429 421
   taskTitle: {
430 422
     color: '#2F7D4F',

+ 20 - 74
app/_layout.tsx

@@ -1,8 +1,10 @@
1 1
 import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
2
-import { Stack } from 'expo-router';
2
+import { Stack, useRouter } from 'expo-router';
3 3
 import { StatusBar } from 'expo-status-bar';
4 4
 import 'react-native-reanimated';
5 5
 
6
+import { Pressable } from 'react-native';
7
+import { IconSymbol } from '@/components/ui/icon-symbol';
6 8
 import { useColorScheme } from '@/hooks/use-color-scheme';
7 9
 import { LocalizationProvider, useTranslation } from '@/localization/i18n';
8 10
 
@@ -22,83 +24,27 @@ export default function RootLayout() {
22 24
 
23 25
 function RootNavigator({ colorScheme }: { colorScheme: string | null }) {
24 26
   const { t } = useTranslation();
27
+  const router = useRouter();
25 28
 
26 29
   return (
27 30
     <ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
28
-      <Stack>
31
+      <Stack
32
+        screenOptions={{
33
+          headerBackTitleVisible: false,
34
+          headerBackTitle: '',
35
+          headerBackTitleStyle: { display: 'none' },
36
+          headerLeft: ({ canGoBack, tintColor }) =>
37
+            canGoBack ? (
38
+              <Pressable onPress={() => router.back()} hitSlop={10} style={{ paddingHorizontal: 8 }}>
39
+                <IconSymbol
40
+                  name="chevron.left"
41
+                  size={20}
42
+                  color={tintColor ?? (colorScheme === 'dark' ? '#FFFFFF' : '#1C1C1C')}
43
+                />
44
+              </Pressable>
45
+            ) : null,
46
+        }}>
29 47
         <Stack.Screen name="(tabs)" options={{ headerShown: false, title: '' }} />
30
-        <Stack.Screen
31
-          name="fields"
32
-          options={{
33
-            headerShown: true,
34
-            title: t('fields.title'),
35
-            headerBackTitleVisible: false,
36
-            headerBackTitle: '',
37
-          }}
38
-        />
39
-        <Stack.Screen
40
-          name="tasks"
41
-          options={{
42
-            headerShown: true,
43
-            title: t('tasks.title'),
44
-            headerBackTitleVisible: false,
45
-            headerBackTitle: '',
46
-          }}
47
-        />
48
-        <Stack.Screen
49
-          name="task-history"
50
-          options={{
51
-            headerShown: true,
52
-            title: t('tasks.historyTitle'),
53
-            headerBackTitleVisible: false,
54
-            headerBackTitle: '',
55
-          }}
56
-        />
57
-        <Stack.Screen
58
-          name="observations"
59
-          options={{
60
-            headerShown: true,
61
-            title: t('observations.title'),
62
-            headerBackTitleVisible: false,
63
-            headerBackTitle: '',
64
-          }}
65
-        />
66
-        <Stack.Screen
67
-          name="crops"
68
-          options={{
69
-            headerShown: true,
70
-            title: t('crops.title'),
71
-            headerBackTitleVisible: false,
72
-            headerBackTitle: '',
73
-          }}
74
-        />
75
-        <Stack.Screen
76
-          name="harvests"
77
-          options={{
78
-            headerShown: true,
79
-            title: t('harvests.title'),
80
-            headerBackTitleVisible: false,
81
-            headerBackTitle: '',
82
-          }}
83
-        />
84
-        <Stack.Screen
85
-          name="sales"
86
-          options={{
87
-            headerShown: true,
88
-            title: t('sales.title'),
89
-            headerBackTitleVisible: false,
90
-            headerBackTitle: '',
91
-          }}
92
-        />
93
-        <Stack.Screen
94
-          name="costs"
95
-          options={{
96
-            headerShown: true,
97
-            title: t('costs.title'),
98
-            headerBackTitleVisible: false,
99
-            headerBackTitle: '',
100
-          }}
101
-        />
102 48
         <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
103 49
       </Stack>
104 50
       <StatusBar style="auto" />

+ 0 - 721
app/fields.tsx

@@ -1,721 +0,0 @@
1
-import { useEffect, useState } from 'react';
2
-import {
3
-  ActivityIndicator,
4
-  Alert,
5
-  FlatList,
6
-  Image,
7
-  KeyboardAvoidingView,
8
-  Modal,
9
-  Pressable,
10
-  Platform,
11
-  ScrollView,
12
-  StyleSheet,
13
-  TextInput,
14
-  View,
15
-} from 'react-native';
16
-import * as ImagePicker from 'expo-image-picker';
17
-
18
-import { ThemedText } from '@/components/themed-text';
19
-import { ThemedView } from '@/components/themed-view';
20
-import { IconSymbol } from '@/components/ui/icon-symbol';
21
-import { ThemedButton } from '@/components/themed-button';
22
-import { IconButton } from '@/components/icon-button';
23
-import { Colors, Fonts } from '@/constants/theme';
24
-import { useTranslation } from '@/localization/i18n';
25
-import { dbPromise, initCoreTables } from '@/services/db';
26
-import { useColorScheme } from '@/hooks/use-color-scheme';
27
-
28
-type FieldRow = {
29
-  id: number;
30
-  name: string | null;
31
-  area_ha: number | null;
32
-  notes: string | null;
33
-  photo_uri: string | null;
34
-  created_at: string | null;
35
-  updated_at: string | null;
36
-};
37
-
38
-export default function FieldsScreen() {
39
-  const { t } = useTranslation();
40
-  const theme = useColorScheme() ?? 'light';
41
-  const palette = Colors[theme];
42
-  const pageSize = 12;
43
-  const [fields, setFields] = useState<FieldRow[]>([]);
44
-  const [status, setStatus] = useState(t('fields.loading'));
45
-  const [name, setName] = useState('');
46
-  const [areaHa, setAreaHa] = useState('');
47
-  const [notes, setNotes] = useState('');
48
-  const [photoUri, setPhotoUri] = useState<string | null>(null);
49
-  const [newModalVisible, setNewModalVisible] = useState(false);
50
-  const [editingId, setEditingId] = useState<number | null>(null);
51
-  const [editModalVisible, setEditModalVisible] = useState(false);
52
-  const [editName, setEditName] = useState('');
53
-  const [editAreaHa, setEditAreaHa] = useState('');
54
-  const [editNotes, setEditNotes] = useState('');
55
-  const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
56
-  const [newErrors, setNewErrors] = useState<{ name?: string; area?: string }>({});
57
-  const [editErrors, setEditErrors] = useState<{ name?: string; area?: string }>({});
58
-  const [page, setPage] = useState(1);
59
-  const [hasMore, setHasMore] = useState(true);
60
-  const [loadingMore, setLoadingMore] = useState(false);
61
-
62
-  useEffect(() => {
63
-    let isActive = true;
64
-
65
-    async function loadFields() {
66
-      await fetchFieldsPage(1, true, isActive);
67
-    }
68
-
69
-    loadFields();
70
-    return () => {
71
-      isActive = false;
72
-    };
73
-  }, [t]);
74
-
75
-  async function fetchFieldsPage(pageToLoad: number, replace: boolean, isActive = true) {
76
-    try {
77
-      await initCoreTables();
78
-      const db = await dbPromise;
79
-      const rows = await db.getAllAsync<FieldRow>(
80
-        'SELECT id, name, area_ha, notes, photo_uri, created_at, updated_at FROM fields ORDER BY id DESC LIMIT ? OFFSET ?;',
81
-        pageSize,
82
-        (pageToLoad - 1) * pageSize
83
-      );
84
-      if (!isActive) return;
85
-      setFields((prev) => (replace ? rows : [...prev, ...rows]));
86
-      setHasMore(rows.length === pageSize);
87
-      setPage(pageToLoad);
88
-      if (replace) {
89
-        setStatus(rows.length === 0 ? t('fields.empty') : '');
90
-      }
91
-    } catch (error) {
92
-      if (isActive) setStatus(`Error: ${String(error)}`);
93
-    } finally {
94
-      if (isActive) setLoadingMore(false);
95
-    }
96
-  }
97
-
98
-  async function handleLoadMore() {
99
-    if (loadingMore || !hasMore) return;
100
-    setLoadingMore(true);
101
-    const nextPage = page + 1;
102
-    await fetchFieldsPage(nextPage, false);
103
-  }
104
-
105
-  async function handleSave() {
106
-    const trimmedName = name.trim();
107
-    const area = areaHa.trim() ? Number(areaHa) : null;
108
-    const nextErrors: { name?: string; area?: string } = {};
109
-    if (!trimmedName) {
110
-      nextErrors.name = t('fields.nameRequired');
111
-    }
112
-    if (areaHa.trim() && !Number.isFinite(area)) {
113
-      nextErrors.area = t('fields.areaInvalid');
114
-    }
115
-    setNewErrors(nextErrors);
116
-    if (Object.keys(nextErrors).length > 0) {
117
-      setStatus(nextErrors.name ?? nextErrors.area ?? t('fields.nameRequired'));
118
-      return false;
119
-    }
120
-    try {
121
-      const db = await dbPromise;
122
-      const now = new Date().toISOString();
123
-      if (editingId) {
124
-        await db.runAsync(
125
-          'UPDATE fields SET name = ?, area_ha = ?, notes = ?, photo_uri = ?, updated_at = ? WHERE id = ?;',
126
-          trimmedName,
127
-          area,
128
-          notes.trim() || null,
129
-          photoUri,
130
-          now,
131
-          editingId
132
-        );
133
-        setEditingId(null);
134
-      } else {
135
-        await db.runAsync(
136
-          'INSERT INTO fields (name, area_ha, notes, photo_uri, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?);',
137
-          trimmedName,
138
-          area,
139
-          notes.trim() || null,
140
-          photoUri,
141
-          now,
142
-          now
143
-        );
144
-      }
145
-      setName('');
146
-      setAreaHa('');
147
-      setNotes('');
148
-      setPhotoUri(null);
149
-      setNewErrors({});
150
-      await fetchFieldsPage(1, true);
151
-      setStatus(t('fields.saved'));
152
-      return true;
153
-    } catch (error) {
154
-      setStatus(`Error: ${String(error)}`);
155
-      return false;
156
-    }
157
-  }
158
-
159
-  async function handleDelete(id: number) {
160
-    try {
161
-      const db = await dbPromise;
162
-      await db.runAsync('DELETE FROM fields WHERE id = ?;', id);
163
-      await fetchFieldsPage(1, true);
164
-    } catch (error) {
165
-      setStatus(`Error: ${String(error)}`);
166
-    }
167
-  }
168
-
169
-  function confirmDelete(id: number) {
170
-    Alert.alert(
171
-      t('fields.deleteTitle'),
172
-      t('fields.deleteMessage'),
173
-      [
174
-        { text: t('fields.cancel'), style: 'cancel' },
175
-        { text: t('fields.delete'), style: 'destructive', onPress: () => handleDelete(id) },
176
-      ]
177
-    );
178
-  }
179
-
180
-  function startEdit(field: FieldRow) {
181
-    setEditingId(field.id);
182
-    setEditName(field.name ?? '');
183
-    setEditAreaHa(field.area_ha !== null ? String(field.area_ha) : '');
184
-    setEditNotes(field.notes ?? '');
185
-    setEditPhotoUri(field.photo_uri ?? null);
186
-    setEditErrors({});
187
-    setEditModalVisible(true);
188
-    setStatus('');
189
-  }
190
-
191
-  function cancelEdit() {
192
-    setEditingId(null);
193
-    setEditName('');
194
-    setEditAreaHa('');
195
-    setEditNotes('');
196
-    setEditPhotoUri(null);
197
-    setEditErrors({});
198
-    setEditModalVisible(false);
199
-    setStatus('');
200
-  }
201
-
202
-  async function handleUpdate() {
203
-    if (!editingId) return;
204
-    const trimmedName = editName.trim();
205
-    const area = editAreaHa.trim() ? Number(editAreaHa) : null;
206
-    const nextErrors: { name?: string; area?: string } = {};
207
-    if (!trimmedName) {
208
-      nextErrors.name = t('fields.nameRequired');
209
-    }
210
-    if (editAreaHa.trim() && !Number.isFinite(area)) {
211
-      nextErrors.area = t('fields.areaInvalid');
212
-    }
213
-    setEditErrors(nextErrors);
214
-    if (Object.keys(nextErrors).length > 0) {
215
-      setStatus(nextErrors.name ?? nextErrors.area ?? t('fields.nameRequired'));
216
-      return;
217
-    }
218
-    try {
219
-      const db = await dbPromise;
220
-      const now = new Date().toISOString();
221
-      await db.runAsync(
222
-        'UPDATE fields SET name = ?, area_ha = ?, notes = ?, photo_uri = ?, updated_at = ? WHERE id = ?;',
223
-        trimmedName,
224
-        area,
225
-        editNotes.trim() || null,
226
-        editPhotoUri,
227
-        now,
228
-        editingId
229
-      );
230
-      setEditModalVisible(false);
231
-      setEditingId(null);
232
-      setEditErrors({});
233
-      await fetchFieldsPage(1, true);
234
-      setStatus(t('fields.saved'));
235
-    } catch (error) {
236
-      setStatus(`Error: ${String(error)}`);
237
-    }
238
-  }
239
-
240
-  const inputStyle = [
241
-    styles.input,
242
-    {
243
-      borderColor: palette.border,
244
-      backgroundColor: palette.input,
245
-      color: palette.text,
246
-    },
247
-  ];
248
-
249
-  return (
250
-    <>
251
-    <FlatList
252
-      data={fields}
253
-      keyExtractor={(item) => String(item.id)}
254
-      extraData={[photoUri, editModalVisible, editPhotoUri, name, areaHa, notes, status]}
255
-      onEndReached={handleLoadMore}
256
-      onEndReachedThreshold={0.4}
257
-      renderItem={({ item }) => (
258
-          <Pressable onPress={() => startEdit(item)}>
259
-          <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
260
-            <ThemedText type="subtitle">{item.name || t('fields.unnamed')}</ThemedText>
261
-            {item.area_ha !== null ? (
262
-              <ThemedText style={styles.meta}>
263
-                {t('fields.areaLabel')} {item.area_ha}
264
-              </ThemedText>
265
-            ) : null}
266
-            {item.photo_uri ? (
267
-              <Image
268
-                source={{ uri: item.photo_uri }}
269
-                style={styles.photoPreview}
270
-                resizeMode="cover"
271
-                onError={(error) =>
272
-                  console.log('[Fields] List image error:', item.photo_uri, error.nativeEvent)
273
-                }
274
-                onLoad={() => console.log('[Fields] List image loaded:', item.photo_uri)}
275
-              />
276
-            ) : null}
277
-            {item.notes ? <ThemedText>{item.notes}</ThemedText> : null}
278
-            <View style={styles.buttonRow}>
279
-              <IconButton
280
-                name="trash"
281
-                onPress={() => confirmDelete(item.id)}
282
-                accessibilityLabel={t('fields.delete')}
283
-                variant="danger"
284
-              />
285
-              {item.updated_at ? (
286
-                  <ThemedText style={styles.metaEnd}>{formatDate(item.updated_at)}</ThemedText>
287
-                ) : null}
288
-              </View>
289
-            </ThemedView>
290
-          </Pressable>
291
-        )}
292
-        ItemSeparatorComponent={() => <View style={styles.separator} />}
293
-        ListHeaderComponent={
294
-          <View>
295
-            <ThemedView style={styles.hero}>
296
-              <Image source={require('@/assets/images/fields.jpg')} style={styles.heroImage} />
297
-            </ThemedView>
298
-            <ThemedView style={styles.titleContainer}>
299
-              <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
300
-                {t('fields.title')}
301
-              </ThemedText>
302
-            </ThemedView>
303
-
304
-            {status ? (
305
-              <ThemedView style={styles.section}>
306
-                <ThemedText>{status}</ThemedText>
307
-              </ThemedView>
308
-            ) : null}
309
-
310
-            <ThemedView style={styles.section}>
311
-              <Pressable
312
-                style={styles.newButton}
313
-                onPress={() => {
314
-                  setNewErrors({});
315
-                  setNewModalVisible(true);
316
-                }}>
317
-                <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
318
-                <ThemedText style={styles.newButtonText}>{t('fields.new')}</ThemedText>
319
-              </Pressable>
320
-            </ThemedView>
321
-          </View>
322
-        }
323
-        ListFooterComponent={
324
-          <View style={styles.footer}>
325
-            {loadingMore ? <ActivityIndicator /> : null}
326
-          </View>
327
-        }
328
-      />
329
-      <Modal
330
-        visible={editModalVisible}
331
-        animationType="slide"
332
-        transparent
333
-        onRequestClose={cancelEdit}>
334
-        <Pressable style={styles.sheetOverlay} onPress={cancelEdit}>
335
-          <KeyboardAvoidingView
336
-            behavior={Platform.OS === 'ios' ? 'padding' : undefined}
337
-            keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
338
-            style={styles.keyboardAvoid}>
339
-            <Pressable
340
-              style={[
341
-                styles.sheet,
342
-                { backgroundColor: palette.card, borderColor: palette.border, paddingBottom: 0 },
343
-              ]}
344
-              onPress={() => {}}>
345
-              <ScrollView
346
-                keyboardShouldPersistTaps="handled"
347
-                contentContainerStyle={styles.sheetContent}>
348
-                <ThemedText type="subtitle">{t('fields.edit')}</ThemedText>
349
-            <ThemedText>
350
-              {t('fields.name')}
351
-              <ThemedText style={styles.requiredMark}> *</ThemedText>
352
-            </ThemedText>
353
-            <TextInput
354
-              value={editName}
355
-              onChangeText={(value) => {
356
-                setEditName(value);
357
-                if (editErrors.name) {
358
-                  setEditErrors((prev) => ({ ...prev, name: undefined }));
359
-                }
360
-              }}
361
-              placeholder={t('fields.name')}
362
-              placeholderTextColor={palette.placeholder}
363
-              style={inputStyle}
364
-            />
365
-            {editErrors.name ? <ThemedText style={styles.errorText}>{editErrors.name}</ThemedText> : null}
366
-            <ThemedText>{t('fields.area')}</ThemedText>
367
-            <TextInput
368
-              value={editAreaHa}
369
-              onChangeText={(value) => {
370
-                setEditAreaHa(value);
371
-                if (editErrors.area) {
372
-                  setEditErrors((prev) => ({ ...prev, area: undefined }));
373
-                }
374
-              }}
375
-              placeholder={t('fields.areaPlaceholder')}
376
-              placeholderTextColor={palette.placeholder}
377
-              style={inputStyle}
378
-              keyboardType="decimal-pad"
379
-            />
380
-            {editErrors.area ? <ThemedText style={styles.errorText}>{editErrors.area}</ThemedText> : null}
381
-            <ThemedText>{t('fields.notes')}</ThemedText>
382
-          <TextInput
383
-            value={editNotes}
384
-            onChangeText={setEditNotes}
385
-            placeholder={t('fields.notesPlaceholder')}
386
-            placeholderTextColor={palette.placeholder}
387
-            style={inputStyle}
388
-            multiline
389
-          />
390
-          <ThemedText>{t('fields.photo')}</ThemedText>
391
-          {editPhotoUri ? (
392
-            <Image
393
-              key={editPhotoUri}
394
-              source={{ uri: editPhotoUri }}
395
-              style={styles.photoPreview}
396
-              resizeMode="cover"
397
-              onError={(error) =>
398
-                console.log('[Fields] Edit image error:', editPhotoUri, error.nativeEvent)
399
-              }
400
-              onLoad={() => console.log('[Fields] Edit image loaded:', editPhotoUri)}
401
-            />
402
-          ) : (
403
-            <ThemedText style={styles.photoPlaceholder}>{t('fields.noPhoto')}</ThemedText>
404
-          )}
405
-          <View style={styles.photoRow}>
406
-            <ThemedButton
407
-              title={t('fields.pickPhoto')}
408
-              onPress={() => handlePickPhoto(setEditPhotoUri)}
409
-              variant="secondary"
410
-            />
411
-            <ThemedButton
412
-              title={t('fields.takePhoto')}
413
-              onPress={() =>
414
-                handleTakePhoto(setEditPhotoUri, (code) =>
415
-                  setStatus(
416
-                    code === 'cameraDenied'
417
-                      ? t('tasks.cameraDenied')
418
-                      : t('tasks.cameraError')
419
-                  )
420
-                )
421
-              }
422
-              variant="secondary"
423
-            />
424
-          </View>
425
-          <View style={styles.modalActions}>
426
-            <ThemedButton title={t('fields.cancel')} onPress={cancelEdit} variant="secondary" />
427
-            <ThemedButton title={t('fields.update')} onPress={handleUpdate} />
428
-          </View>
429
-              </ScrollView>
430
-            </Pressable>
431
-          </KeyboardAvoidingView>
432
-        </Pressable>
433
-      </Modal>
434
-      <Modal
435
-        visible={newModalVisible}
436
-        animationType="slide"
437
-        transparent
438
-        onRequestClose={() => setNewModalVisible(false)}>
439
-        <Pressable style={styles.sheetOverlay} onPress={() => setNewModalVisible(false)}>
440
-          <KeyboardAvoidingView
441
-            behavior={Platform.OS === 'ios' ? 'padding' : undefined}
442
-            keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
443
-            style={styles.keyboardAvoid}>
444
-            <Pressable
445
-              style={[
446
-                styles.sheet,
447
-                { backgroundColor: palette.card, borderColor: palette.border, paddingBottom: 0 },
448
-              ]}
449
-              onPress={() => {}}>
450
-              <ScrollView
451
-                keyboardShouldPersistTaps="handled"
452
-                contentContainerStyle={styles.sheetContent}>
453
-                <ThemedText type="subtitle">{t('fields.new')}</ThemedText>
454
-            <ThemedText>
455
-              {t('fields.name')}
456
-              <ThemedText style={styles.requiredMark}> *</ThemedText>
457
-            </ThemedText>
458
-            <TextInput
459
-              value={name}
460
-              onChangeText={(value) => {
461
-                setName(value);
462
-                if (newErrors.name) {
463
-                  setNewErrors((prev) => ({ ...prev, name: undefined }));
464
-                }
465
-              }}
466
-              placeholder={t('fields.name')}
467
-              placeholderTextColor={palette.placeholder}
468
-              style={inputStyle}
469
-            />
470
-            {newErrors.name ? <ThemedText style={styles.errorText}>{newErrors.name}</ThemedText> : null}
471
-            <ThemedText>{t('fields.area')}</ThemedText>
472
-            <TextInput
473
-              value={areaHa}
474
-              onChangeText={(value) => {
475
-                setAreaHa(value);
476
-                if (newErrors.area) {
477
-                  setNewErrors((prev) => ({ ...prev, area: undefined }));
478
-                }
479
-              }}
480
-              placeholder={t('fields.areaPlaceholder')}
481
-              placeholderTextColor={palette.placeholder}
482
-              style={inputStyle}
483
-              keyboardType="decimal-pad"
484
-            />
485
-            {newErrors.area ? <ThemedText style={styles.errorText}>{newErrors.area}</ThemedText> : null}
486
-            <ThemedText>{t('fields.notes')}</ThemedText>
487
-            <TextInput
488
-              value={notes}
489
-              onChangeText={setNotes}
490
-              placeholder={t('fields.notesPlaceholder')}
491
-              placeholderTextColor={palette.placeholder}
492
-              style={inputStyle}
493
-              multiline
494
-            />
495
-            <ThemedText>{t('fields.photo')}</ThemedText>
496
-            {photoUri ? (
497
-              <Image
498
-                key={photoUri}
499
-                source={{ uri: photoUri }}
500
-                style={styles.photoPreview}
501
-                resizeMode="cover"
502
-                onError={(error) =>
503
-                  console.log('[Fields] New image error:', photoUri, error.nativeEvent)
504
-                }
505
-                onLoad={() => console.log('[Fields] New image loaded:', photoUri)}
506
-              />
507
-            ) : (
508
-              <ThemedText style={styles.photoPlaceholder}>{t('fields.noPhoto')}</ThemedText>
509
-            )}
510
-            <View style={styles.photoRow}>
511
-              <ThemedButton
512
-                title={t('fields.pickPhoto')}
513
-                onPress={() => handlePickPhoto(setPhotoUri)}
514
-                variant="secondary"
515
-              />
516
-              <ThemedButton
517
-                title={t('fields.takePhoto')}
518
-                onPress={() =>
519
-                  handleTakePhoto(setPhotoUri, (code) =>
520
-                    setStatus(
521
-                      code === 'cameraDenied'
522
-                        ? t('tasks.cameraDenied')
523
-                        : t('tasks.cameraError')
524
-                    )
525
-                  )
526
-                }
527
-                variant="secondary"
528
-              />
529
-            </View>
530
-            <View style={styles.modalActions}>
531
-              <ThemedButton
532
-                title={t('fields.cancel')}
533
-                onPress={() => setNewModalVisible(false)}
534
-                variant="secondary"
535
-              />
536
-              <ThemedButton
537
-                title={t('fields.save')}
538
-                onPress={async () => {
539
-                  const ok = await handleSave();
540
-                  if (ok) setNewModalVisible(false);
541
-                }}
542
-              />
543
-            </View>
544
-              </ScrollView>
545
-            </Pressable>
546
-          </KeyboardAvoidingView>
547
-        </Pressable>
548
-      </Modal>
549
-    </>
550
-  );
551
-}
552
-
553
-function formatDate(value: string) {
554
-  try {
555
-    return new Date(value).toLocaleString();
556
-  } catch {
557
-    return value;
558
-  }
559
-}
560
-
561
-async function handlePickPhoto(setter: (value: string | null) => void) {
562
-  const result = await ImagePicker.launchImageLibraryAsync({
563
-    mediaTypes: getImageMediaTypes(),
564
-    quality: 1,
565
-  });
566
-  if (result.canceled) return;
567
-  const asset = result.assets[0];
568
-  console.log('[Fields] Picked photo:', asset.uri);
569
-  setter(asset.uri);
570
-}
571
-
572
-async function handleTakePhoto(setter: (value: string | null) => void, onError?: (msg: string) => void) {
573
-  try {
574
-    const permission = await ImagePicker.requestCameraPermissionsAsync();
575
-    if (!permission.granted) {
576
-      onError?.('cameraDenied');
577
-      return;
578
-    }
579
-    const result = await ImagePicker.launchCameraAsync({ quality: 1 });
580
-    if (result.canceled) return;
581
-    const asset = result.assets[0];
582
-    console.log('[Fields] Captured photo:', asset.uri);
583
-    setter(asset.uri);
584
-  } catch {
585
-    onError?.('cameraError');
586
-  }
587
-}
588
-
589
-function getImageMediaTypes() {
590
-  const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown } })
591
-    .MediaType;
592
-  return mediaType?.Image ?? mediaType?.Images ?? ['images'];
593
-}
594
-
595
-
596
-const styles = StyleSheet.create({
597
-  hero: {
598
-    backgroundColor: '#E8E6DA',
599
-    aspectRatio: 16 / 9,
600
-    width: '100%',
601
-  },
602
-  heroImage: {
603
-    width: '100%',
604
-    height: '100%',
605
-  },
606
-  titleContainer: {
607
-    gap: 8,
608
-    paddingHorizontal: 16,
609
-    paddingVertical: 12,
610
-  },
611
-  section: {
612
-    gap: 8,
613
-    marginBottom: 16,
614
-    paddingHorizontal: 16,
615
-  },
616
-  newButton: {
617
-    flexDirection: 'row',
618
-    alignItems: 'center',
619
-    gap: 8,
620
-    borderRadius: 10,
621
-    borderWidth: 1,
622
-    borderColor: '#B9B9B9',
623
-    paddingHorizontal: 12,
624
-    paddingVertical: 10,
625
-    alignSelf: 'flex-start',
626
-  },
627
-  newButtonText: {
628
-    fontSize: 15,
629
-    fontWeight: '600',
630
-  },
631
-  card: {
632
-    borderRadius: 12,
633
-    borderWidth: 1,
634
-    borderColor: '#C6C6C6',
635
-    padding: 12,
636
-    marginHorizontal: 16,
637
-    gap: 6,
638
-    backgroundColor: '#FFFFFF',
639
-  },
640
-  meta: {
641
-    opacity: 0.7,
642
-  },
643
-  input: {
644
-    borderRadius: 10,
645
-    borderWidth: 1,
646
-    borderColor: '#B9B9B9',
647
-    paddingHorizontal: 12,
648
-    paddingVertical: 10,
649
-    fontSize: 15,
650
-  },
651
-  requiredMark: {
652
-    color: '#C0392B',
653
-    fontWeight: '700',
654
-  },
655
-  errorText: {
656
-    color: '#C0392B',
657
-    fontSize: 12,
658
-  },
659
-  photoPreview: {
660
-    height: 160,
661
-    width: '100%',
662
-    borderRadius: 12,
663
-  },
664
-  buttonRow: {
665
-    alignSelf: 'flex-start',
666
-    flexDirection: 'row',
667
-    gap: 8,
668
-    alignItems: 'center',
669
-    width: '100%',
670
-  },
671
-  metaEnd: {
672
-    marginLeft: 'auto',
673
-    opacity: 0.7,
674
-    fontSize: 12,
675
-  },
676
-  cancelRow: {
677
-    marginTop: 8,
678
-  },
679
-  modalActions: {
680
-    flexDirection: 'row',
681
-    justifyContent: 'space-between',
682
-    gap: 12,
683
-  },
684
-  sheetOverlay: {
685
-    flex: 1,
686
-    backgroundColor: 'rgba(0,0,0,0.3)',
687
-    justifyContent: 'flex-end',
688
-  },
689
-  sheet: {
690
-    borderTopLeftRadius: 16,
691
-    borderTopRightRadius: 16,
692
-    borderWidth: 1,
693
-    borderColor: '#C6C6C6',
694
-    padding: 16,
695
-    backgroundColor: '#FFFFFF',
696
-    gap: 10,
697
-    maxHeight: '85%',
698
-  },
699
-  sheetContent: {
700
-    gap: 10,
701
-    paddingBottom: 80,
702
-  },
703
-  keyboardAvoid: {
704
-    width: '100%',
705
-    flex: 1,
706
-    justifyContent: 'flex-end',
707
-  },
708
-  separator: {
709
-    height: 12,
710
-  },
711
-  footer: {
712
-    height: 24,
713
-  },
714
-  photoRow: {
715
-    flexDirection: 'row',
716
-    gap: 8,
717
-  },
718
-  photoPlaceholder: {
719
-    opacity: 0.6,
720
-  },
721
-});

+ 1 - 0
components/ui/icon-symbol.tsx

@@ -19,6 +19,7 @@ const MAPPING = {
19 19
   'paperplane.fill': 'send',
20 20
   'chevron.left.forwardslash.chevron.right': 'code',
21 21
   'chevron.right': 'chevron-right',
22
+  'chevron.left': 'chevron-left',
22 23
   'doc.text.image': 'description',
23 24
   'bolt.circle.fill': 'flash-on',
24 25
   'leaf.fill': 'eco',

+ 20 - 0
components/zoom-image-modal.tsx

@@ -0,0 +1,20 @@
1
+import ImageViewing from 'react-native-image-viewing';
2
+
3
+type ZoomImageModalProps = {
4
+  uri: string | null;
5
+  visible: boolean;
6
+  onClose: () => void;
7
+};
8
+
9
+export function ZoomImageModal({ uri, visible, onClose }: ZoomImageModalProps) {
10
+  const safeUri = typeof uri === 'string' ? uri : null;
11
+  if (!visible || !safeUri) return null;
12
+  return (
13
+    <ImageViewing
14
+      images={[{ uri: safeUri }]}
15
+      imageIndex={0}
16
+      visible
17
+      onRequestClose={onClose}
18
+    />
19
+  );
20
+}

+ 7 - 0
ios/Podfile.lock

@@ -1,4 +1,7 @@
1 1
 PODS:
2
+  - EXAV (16.0.8):
3
+    - ExpoModulesCore
4
+    - ReactCommon/turbomodule/core
2 5
   - EXConstants (18.0.13):
3 6
     - ExpoModulesCore
4 7
   - EXImageLoader (6.0.0):
@@ -2241,6 +2244,7 @@ PODS:
2241 2244
   - Yoga (0.0.0)
2242 2245
 
2243 2246
 DEPENDENCIES:
2247
+  - EXAV (from `../node_modules/expo-av/ios`)
2244 2248
   - EXConstants (from `../node_modules/expo-constants/ios`)
2245 2249
   - EXImageLoader (from `../node_modules/expo-image-loader/ios`)
2246 2250
   - EXJSONUtils (from `../node_modules/expo-json-utils/ios`)
@@ -2356,6 +2360,8 @@ SPEC REPOS:
2356 2360
     - SDWebImageWebPCoder
2357 2361
 
2358 2362
 EXTERNAL SOURCES:
2363
+  EXAV:
2364
+    :path: "../node_modules/expo-av/ios"
2359 2365
   EXConstants:
2360 2366
     :path: "../node_modules/expo-constants/ios"
2361 2367
   EXImageLoader:
@@ -2561,6 +2567,7 @@ EXTERNAL SOURCES:
2561 2567
     :path: "../node_modules/react-native/ReactCommon/yoga"
2562 2568
 
2563 2569
 SPEC CHECKSUMS:
2570
+  EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea
2564 2571
   EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
2565 2572
   EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
2566 2573
   EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd

+ 66 - 68
ios/SmartFarmLite.xcodeproj/project.pbxproj

@@ -7,29 +7,29 @@
7 7
 	objects = {
8 8
 
9 9
 /* Begin PBXBuildFile section */
10
+		08CB32ABB7130BA3D31CF320 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D956EBD829046496208CCC0C /* ExpoModulesProvider.swift */; };
10 11
 		13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
11
-		14D00DC3363C51F4B185FEFF /* libPods-SmartFarmLite.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6D964F19807699C95C647FDD /* libPods-SmartFarmLite.a */; };
12 12
 		3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
13
-		783EF2D6BD2B8C5E56308F72 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7051F97AC1E5EA8F343B7D /* ExpoModulesProvider.swift */; };
14
-		B677F4E51DEF9C34BDC03AC7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = CB18B40FC43E69DE872D9FBE /* PrivacyInfo.xcprivacy */; };
13
+		95DBE0AD1E4D202E4A1A2E00 /* libPods-SmartFarmLite.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6B0745114E7EFBE9389C7D1D /* libPods-SmartFarmLite.a */; };
14
+		A8ADDDDDCEFAC39F5E0862C9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 4F8CFE62E89BB9982CCAA502 /* PrivacyInfo.xcprivacy */; };
15 15
 		BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
16 16
 		F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
17 17
 /* End PBXBuildFile section */
18 18
 
19 19
 /* Begin PBXFileReference section */
20
-		00C0199C0BF8EA9BA3F08885 /* Pods-SmartFarmLite.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmartFarmLite.debug.xcconfig"; path = "Target Support Files/Pods-SmartFarmLite/Pods-SmartFarmLite.debug.xcconfig"; sourceTree = "<group>"; };
21 20
 		13B07F961A680F5B00A75B9A /* SmartFarmLite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SmartFarmLite.app; sourceTree = BUILT_PRODUCTS_DIR; };
22 21
 		13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = SmartFarmLite/Images.xcassets; sourceTree = "<group>"; };
23 22
 		13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = SmartFarmLite/Info.plist; sourceTree = "<group>"; };
24
-		6D964F19807699C95C647FDD /* libPods-SmartFarmLite.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SmartFarmLite.a"; sourceTree = BUILT_PRODUCTS_DIR; };
23
+		4F8CFE62E89BB9982CCAA502 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = SmartFarmLite/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
24
+		6B0745114E7EFBE9389C7D1D /* libPods-SmartFarmLite.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-SmartFarmLite.a"; sourceTree = BUILT_PRODUCTS_DIR; };
25
+		8BA02391F1DC07669F659B6F /* Pods-SmartFarmLite.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmartFarmLite.debug.xcconfig"; path = "Target Support Files/Pods-SmartFarmLite/Pods-SmartFarmLite.debug.xcconfig"; sourceTree = "<group>"; };
25 26
 		AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = SmartFarmLite/SplashScreen.storyboard; sourceTree = "<group>"; };
26 27
 		BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
27
-		CB18B40FC43E69DE872D9FBE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = SmartFarmLite/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
28
-		DAB77429D3906F5AA332651F /* Pods-SmartFarmLite.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmartFarmLite.release.xcconfig"; path = "Target Support Files/Pods-SmartFarmLite/Pods-SmartFarmLite.release.xcconfig"; sourceTree = "<group>"; };
28
+		D956EBD829046496208CCC0C /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-SmartFarmLite/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
29 29
 		ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
30 30
 		F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = SmartFarmLite/AppDelegate.swift; sourceTree = "<group>"; };
31 31
 		F11748442D0722820044C1D9 /* SmartFarmLite-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SmartFarmLite-Bridging-Header.h"; path = "SmartFarmLite/SmartFarmLite-Bridging-Header.h"; sourceTree = "<group>"; };
32
-		FC7051F97AC1E5EA8F343B7D /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-SmartFarmLite/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
32
+		F7602F7CF10B66BEB807FC18 /* Pods-SmartFarmLite.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmartFarmLite.release.xcconfig"; path = "Target Support Files/Pods-SmartFarmLite/Pods-SmartFarmLite.release.xcconfig"; sourceTree = "<group>"; };
33 33
 /* End PBXFileReference section */
34 34
 
35 35
 /* Begin PBXFrameworksBuildPhase section */
@@ -37,7 +37,7 @@
37 37
 			isa = PBXFrameworksBuildPhase;
38 38
 			buildActionMask = 2147483647;
39 39
 			files = (
40
-				14D00DC3363C51F4B185FEFF /* libPods-SmartFarmLite.a in Frameworks */,
40
+				95DBE0AD1E4D202E4A1A2E00 /* libPods-SmartFarmLite.a in Frameworks */,
41 41
 			);
42 42
 			runOnlyForDeploymentPostprocessing = 0;
43 43
 		};
@@ -53,7 +53,15 @@
53 53
 				13B07FB51A68108700A75B9A /* Images.xcassets */,
54 54
 				13B07FB61A68108700A75B9A /* Info.plist */,
55 55
 				AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
56
-				CB18B40FC43E69DE872D9FBE /* PrivacyInfo.xcprivacy */,
56
+				4F8CFE62E89BB9982CCAA502 /* PrivacyInfo.xcprivacy */,
57
+			);
58
+			name = SmartFarmLite;
59
+			sourceTree = "<group>";
60
+		};
61
+		2951FA07DC911E5F1E6660DE /* SmartFarmLite */ = {
62
+			isa = PBXGroup;
63
+			children = (
64
+				D956EBD829046496208CCC0C /* ExpoModulesProvider.swift */,
57 65
 			);
58 66
 			name = SmartFarmLite;
59 67
 			sourceTree = "<group>";
@@ -62,11 +70,19 @@
62 70
 			isa = PBXGroup;
63 71
 			children = (
64 72
 				ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
65
-				6D964F19807699C95C647FDD /* libPods-SmartFarmLite.a */,
73
+				6B0745114E7EFBE9389C7D1D /* libPods-SmartFarmLite.a */,
66 74
 			);
67 75
 			name = Frameworks;
68 76
 			sourceTree = "<group>";
69 77
 		};
78
+		3DEE688EA78289E27DBCBEC9 /* ExpoModulesProviders */ = {
79
+			isa = PBXGroup;
80
+			children = (
81
+				2951FA07DC911E5F1E6660DE /* SmartFarmLite */,
82
+			);
83
+			name = ExpoModulesProviders;
84
+			sourceTree = "<group>";
85
+		};
70 86
 		832341AE1AAA6A7D00B99B32 /* Libraries */ = {
71 87
 			isa = PBXGroup;
72 88
 			children = (
@@ -81,8 +97,8 @@
81 97
 				832341AE1AAA6A7D00B99B32 /* Libraries */,
82 98
 				83CBBA001A601CBA00E9B192 /* Products */,
83 99
 				2D16E6871FA4F8E400B85C8A /* Frameworks */,
84
-				D5C78DCA3961730F3E71F6ED /* Pods */,
85
-				9C80A3202A4FCC265DFEC483 /* ExpoModulesProviders */,
100
+				CA535907B1144C4A9C49576D /* Pods */,
101
+				3DEE688EA78289E27DBCBEC9 /* ExpoModulesProviders */,
86 102
 			);
87 103
 			indentWidth = 2;
88 104
 			sourceTree = "<group>";
@@ -97,22 +113,6 @@
97 113
 			name = Products;
98 114
 			sourceTree = "<group>";
99 115
 		};
100
-		9C80A3202A4FCC265DFEC483 /* ExpoModulesProviders */ = {
101
-			isa = PBXGroup;
102
-			children = (
103
-				A1A5616A5D4AC307C5E105F0 /* SmartFarmLite */,
104
-			);
105
-			name = ExpoModulesProviders;
106
-			sourceTree = "<group>";
107
-		};
108
-		A1A5616A5D4AC307C5E105F0 /* SmartFarmLite */ = {
109
-			isa = PBXGroup;
110
-			children = (
111
-				FC7051F97AC1E5EA8F343B7D /* ExpoModulesProvider.swift */,
112
-			);
113
-			name = SmartFarmLite;
114
-			sourceTree = "<group>";
115
-		};
116 116
 		BB2F792B24A3F905000567C9 /* Supporting */ = {
117 117
 			isa = PBXGroup;
118 118
 			children = (
@@ -122,11 +122,11 @@
122 122
 			path = SmartFarmLite/Supporting;
123 123
 			sourceTree = "<group>";
124 124
 		};
125
-		D5C78DCA3961730F3E71F6ED /* Pods */ = {
125
+		CA535907B1144C4A9C49576D /* Pods */ = {
126 126
 			isa = PBXGroup;
127 127
 			children = (
128
-				00C0199C0BF8EA9BA3F08885 /* Pods-SmartFarmLite.debug.xcconfig */,
129
-				DAB77429D3906F5AA332651F /* Pods-SmartFarmLite.release.xcconfig */,
128
+				8BA02391F1DC07669F659B6F /* Pods-SmartFarmLite.debug.xcconfig */,
129
+				F7602F7CF10B66BEB807FC18 /* Pods-SmartFarmLite.release.xcconfig */,
130 130
 			);
131 131
 			path = Pods;
132 132
 			sourceTree = "<group>";
@@ -139,13 +139,13 @@
139 139
 			buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "SmartFarmLite" */;
140 140
 			buildPhases = (
141 141
 				08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
142
-				FC91648B10D092C6710F024B /* [Expo] Configure project */,
142
+				6B491B23192835B9454ECC2C /* [Expo] Configure project */,
143 143
 				13B07F871A680F5B00A75B9A /* Sources */,
144 144
 				13B07F8C1A680F5B00A75B9A /* Frameworks */,
145 145
 				13B07F8E1A680F5B00A75B9A /* Resources */,
146 146
 				00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
147 147
 				800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
148
-				1CF9E7F085A67401A944005A /* [CP] Embed Pods Frameworks */,
148
+				A7369614AE044EC788C986ED /* [CP] Embed Pods Frameworks */,
149 149
 			);
150 150
 			buildRules = (
151 151
 			);
@@ -195,7 +195,7 @@
195 195
 				BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
196 196
 				13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
197 197
 				3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
198
-				B677F4E51DEF9C34BDC03AC7 /* PrivacyInfo.xcprivacy in Resources */,
198
+				A8ADDDDDCEFAC39F5E0862C9 /* PrivacyInfo.xcprivacy in Resources */,
199 199
 			);
200 200
 			runOnlyForDeploymentPostprocessing = 0;
201 201
 		};
@@ -241,27 +241,29 @@
241 241
 			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
242 242
 			showEnvVarsInLog = 0;
243 243
 		};
244
-		1CF9E7F085A67401A944005A /* [CP] Embed Pods Frameworks */ = {
244
+		6B491B23192835B9454ECC2C /* [Expo] Configure project */ = {
245 245
 			isa = PBXShellScriptBuildPhase;
246
+			alwaysOutOfDate = 1;
246 247
 			buildActionMask = 2147483647;
247 248
 			files = (
248 249
 			);
250
+			inputFileListPaths = (
251
+			);
249 252
 			inputPaths = (
250
-				"${PODS_ROOT}/Target Support Files/Pods-SmartFarmLite/Pods-SmartFarmLite-frameworks.sh",
251
-				"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
252
-				"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
253
-				"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
253
+				"$(SRCROOT)/.xcode.env",
254
+				"$(SRCROOT)/.xcode.env.local",
255
+				"$(SRCROOT)/SmartFarmLite/SmartFarmLite.entitlements",
256
+				"$(SRCROOT)/Pods/Target Support Files/Pods-SmartFarmLite/expo-configure-project.sh",
257
+			);
258
+			name = "[Expo] Configure project";
259
+			outputFileListPaths = (
254 260
 			);
255
-			name = "[CP] Embed Pods Frameworks";
256 261
 			outputPaths = (
257
-				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
258
-				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
259
-				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
262
+				"$(SRCROOT)/Pods/Target Support Files/Pods-SmartFarmLite/ExpoModulesProvider.swift",
260 263
 			);
261 264
 			runOnlyForDeploymentPostprocessing = 0;
262 265
 			shellPath = /bin/sh;
263
-			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SmartFarmLite/Pods-SmartFarmLite-frameworks.sh\"\n";
264
-			showEnvVarsInLog = 0;
266
+			shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-SmartFarmLite/expo-configure-project.sh\"\n";
265 267
 		};
266 268
 		800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
267 269
 			isa = PBXShellScriptBuildPhase;
@@ -297,29 +299,27 @@
297 299
 			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SmartFarmLite/Pods-SmartFarmLite-resources.sh\"\n";
298 300
 			showEnvVarsInLog = 0;
299 301
 		};
300
-		FC91648B10D092C6710F024B /* [Expo] Configure project */ = {
302
+		A7369614AE044EC788C986ED /* [CP] Embed Pods Frameworks */ = {
301 303
 			isa = PBXShellScriptBuildPhase;
302
-			alwaysOutOfDate = 1;
303 304
 			buildActionMask = 2147483647;
304 305
 			files = (
305 306
 			);
306
-			inputFileListPaths = (
307
-			);
308 307
 			inputPaths = (
309
-				"$(SRCROOT)/.xcode.env",
310
-				"$(SRCROOT)/.xcode.env.local",
311
-				"$(SRCROOT)/SmartFarmLite/SmartFarmLite.entitlements",
312
-				"$(SRCROOT)/Pods/Target Support Files/Pods-SmartFarmLite/expo-configure-project.sh",
313
-			);
314
-			name = "[Expo] Configure project";
315
-			outputFileListPaths = (
308
+				"${PODS_ROOT}/Target Support Files/Pods-SmartFarmLite/Pods-SmartFarmLite-frameworks.sh",
309
+				"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
310
+				"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
311
+				"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
316 312
 			);
313
+			name = "[CP] Embed Pods Frameworks";
317 314
 			outputPaths = (
318
-				"$(SRCROOT)/Pods/Target Support Files/Pods-SmartFarmLite/ExpoModulesProvider.swift",
315
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
316
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
317
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
319 318
 			);
320 319
 			runOnlyForDeploymentPostprocessing = 0;
321 320
 			shellPath = /bin/sh;
322
-			shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-SmartFarmLite/expo-configure-project.sh\"\n";
321
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SmartFarmLite/Pods-SmartFarmLite-frameworks.sh\"\n";
322
+			showEnvVarsInLog = 0;
323 323
 		};
324 324
 /* End PBXShellScriptBuildPhase section */
325 325
 
@@ -329,7 +329,7 @@
329 329
 			buildActionMask = 2147483647;
330 330
 			files = (
331 331
 				F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
332
-				783EF2D6BD2B8C5E56308F72 /* ExpoModulesProvider.swift in Sources */,
332
+				08CB32ABB7130BA3D31CF320 /* ExpoModulesProvider.swift in Sources */,
333 333
 			);
334 334
 			runOnlyForDeploymentPostprocessing = 0;
335 335
 		};
@@ -338,12 +338,12 @@
338 338
 /* Begin XCBuildConfiguration section */
339 339
 		13B07F941A680F5B00A75B9A /* Debug */ = {
340 340
 			isa = XCBuildConfiguration;
341
-			baseConfigurationReference = 00C0199C0BF8EA9BA3F08885 /* Pods-SmartFarmLite.debug.xcconfig */;
341
+			baseConfigurationReference = 8BA02391F1DC07669F659B6F /* Pods-SmartFarmLite.debug.xcconfig */;
342 342
 			buildSettings = {
343 343
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
344 344
 				CLANG_ENABLE_MODULES = YES;
345 345
 				CODE_SIGN_ENTITLEMENTS = SmartFarmLite/SmartFarmLite.entitlements;
346
-				CURRENT_PROJECT_VERSION = 2;
346
+				CURRENT_PROJECT_VERSION = 7;
347 347
 				DEVELOPMENT_TEAM = 5QTJEGL2H2;
348 348
 				ENABLE_BITCODE = NO;
349 349
 				GCC_PREPROCESSOR_DEFINITIONS = (
@@ -351,13 +351,12 @@
351 351
 					"FB_SONARKIT_ENABLED=1",
352 352
 				);
353 353
 				INFOPLIST_FILE = SmartFarmLite/Info.plist;
354
-				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
355 354
 				IPHONEOS_DEPLOYMENT_TARGET = 15.1;
356 355
 				LD_RUNPATH_SEARCH_PATHS = (
357 356
 					"$(inherited)",
358 357
 					"@executable_path/Frameworks",
359 358
 				);
360
-				MARKETING_VERSION = 1.1;
359
+				MARKETING_VERSION = 1.4;
361 360
 				OTHER_LDFLAGS = (
362 361
 					"$(inherited)",
363 362
 					"-ObjC",
@@ -380,21 +379,20 @@
380 379
 		};
381 380
 		13B07F951A680F5B00A75B9A /* Release */ = {
382 381
 			isa = XCBuildConfiguration;
383
-			baseConfigurationReference = DAB77429D3906F5AA332651F /* Pods-SmartFarmLite.release.xcconfig */;
382
+			baseConfigurationReference = F7602F7CF10B66BEB807FC18 /* Pods-SmartFarmLite.release.xcconfig */;
384 383
 			buildSettings = {
385 384
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
386 385
 				CLANG_ENABLE_MODULES = YES;
387 386
 				CODE_SIGN_ENTITLEMENTS = SmartFarmLite/SmartFarmLite.entitlements;
388
-				CURRENT_PROJECT_VERSION = 2;
387
+				CURRENT_PROJECT_VERSION = 7;
389 388
 				DEVELOPMENT_TEAM = 5QTJEGL2H2;
390 389
 				INFOPLIST_FILE = SmartFarmLite/Info.plist;
391
-				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
392 390
 				IPHONEOS_DEPLOYMENT_TARGET = 15.1;
393 391
 				LD_RUNPATH_SEARCH_PATHS = (
394 392
 					"$(inherited)",
395 393
 					"@executable_path/Frameworks",
396 394
 				);
397
-				MARKETING_VERSION = 1.1;
395
+				MARKETING_VERSION = 1.4;
398 396
 				OTHER_LDFLAGS = (
399 397
 					"$(inherited)",
400 398
 					"-ObjC",

+ 2 - 9
ios/SmartFarmLite/Info.plist

@@ -19,7 +19,7 @@
19 19
     <key>CFBundlePackageType</key>
20 20
     <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
21 21
     <key>CFBundleShortVersionString</key>
22
-    <string>1.0.0</string>
22
+    <string>1.4.0</string>
23 23
     <key>CFBundleSignature</key>
24 24
     <string>????</string>
25 25
     <key>CFBundleURLTypes</key>
@@ -39,7 +39,7 @@
39 39
       </dict>
40 40
     </array>
41 41
     <key>CFBundleVersion</key>
42
-    <string>1</string>
42
+    <string>7</string>
43 43
     <key>LSMinimumSystemVersion</key>
44 44
     <string>12.0</string>
45 45
     <key>LSRequiresIPhoneOS</key>
@@ -78,13 +78,6 @@
78 78
       <string>UIInterfaceOrientationPortrait</string>
79 79
       <string>UIInterfaceOrientationPortraitUpsideDown</string>
80 80
     </array>
81
-    <key>UISupportedInterfaceOrientations~ipad</key>
82
-    <array>
83
-      <string>UIInterfaceOrientationPortrait</string>
84
-      <string>UIInterfaceOrientationPortraitUpsideDown</string>
85
-      <string>UIInterfaceOrientationLandscapeLeft</string>
86
-      <string>UIInterfaceOrientationLandscapeRight</string>
87
-    </array>
88 81
     <key>UIUserInterfaceStyle</key>
89 82
     <string>Automatic</string>
90 83
     <key>UIViewControllerBasedStatusBarAppearance</key>

+ 530 - 7
localization/i18n.tsx

@@ -7,11 +7,12 @@ import {
7 7
   type ReactNode,
8 8
 } from 'react';
9 9
 
10
-type Language = 'en' | 'th';
10
+type Language = 'en' | 'th' | 'ja';
11 11
 type Params = Record<string, string | number>;
12 12
 
13 13
 const strings: Record<Language, Record<string, string>> = {
14 14
   en: {
15
+    'common.close': 'Close',
15 16
     'tabs.home': 'Home',
16 17
     'tabs.explore': 'Explore',
17 18
     'tabs.onnx': 'Leaf Scan',
@@ -32,7 +33,7 @@ const strings: Record<Language, Record<string, string>> = {
32 33
     'setup.noPhoto': 'No photo selected.',
33 34
     'setup.uploadPhoto': 'Upload photo',
34 35
     'setup.exportTitle': 'Export data',
35
-    'setup.exportHint': 'Download your local data as a CSV file.',
36
+    'setup.exportHint': 'Download your local data as a JSON file.',
36 37
     'setup.exportButton': 'Export data',
37 38
     'setup.exported': 'Exported data.',
38 39
     'setup.exportError': 'Export failed.',
@@ -44,6 +45,7 @@ const strings: Record<Language, Record<string, string>> = {
44 45
     'setup.language': 'Language',
45 46
     'setup.lang.en': 'English',
46 47
     'setup.lang.th': 'Thai',
48
+    'setup.lang.ja': 'Japanese',
47 49
     'setup.currency': 'Default currency',
48 50
     'setup.currencyPlaceholder': 'e.g. THB',
49 51
     'setup.currency.thb': 'THB',
@@ -127,13 +129,20 @@ const strings: Record<Language, Record<string, string>> = {
127 129
     'tasks.title': 'Daily Tasks',
128 130
     'tasks.subtitle': 'Log today’s field routine.',
129 131
     'tasks.loading': 'Loading tasks...',
132
+    'tasks.saving': 'Saving...',
133
+    'tasks.saved': 'Saved.',
130 134
     'tasks.empty': 'No tasks configured yet.',
131 135
     'tasks.pending': 'Pending',
132 136
     'tasks.done': 'Done',
133 137
     'tasks.complete': 'Mark done',
138
+    'tasks.save': 'Save',
139
+    'tasks.open': 'In progress',
134 140
     'tasks.undo': 'Undo',
135 141
     'tasks.notePlaceholder': 'Add notes for today...',
136 142
     'tasks.photo': 'Task photo',
143
+    'tasks.addMedia': 'Add media',
144
+    'tasks.pickFromGallery': 'Pick from gallery',
145
+    'tasks.takeMedia': 'Take photo / video',
137 146
     'tasks.pickPhoto': 'Pick photo',
138 147
     'tasks.takePhoto': 'Take photo',
139 148
     'tasks.cameraDenied': 'Camera permission denied.',
@@ -153,6 +162,7 @@ const strings: Record<Language, Record<string, string>> = {
153 162
     'fields.nameRequired': 'Field name is required.',
154 163
     'fields.areaInvalid': 'Area must be a number.',
155 164
     'fields.saved': 'Saved.',
165
+    'fields.saving': 'Saving...',
156 166
     'fields.name': 'Field name',
157 167
     'fields.area': 'Area (ha)',
158 168
     'fields.areaPlaceholder': 'e.g. 1.5',
@@ -169,6 +179,10 @@ const strings: Record<Language, Record<string, string>> = {
169 179
     'fields.unnamed': 'Unnamed field',
170 180
     'fields.areaLabel': 'Area:',
171 181
     'fields.photo': 'Field photo',
182
+    'fields.addMedia': 'Add media',
183
+    'fields.pickFromGallery': 'Pick from gallery',
184
+    'fields.takeMedia': 'Take photo / video',
185
+    'fields.videoSelected': 'Video selected.',
172 186
     'fields.pickPhoto': 'Pick photo',
173 187
     'fields.takePhoto': 'Take photo',
174 188
     'fields.noPhoto': 'No photo selected.',
@@ -195,6 +209,7 @@ const strings: Record<Language, Record<string, string>> = {
195 209
     'observations.loading': 'Loading observations...',
196 210
     'observations.empty': 'No observations yet.',
197 211
     'observations.saved': 'Saved.',
212
+    'observations.saving': 'Saving...',
198 213
     'observations.field': 'Field',
199 214
     'observations.crop': 'Crop',
200 215
     'observations.type': 'Type',
@@ -218,6 +233,9 @@ const strings: Record<Language, Record<string, string>> = {
218 233
     'observations.edit': 'Edit observation',
219 234
     'observations.update': 'Update observation',
220 235
     'observations.photo': 'Observation photo',
236
+    'observations.addMedia': 'Add media',
237
+    'observations.pickFromGallery': 'Pick from gallery',
238
+    'observations.takeMedia': 'Take photo / video',
221 239
     'observations.pickPhoto': 'Pick photo',
222 240
     'observations.takePhoto': 'Take photo',
223 241
     'observations.noPhoto': 'No photo selected.',
@@ -225,6 +243,7 @@ const strings: Record<Language, Record<string, string>> = {
225 243
     'harvests.loading': 'Loading harvest records...',
226 244
     'harvests.empty': 'No harvest records yet.',
227 245
     'harvests.saved': 'Saved.',
246
+    'harvests.saving': 'Saving...',
228 247
     'harvests.new': 'New harvest',
229 248
     'harvests.field': 'Field',
230 249
     'harvests.crop': 'Crop',
@@ -239,6 +258,9 @@ const strings: Record<Language, Record<string, string>> = {
239 258
     'harvests.notes': 'Notes',
240 259
     'harvests.notesPlaceholder': 'Quality, weather, labor notes...',
241 260
     'harvests.photo': 'Harvest photo',
261
+    'harvests.addMedia': 'Add media',
262
+    'harvests.pickFromGallery': 'Pick from gallery',
263
+    'harvests.takeMedia': 'Take photo / video',
242 264
     'harvests.pickPhoto': 'Pick photo',
243 265
     'harvests.takePhoto': 'Take photo',
244 266
     'harvests.noPhoto': 'No photo selected.',
@@ -263,6 +285,7 @@ const strings: Record<Language, Record<string, string>> = {
263 285
     'sales.loading': 'Loading sales records...',
264 286
     'sales.empty': 'No sales records yet.',
265 287
     'sales.saved': 'Saved.',
288
+    'sales.saving': 'Saving...',
266 289
     'sales.new': 'New sale',
267 290
     'sales.field': 'Field',
268 291
     'sales.crop': 'Crop',
@@ -291,6 +314,8 @@ const strings: Record<Language, Record<string, string>> = {
291 314
     'sales.deleteTitle': 'Delete sales record?',
292 315
     'sales.deleteMessage': 'This action cannot be undone.',
293 316
     'sales.cancel': 'Cancel',
317
+    'sales.pickFromGallery': 'Pick from gallery',
318
+    'sales.takeMedia': 'Take photo / video',
294 319
     'sales.save': 'Save sale',
295 320
     'sales.untitled': 'Sale',
296 321
     'sales.noField': 'No field',
@@ -301,6 +326,7 @@ const strings: Record<Language, Record<string, string>> = {
301 326
     'costs.loading': 'Loading costs...',
302 327
     'costs.empty': 'No cost records yet.',
303 328
     'costs.saved': 'Saved.',
329
+    'costs.saving': 'Saving...',
304 330
     'costs.new': 'New cost',
305 331
     'costs.field': 'Field',
306 332
     'costs.crop': 'Crop (optional)',
@@ -324,6 +350,9 @@ const strings: Record<Language, Record<string, string>> = {
324 350
     'costs.notes': 'Notes',
325 351
     'costs.notesPlaceholder': 'Receipt, usage, purpose...',
326 352
     'costs.photo': 'Receipt photo',
353
+    'costs.addMedia': 'Add media',
354
+    'costs.pickFromGallery': 'Pick from gallery',
355
+    'costs.takeMedia': 'Take photo / video',
327 356
     'costs.pickPhoto': 'Pick photo',
328 357
     'costs.takePhoto': 'Take photo',
329 358
     'costs.noPhoto': 'No photo selected.',
@@ -339,10 +368,6 @@ const strings: Record<Language, Record<string, string>> = {
339 368
     'costs.untitled': 'Cost',
340 369
     'costs.noField': 'No field',
341 370
     'costs.noCrop': 'No crop',
342
-    'units.kg': 'กก.',
343
-    'units.g': 'กรัม',
344
-    'units.ton': 'ตัน',
345
-    'units.pcs': 'ชิ้น',
346 371
     'demo.field.north': 'North Field',
347 372
     'demo.field.northNote': 'Loamy soil · drip irrigation',
348 373
     'demo.field.river': 'River Plot',
@@ -396,12 +421,14 @@ const strings: Record<Language, Record<string, string>> = {
396 421
     'crops.loading': 'Loading crops...',
397 422
     'crops.empty': 'No crops yet.',
398 423
     'crops.saved': 'Saved.',
424
+    'crops.saving': 'Saving...',
399 425
     'crops.field': 'Field',
400 426
     'crops.selectField': 'Select field',
401 427
     'crops.name': 'Crop name',
402 428
     'crops.variety': 'Variety',
403 429
     'crops.planting': 'Planting date',
404 430
     'crops.harvest': 'Expected harvest',
431
+    'crops.selectDate': 'Select date',
405 432
     'crops.namePlaceholder': 'e.g. Tomato',
406 433
     'crops.varietyPlaceholder': 'e.g. Cherry',
407 434
     'crops.plantingPlaceholder': 'YYYY-MM-DD',
@@ -418,6 +445,9 @@ const strings: Record<Language, Record<string, string>> = {
418 445
     'crops.save': 'Save crop',
419 446
     'crops.new': 'New crop',
420 447
     'crops.photo': 'Crop photo',
448
+    'crops.addMedia': 'Add media',
449
+    'crops.pickFromGallery': 'Pick from gallery',
450
+    'crops.takeMedia': 'Take photo / video',
421 451
     'crops.pickPhoto': 'Pick photo',
422 452
     'crops.takePhoto': 'Take photo',
423 453
     'crops.noPhoto': 'No photo selected.',
@@ -428,6 +458,7 @@ const strings: Record<Language, Record<string, string>> = {
428 458
     'crops.cancel': 'Cancel',
429 459
   },
430 460
   th: {
461
+    'common.close': 'ปิด',
431 462
     'tabs.home': 'หน้าแรก',
432 463
     'tabs.explore': 'สำรวจ',
433 464
     'tabs.onnx': 'สแกนใบ',
@@ -448,7 +479,7 @@ const strings: Record<Language, Record<string, string>> = {
448 479
     'setup.noPhoto': 'ยังไม่ได้เลือกรูป',
449 480
     'setup.uploadPhoto': 'อัปโหลดรูป',
450 481
     'setup.exportTitle': 'ส่งออกข้อมูล',
451
-    'setup.exportHint': 'ดาวน์โหลดข้อมูลในเครื่องเป็นไฟล์ CSV',
482
+    'setup.exportHint': 'ดาวน์โหลดข้อมูลในเครื่องเป็นไฟล์ JSON',
452 483
     'setup.exportButton': 'ส่งออกข้อมูล',
453 484
     'setup.exported': 'ส่งออกข้อมูลแล้ว',
454 485
     'setup.exportError': 'ส่งออกข้อมูลไม่สำเร็จ',
@@ -460,6 +491,7 @@ const strings: Record<Language, Record<string, string>> = {
460 491
     'setup.language': 'ภาษา',
461 492
     'setup.lang.en': 'English',
462 493
     'setup.lang.th': 'ไทย',
494
+    'setup.lang.ja': '日本語',
463 495
     'setup.currency': 'สกุลเงินเริ่มต้น',
464 496
     'setup.currencyPlaceholder': 'เช่น THB',
465 497
     'setup.currency.thb': 'บาท (THB)',
@@ -543,13 +575,20 @@ const strings: Record<Language, Record<string, string>> = {
543 575
     'tasks.title': 'งานประจำวัน',
544 576
     'tasks.subtitle': 'บันทึกงานประจำวันของแปลงวันนี้',
545 577
     'tasks.loading': 'กำลังโหลดงาน...',
578
+    'tasks.saving': 'กำลังบันทึก...',
579
+    'tasks.saved': 'บันทึกแล้ว',
546 580
     'tasks.empty': 'ยังไม่มีงานที่ตั้งไว้',
547 581
     'tasks.pending': 'รอดำเนินการ',
548 582
     'tasks.done': 'เสร็จแล้ว',
549 583
     'tasks.complete': 'ทำเสร็จแล้ว',
584
+    'tasks.save': 'บันทึก',
585
+    'tasks.open': 'กำลังดำเนินการ',
550 586
     'tasks.undo': 'ย้อนกลับ',
551 587
     'tasks.notePlaceholder': 'เพิ่มบันทึกสำหรับวันนี้...',
552 588
     'tasks.photo': 'รูปงาน',
589
+    'tasks.addMedia': 'เพิ่มสื่อ',
590
+    'tasks.pickFromGallery': 'เลือกรูปจากแกลเลอรี',
591
+    'tasks.takeMedia': 'ถ่ายรูป / วิดีโอ',
553 592
     'tasks.pickPhoto': 'เลือกรูป',
554 593
     'tasks.takePhoto': 'ถ่ายรูป',
555 594
     'tasks.cameraDenied': 'ไม่ได้รับอนุญาตให้ใช้กล้อง',
@@ -569,6 +608,7 @@ const strings: Record<Language, Record<string, string>> = {
569 608
     'fields.nameRequired': 'ต้องระบุชื่อแปลง',
570 609
     'fields.areaInvalid': 'พื้นที่ต้องเป็นตัวเลข',
571 610
     'fields.saved': 'บันทึกแล้ว',
611
+    'fields.saving': 'กำลังบันทึก...',
572 612
     'fields.name': 'ชื่อแปลง',
573 613
     'fields.area': 'พื้นที่ (ไร่)',
574 614
     'fields.areaPlaceholder': 'เช่น 1.5',
@@ -585,6 +625,10 @@ const strings: Record<Language, Record<string, string>> = {
585 625
     'fields.unnamed': 'แปลงไม่มีชื่อ',
586 626
     'fields.areaLabel': 'พื้นที่:',
587 627
     'fields.photo': 'รูปแปลง',
628
+    'fields.addMedia': 'เพิ่มสื่อ',
629
+    'fields.pickFromGallery': 'เลือกรูปจากแกลเลอรี',
630
+    'fields.takeMedia': 'ถ่ายรูป / วิดีโอ',
631
+    'fields.videoSelected': 'เลือกวิดีโอแล้ว',
588 632
     'fields.pickPhoto': 'เลือกรูป',
589 633
     'fields.takePhoto': 'ถ่ายรูป',
590 634
     'fields.noPhoto': 'ยังไม่ได้เลือกรูป',
@@ -611,6 +655,7 @@ const strings: Record<Language, Record<string, string>> = {
611 655
     'observations.loading': 'กำลังโหลดบันทึก...',
612 656
     'observations.empty': 'ยังไม่มีบันทึก',
613 657
     'observations.saved': 'บันทึกแล้ว',
658
+    'observations.saving': 'กำลังบันทึก...',
614 659
     'observations.field': 'แปลง',
615 660
     'observations.crop': 'พืช',
616 661
     'observations.type': 'ประเภท',
@@ -634,6 +679,9 @@ const strings: Record<Language, Record<string, string>> = {
634 679
     'observations.edit': 'แก้ไขบันทึก',
635 680
     'observations.update': 'อัปเดตบันทึก',
636 681
     'observations.photo': 'รูปประกอบ',
682
+    'observations.addMedia': 'เพิ่มสื่อ',
683
+    'observations.pickFromGallery': 'เลือกรูปจากแกลเลอรี',
684
+    'observations.takeMedia': 'ถ่ายรูป / วิดีโอ',
637 685
     'observations.pickPhoto': 'เลือกรูป',
638 686
     'observations.takePhoto': 'ถ่ายรูป',
639 687
     'observations.noPhoto': 'ยังไม่ได้เลือกรูป',
@@ -641,6 +689,7 @@ const strings: Record<Language, Record<string, string>> = {
641 689
     'harvests.loading': 'กำลังโหลดการเก็บเกี่ยว...',
642 690
     'harvests.empty': 'ยังไม่มีบันทึกการเก็บเกี่ยว',
643 691
     'harvests.saved': 'บันทึกแล้ว',
692
+    'harvests.saving': 'กำลังบันทึก...',
644 693
     'harvests.new': 'เพิ่มการเก็บเกี่ยว',
645 694
     'harvests.field': 'แปลง',
646 695
     'harvests.crop': 'พืช',
@@ -655,6 +704,9 @@ const strings: Record<Language, Record<string, string>> = {
655 704
     'harvests.notes': 'บันทึก',
656 705
     'harvests.notesPlaceholder': 'คุณภาพ สภาพอากาศ แรงงาน...',
657 706
     'harvests.photo': 'รูปการเก็บเกี่ยว',
707
+    'harvests.addMedia': 'เพิ่มสื่อ',
708
+    'harvests.pickFromGallery': 'เลือกรูปจากแกลเลอรี',
709
+    'harvests.takeMedia': 'ถ่ายรูป / วิดีโอ',
658 710
     'harvests.pickPhoto': 'เลือกรูป',
659 711
     'harvests.takePhoto': 'ถ่ายรูป',
660 712
     'harvests.noPhoto': 'ยังไม่ได้เลือกรูป',
@@ -675,6 +727,7 @@ const strings: Record<Language, Record<string, string>> = {
675 727
     'sales.loading': 'กำลังโหลดการขาย...',
676 728
     'sales.empty': 'ยังไม่มีบันทึกการขาย',
677 729
     'sales.saved': 'บันทึกแล้ว',
730
+    'sales.saving': 'กำลังบันทึก...',
678 731
     'sales.new': 'เพิ่มการขาย',
679 732
     'sales.field': 'แปลง',
680 733
     'sales.crop': 'พืช',
@@ -703,6 +756,8 @@ const strings: Record<Language, Record<string, string>> = {
703 756
     'sales.deleteTitle': 'ลบบันทึกการขาย?',
704 757
     'sales.deleteMessage': 'การลบไม่สามารถย้อนกลับได้',
705 758
     'sales.cancel': 'ยกเลิก',
759
+    'sales.pickFromGallery': 'เลือกรูปจากแกลเลอรี',
760
+    'sales.takeMedia': 'ถ่ายรูป / วิดีโอ',
706 761
     'sales.save': 'บันทึกการขาย',
707 762
     'sales.untitled': 'การขาย',
708 763
     'sales.noField': 'ไม่มีแปลง',
@@ -713,6 +768,7 @@ const strings: Record<Language, Record<string, string>> = {
713 768
     'costs.loading': 'กำลังโหลดต้นทุน...',
714 769
     'costs.empty': 'ยังไม่มีบันทึกต้นทุน',
715 770
     'costs.saved': 'บันทึกแล้ว',
771
+    'costs.saving': 'กำลังบันทึก...',
716 772
     'costs.new': 'เพิ่มต้นทุน',
717 773
     'costs.field': 'แปลง',
718 774
     'costs.crop': 'พืช (ไม่บังคับ)',
@@ -736,6 +792,9 @@ const strings: Record<Language, Record<string, string>> = {
736 792
     'costs.notes': 'บันทึก',
737 793
     'costs.notesPlaceholder': 'ใบเสร็จ การใช้งาน วัตถุประสงค์...',
738 794
     'costs.photo': 'รูปใบเสร็จ',
795
+    'costs.addMedia': 'เพิ่มสื่อ',
796
+    'costs.pickFromGallery': 'เลือกรูปจากแกลเลอรี',
797
+    'costs.takeMedia': 'ถ่ายรูป / วิดีโอ',
739 798
     'costs.pickPhoto': 'เลือกรูป',
740 799
     'costs.takePhoto': 'ถ่ายรูป',
741 800
     'costs.noPhoto': 'ยังไม่ได้เลือกรูป',
@@ -804,12 +863,14 @@ const strings: Record<Language, Record<string, string>> = {
804 863
     'crops.loading': 'กำลังโหลดพืช...',
805 864
     'crops.empty': 'ยังไม่มีพืช',
806 865
     'crops.saved': 'บันทึกแล้ว',
866
+    'crops.saving': 'กำลังบันทึก...',
807 867
     'crops.field': 'แปลง',
808 868
     'crops.selectField': 'เลือกแปลง',
809 869
     'crops.name': 'ชื่อพืช',
810 870
     'crops.variety': 'สายพันธุ์',
811 871
     'crops.planting': 'วันที่ปลูก',
812 872
     'crops.harvest': 'คาดว่าจะเก็บเกี่ยว',
873
+    'crops.selectDate': 'เลือกวันที่',
813 874
     'crops.namePlaceholder': 'เช่น มะเขือเทศ',
814 875
     'crops.varietyPlaceholder': 'เช่น เชอรี่',
815 876
     'crops.plantingPlaceholder': 'YYYY-MM-DD',
@@ -826,6 +887,9 @@ const strings: Record<Language, Record<string, string>> = {
826 887
     'crops.save': 'บันทึกพืช',
827 888
     'crops.new': 'เพิ่มพืช',
828 889
     'crops.photo': 'รูปพืช',
890
+    'crops.addMedia': 'เพิ่มสื่อ',
891
+    'crops.pickFromGallery': 'เลือกรูปจากแกลเลอรี',
892
+    'crops.takeMedia': 'ถ่ายรูป / วิดีโอ',
829 893
     'crops.pickPhoto': 'เลือกรูป',
830 894
     'crops.takePhoto': 'ถ่ายรูป',
831 895
     'crops.noPhoto': 'ยังไม่ได้เลือกรูป',
@@ -835,12 +899,471 @@ const strings: Record<Language, Record<string, string>> = {
835 899
     'crops.update': 'อัปเดตพืช',
836 900
     'crops.cancel': 'ยกเลิก',
837 901
   },
902
+  ja: {
903
+    'common.close': '閉じる',
904
+    'tabs.home': 'ホーム',
905
+    'tabs.explore': '探索',
906
+    'tabs.onnx': '葉スキャン',
907
+    'tabs.setup': '設定',
908
+    'tabs.blog': 'ブログ',
909
+    'tabs.tasks': '日次タスク',
910
+    'tabs.fields': '圃場',
911
+    'tabs.logbook': 'ログブック',
912
+    'tabs.taskHistory': '履歴',
913
+    'setup.title': 'ユーザー設定',
914
+    'setup.profile': 'プロフィール',
915
+    'setup.loading': '読み込み中...',
916
+    'setup.loaded': '保存したプロフィールを読み込みました。',
917
+    'setup.none': 'プロフィールがまだありません。入力して保存してください。',
918
+    'setup.saving': '保存中...',
919
+    'setup.saved': 'ローカルに保存しました。',
920
+    'setup.photo': 'プロフィール写真',
921
+    'setup.noPhoto': '写真が選択されていません。',
922
+    'setup.uploadPhoto': '写真をアップロード',
923
+    'setup.exportTitle': 'データを書き出し',
924
+    'setup.exportHint': 'ローカルデータをJSONとしてダウンロードします。',
925
+    'setup.exportButton': 'データを書き出し',
926
+    'setup.exported': 'データを書き出しました。',
927
+    'setup.exportError': '書き出しに失敗しました。',
928
+    'setup.name': '名前',
929
+    'setup.farmName': '農場名',
930
+    'setup.location': '所在地',
931
+    'setup.save': 'ローカルに保存',
932
+    'setup.saveIndicator': '保存しました!',
933
+    'setup.language': '言語',
934
+    'setup.lang.en': 'English',
935
+    'setup.lang.th': 'ไทย',
936
+    'setup.lang.ja': '日本語',
937
+    'setup.currency': '既定の通貨',
938
+    'setup.currencyPlaceholder': '例: JPY',
939
+    'setup.currency.thb': 'THB',
940
+    'setup.currency.usd': 'USD',
941
+    'setup.currency.eur': 'EUR',
942
+    'setup.currency.jpy': 'JPY',
943
+    'setup.demoTitle': 'デモデータ',
944
+    'setup.demoHint': 'サンプルの圃場、作物、観察、履歴を挿入します。',
945
+    'setup.demoButton': 'デモデータを挿入',
946
+    'setup.demoInserting': 'デモデータを挿入中...',
947
+    'setup.demoInserted': 'デモデータを挿入しました。',
948
+    'setup.demoExists': 'デモデータは既にあります。',
949
+    'setup.demoError': 'デモデータの挿入に失敗しました。',
950
+    'setup.demoClearButton': 'デモデータを削除',
951
+    'setup.demoClearing': 'デモデータを削除中...',
952
+    'setup.demoCleared': 'デモデータを削除しました。',
953
+    'setup.demoClearError': 'デモデータの削除に失敗しました。',
954
+    'setup.demoClearedUndo': 'デモデータを削除しました。',
955
+    'setup.demoUndo': '元に戻す',
956
+    'setup.demoUndoDone': 'デモデータを復元しました。',
957
+    'setup.demoUndoError': '元に戻せませんでした。',
958
+    'onnx.title': '葉の分類',
959
+    'onnx.howTitle': 'ONNXモデルの使い方',
960
+    'onnx.howBody':
961
+      'このページはPlantVillage MobileNetV3-Smallモデルを読み込み、224x224のRGBテンソルで推論します。入力は各チャンネル0..1に正規化されます。',
962
+    'onnx.sampleTitle': 'サンプルコード',
963
+    'onnx.testTitle': 'モデルをテスト',
964
+    'onnx.pickImage': '画像を選ぶ',
965
+    'onnx.runModel': 'モデルを実行',
966
+    'onnx.status.pick': '画像を選択してモデルを実行します。',
967
+    'onnx.status.ready': '準備完了。「モデルを実行」をタップしてください。',
968
+    'onnx.status.preprocessing': '画像を前処理中...',
969
+    'onnx.status.running': 'PlantVillageモデルを実行中...',
970
+    'onnx.status.done': '完了。',
971
+    'onnx.status.nativeMissing': 'ONNX runtimeが利用できません。dev buildを使用してください。',
972
+    'onnx.topPredictions': '上位の予測',
973
+    'blog.title': 'ブログ記事',
974
+    'blog.loading': '記事を読み込み中...',
975
+    'blog.error': '記事の読み込みに失敗しました。',
976
+    'blog.empty': '記事がありません。',
977
+    'blog.loadMore': 'さらに読み込む',
978
+    'blog.loadingMore': 'さらに読み込み中...',
979
+    'blog.language': '言語',
980
+    'blog.lang.en': 'English',
981
+    'blog.lang.th': 'ไทย',
982
+    'blog.lang.ja': '日本語',
983
+    'blog.lang.zh': '中文',
984
+    'home.badge': 'Smartfarming Lite',
985
+    'home.title': '圃場・作物・観察を、農場から離れずに記録。',
986
+    'home.subtitle': '日々の作業、写真、作物履歴をオフラインで管理。',
987
+    'home.openLogbook': 'ログブックを開く',
988
+    'home.todayTasks': '今日のタスク',
989
+    'home.quickActions': 'クイック操作',
990
+    'home.fields': '圃場',
991
+    'home.fieldsHint': '面積、メモ、写真を追加。',
992
+    'home.crops': '作物',
993
+    'home.cropsHint': '定植日と収穫予定。',
994
+    'home.observations': '観察',
995
+    'home.observationsHint': 'メモ、深刻度、画像。',
996
+    'home.onnx': '葉スキャン',
997
+    'home.onnxHint': '葉の分類を実行。',
998
+    'home.harvests': '収穫',
999
+    'home.harvestsHint': '収量と収穫記録。',
1000
+    'home.sales': '販売',
1001
+    'home.salesHint': '販売記録と買い手。',
1002
+    'home.costs': 'コスト',
1003
+    'home.costsHint': '支出を記録。',
1004
+    'home.todayTitle': '今日',
1005
+    'home.todayCardTitle': '圃場作業を記録',
1006
+    'home.todayCardBody': '観察メモを残し、写真を添付して次回のための履歴を整理。',
1007
+    'home.openTasks': 'タスクを開く',
1008
+    'home.taskHistory': 'タスク履歴',
1009
+    'home.learnAnalyze': '学ぶ・分析する',
1010
+    'home.blogs': 'ブログ',
1011
+    'home.blogsHint': 'スマート農業の最新記事。',
1012
+    'home.profile': 'プロフィール',
1013
+    'home.profileHint': '農場情報と言語設定。',
1014
+    'home.count.tasks': 'タスク',
1015
+    'home.count.history': '履歴',
1016
+    'tasks.title': '日次タスク',
1017
+    'tasks.subtitle': '今日の圃場作業を記録。',
1018
+    'tasks.loading': 'タスクを読み込み中...',
1019
+    'tasks.saving': '保存中...',
1020
+    'tasks.saved': '保存しました。',
1021
+    'tasks.empty': 'タスクがまだありません。',
1022
+    'tasks.pending': '未完了',
1023
+    'tasks.done': '完了',
1024
+    'tasks.complete': '完了にする',
1025
+    'tasks.save': '保存',
1026
+    'tasks.open': '進行中',
1027
+    'tasks.undo': '元に戻す',
1028
+    'tasks.notePlaceholder': '今日のメモを追加...',
1029
+    'tasks.photo': 'タスク写真',
1030
+    'tasks.addMedia': 'メディアを追加',
1031
+    'tasks.pickFromGallery': 'ギャラリーから選ぶ',
1032
+    'tasks.takeMedia': '写真 / 動画を撮る',
1033
+    'tasks.pickPhoto': '写真を選ぶ',
1034
+    'tasks.takePhoto': '写真を撮る',
1035
+    'tasks.cameraDenied': 'カメラの権限がありません。',
1036
+    'tasks.cameraError': 'カメラが利用できません。',
1037
+    'tasks.historyTitle': 'タスク履歴',
1038
+    'tasks.historyEmpty': 'タスク履歴がまだありません。',
1039
+    'tasks.back': 'タスクに戻る',
1040
+    'tasks.default.fieldCheck': '圃場チェック',
1041
+    'tasks.default.fieldCheckDesc': '圃場の状態とメモを簡単に記録。',
1042
+    'tasks.default.scouting': '病害虫の確認',
1043
+    'tasks.default.scoutingDesc': '葉を確認して深刻度を記録。',
1044
+    'tasks.default.sensors': 'センサー計測',
1045
+    'tasks.default.sensorsDesc': '土壌水分や天候の記録。',
1046
+    'fields.title': '圃場',
1047
+    'fields.loading': '圃場を読み込み中...',
1048
+    'fields.empty': '圃場がまだありません。',
1049
+    'fields.nameRequired': '圃場名は必須です。',
1050
+    'fields.areaInvalid': '面積は数値で入力してください。',
1051
+    'fields.saved': '保存しました。',
1052
+    'fields.saving': '保存中...',
1053
+    'fields.name': '圃場名',
1054
+    'fields.area': '面積 (ha)',
1055
+    'fields.areaPlaceholder': '例: 1.5',
1056
+    'fields.notes': 'メモ',
1057
+    'fields.notesPlaceholder': '土壌、灌漑、目印など...',
1058
+    'fields.save': '圃場を保存',
1059
+    'fields.update': '圃場を更新',
1060
+    'fields.cancel': 'キャンセル',
1061
+    'fields.edit': '編集',
1062
+    'fields.delete': '削除',
1063
+    'fields.deleteTitle': '圃場を削除しますか?',
1064
+    'fields.deleteMessage': 'この操作は取り消せません。',
1065
+    'fields.new': '新しい圃場',
1066
+    'fields.unnamed': '無名の圃場',
1067
+    'fields.areaLabel': '面積:',
1068
+    'fields.photo': '圃場写真',
1069
+    'fields.addMedia': 'メディアを追加',
1070
+    'fields.pickFromGallery': 'ギャラリーから選ぶ',
1071
+    'fields.takeMedia': '写真 / 動画を撮る',
1072
+    'fields.videoSelected': '動画を選択しました。',
1073
+    'fields.pickPhoto': '写真を選ぶ',
1074
+    'fields.takePhoto': '写真を撮る',
1075
+    'fields.noPhoto': '写真が選択されていません。',
1076
+    'fields.updatedAt': '更新:',
1077
+    'logbook.title': 'ログブック',
1078
+    'logbook.subtitle': '主要データを管理。',
1079
+    'logbook.fields': '圃場',
1080
+    'logbook.fieldsHint': '面積、メモ、境界。',
1081
+    'logbook.observations': '観察',
1082
+    'logbook.observationsHint': '観察メモと深刻度。',
1083
+    'logbook.crops': '作物',
1084
+    'logbook.cropsHint': '圃場ごとの作物情報。',
1085
+    'logbook.tasks': '日次タスク',
1086
+    'logbook.tasksHint': 'メモ、写真、状態。',
1087
+    'logbook.history': 'タスク履歴',
1088
+    'logbook.historyHint': '日付別の完了記録。',
1089
+    'logbook.harvests': '収穫',
1090
+    'logbook.harvestsHint': '収量と収穫の詳細。',
1091
+    'logbook.sales': '販売',
1092
+    'logbook.salesHint': '販売記録と買い手。',
1093
+    'logbook.costs': 'コスト',
1094
+    'logbook.costsHint': '支出と領収書。',
1095
+    'observations.title': '観察',
1096
+    'observations.loading': '観察を読み込み中...',
1097
+    'observations.empty': '観察がまだありません。',
1098
+    'observations.saved': '保存しました。',
1099
+    'observations.saving': '保存中...',
1100
+    'observations.field': '圃場',
1101
+    'observations.crop': '作物',
1102
+    'observations.type': '種類',
1103
+    'observations.note': 'メモ',
1104
+    'observations.severity': '深刻度',
1105
+    'observations.severityLabel': '深刻度:',
1106
+    'observations.selectField': '圃場を選択',
1107
+    'observations.selectCrop': '作物を選択',
1108
+    'observations.typePlaceholder': '例: 観察',
1109
+    'observations.notePlaceholder': '観察内容を入力してください。',
1110
+    'observations.severityPlaceholder': '0-10',
1111
+    'observations.fieldRequired': '圃場は必須です。',
1112
+    'observations.severityInvalid': '深刻度は数値で入力してください。',
1113
+    'observations.delete': '削除',
1114
+    'observations.noField': '圃場なし',
1115
+    'observations.noCrop': '作物なし',
1116
+    'observations.untitled': '観察',
1117
+    'observations.save': '観察を保存',
1118
+    'observations.new': '新しい観察',
1119
+    'observations.cancel': 'キャンセル',
1120
+    'observations.edit': '観察を編集',
1121
+    'observations.update': '観察を更新',
1122
+    'observations.photo': '観察写真',
1123
+    'observations.addMedia': 'メディアを追加',
1124
+    'observations.pickFromGallery': 'ギャラリーから選ぶ',
1125
+    'observations.takeMedia': '写真 / 動画を撮る',
1126
+    'observations.pickPhoto': '写真を選ぶ',
1127
+    'observations.takePhoto': '写真を撮る',
1128
+    'observations.noPhoto': '写真が選択されていません。',
1129
+    'harvests.title': '収穫記録',
1130
+    'harvests.loading': '収穫記録を読み込み中...',
1131
+    'harvests.empty': '収穫記録がまだありません。',
1132
+    'harvests.saved': '保存しました。',
1133
+    'harvests.saving': '保存中...',
1134
+    'harvests.new': '新しい収穫',
1135
+    'harvests.field': '圃場',
1136
+    'harvests.crop': '作物',
1137
+    'harvests.selectField': '圃場を選択',
1138
+    'harvests.selectCrop': '作物を選択',
1139
+    'harvests.date': '収穫日',
1140
+    'harvests.datePlaceholder': 'YYYY-MM-DD',
1141
+    'harvests.quantity': '数量',
1142
+    'harvests.quantityPlaceholder': '例: 120',
1143
+    'harvests.unit': '単位',
1144
+    'harvests.unitPlaceholder': 'kg',
1145
+    'harvests.notes': 'メモ',
1146
+    'harvests.notesPlaceholder': '品質、天候、人員メモ...',
1147
+    'harvests.photo': '収穫写真',
1148
+    'harvests.addMedia': 'メディアを追加',
1149
+    'harvests.pickFromGallery': 'ギャラリーから選ぶ',
1150
+    'harvests.takeMedia': '写真 / 動画を撮る',
1151
+    'harvests.pickPhoto': '写真を選ぶ',
1152
+    'harvests.takePhoto': '写真を撮る',
1153
+    'harvests.noPhoto': '写真が選択されていません。',
1154
+    'harvests.fieldRequired': '圃場は必須です。',
1155
+    'harvests.cropRequired': '作物は必須です。',
1156
+    'harvests.quantityInvalid': '数量は数値で入力してください。',
1157
+    'harvests.delete': '削除',
1158
+    'harvests.deleteTitle': '収穫記録を削除しますか?',
1159
+    'harvests.deleteMessage': 'この操作は取り消せません。',
1160
+    'harvests.cancel': 'キャンセル',
1161
+    'harvests.save': '収穫を保存',
1162
+    'harvests.untitled': '収穫',
1163
+    'harvests.noField': '圃場なし',
1164
+    'harvests.noCrop': '作物なし',
1165
+    'harvests.edit': '収穫を編集',
1166
+    'harvests.update': '収穫を更新',
1167
+    'sales.title': '販売記録',
1168
+    'sales.loading': '販売記録を読み込み中...',
1169
+    'sales.empty': '販売記録がまだありません。',
1170
+    'sales.saved': '保存しました。',
1171
+    'sales.saving': '保存中...',
1172
+    'sales.new': '新しい販売',
1173
+    'sales.field': '圃場',
1174
+    'sales.crop': '作物',
1175
+    'sales.harvest': '収穫 (任意)',
1176
+    'sales.selectField': '圃場を選択',
1177
+    'sales.selectCrop': '作物を選択',
1178
+    'sales.selectHarvest': '収穫を選択',
1179
+    'sales.noHarvest': '収穫記録がありません',
1180
+    'sales.date': '販売日',
1181
+    'sales.datePlaceholder': 'YYYY-MM-DD',
1182
+    'sales.quantity': '数量',
1183
+    'sales.quantityPlaceholder': '例: 50',
1184
+    'sales.unit': '単位',
1185
+    'sales.unitPlaceholder': 'kg',
1186
+    'sales.price': '価格',
1187
+    'sales.pricePlaceholder': '例: 35',
1188
+    'sales.priceLabel': '価格:',
1189
+    'sales.buyer': '買い手',
1190
+    'sales.buyerPlaceholder': '例: 地元市場',
1191
+    'sales.notes': 'メモ',
1192
+    'sales.notesPlaceholder': '支払い、輸送、品質...',
1193
+    'sales.fieldRequired': '圃場は必須です。',
1194
+    'sales.cropRequired': '作物は必須です。',
1195
+    'sales.quantityInvalid': '数量は数値で入力してください。',
1196
+    'sales.delete': '削除',
1197
+    'sales.deleteTitle': '販売記録を削除しますか?',
1198
+    'sales.deleteMessage': 'この操作は取り消せません。',
1199
+    'sales.cancel': 'キャンセル',
1200
+    'sales.pickFromGallery': 'ギャラリーから選ぶ',
1201
+    'sales.takeMedia': '写真 / 動画を撮る',
1202
+    'sales.save': '販売を保存',
1203
+    'sales.untitled': '販売',
1204
+    'sales.noField': '圃場なし',
1205
+    'sales.noCrop': '作物なし',
1206
+    'sales.edit': '販売を編集',
1207
+    'sales.update': '販売を更新',
1208
+    'costs.title': '費用記録',
1209
+    'costs.loading': '費用を読み込み中...',
1210
+    'costs.empty': '費用記録がまだありません。',
1211
+    'costs.saved': '保存しました。',
1212
+    'costs.saving': '保存中...',
1213
+    'costs.new': '新しい費用',
1214
+    'costs.field': '圃場',
1215
+    'costs.crop': '作物 (任意)',
1216
+    'costs.selectField': '圃場を選択',
1217
+    'costs.selectCrop': '作物を選択',
1218
+    'costs.category': 'カテゴリ',
1219
+    'costs.category.seed': '種子',
1220
+    'costs.category.fertilizer': '肥料',
1221
+    'costs.category.labor': '人件費',
1222
+    'costs.category.fuel': '燃料',
1223
+    'costs.category.equipment': '設備',
1224
+    'costs.category.transport': '輸送',
1225
+    'costs.category.misc': 'その他',
1226
+    'costs.categoryPlaceholder': '例: 種子',
1227
+    'costs.amount': '金額',
1228
+    'costs.amountPlaceholder': '例: 1200',
1229
+    'costs.vendor': '仕入先',
1230
+    'costs.vendorPlaceholder': '例: 地元の資材店',
1231
+    'costs.date': '日付',
1232
+    'costs.datePlaceholder': 'YYYY-MM-DD',
1233
+    'costs.notes': 'メモ',
1234
+    'costs.notesPlaceholder': '領収書、用途、目的...',
1235
+    'costs.photo': '領収書写真',
1236
+    'costs.addMedia': 'メディアを追加',
1237
+    'costs.pickFromGallery': 'ギャラリーから選ぶ',
1238
+    'costs.takeMedia': '写真 / 動画を撮る',
1239
+    'costs.pickPhoto': '写真を選ぶ',
1240
+    'costs.takePhoto': '写真を撮る',
1241
+    'costs.noPhoto': '写真が選択されていません。',
1242
+    'costs.fieldRequired': '圃場は必須です。',
1243
+    'costs.amountInvalid': '金額は数値で入力してください。',
1244
+    'costs.delete': '削除',
1245
+    'costs.deleteTitle': '費用記録を削除しますか?',
1246
+    'costs.deleteMessage': 'この操作は取り消せません。',
1247
+    'costs.cancel': 'キャンセル',
1248
+    'costs.save': '費用を保存',
1249
+    'costs.edit': '費用を編集',
1250
+    'costs.update': '費用を更新',
1251
+    'costs.untitled': '費用',
1252
+    'costs.noField': '圃場なし',
1253
+    'costs.noCrop': '作物なし',
1254
+    'units.kg': 'kg',
1255
+    'units.g': 'g',
1256
+    'units.ton': 't',
1257
+    'units.pcs': '個',
1258
+    'demo.field.north': '北圃場',
1259
+    'demo.field.northNote': '壌土 · 点滴灌漑',
1260
+    'demo.field.river': '川沿い区画',
1261
+    'demo.field.riverNote': '水路沿いの低地',
1262
+    'demo.field.greenhouse': '温室',
1263
+    'demo.field.greenhouseNote': '遮光ベッド、毎日灌漑',
1264
+    'demo.field.orchard': '果樹園区画',
1265
+    'demo.field.orchardNote': '畝立て、東側に防風林',
1266
+    'demo.field.terrace': '段々畑区画',
1267
+    'demo.field.terraceNote': '段々斜面に点滴ライン',
1268
+    'demo.crop.tomato': 'トマト',
1269
+    'demo.crop.tomatoVariety': 'チェリー',
1270
+    'demo.crop.rice': '米',
1271
+    'demo.crop.riceVariety': 'ジャスミン',
1272
+    'demo.crop.lettuce': 'レタス',
1273
+    'demo.crop.lettuceVariety': 'バターヘッド',
1274
+    'demo.crop.chili': '唐辛子',
1275
+    'demo.crop.chiliVariety': 'バードアイ',
1276
+    'demo.crop.cabbage': 'キャベツ',
1277
+    'demo.crop.cabbageVariety': '青キャベツ',
1278
+    'demo.observation.scoutingNote': '畝の端で初期の斑点を確認。',
1279
+    'demo.observation.diseaseNote': '雨の後に病斑が見られました。',
1280
+    'demo.observation.irrigationNote': '灌漑時間を夕方に調整。',
1281
+    'demo.observation.pestNote': '新芽にアブラムシを確認。',
1282
+    'demo.observation.nutrientNote': '下葉が淡く、葉面散布を実施。',
1283
+    'demo.task.note': '予定通り完了。',
1284
+    'demo.task.note2': '標準チェックリストを実施。',
1285
+    'demo.task.note3': '機材確認と記録を完了。',
1286
+    'demo.harvest.note1': '朝の収穫、品質良好。',
1287
+    'demo.harvest.note2': '小雨後に収穫。',
1288
+    'demo.harvest.note3': '市場向けにサイズ別選別。',
1289
+    'demo.sale.buyer1': '地元市場',
1290
+    'demo.sale.buyer2': '卸売業者',
1291
+    'demo.sale.buyer3': 'レストラン取引先',
1292
+    'demo.sale.note1': '当日配送。',
1293
+    'demo.sale.note2': '全額支払い済み。',
1294
+    'demo.sale.note3': '週次供給を依頼。',
1295
+    'demo.cost.vendor1': '農業資材店',
1296
+    'demo.cost.vendor2': '肥料店',
1297
+    'demo.cost.vendor3': '作業班',
1298
+    'demo.cost.note1': 'トマト種子とトレー。',
1299
+    'demo.cost.note2': '水田用の元肥。',
1300
+    'demo.cost.note3': '収穫補助(半日)。',
1301
+    'observations.type.scouting': '観察',
1302
+    'observations.type.pest': '害虫',
1303
+    'observations.type.disease': '病害',
1304
+    'observations.type.irrigation': '灌漑',
1305
+    'observations.type.weeds': '雑草',
1306
+    'observations.type.nutrients': '栄養',
1307
+    'crops.title': '作物',
1308
+    'crops.loading': '作物を読み込み中...',
1309
+    'crops.empty': '作物がまだありません。',
1310
+    'crops.saved': '保存しました。',
1311
+    'crops.saving': '保存中...',
1312
+    'crops.field': '圃場',
1313
+    'crops.selectField': '圃場を選択',
1314
+    'crops.name': '作物名',
1315
+    'crops.variety': '品種',
1316
+    'crops.planting': '定植日',
1317
+    'crops.harvest': '収穫予定',
1318
+    'crops.selectDate': '日付を選択',
1319
+    'crops.namePlaceholder': '例: トマト',
1320
+    'crops.varietyPlaceholder': '例: チェリー',
1321
+    'crops.plantingPlaceholder': 'YYYY-MM-DD',
1322
+    'crops.harvestPlaceholder': 'YYYY-MM-DD',
1323
+    'crops.fieldRequired': '圃場は必須です。',
1324
+    'crops.nameRequired': '作物名は必須です。',
1325
+    'crops.delete': '削除',
1326
+    'crops.deleteTitle': '作物を削除しますか?',
1327
+    'crops.deleteMessage': 'この操作は取り消せません。',
1328
+    'crops.noField': '圃場なし',
1329
+    'crops.untitled': '作物',
1330
+    'crops.plantingLabel': '定植:',
1331
+    'crops.harvestLabel': '収穫:',
1332
+    'crops.save': '作物を保存',
1333
+    'crops.new': '新しい作物',
1334
+    'crops.photo': '作物写真',
1335
+    'crops.addMedia': 'メディアを追加',
1336
+    'crops.pickFromGallery': 'ギャラリーから選ぶ',
1337
+    'crops.takeMedia': '写真 / 動画を撮る',
1338
+    'crops.pickPhoto': '写真を選ぶ',
1339
+    'crops.takePhoto': '写真を撮る',
1340
+    'crops.noPhoto': '写真が選択されていません。',
1341
+    'crops.today': '今日',
1342
+    'crops.done': '完了',
1343
+    'crops.edit': '作物を編集',
1344
+    'crops.update': '作物を更新',
1345
+    'crops.cancel': 'キャンセル',
1346
+  },
838 1347
 };
839 1348
 
1349
+export function createTranslator(language: Language) {
1350
+  return (key: string, params?: Params) => {
1351
+    const template = strings[language][key] ?? strings.en[key] ?? key;
1352
+    if (!params) return template;
1353
+    return Object.keys(params).reduce((result, paramKey) => {
1354
+      return result.replace(
1355
+        new RegExp(`{{${paramKey}}}`, 'g'),
1356
+        String(params[paramKey])
1357
+      );
1358
+    }, template);
1359
+  };
1360
+}
1361
+
840 1362
 function getDeviceLanguage(): Language {
841 1363
   try {
842 1364
     const locale = Intl.DateTimeFormat().resolvedOptions().locale;
843 1365
     if (locale.toLowerCase().startsWith('th')) return 'th';
1366
+    if (locale.toLowerCase().startsWith('ja')) return 'ja';
844 1367
   } catch {
845 1368
     // fall through
846 1369
   }

+ 29 - 0
package-lock.json

@@ -14,6 +14,7 @@
14 14
         "@react-navigation/elements": "^2.6.3",
15 15
         "@react-navigation/native": "^7.1.8",
16 16
         "expo": "~54.0.31",
17
+        "expo-av": "~16.0.8",
17 18
         "expo-constants": "~18.0.13",
18 19
         "expo-dev-client": "~6.0.20",
19 20
         "expo-file-system": "~19.0.21",
@@ -37,6 +38,7 @@
37 38
         "react-dom": "19.1.0",
38 39
         "react-native": "0.81.5",
39 40
         "react-native-gesture-handler": "~2.28.0",
41
+        "react-native-image-viewing": "^0.2.2",
40 42
         "react-native-reanimated": "~4.1.1",
41 43
         "react-native-safe-area-context": "~5.6.0",
42 44
         "react-native-screens": "~4.16.0",
@@ -6112,6 +6114,23 @@
6112 6114
         "react-native": "*"
6113 6115
       }
6114 6116
     },
6117
+    "node_modules/expo-av": {
6118
+      "version": "16.0.8",
6119
+      "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz",
6120
+      "integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==",
6121
+      "license": "MIT",
6122
+      "peerDependencies": {
6123
+        "expo": "*",
6124
+        "react": "*",
6125
+        "react-native": "*",
6126
+        "react-native-web": "*"
6127
+      },
6128
+      "peerDependenciesMeta": {
6129
+        "react-native-web": {
6130
+          "optional": true
6131
+        }
6132
+      }
6133
+    },
6115 6134
     "node_modules/expo-constants": {
6116 6135
       "version": "18.0.13",
6117 6136
       "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
@@ -10485,6 +10504,16 @@
10485 10504
         "react-native": "*"
10486 10505
       }
10487 10506
     },
10507
+    "node_modules/react-native-image-viewing": {
10508
+      "version": "0.2.2",
10509
+      "resolved": "https://registry.npmjs.org/react-native-image-viewing/-/react-native-image-viewing-0.2.2.tgz",
10510
+      "integrity": "sha512-osWieG+p/d2NPbAyonOMubttajtYEYiRGQaJA54slFxZ69j1V4/dCmcrVQry47ktVKy8/qpFwCpW1eT6MH5T2Q==",
10511
+      "license": "MIT",
10512
+      "peerDependencies": {
10513
+        "react": ">=16.11.0",
10514
+        "react-native": ">=0.61.3"
10515
+      }
10516
+    },
10488 10517
     "node_modules/react-native-is-edge-to-edge": {
10489 10518
       "version": "1.2.1",
10490 10519
       "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",

+ 2 - 0
package.json

@@ -17,6 +17,7 @@
17 17
     "@react-navigation/elements": "^2.6.3",
18 18
     "@react-navigation/native": "^7.1.8",
19 19
     "expo": "~54.0.31",
20
+    "expo-av": "~16.0.8",
20 21
     "expo-constants": "~18.0.13",
21 22
     "expo-dev-client": "~6.0.20",
22 23
     "expo-file-system": "~19.0.21",
@@ -40,6 +41,7 @@
40 41
     "react-dom": "19.1.0",
41 42
     "react-native": "0.81.5",
42 43
     "react-native-gesture-handler": "~2.28.0",
44
+    "react-native-image-viewing": "^0.2.2",
43 45
     "react-native-reanimated": "~4.1.1",
44 46
     "react-native-safe-area-context": "~5.6.0",
45 47
     "react-native-screens": "~4.16.0",

+ 52 - 0
services/db.ts

@@ -98,6 +98,12 @@ export async function initCoreTables() {
98 98
       'CREATE TABLE IF NOT EXISTS harvests (id INTEGER PRIMARY KEY NOT NULL, field_id INTEGER, crop_id INTEGER, harvested_at TEXT, quantity REAL, unit TEXT, notes TEXT, photo_uri TEXT, created_at TEXT, FOREIGN KEY(field_id) REFERENCES fields(id), FOREIGN KEY(crop_id) REFERENCES crops(id));',
99 99
       'CREATE TABLE IF NOT EXISTS sales (id INTEGER PRIMARY KEY NOT NULL, harvest_id INTEGER, field_id INTEGER, crop_id INTEGER, sold_at TEXT, quantity REAL, unit TEXT, price REAL, buyer TEXT, notes TEXT, created_at TEXT, FOREIGN KEY(harvest_id) REFERENCES harvests(id), FOREIGN KEY(field_id) REFERENCES fields(id), FOREIGN KEY(crop_id) REFERENCES crops(id));',
100 100
       'CREATE TABLE IF NOT EXISTS costs (id INTEGER PRIMARY KEY NOT NULL, field_id INTEGER, crop_id INTEGER, category TEXT, amount REAL, currency TEXT, vendor TEXT, notes TEXT, spent_at TEXT, photo_uri TEXT, created_at TEXT, FOREIGN KEY(field_id) REFERENCES fields(id), FOREIGN KEY(crop_id) REFERENCES crops(id));',
101
+      'CREATE TABLE IF NOT EXISTS field_media (id INTEGER PRIMARY KEY NOT NULL, field_id INTEGER, uri TEXT, media_type TEXT, created_at TEXT, FOREIGN KEY(field_id) REFERENCES fields(id));',
102
+      'CREATE TABLE IF NOT EXISTS crop_media (id INTEGER PRIMARY KEY NOT NULL, crop_id INTEGER, uri TEXT, media_type TEXT, created_at TEXT, FOREIGN KEY(crop_id) REFERENCES crops(id));',
103
+      'CREATE TABLE IF NOT EXISTS harvest_media (id INTEGER PRIMARY KEY NOT NULL, harvest_id INTEGER, uri TEXT, media_type TEXT, created_at TEXT, FOREIGN KEY(harvest_id) REFERENCES harvests(id));',
104
+      'CREATE TABLE IF NOT EXISTS sale_media (id INTEGER PRIMARY KEY NOT NULL, sale_id INTEGER, uri TEXT, media_type TEXT, created_at TEXT, FOREIGN KEY(sale_id) REFERENCES sales(id));',
105
+      'CREATE TABLE IF NOT EXISTS cost_media (id INTEGER PRIMARY KEY NOT NULL, cost_id INTEGER, uri TEXT, media_type TEXT, created_at TEXT, FOREIGN KEY(cost_id) REFERENCES costs(id));',
106
+      'CREATE TABLE IF NOT EXISTS task_entry_media (id INTEGER PRIMARY KEY NOT NULL, entry_id INTEGER, uri TEXT, media_type TEXT, created_at TEXT, FOREIGN KEY(entry_id) REFERENCES daily_task_entries(id));',
101 107
       'CREATE INDEX IF NOT EXISTS idx_observations_field_id ON observations(field_id);',
102 108
       'CREATE INDEX IF NOT EXISTS idx_observations_crop_id ON observations(crop_id);',
103 109
       'CREATE INDEX IF NOT EXISTS idx_images_observation_id ON images(observation_id);',
@@ -112,6 +118,12 @@ export async function initCoreTables() {
112 118
       'CREATE INDEX IF NOT EXISTS idx_sales_crop_id ON sales(crop_id);',
113 119
       'CREATE INDEX IF NOT EXISTS idx_costs_field_id ON costs(field_id);',
114 120
       'CREATE INDEX IF NOT EXISTS idx_costs_crop_id ON costs(crop_id);',
121
+      'CREATE INDEX IF NOT EXISTS idx_field_media_field_id ON field_media(field_id);',
122
+      'CREATE INDEX IF NOT EXISTS idx_crop_media_crop_id ON crop_media(crop_id);',
123
+      'CREATE INDEX IF NOT EXISTS idx_harvest_media_harvest_id ON harvest_media(harvest_id);',
124
+      'CREATE INDEX IF NOT EXISTS idx_sale_media_sale_id ON sale_media(sale_id);',
125
+      'CREATE INDEX IF NOT EXISTS idx_cost_media_cost_id ON cost_media(cost_id);',
126
+      'CREATE INDEX IF NOT EXISTS idx_task_entry_media_entry_id ON task_entry_media(entry_id);',
115 127
     ].join('\n')
116 128
   );
117 129
 
@@ -121,6 +133,46 @@ export async function initCoreTables() {
121 133
   await ensureColumns(db, 'harvests', harvestColumns);
122 134
   await ensureColumns(db, 'sales', saleColumns);
123 135
   await ensureColumns(db, 'costs', costColumns);
136
+
137
+  await db.execAsync(
138
+    `INSERT INTO field_media (field_id, uri, media_type, created_at)
139
+     SELECT f.id, f.photo_uri, 'image', f.created_at
140
+     FROM fields f
141
+     WHERE f.photo_uri IS NOT NULL AND TRIM(f.photo_uri) <> ''
142
+       AND NOT EXISTS (
143
+         SELECT 1 FROM field_media fm WHERE fm.field_id = f.id
144
+       );`
145
+  );
146
+
147
+  await db.execAsync(
148
+    `INSERT INTO crop_media (crop_id, uri, media_type, created_at)
149
+     SELECT c.id, c.photo_uri, 'image', datetime('now')
150
+     FROM crops c
151
+     WHERE c.photo_uri IS NOT NULL AND TRIM(c.photo_uri) <> ''
152
+       AND NOT EXISTS (
153
+         SELECT 1 FROM crop_media cm WHERE cm.crop_id = c.id
154
+       );`
155
+  );
156
+
157
+  await db.execAsync(
158
+    `INSERT INTO harvest_media (harvest_id, uri, media_type, created_at)
159
+     SELECT h.id, h.photo_uri, 'image', h.created_at
160
+     FROM harvests h
161
+     WHERE h.photo_uri IS NOT NULL AND TRIM(h.photo_uri) <> ''
162
+       AND NOT EXISTS (
163
+         SELECT 1 FROM harvest_media hm WHERE hm.harvest_id = h.id
164
+       );`
165
+  );
166
+
167
+  await db.execAsync(
168
+    `INSERT INTO cost_media (cost_id, uri, media_type, created_at)
169
+     SELECT c.id, c.photo_uri, 'image', c.created_at
170
+     FROM costs c
171
+     WHERE c.photo_uri IS NOT NULL AND TRIM(c.photo_uri) <> ''
172
+       AND NOT EXISTS (
173
+         SELECT 1 FROM cost_media cm WHERE cm.cost_id = c.id
174
+       );`
175
+  );
124 176
 }
125 177
 
126 178
 async function ensureColumns(

tum/soc - Gogs: Simplico Git Service

暂无描述

tum 619a0c6d4b config 1 月之前
..
iris_dev_cert.pem 619a0c6d4b config 1 月之前
iris_dev_key.pem 619a0c6d4b config 1 月之前