"add-code nl-174 ol-174"> 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/shaqfindbeds - Gogs: Simplico Git Service

1 Commits (master)

Autor SHA1 Mensagem Data
  tum f022c07ac9 goto server 5 anos atrás