+  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/whitesports - Gogs: Simplico Git Service

1 Commits (0b697c9ce1d2bfdbc1b58e753a308bd6fb5968aa)

Autor SHA1 Nachricht Datum
  Tum d01d7cf85d first commit vor 4 Jahren