91
+    async function loadCost() {
92
+      try {
93
+        await initCoreTables();
94
+        const db = await dbPromise;
95
+        const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
96
+        const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
97
+        const profileRow = await db.getFirstAsync<{ currency: string | null }>(
98
+          'SELECT currency FROM user_profile WHERE id = 1;'
99
+        );
100
+        const rows = await db.getAllAsync<CostRow>(
101
+          `SELECT id, field_id, crop_id, category, amount, currency, vendor, notes, spent_at, photo_uri
102
+           FROM costs WHERE id = ? LIMIT 1;`,
103
+          costId
104
+        );
105
+        if (!isActive) return;
106
+        setFields(fieldRows);
107
+        setCrops(cropRows);
108
+        setCurrency(profileRow?.currency ?? 'THB');
109
+        const cost = rows[0];
110
+        if (!cost) {
111
+          setStatus(t('costs.empty'));
112
+          setLoading(false);
113
+          return;
114
+        }
115
+        setSelectedFieldId(cost.field_id ?? null);
116
+        setSelectedCropId(cost.crop_id ?? null);
117
+        setCategory(cost.category ?? '');
118
+        setAmount(cost.amount !== null ? String(cost.amount) : '');
119
+        setVendor(cost.vendor ?? '');
120
+        setNotes(cost.notes ?? '');
121
+        setSpentDate(cost.spent_at ?? '');
122
+        const mediaRows = await db.getAllAsync<MediaRow>(
123
+          'SELECT uri FROM cost_media WHERE cost_id = ? ORDER BY created_at ASC;',
124
+          costId
125
+        );
126
+        const media = uniqueMediaUris([
127
+          ...mediaRows.map((row) => row.uri).filter(Boolean),
128
+          ...(normalizeMediaUri(cost.photo_uri) ? [normalizeMediaUri(cost.photo_uri) as string] : []),
129
+        ] as string[]);
130
+        setMediaUris(media);
131
+        setActiveUri(media[0] ?? normalizeMediaUri(cost.photo_uri));
132
+      } catch (error) {
133
+        if (isActive) setStatus(`Error: ${String(error)}`);
134
+      } finally {
135
+        if (isActive) setLoading(false);
136
+      }
137
+    }
138
+
139
+    loadCost();
140
+    return () => {
141
+      isActive = false;
142
+    };
143
+  }, [costId, t]);
144
+
145
+  const selectedField = useMemo(
146
+    () => fields.find((item) => item.id === selectedFieldId),
147
+    [fields, selectedFieldId]
148
+  );
149
+  const selectedCrop = useMemo(
150
+    () => crops.find((item) => item.id === selectedCropId),
151
+    [crops, selectedCropId]
152
+  );
153
+
154
+  const inputStyle = [
155
+    styles.input,
156
+    {
157
+      borderColor: palette.border,
158
+      backgroundColor: palette.input,
159
+      color: palette.text,
160
+    },
161
+  ];
162
+
163
+  async function handleUpdate() {
164
+    const parsedAmount = amount.trim() ? Number(amount) : null;
165
+    const nextErrors: { field?: string; amount?: string } = {};
166
+    if (!selectedFieldId) nextErrors.field = t('costs.fieldRequired');
167
+    if (!parsedAmount || !Number.isFinite(parsedAmount)) nextErrors.amount = t('costs.amountInvalid');
168
+    setErrors(nextErrors);
169
+    if (Object.keys(nextErrors).length > 0) return;
170
+    try {
171
+      setSaving(true);
172
+      const db = await dbPromise;
173
+      const primaryUri = mediaUris[0] ?? normalizeMediaUri(activeUri);
174
+      await db.runAsync(
175
+        'UPDATE costs SET field_id = ?, crop_id = ?, category = ?, amount = ?, currency = ?, vendor = ?, notes = ?, spent_at = ?, photo_uri = ? WHERE id = ?;',
176
+        selectedFieldId,
177
+        selectedCropId,
178
+        category.trim() || null,
179
+        parsedAmount,
180
+        currency,
181
+        vendor.trim() || null,
182
+        notes.trim() || null,
183
+        spentDate || null,
184
+        primaryUri ?? null,
185
+        costId
186
+      );
187
+      await db.runAsync('DELETE FROM cost_media WHERE cost_id = ?;', costId);
188
+      const now = new Date().toISOString();
189
+      const mediaToInsert = uniqueMediaUris([
190
+        ...mediaUris,
191
+        ...(normalizeMediaUri(activeUri) ? [normalizeMediaUri(activeUri) as string] : []),
192
+      ]);
193
+      for (const uri of mediaToInsert) {
194
+        await db.runAsync(
195
+          'INSERT INTO cost_media (cost_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
196
+          costId,
197
+          uri,
198
+          isVideoUri(uri) ? 'video' : 'image',
199
+          now
200
+        );
201
+      }
202
+      setStatus(t('costs.saved'));
203
+      setShowSaved(true);
204
+      setTimeout(() => {
205
+        setShowSaved(false);
206
+        setStatus('');
207
+      }, 1800);
208
+    } catch (error) {
209
+      setStatus(`Error: ${String(error)}`);
210
+    } finally {
211
+      setSaving(false);
212
+    }
213
+  }
214
+
215
+  async function handleDelete() {
216
+    Alert.alert(t('costs.deleteTitle'), t('costs.deleteMessage'), [
217
+      { text: t('costs.cancel'), style: 'cancel' },
218
+      {
219
+        text: t('costs.delete'),
220
+        style: 'destructive',
221
+        onPress: async () => {
222
+          const db = await dbPromise;
223
+          await db.runAsync('DELETE FROM costs WHERE id = ?;', costId);
224
+          router.back();
225
+        },
226
+      },
227
+    ]);
228
+  }
229
+
230
+  if (loading) {
231
+    return (
232
+      <ThemedView style={[styles.container, { backgroundColor: palette.background, padding: 16 }]}>
233
+        <ThemedText>{t('costs.loading')}</ThemedText>
234
+      </ThemedView>
235
+    );
236
+  }
237
+
238
+  return (
239
+    <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
240
+      <KeyboardAvoidingView
241
+        behavior={Platform.OS === 'ios' ? 'padding' : undefined}
242
+        style={styles.keyboardAvoid}>
243
+        <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
244
+          <ThemedText type="title">{t('costs.edit')}</ThemedText>
245
+          {status && !showSaved ? <ThemedText>{status}</ThemedText> : null}
246
+
247
+          <ThemedText>
248
+            {t('costs.field')}
249
+            <ThemedText style={styles.requiredMark}> *</ThemedText>
250
+          </ThemedText>
251
+          <ThemedButton
252
+            title={selectedField?.name || t('costs.selectField')}
253
+            onPress={() => setFieldModalOpen(true)}
254
+            variant="secondary"
255
+          />
256
+          {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
257
+
258
+          <ThemedText>{t('costs.crop')}</ThemedText>
259
+          <ThemedButton
260
+            title={selectedCrop?.crop_name || t('costs.selectCrop')}
261
+            onPress={() => setCropModalOpen(true)}
262
+            variant="secondary"
263
+          />
264
+
265
+          <ThemedText>{t('costs.category')}</ThemedText>
266
+          <View style={styles.chipRow}>
267
+            {CATEGORY_PRESETS.map((preset) => {
268
+              const label = t(`costs.category.${preset}`);
269
+              const isActive = label === category || preset === category;
270
+              return (
271
+                <Pressable
272
+                  key={preset}
273
+                  style={[styles.chip, isActive ? styles.chipActive : null]}
274
+                  onPress={() => setCategory(label)}>
275
+                  <ThemedText style={styles.chipText}>{label}</ThemedText>
276
+                </Pressable>
277
+              );
278
+            })}
279
+          </View>
280
+          <TextInput
281
+            value={category}
282
+            onChangeText={setCategory}
283
+            placeholder={t('costs.categoryPlaceholder')}
284
+            placeholderTextColor={palette.placeholder}
285
+            style={inputStyle}
286
+          />
287
+
288
+          <ThemedText>
289
+            {t('costs.amount')} ({currency})
290
+            <ThemedText style={styles.requiredMark}> *</ThemedText>
291
+          </ThemedText>
292
+          <TextInput
293
+            value={amount}
294
+            onChangeText={(value) => {
295
+              setAmount(value);
296
+              if (errors.amount) setErrors((prev) => ({ ...prev, amount: undefined }));
297
+            }}
298
+            placeholder={t('costs.amountPlaceholder')}
299
+            placeholderTextColor={palette.placeholder}
300
+            style={inputStyle}
301
+            keyboardType="decimal-pad"
302
+          />
303
+          {errors.amount ? <ThemedText style={styles.errorText}>{errors.amount}</ThemedText> : null}
304
+
305
+          <ThemedText>{t('costs.vendor')}</ThemedText>
306
+          <TextInput
307
+            value={vendor}
308
+            onChangeText={setVendor}
309
+            placeholder={t('costs.vendorPlaceholder')}
310
+            placeholderTextColor={palette.placeholder}
311
+            style={inputStyle}
312
+          />
313
+
314
+          <ThemedText>{t('costs.date')}</ThemedText>
315
+          <Pressable onPress={() => setShowSpentPicker(true)} style={styles.dateInput}>
316
+            <ThemedText style={styles.dateValue}>
317
+              {spentDate || t('costs.datePlaceholder')}
318
+            </ThemedText>
319
+          </Pressable>
320
+          {showSpentPicker ? (
321
+            <DateTimePicker
322
+              value={spentDate ? new Date(spentDate) : new Date()}
323
+              mode="date"
324
+              onChange={(event, date) => {
325
+                setShowSpentPicker(false);
326
+                if (date) setSpentDate(toDateOnly(date));
327
+              }}
328
+            />
329
+          ) : null}
330
+
331
+          <ThemedText>{t('costs.notes')}</ThemedText>
332
+          <TextInput
333
+            value={notes}
334
+            onChangeText={setNotes}
335
+            placeholder={t('costs.notesPlaceholder')}
336
+            placeholderTextColor={palette.placeholder}
337
+            style={[inputStyle, styles.multiline]}
338
+            multiline
339
+          />
340
+
341
+          <ThemedText>{t('costs.addMedia')}</ThemedText>
342
+          {normalizeMediaUri(activeUri) ? (
343
+            isVideoUri(normalizeMediaUri(activeUri) as string) ? (
344
+              <Video
345
+                source={{ uri: normalizeMediaUri(activeUri) as string }}
346
+                style={styles.mediaPreview}
347
+                useNativeControls
348
+                resizeMode={ResizeMode.CONTAIN}
349
+              />
350
+            ) : (
351
+              <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
352
+                <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
353
+              </Pressable>
354
+            )
355
+          ) : (
356
+            <ThemedView style={[styles.mediaPlaceholder, { borderColor: palette.border }]}>
357
+              <ThemedText style={styles.mediaPlaceholderText}>{t('costs.noPhoto')}</ThemedText>
358
+            </ThemedView>
359
+          )}
360
+          {mediaUris.length > 0 ? (
361
+            <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaRow}>
362
+              {mediaUris.map((uri) => (
363
+                <Pressable
364
+                  key={uri}
365
+                  style={styles.mediaThumbWrap}
366
+                  onPress={() => setActiveUri(uri)}>
367
+                  {isVideoUri(uri) ? (
368
+                    <Video
369
+                      source={{ uri }}
370
+                      style={styles.mediaThumb}
371
+                      resizeMode={ResizeMode.COVER}
372
+                      isMuted
373
+                    />
374
+                  ) : (
375
+                    <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
376
+                  )}
377
+                  <Pressable
378
+                    style={styles.mediaRemove}
379
+                    onPress={() => {
380
+                      setMediaUris((prev) => {
381
+                        const next = prev.filter((item) => item !== uri);
382
+                        if (activeUri === uri) setActiveUri(next[0] ?? null);
383
+                        return next;
384
+                      });
385
+                    }}>
386
+                    <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
387
+                  </Pressable>
388
+                </Pressable>
389
+              ))}
390
+            </ScrollView>
391
+          ) : null}
392
+
393
+          <View style={styles.mediaButtons}>
394
+            <ThemedButton
395
+              title={t('costs.pickFromGallery')}
396
+              onPress={() =>
397
+                handlePickMedia((uris) => {
398
+                  setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
399
+                  if (!activeUri && uris[0]) setActiveUri(uris[0]);
400
+                })
401
+              }
402
+              variant="secondary"
403
+            />
404
+            <ThemedButton
405
+              title={t('costs.takeMedia')}
406
+              onPress={() =>
407
+                handleTakeMedia((uri) => {
408
+                  if (!uri) return;
409
+                  setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
410
+                  if (!activeUri) setActiveUri(uri);
411
+                })
412
+              }
413
+              variant="secondary"
414
+            />
415
+          </View>
416
+
417
+          <View style={styles.actions}>
418
+            <IconButton
419
+              name="trash"
420
+              onPress={handleDelete}
421
+              accessibilityLabel={t('costs.delete')}
422
+              variant="danger"
423
+            />
424
+            <View style={styles.updateGroup}>
425
+              {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('costs.saved')}</ThemedText> : null}
426
+              <ThemedButton
427
+                title={saving ? t('costs.saving') : t('costs.update')}
428
+                onPress={handleUpdate}
429
+              />
430
+            </View>
431
+          </View>
432
+        </ScrollView>
433
+      </KeyboardAvoidingView>
434
+
435
+      <Modal transparent visible={fieldModalOpen} animationType="fade">
436
+        <Pressable style={styles.modalOverlay} onPress={() => setFieldModalOpen(false)}>
437
+          <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
438
+            <ThemedText type="subtitle">{t('costs.selectField')}</ThemedText>
439
+            <ScrollView>
440
+              {fields.map((item) => (
441
+                <Pressable
442
+                  key={item.id}
443
+                  onPress={() => {
444
+                    setSelectedFieldId(item.id);
445
+                    setErrors((prev) => ({ ...prev, field: undefined }));
446
+                    setFieldModalOpen(false);
447
+                  }}
448
+                  style={styles.modalItem}>
449
+                  <ThemedText>{item.name || t('costs.untitled')}</ThemedText>
450
+                </Pressable>
451
+              ))}
452
+            </ScrollView>
453
+          </View>
454
+        </Pressable>
455
+      </Modal>
456
+
457
+      <Modal transparent visible={cropModalOpen} animationType="fade">
458
+        <Pressable style={styles.modalOverlay} onPress={() => setCropModalOpen(false)}>
459
+          <View style={[styles.modalCard, { backgroundColor: palette.card, borderColor: palette.border }]}>
460
+            <ThemedText type="subtitle">{t('costs.selectCrop')}</ThemedText>
461
+            <ScrollView>
462
+              {crops.map((item) => (
463
+                <Pressable
464
+                  key={item.id}
465
+                  onPress={() => {
466
+                    setSelectedCropId(item.id);
467
+                    setCropModalOpen(false);
468
+                  }}
469
+                  style={styles.modalItem}>
470
+                  <ThemedText>{item.crop_name || t('costs.untitled')}</ThemedText>
471
+                </Pressable>
472
+              ))}
473
+            </ScrollView>
474
+          </View>
475
+        </Pressable>
476
+      </Modal>
477
+
478
+      <ZoomImageModal visible={Boolean(zoomUri)} uri={zoomUri} onClose={() => setZoomUri(null)} />
479
+    </ThemedView>
480
+  );
481
+}
482
+
483
+async function handlePickMedia(onAdd: (uris: string[]) => void) {
484
+  const pickerTypes =
485
+    (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
486
+    (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
487
+    undefined;
488
+  const mediaTypes =
489
+    pickerTypes?.Images && pickerTypes?.Videos
490
+      ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
491
+      : ['images', 'videos'];
492
+  const result = await ImagePicker.launchImageLibraryAsync({
493
+    mediaTypes,
494
+    quality: 1,
495
+    allowsMultipleSelection: true,
496
+  });
497
+  if (result.canceled) return;
498
+  const uris = result.assets.map((asset) => asset.uri).filter(Boolean);
499
+  if (uris.length > 0) onAdd(uris);
500
+}
501
+
502
+async function handleTakeMedia(onAdd: (uri: string | null) => void) {
503
+  const pickerTypes =
504
+    (ImagePicker as { MediaType?: { Images?: string; Videos?: string } }).MediaType ??
505
+    (ImagePicker as { MediaTypeOptions?: { Images?: string; Videos?: string } }).MediaTypeOptions ??
506
+    undefined;
507
+  const mediaTypes =
508
+    pickerTypes?.Images && pickerTypes?.Videos
509
+      ? [pickerTypes.Images.toLowerCase(), pickerTypes.Videos.toLowerCase()]
510
+      : ['images', 'videos'];
511
+  const result = await ImagePicker.launchCameraAsync({
512
+    mediaTypes,
513
+    quality: 1,
514
+  });
515
+  if (result.canceled) return;
516
+  onAdd(result.assets[0]?.uri ?? null);
517
+}
518
+
519
+function isVideoUri(uri: string) {
520
+  const cleaned = uri.split('?')[0]?.toLowerCase() ?? '';
521
+  return ['.mp4', '.mov', '.m4v', '.webm', '.mkv', '.avi'].some((ext) => cleaned.endsWith(ext));
522
+}
523
+
524
+function normalizeMediaUri(uri?: string | null) {
525
+  if (!uri) return null;
526
+  if (uri.startsWith('file://') || uri.startsWith('http://') || uri.startsWith('https://')) return uri;
527
+  return `file://${uri}`;
528
+}
529
+
530
+function uniqueMediaUris(uris: string[]) {
531
+  return Array.from(new Set(uris.filter(Boolean)));
532
+}
533
+
534
+function toDateOnly(date: Date) {
535
+  const year = date.getFullYear();
536
+  const month = String(date.getMonth() + 1).padStart(2, '0');
537
+  const day = String(date.getDate()).padStart(2, '0');
538
+  return `${year}-${month}-${day}`;
539
+}
540
+
541
+const styles = StyleSheet.create({
542
+  container: { flex: 1 },
543
+  keyboardAvoid: { flex: 1 },
544
+  content: { padding: 16, paddingBottom: 40 },
545
+  input: {
546
+    borderWidth: 1,
547
+    borderRadius: 12,
548
+    paddingHorizontal: 12,
549
+    paddingVertical: 10,
550
+    marginTop: 6,
551
+    marginBottom: 12,
552
+    fontSize: 16,
553
+  },
554
+  multiline: { minHeight: 90, textAlignVertical: 'top' },
555
+  errorText: { color: '#C0392B', marginBottom: 8 },
556
+  requiredMark: { color: '#C0392B' },
557
+  dateInput: {
558
+    borderWidth: 1,
559
+    borderRadius: 12,
560
+    paddingHorizontal: 12,
561
+    paddingVertical: 12,
562
+    marginTop: 6,
563
+    marginBottom: 12,
564
+  },
565
+  dateValue: { fontSize: 16 },
566
+  chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 8 },
567
+  chip: {
568
+    paddingHorizontal: 12,
569
+    paddingVertical: 6,
570
+    borderRadius: 999,
571
+    borderWidth: 1,
572
+    borderColor: '#C9D2C4',
573
+    backgroundColor: '#F8F6F0',
574
+  },
575
+  chipActive: { backgroundColor: '#DDE8DA', borderColor: '#88A68F' },
576
+  chipText: { fontSize: 13 },
577
+  mediaPreview: {
578
+    width: '100%',
579
+    height: 200,
580
+    borderRadius: 16,
581
+    backgroundColor: '#F1F1F1',
582
+    marginBottom: 12,
583
+  },
584
+  mediaPlaceholder: {
585
+    width: '100%',
586
+    height: 200,
587
+    borderRadius: 16,
588
+    borderWidth: 1,
589
+    justifyContent: 'center',
590
+    alignItems: 'center',
591
+    marginBottom: 12,
592
+  },
593
+  mediaPlaceholderText: { opacity: 0.7 },
594
+  mediaRow: { marginBottom: 12 },
595
+  mediaThumbWrap: { marginRight: 8 },
596
+  mediaThumb: { width: 72, height: 72, borderRadius: 12 },
597
+  mediaRemove: {
598
+    position: 'absolute',
599
+    top: -6,
600
+    right: -6,
601
+    width: 22,
602
+    height: 22,
603
+    borderRadius: 11,
604
+    backgroundColor: '#2C2C2C',
605
+    justifyContent: 'center',
606
+    alignItems: 'center',
607
+  },
608
+  mediaRemoveText: { color: '#fff', fontSize: 14, lineHeight: 16 },
609
+  updateGroup: {
610
+    flexDirection: 'row',
611
+    alignItems: 'center',
612
+    gap: 8,
613
+  },
614
+  inlineToastText: {
615
+    fontWeight: '700',
616
+    fontSize: 12,
617
+  },
618
+  mediaButtons: { flexDirection: 'row', gap: 12, flexWrap: 'wrap', marginBottom: 16 },
619
+  actions: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 8 },
620
+  modalOverlay: {
621
+    flex: 1,
622
+    backgroundColor: 'rgba(0,0,0,0.4)',
623
+    justifyContent: 'center',
624
+    padding: 20,
625
+  },
626
+  modalCard: {
627
+    borderRadius: 16,
628
+    borderWidth: 1,
629
+    padding: 16,
630
+    maxHeight: '70%',
631
+  },
632
+  modalItem: { paddingVertical: 10 },
633
+});

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

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

+ 181 - 33
app/costs.tsx

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

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

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

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

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

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

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

+ 176 - 31
app/harvests.tsx

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

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

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

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

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

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

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

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

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

+ 146 - 36
app/crops.tsx

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

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

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

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

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

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


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

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

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

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

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

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

+ 157 - 39
app/observations.tsx

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

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

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

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

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

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

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

+ 60 - 14
app/sales.tsx

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

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

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

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

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

+ 31 - 1
app/task-history.tsx

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

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

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

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

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

+ 158 - 166
app/tasks.tsx

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

+ 20 - 74
app/_layout.tsx

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

+ 0 - 721
app/fields.tsx

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

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

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

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

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

+ 7 - 0
ios/Podfile.lock

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

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

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

+ 2 - 9
ios/SmartFarmLite/Info.plist

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

+ 530 - 7
localization/i18n.tsx

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

+ 29 - 0
package-lock.json

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

+ 2 - 0
package.json

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

+ 52 - 0
services/db.ts

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

tum/soc - Gogs: Simplico Git Service

暫無描述

timeline.css 91KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128
  1. /*!
  2. Timeline JS 3
  3. Designed and built by Zach Wise for the Northwestern University Knight Lab
  4. This Source Code Form is subject to the terms of the Mozilla Public
  5. License, v. 2.0. If a copy of the MPL was not distributed with this
  6. file, You can obtain one at https://mozilla.org/MPL/2.0/.
  7. */
  8. /* Includes
  9. ================================================== */
  10. /* VARIABLES
  11. BASE
  12. ----------------------------------------------------- */
  13. /* ICON PATH
  14. ================================================== */
  15. /* TYPEFACE
  16. ================================================== */
  17. /* COLOR SCHEME
  18. ================================================== */
  19. /* UI COLOR
  20. ================================================== */
  21. /* UI
  22. ================================================== */
  23. /* Animation
  24. ================================================== */
  25. /* GFX
  26. ================================================== */
  27. /*!
  28. Timeline JS 3
  29. Designed and built by Zach Wise for the Northwestern University Knight Lab
  30. This Source Code Form is subject to the terms of the Mozilla Public
  31. License, v. 2.0. If a copy of the MPL was not distributed with this
  32. file, You can obtain one at https://mozilla.org/MPL/2.0/.
  33. */
  34. /* Includes
  35. ================================================== */
  36. /* Mixins.less
  37. Snippets of reusable CSS to develop faster and keep code readable
  38. * ----------------------------------------------------------------- */
  39. /* Reset
  40. ------------------------------------------------------------------------------------------- */
  41. .tl-storyjs {
  42. /* Reset tags and common classes
  43. Display in IE6-9 and FF3
  44. Based on: http://github.com/necolas/normalize.css
  45. ------------------------------------------------------------------------------------------- */
  46. /* Prevents modern browsers from displaying 'audio' without controls
  47. Based on: http://github.com/necolas/normalize.css
  48. ------------------------------------------------------------------------------------------- */
  49. /* Prevents sub and sup affecting line-height in all browsers
  50. Based on: http://github.com/necolas/normalize.css
  51. ------------------------------------------------------------------------------------------- */
  52. /* Img border in a's and image quality
  53. Based on: http://github.com/necolas/normalize.css
  54. ------------------------------------------------------------------------------------------- */
  55. /* Forms
  56. Based on: http://github.com/necolas/normalize.css
  57. ------------------------------------------------------------------------------------------- */
  58. }
  59. .tl-storyjs div * {
  60. -webkit-box-sizing: content-box;
  61. -moz-box-sizing: content-box;
  62. box-sizing: content-box;
  63. }
  64. .tl-storyjs h1,
  65. .tl-storyjs h2,
  66. .tl-storyjs h3,
  67. .tl-storyjs h4,
  68. .tl-storyjs h5,
  69. .tl-storyjs h6,
  70. .tl-storyjs p,
  71. .tl-storyjs blockquote,
  72. .tl-storyjs pre,
  73. .tl-storyjs a,
  74. .tl-storyjs abbr,
  75. .tl-storyjs acronym,
  76. .tl-storyjs address,
  77. .tl-storyjs cite,
  78. .tl-storyjs code,
  79. .tl-storyjs del,
  80. .tl-storyjs dfn,
  81. .tl-storyjs em,
  82. .tl-storyjs img,
  83. .tl-storyjs q,
  84. .tl-storyjs s,
  85. .tl-storyjs samp,
  86. .tl-storyjs small,
  87. .tl-storyjs strike,
  88. .tl-storyjs strong,
  89. .tl-storyjs sub,
  90. .tl-storyjs sup,
  91. .tl-storyjs tt,
  92. .tl-storyjs var,
  93. .tl-storyjs dd,
  94. .tl-storyjs dl,
  95. .tl-storyjs dt,
  96. .tl-storyjs li,
  97. .tl-storyjs ol,
  98. .tl-storyjs ul,
  99. .tl-storyjs fieldset,
  100. .tl-storyjs form,
  101. .tl-storyjs label,
  102. .tl-storyjs legend,
  103. .tl-storyjs button,
  104. .tl-storyjs table,
  105. .tl-storyjs caption,
  106. .tl-storyjs tbody,
  107. .tl-storyjs tfoot,
  108. .tl-storyjs thead,
  109. .tl-storyjs tr,
  110. .tl-storyjs th,
  111. .tl-storyjs td,
  112. .tl-storyjs .tl-container,
  113. .tl-storyjs .content-container,
  114. .tl-storyjs .media,
  115. .tl-storyjs .text,
  116. .tl-storyjs .tl-slider,
  117. .tl-storyjs .slider,
  118. .tl-storyjs .date,
  119. .tl-storyjs .title,
  120. .tl-storyjs .message,
  121. .tl-storyjs .map,
  122. .tl-storyjs .credit,
  123. .tl-storyjs .caption,
  124. .tl-storyjs .tl-feedback,
  125. .tl-storyjs .tl-feature,
  126. .tl-storyjs .toolbar,
  127. .tl-storyjs .marker,
  128. .tl-storyjs .dot,
  129. .tl-storyjs .line,
  130. .tl-storyjs .flag,
  131. .tl-storyjs .time,
  132. .tl-storyjs .era,
  133. .tl-storyjs .major,
  134. .tl-storyjs .minor,
  135. .tl-storyjs .tl-navigation,
  136. .tl-storyjs .start,
  137. .tl-storyjs .active {
  138. margin: 0;
  139. padding: 0;
  140. border: 0;
  141. font-weight: normal;
  142. font-style: normal;
  143. font-size: 100%;
  144. line-height: 1;
  145. font-family: inherit;
  146. width: auto;
  147. float: none;
  148. }
  149. .tl-storyjs h1,
  150. .tl-storyjs h2,
  151. .tl-storyjs h3,
  152. .tl-storyjs h4,
  153. .tl-storyjs h5,
  154. .tl-storyjs h6 {
  155. clear: none;
  156. }
  157. .tl-storyjs table {
  158. border-collapse: collapse;
  159. border-spacing: 0;
  160. }
  161. .tl-storyjs ol,
  162. .tl-storyjs ul {
  163. list-style: none;
  164. }
  165. .tl-storyjs q:before,
  166. .tl-storyjs q:after,
  167. .tl-storyjs blockquote:before,
  168. .tl-storyjs blockquote:after {
  169. content: "";
  170. }
  171. .tl-storyjs a:focus {
  172. outline: thin dotted;
  173. }
  174. .tl-storyjs a:hover,
  175. .tl-storyjs a:active {
  176. outline: 0;
  177. }
  178. .tl-storyjs article,
  179. .tl-storyjs aside,
  180. .tl-storyjs details,
  181. .tl-storyjs figcaption,
  182. .tl-storyjs figure,
  183. .tl-storyjs footer,
  184. .tl-storyjs header,
  185. .tl-storyjs hgroup,
  186. .tl-storyjs nav,
  187. .tl-storyjs section {
  188. display: block;
  189. }
  190. .tl-storyjs audio,
  191. .tl-storyjs canvas,
  192. .tl-storyjs video {
  193. display: inline-block;
  194. }
  195. .tl-storyjs audio:not([controls]) {
  196. display: none;
  197. }
  198. .tl-storyjs div {
  199. max-width: none;
  200. }
  201. .tl-storyjs sub,
  202. .tl-storyjs sup {
  203. font-size: 75%;
  204. line-height: 0;
  205. position: relative;
  206. vertical-align: baseline;
  207. }
  208. .tl-storyjs sup {
  209. top: -0.5em;
  210. }
  211. .tl-storyjs sub {
  212. bottom: -0.25em;
  213. }
  214. .tl-storyjs img {
  215. border: 0;
  216. -ms-interpolation-mode: bicubic;
  217. }
  218. .tl-storyjs button,
  219. .tl-storyjs input,
  220. .tl-storyjs select,
  221. .tl-storyjs textarea {
  222. font-size: 100%;
  223. margin: 0;
  224. vertical-align: baseline;
  225. *vertical-align: middle;
  226. }
  227. .tl-storyjs button,
  228. .tl-storyjs input {
  229. line-height: normal;
  230. *overflow: visible;
  231. }
  232. .tl-storyjs button::-moz-focus-inner,
  233. .tl-storyjs input::-moz-focus-inner {
  234. border: 0;
  235. padding: 0;
  236. }
  237. .tl-storyjs button,
  238. .tl-storyjs input[type="button"],
  239. .tl-storyjs input[type="reset"],
  240. .tl-storyjs input[type="submit"] {
  241. cursor: pointer;
  242. -webkit-appearance: button;
  243. }
  244. .tl-storyjs input[type="search"] {
  245. -webkit-appearance: textfield;
  246. -webkit-box-sizing: content-box;
  247. -moz-box-sizing: content-box;
  248. box-sizing: content-box;
  249. }
  250. .tl-storyjs input[type="search"]::-webkit-search-decoration {
  251. -webkit-appearance: none;
  252. }
  253. .tl-storyjs textarea {
  254. overflow: auto;
  255. vertical-align: top;
  256. }
  257. .tl-timeline {
  258. font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  259. /* VCard
  260. ================================================== */
  261. }
  262. .tl-timeline h1,
  263. .tl-timeline h2,
  264. .tl-timeline h3,
  265. .tl-timeline h4,
  266. .tl-timeline h5,
  267. .tl-timeline h6 {
  268. color: #000;
  269. }
  270. .tl-timeline h1,
  271. .tl-timeline h2,
  272. .tl-timeline h3 {
  273. font-size: 28px;
  274. line-height: 28px;
  275. }
  276. .tl-timeline h1 small,
  277. .tl-timeline h2 small,
  278. .tl-timeline h3 small {
  279. font-size: 24px;
  280. line-height: 24px;
  281. }
  282. .tl-timeline h4,
  283. .tl-timeline h5,
  284. .tl-timeline h6 {
  285. font-size: 24px;
  286. line-height: 24px;
  287. margin-bottom: 0px;
  288. }
  289. .tl-timeline h4 small,
  290. .tl-timeline h5 small,
  291. .tl-timeline h6 small {
  292. font-size: 15px;
  293. line-height: 15px;
  294. }
  295. .tl-timeline h2.tl-headline-title {
  296. font-size: 38px;
  297. line-height: 38px;
  298. }
  299. .tl-timeline h2.tl-headline-title small {
  300. display: block;
  301. margin-top: 5px;
  302. font-size: 24px;
  303. line-height: 24px;
  304. }
  305. .tl-timeline h2 {
  306. margin-top: 20px;
  307. margin-bottom: 5px;
  308. }
  309. .tl-timeline p {
  310. margin-top: 5px;
  311. margin-bottom: 10px;
  312. font-size: 15px;
  313. line-height: 1.42857143;
  314. color: #666666;
  315. }
  316. .tl-timeline p.lead {
  317. font-size: 24px;
  318. }
  319. .tl-timeline p a {
  320. /*
  321. color: lighten(@color-dark, 40%);
  322. text-decoration: none;
  323. background-image: -moz-linear-gradient(top, fade(lighten(@color-dark, 40%), 0%) 50%, fade(lighten(@color-dark, 40%), 60%) 50%);
  324. background-image: -webkit-linear-gradient(top, fade(lighten(@color-dark, 40%), 0%) 50%, fade(lighten(@color-dark, 40%), 60%) 50%);
  325. background-image: -o-linear-gradient(top, fade(lighten(@color-dark, 40%), 0%) 50%, fade(lighten(@color-dark, 40%), 60%) 50%);
  326. background-image: linear-gradient(to bottom, fade(lighten(@color-dark, 40%), 0%) 50%, fade(lighten(@color-dark, 40%), 60%) 50%);
  327. background-repeat: repeat-x;
  328. background-size: 2px 2px;
  329. background-position: 0 @base-font-size+2;
  330. text-shadow: -2px -1px 0 white, 2px -1px 0 white, -2px 1px 0 white, 2px 1px 0 white;
  331. &:hover,
  332. &:focus {
  333. color:@color-theme;
  334. text-decoration: none;
  335. }
  336. */
  337. color: #666666;
  338. text-decoration: underline;
  339. }
  340. .tl-timeline p a:hover,
  341. .tl-timeline p a:focus {
  342. color: #c34528;
  343. }
  344. @media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 144dpi) {
  345. .tl-timeline p a {
  346. text-decoration: underline;
  347. background-image: none;
  348. text-shadow: none;
  349. }
  350. .tl-timeline p a:hover,
  351. .tl-timeline p a:focus {
  352. color: #c34528;
  353. text-decoration: underline;
  354. }
  355. }
  356. .tl-timeline b,
  357. .tl-timeline strong {
  358. font-weight: bold;
  359. }
  360. .tl-timeline i,
  361. .tl-timeline em {
  362. font-style: italic;
  363. }
  364. .tl-timeline a {
  365. text-decoration: none;
  366. color: #c34528;
  367. }
  368. .tl-timeline a:hover {
  369. text-decoration: underline;
  370. color: #6e2717;
  371. }
  372. .tl-timeline .tl-caption,
  373. .tl-timeline .tl-credit,
  374. .tl-timeline .tl-slidenav-next,
  375. .tl-timeline .tl-slidenav-previous {
  376. font-size: 11px;
  377. line-height: 11px;
  378. }
  379. .tl-timeline .tl-caption a,
  380. .tl-timeline .tl-credit a,
  381. .tl-timeline .tl-slidenav-next a,
  382. .tl-timeline .tl-slidenav-previous a {
  383. color: #000;
  384. }
  385. .tl-timeline .tl-makelink {
  386. word-break: break-all;
  387. word-break: break-word;
  388. -webkit-hyphens: auto;
  389. -moz-hyphens: auto;
  390. hyphens: auto;
  391. }
  392. .tl-timeline blockquote,
  393. .tl-timeline blockquote p {
  394. font-family: "Georgia", "Times New Roman", Times, serif;
  395. color: #999999;
  396. font-size: 24px;
  397. line-height: 24px;
  398. text-align: left;
  399. background: transparent;
  400. border: 0px;
  401. padding: 0px;
  402. }
  403. .tl-timeline blockquote cite,
  404. .tl-timeline blockquote p cite {
  405. font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  406. font-size: 11px;
  407. color: #666666;
  408. display: block;
  409. text-align: right;
  410. font-style: normal;
  411. }
  412. .tl-timeline blockquote cite:before,
  413. .tl-timeline blockquote p cite:before {
  414. content: "\2014";
  415. }
  416. .tl-timeline blockquote p:before {
  417. content: open-quote;
  418. display: inline-block;
  419. font-size: 28px;
  420. position: relative;
  421. top: 8px;
  422. margin-right: 5px;
  423. }
  424. .tl-timeline blockquote p:after {
  425. content: close-quote;
  426. display: inline-block;
  427. font-size: 28px;
  428. position: relative;
  429. top: 8px;
  430. margin-left: 3px;
  431. }
  432. .tl-timeline blockquote {
  433. margin: 10px;
  434. }
  435. .tl-timeline blockquote p {
  436. margin: 0;
  437. }
  438. .tl-timeline .vcard {
  439. font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  440. font-size: 15px;
  441. line-height: 15px;
  442. margin-bottom: 15px;
  443. margin-top: 10px;
  444. }
  445. .tl-timeline .vcard:before,
  446. .tl-timeline .vcard:after {
  447. display: table;
  448. content: "";
  449. }
  450. .tl-timeline .vcard:after {
  451. clear: both;
  452. }
  453. .tl-timeline .vcard .twitter-date {
  454. text-align: left;
  455. font-size: 11px;
  456. }
  457. .tl-timeline .vcard .author {
  458. float: right;
  459. }
  460. .tl-timeline .vcard a {
  461. color: #333333;
  462. text-decoration: none;
  463. }
  464. .tl-timeline .vcard a:hover {
  465. text-decoration: none;
  466. }
  467. .tl-timeline .vcard a:hover .fn,
  468. .tl-timeline .vcard a:hover .nickname {
  469. color: #c34528;
  470. }
  471. .tl-timeline .vcard .fn,
  472. .tl-timeline .vcard .nickname {
  473. padding-left: 42px;
  474. }
  475. .tl-timeline .vcard .fn {
  476. display: block;
  477. font-weight: bold;
  478. }
  479. .tl-timeline .vcard .nickname {
  480. margin-top: 1px;
  481. display: block;
  482. color: #666666;
  483. }
  484. .tl-timeline .vcard .avatar {
  485. float: left;
  486. display: block;
  487. width: 32px;
  488. height: 32px;
  489. }
  490. .tl-timeline .vcard .avatar img {
  491. -moz-border-radius: 5px;
  492. -webkit-border-radius: 5px;
  493. border-radius: 5px;
  494. }
  495. .tl-timeline .tl-text ul {
  496. padding: 0px;
  497. padding-left: 30px;
  498. margin: 0;
  499. }
  500. .tl-timeline .tl-text ul li {
  501. margin-bottom: 5px;
  502. }
  503. .tl-timeline .tl-button-calltoaction {
  504. cursor: pointer;
  505. font-weight: bold;
  506. padding-top: 10px;
  507. margin-bottom: 10px;
  508. padding-bottom: 10px;
  509. }
  510. .tl-timeline .tl-button-calltoaction .tl-button-calltoaction-text {
  511. display: inline-block;
  512. background-color: #c34528;
  513. color: #fff;
  514. padding: 10px 15px 10px 15px;
  515. border-radius: 7px;
  516. }
  517. .tl-timeline .tl-note {
  518. display: block;
  519. font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  520. font-style: italic;
  521. background-color: #e6e6e6;
  522. font-size: 15px;
  523. line-height: 17px;
  524. padding: 10px;
  525. -webkit-border-radius: 7px;
  526. -moz-border-radius: 7px;
  527. border-radius: 7px;
  528. color: #8a6d3b;
  529. background-color: #fcf8e3;
  530. border: 1px solid #faebcc;
  531. text-shadow: none;
  532. }
  533. @media only screen and (max-width: 800px), only screen and (max-device-width: 800px) {
  534. .tl-timeline h1,
  535. .tl-timeline h2,
  536. .tl-timeline h3 {
  537. font-size: 28px;
  538. line-height: 28px;
  539. }
  540. }
  541. @media only screen and (max-width: 500px), only screen and (max-device-width: 500px) {
  542. .tl-timeline h1,
  543. .tl-timeline h2,
  544. .tl-timeline h3 {
  545. font-size: 24px;
  546. line-height: 24px;
  547. }
  548. }
  549. .tl-skinny h2 {
  550. margin-top: 0px;
  551. }
  552. /* Icons
  553. ================================================== */
  554. @font-face {
  555. font-family: 'tl-icons';
  556. src: url(../css/icons/tl-icons.eot);
  557. src: url(../css/icons/tl-icons.eot?#iefix) format('embedded-opentype'), url(../css/icons/tl-icons.ttf) format('truetype'), url(../css/icons/tl-icons.woff2) format('woff2'), url(../css/icons/tl-icons.woff) format('woff'), url(../css/icons/tl-icons.svg#tl-icons) format('svg');
  558. font-weight: normal;
  559. font-style: normal;
  560. }
  561. [class^="tl-icon-"],
  562. [class*=" tl-icon-"] {
  563. font-family: 'tl-icons';
  564. speak: none;
  565. font-style: normal;
  566. font-weight: normal;
  567. font-variant: normal;
  568. text-transform: none;
  569. line-height: 1;
  570. /* Better Font Rendering =========== */
  571. -webkit-font-smoothing: antialiased;
  572. -moz-osx-font-smoothing: grayscale;
  573. }
  574. .tl-icon-vine:after {
  575. content: "\e64d";
  576. }
  577. .tl-icon-wikipedia:after {
  578. content: "\e64e";
  579. }
  580. .tl-icon-chevron-right:after {
  581. content: "\e64f";
  582. }
  583. .tl-icon-chevron-left:after {
  584. content: "\e650";
  585. }
  586. .tl-icon-youtube-logo:after {
  587. content: "\e651";
  588. }
  589. .tl-icon-foursquare:after {
  590. content: "\e652";
  591. }
  592. .tl-icon-camera-retro:after {
  593. content: "\e653";
  594. }
  595. .tl-icon-doc:after {
  596. content: "\e654";
  597. }
  598. .tl-icon-weibo:after {
  599. content: "\e655";
  600. }
  601. .tl-icon-resize-horizontal:after {
  602. content: "\e656";
  603. }
  604. .tl-icon-resize-vertical:after {
  605. content: "\e657";
  606. }
  607. .tl-icon-resize-full:after {
  608. content: "\e658";
  609. }
  610. .tl-icon-resize-small:after {
  611. content: "\e659";
  612. }
  613. .tl-icon-twitter:after {
  614. content: "\e62b";
  615. }
  616. .tl-icon-google-plus:after {
  617. content: "\e62c";
  618. }
  619. .tl-icon-video:after {
  620. content: "\e62d";
  621. }
  622. .tl-icon-youtube:after {
  623. content: "\e62d";
  624. }
  625. .tl-icon-plaintext:after {
  626. content: "\e62e";
  627. }
  628. .tl-icon-storify:after {
  629. content: "\e62e";
  630. }
  631. .tl-icon-image-v2:after {
  632. content: "\e62f";
  633. }
  634. .tl-icon-quote-v2:after {
  635. content: "\e630";
  636. }
  637. .tl-icon-zoom-in:after {
  638. content: "\e631";
  639. }
  640. .tl-icon-zoom-out:after {
  641. content: "\e632";
  642. }
  643. .tl-icon-list:after {
  644. content: "\e633";
  645. }
  646. .tl-icon-music:after {
  647. content: "\e634";
  648. }
  649. .tl-icon-spotify:after {
  650. content: "\e634";
  651. }
  652. .tl-icon-location:after {
  653. content: "\e635";
  654. }
  655. .tl-icon-googlemaps:after {
  656. content: "\e635";
  657. }
  658. .tl-icon-web:after {
  659. content: "\e636";
  660. }
  661. .tl-icon-share-v2:after {
  662. content: "\e637";
  663. }
  664. .tl-icon-soundcloud:after {
  665. content: "\e639";
  666. }
  667. .tl-icon-video-v2:after {
  668. content: "\e63a";
  669. }
  670. .tl-icon-dailymotion:after {
  671. content: "\e63a";
  672. }
  673. .tl-icon-tumblr:after {
  674. content: "\e63b";
  675. }
  676. .tl-icon-lastfm:after {
  677. content: "\e63c";
  678. }
  679. .tl-icon-github:after {
  680. content: "\e63d";
  681. }
  682. .tl-icon-goback:after {
  683. content: "\e63e";
  684. }
  685. .tl-icon-doc-v2:after {
  686. content: "\e63f";
  687. }
  688. .tl-icon-googledrive:after {
  689. content: "\e640";
  690. }
  691. .tl-icon-facebook:after {
  692. content: "\e641";
  693. }
  694. .tl-icon-flickr:after {
  695. content: "\e642";
  696. }
  697. .tl-icon-dribbble:after {
  698. content: "\e643";
  699. }
  700. .tl-icon-image:after {
  701. content: "\e605";
  702. }
  703. .tl-icon-vimeo:after {
  704. content: "\e606";
  705. }
  706. .tl-icon-instagram:after {
  707. content: "\e644";
  708. }
  709. .tl-icon-pinterest:after {
  710. content: "\e645";
  711. }
  712. .tl-icon-arrow-left:after {
  713. content: "\e646";
  714. }
  715. .tl-icon-arrow-down:after {
  716. content: "\e647";
  717. }
  718. .tl-icon-arrow-up:after {
  719. content: "\e648";
  720. }
  721. .tl-icon-arrow-right:after {
  722. content: "\e649";
  723. }
  724. .tl-icon-share:after {
  725. content: "\e64a";
  726. }
  727. .tl-icon-blockquote:after {
  728. content: "\e64b";
  729. }
  730. .tl-icon-evernote:after {
  731. content: "\e64c";
  732. }
  733. .tl-icon-mappin:after {
  734. content: "\e600";
  735. }
  736. .tl-icon-swipe-right:after {
  737. content: "\e601";
  738. }
  739. .tl-icon-swipe-left:after {
  740. content: "\e602";
  741. }
  742. .tl-icon-touch-spread:after {
  743. content: "\e603";
  744. }
  745. .tl-icon-touch-pinch:after {
  746. content: "\e604";
  747. }
  748. /* Disable Text selection when dragging
  749. ================================================== */
  750. .tl-dragging {
  751. -webkit-touch-callout: none;
  752. -webkit-user-select: none;
  753. -moz-user-select: none;
  754. -ms-user-select: none;
  755. -o-user-select: none;
  756. user-select: none;
  757. }
  758. /* MenuBar
  759. ================================================== */
  760. .tl-menubar {
  761. position: absolute;
  762. z-index: 11;
  763. text-align: center;
  764. color: #333;
  765. overflow: hidden;
  766. border-bottom-right-radius: 10px;
  767. border-top-right-radius: 10px;
  768. top: 100%;
  769. left: 50%;
  770. left: 0;
  771. }
  772. /* Skinny
  773. ================================================== */
  774. /* Mobile, iPhone
  775. ================================================== */
  776. /* Mobile, iPhone and skinny
  777. ================================================== */
  778. /* Color
  779. ================================================== */
  780. /*
  781. .tl-sizebar.tl-sizebar-inverted {
  782. border-bottom: 1px solid #FFF;
  783. //background-color:#000;
  784. color:#a5a5a5;
  785. .tl-sizebar-button {
  786. border-left: 1px solid darken(@color-background, 70);
  787. //color:#a5a5a5;
  788. }
  789. .tl-sizebar-button:hover {
  790. //background:@color-theme;
  791. color:@color-background;
  792. }
  793. }
  794. .tl-sizebar.tl-sizebar-inverted:before {
  795. background-color:#000;
  796. //.gradient-vertical (rgba(0,0,0,0.25), rgba(0,0,0,1));
  797. //.translucent-background(rgb(0,0,0), .5);
  798. border-top: 2px solid #000;
  799. animation: invertToBlack 1s;
  800. -webkit-animation:invertToBlack 1s;
  801. }
  802. */
  803. @keyframes invertToBlack {
  804. from {
  805. background-color: #FFF;
  806. }
  807. to {
  808. background-color: #000;
  809. }
  810. }
  811. @-webkit-keyframes invertToBlack {
  812. from {
  813. background: #FFF;
  814. }
  815. to {
  816. background: #000;
  817. }
  818. }
  819. @keyframes invertToWhite {
  820. from {
  821. background-color: #000;
  822. }
  823. to {
  824. background-color: #FFF;
  825. }
  826. }
  827. @-webkit-keyframes invertToWhite {
  828. from {
  829. background: #000;
  830. }
  831. to {
  832. background: #FFF;
  833. }
  834. }
  835. /* MenuBar Button
  836. ================================================== */
  837. .tl-menubar-button {
  838. font-size: 18px;
  839. line-height: 18px;
  840. background-color: rgba(242, 242, 242, 0.9);
  841. cursor: pointer;
  842. padding: 6px 12px 6px 12px;
  843. display: inline-block;
  844. display: block;
  845. color: #bfbfbf;
  846. }
  847. .tl-menubar-button.tl-menubar-button-inactive {
  848. opacity: 0.33;
  849. }
  850. .tl-menubar-button:hover {
  851. background: #333;
  852. color: #FFF;
  853. }
  854. .tl-menubar-button:hover.tl-menubar-button-inactive {
  855. color: #bfbfbf;
  856. background-color: rgba(242, 242, 242, 0.9);
  857. }
  858. .tl-mobile .tl-menubar-button {
  859. display: block;
  860. }
  861. .tl-mobile .tl-menubar-button:hover {
  862. background-color: rgba(242, 242, 242, 0.67);
  863. color: #737373;
  864. }
  865. .tl-mobile .tl-menubar-button:active {
  866. background: #c34528;
  867. color: #FFF;
  868. }
  869. @keyframes invertToBlack {
  870. from {
  871. background-color: #FFF;
  872. }
  873. to {
  874. background-color: #000;
  875. }
  876. }
  877. @-webkit-keyframes invertToBlack {
  878. from {
  879. background: #FFF;
  880. }
  881. to {
  882. background: #000;
  883. }
  884. }
  885. @keyframes invertToWhite {
  886. from {
  887. background-color: #000;
  888. }
  889. to {
  890. background-color: #FFF;
  891. }
  892. }
  893. @-webkit-keyframes invertToWhite {
  894. from {
  895. background: #000;
  896. }
  897. to {
  898. background: #FFF;
  899. }
  900. }
  901. /* MESSAGE
  902. ================================================== */
  903. .tl-message,
  904. .tl-message-full {
  905. width: 100%;
  906. height: 100%;
  907. position: absolute;
  908. display: table;
  909. overflow: hidden;
  910. top: 0px;
  911. left: 0px;
  912. z-index: 99;
  913. margin: auto;
  914. text-align: center;
  915. }
  916. .tl-message .tl-message-container,
  917. .tl-message-full .tl-message-container {
  918. padding: 20px;
  919. margin: 20px;
  920. text-align: center;
  921. vertical-align: middle;
  922. display: table-cell;
  923. }
  924. .tl-message .tl-message-container .tl-message-content,
  925. .tl-message-full .tl-message-container .tl-message-content {
  926. color: #666;
  927. text-align: center;
  928. font-size: 11px;
  929. line-height: 13px;
  930. text-transform: uppercase;
  931. margin-top: 7.5px;
  932. margin-bottom: 7.5px;
  933. text-shadow: 1px 1px 1px #FFF;
  934. }
  935. .tl-message .tl-message-container .tl-message-content strong,
  936. .tl-message-full .tl-message-container .tl-message-content strong {
  937. text-transform: uppercase;
  938. }
  939. .tl-message .tl-message-container .tl-loading-icon,
  940. .tl-message-full .tl-message-container .tl-loading-icon {
  941. width: 30px;
  942. height: 30px;
  943. background-color: #666;
  944. vertical-align: middle;
  945. -webkit-box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.1);
  946. -moz-box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.1);
  947. box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.1);
  948. margin-left: auto;
  949. margin-right: auto;
  950. text-align: center;
  951. -webkit-animation: rotateplane 1.2s infinite ease-in-out;
  952. animation: rotateplane 1.2s infinite ease-in-out;
  953. }
  954. @-webkit-keyframes rotateplane {
  955. 0% {
  956. -webkit-transform: perspective(120px);
  957. }
  958. 50% {
  959. -webkit-transform: perspective(120px) rotateY(180deg);
  960. }
  961. 100% {
  962. -webkit-transform: perspective(120px) rotateY(180deg) rotateX(180deg);
  963. }
  964. }
  965. @keyframes rotateplane {
  966. 0% {
  967. transform: perspective(120px) rotateX(0deg) rotateY(0deg);
  968. }
  969. 50% {
  970. transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg);
  971. }
  972. 100% {
  973. transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg);
  974. }
  975. }
  976. .tl-message-full {
  977. background-color: hsla(0, 0%, 100%, 0.8);
  978. }
  979. .tl-message-full [class^="tl-icon-"],
  980. .tl-message-full [class*=" tl-icon-"] {
  981. color: #666;
  982. font-size: 72px;
  983. }
  984. .tl-message-full .tl-message-container .tl-message-content {
  985. font-size: 22px;
  986. line-height: 22px;
  987. text-shadow: none;
  988. color: #666;
  989. text-transform: none;
  990. font-weight: normal;
  991. }
  992. .tl-message-full .tl-message-container .tl-message-content .tl-button {
  993. display: inline-block;
  994. cursor: pointer;
  995. background-color: #FFF;
  996. color: #333;
  997. padding: 10px;
  998. margin-top: 10px;
  999. -webkit-border-radius: 7px;
  1000. -moz-border-radius: 7px;
  1001. border-radius: 7px;
  1002. }
  1003. /* TL.TimeNav
  1004. ================================================== */
  1005. .tl-timenav {
  1006. width: 100%;
  1007. background-color: #f2f2f2;
  1008. position: relative;
  1009. overflow: hidden;
  1010. border-top: 1px solid #e5e5e5;
  1011. }
  1012. .tl-timenav .tl-attribution {
  1013. cursor: pointer;
  1014. z-index: 9;
  1015. position: absolute;
  1016. bottom: 2px;
  1017. left: 0px;
  1018. font-size: 10px;
  1019. line-height: 10px;
  1020. font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important;
  1021. background-color: rgba(255, 255, 255, 0.85);
  1022. padding: 3px;
  1023. /*
  1024. right:-26px;
  1025. top:30px;
  1026. transform: rotate(90deg);
  1027. -ms-transform: rotate(90deg);
  1028. -webkit-transform: rotate(90deg);
  1029. background-color: fadeout(@ui-background-color, 15%);
  1030. */
  1031. }
  1032. .tl-timenav .tl-attribution a {
  1033. color: #cccccc;
  1034. }
  1035. .tl-timenav .tl-attribution a:hover {
  1036. color: #000;
  1037. text-decoration: none;
  1038. }
  1039. .tl-timenav .tl-attribution a:hover .tl-knightlab-logo {
  1040. background-color: #c34528;
  1041. }
  1042. .tl-timenav .tl-attribution .tl-knightlab-logo {
  1043. display: inline-block;
  1044. vertical-align: middle;
  1045. height: 8px;
  1046. width: 8px;
  1047. margin-right: 3px;
  1048. background-color: #c34528;
  1049. background-color: #cccccc;
  1050. transform: rotate(45deg);
  1051. -ms-transform: rotate(45deg);
  1052. -webkit-transform: rotate(45deg);
  1053. }
  1054. .tl-timenav .tl-timenav-line {
  1055. position: absolute;
  1056. top: 0;
  1057. left: 50%;
  1058. width: 1px;
  1059. height: 100%;
  1060. background-color: #d9d9d9;
  1061. z-index: 2;
  1062. display: none;
  1063. }
  1064. .tl-timenav .tl-timenav-line:before,
  1065. .tl-timenav .tl-timenav-line:after {
  1066. font-family: 'tl-icons';
  1067. speak: none;
  1068. font-style: normal;
  1069. font-weight: normal;
  1070. font-variant: normal;
  1071. text-transform: none;
  1072. line-height: 1;
  1073. /* Better Font Rendering =========== */
  1074. -webkit-font-smoothing: antialiased;
  1075. -moz-osx-font-smoothing: grayscale;
  1076. color: #c34528;
  1077. font-size: 32px;
  1078. line-height: 32px;
  1079. position: absolute;
  1080. left: -14px;
  1081. }
  1082. .tl-timenav .tl-timenav-line:before {
  1083. top: -10px;
  1084. }
  1085. .tl-timenav .tl-timenav-line:after {
  1086. content: "\e648";
  1087. bottom: 24px;
  1088. }
  1089. .tl-timenav .tl-timenav-slider {
  1090. position: absolute;
  1091. height: 100%;
  1092. width: 100%;
  1093. top: 0;
  1094. }
  1095. .tl-timenav .tl-timenav-slider.tl-timenav-slider-animate {
  1096. -webkit-transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1097. -moz-transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1098. -o-transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1099. transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1100. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1101. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1102. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1103. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1104. }
  1105. .tl-timenav .tl-timenav-slider .tl-timenav-slider-background {
  1106. position: absolute;
  1107. height: 100%;
  1108. width: 100%;
  1109. cursor: move;
  1110. z-index: 6;
  1111. }
  1112. .tl-timenav .tl-timenav-slider .tl-timenav-container-mask {
  1113. position: absolute;
  1114. height: 100%;
  1115. top: 0;
  1116. }
  1117. .tl-timenav .tl-timenav-slider .tl-timenav-container-mask .tl-timenav-container {
  1118. position: absolute;
  1119. height: 100%;
  1120. }
  1121. .tl-timenav .tl-timenav-slider .tl-timenav-container-mask .tl-timenav-container .tl-timenav-item-container {
  1122. position: absolute;
  1123. height: 100%;
  1124. }
  1125. /* Skinny
  1126. ================================================== */
  1127. /* Mobile, iPhone
  1128. ================================================== */
  1129. /* Mobile, iPhone and skinny
  1130. ================================================== */
  1131. /* TL.TimeMarker
  1132. ================================================== */
  1133. .tl-timemarker {
  1134. height: 100%;
  1135. position: absolute;
  1136. top: 0;
  1137. left: 0;
  1138. cursor: pointer;
  1139. /* Animate Left Width and Top
  1140. ================================================== */
  1141. -webkit-transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1142. -moz-transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1143. -o-transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1144. transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1145. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1146. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1147. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1148. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1149. /* Timespan
  1150. ================================================== */
  1151. /* Lines
  1152. ================================================== */
  1153. /* Content
  1154. ================================================== */
  1155. /* Hover State
  1156. ================================================== */
  1157. /* Hover Active State
  1158. ================================================== */
  1159. /* Active Markers
  1160. ================================================== */
  1161. /* Markers with End Dates
  1162. ================================================== */
  1163. /* Markers with End Dates and Hover
  1164. ================================================== */
  1165. /* Markers with End Dates and Active
  1166. ================================================== */
  1167. /* Markers with End Dates and Active and Hover
  1168. ================================================== */
  1169. }
  1170. .tl-timemarker.tl-timemarker-fast {
  1171. -webkit-transition: top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1172. -moz-transition: top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1173. -o-transition: top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1174. transition: top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1175. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1176. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1177. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1178. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1179. }
  1180. .tl-timemarker.tl-timemarker-fast .tl-timemarker-content-container {
  1181. -webkit-transition: width 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1182. -moz-transition: width 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1183. -o-transition: width 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1184. transition: width 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1185. }
  1186. .tl-timemarker.tl-timemarker-fast .tl-timemarker-timespan {
  1187. -webkit-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1188. -moz-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1189. -o-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1190. transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1191. }
  1192. .tl-timemarker .tl-timemarker-timespan {
  1193. pointer-events: none;
  1194. position: absolute;
  1195. margin: 0;
  1196. width: 100%;
  1197. height: 100%;
  1198. background-color: rgba(229, 229, 229, 0.15);
  1199. border-top-right-radius: 4px;
  1200. border-top-left-radius: 4px;
  1201. -webkit-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1202. -moz-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1203. -o-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1204. transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1205. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1206. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1207. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1208. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1209. }
  1210. .tl-timemarker .tl-timemarker-timespan .tl-timemarker-timespan-content {
  1211. display: none;
  1212. position: absolute;
  1213. width: 100%;
  1214. background-color: #e5e5e5;
  1215. border-top-left-radius: 4px;
  1216. border-top-right-radius: 4px;
  1217. height: 100px;
  1218. box-sizing: border-box;
  1219. }
  1220. .tl-timemarker .tl-timemarker-line-right {
  1221. display: none;
  1222. right: 0px;
  1223. }
  1224. .tl-timemarker .tl-timemarker-line-left {
  1225. width: 1px;
  1226. left: 0px;
  1227. }
  1228. .tl-timemarker .tl-timemarker-line-left,
  1229. .tl-timemarker .tl-timemarker-line-right {
  1230. margin-top: 7px;
  1231. -webkit-box-sizing: border-box;
  1232. /* Safari/Chrome, other WebKit */
  1233. -moz-box-sizing: border-box;
  1234. /* Firefox, other Gecko */
  1235. box-sizing: border-box;
  1236. border-left: 1px solid #d9d9d9;
  1237. z-index: 5;
  1238. content: " ";
  1239. position: absolute;
  1240. height: 100%;
  1241. -webkit-touch-callout: none;
  1242. -webkit-user-select: none;
  1243. -khtml-user-select: none;
  1244. -moz-user-select: none;
  1245. -ms-user-select: none;
  1246. user-select: none;
  1247. box-shadow: 1px 1px 1px #FFF;
  1248. }
  1249. .tl-timemarker .tl-timemarker-line-left:after,
  1250. .tl-timemarker .tl-timemarker-line-right:after {
  1251. display: block;
  1252. content: " ";
  1253. position: absolute;
  1254. left: -4px;
  1255. bottom: 0px;
  1256. height: 6px;
  1257. width: 6px;
  1258. background-color: #919191;
  1259. z-index: 8;
  1260. -webkit-border-radius: 50%;
  1261. -moz-border-radius: 50%;
  1262. border-radius: 50%;
  1263. }
  1264. .tl-timemarker .tl-timemarker-content-container {
  1265. position: absolute;
  1266. background-color: #e5e5e5;
  1267. border: 0;
  1268. border-top-left-radius: 4px;
  1269. border-top-right-radius: 4px;
  1270. border-bottom-right-radius: 4px;
  1271. height: 100%;
  1272. width: 100px;
  1273. overflow: hidden;
  1274. z-index: 6;
  1275. -webkit-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1276. -moz-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1277. -o-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1278. transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1279. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1280. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1281. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1282. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1283. box-sizing: border-box;
  1284. border: 1px solid #d9d9d9;
  1285. box-shadow: 1px 1px 1px #FFF;
  1286. }
  1287. .tl-timemarker .tl-timemarker-content-container:hover {
  1288. z-index: 9;
  1289. }
  1290. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content {
  1291. position: relative;
  1292. overflow: hidden;
  1293. height: 100%;
  1294. z-index: 8;
  1295. padding: 5px;
  1296. -webkit-box-sizing: border-box;
  1297. /* Safari/Chrome, other WebKit */
  1298. -moz-box-sizing: border-box;
  1299. /* Firefox, other Gecko */
  1300. box-sizing: border-box;
  1301. }
  1302. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-text {
  1303. overflow: hidden;
  1304. position: relative;
  1305. }
  1306. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-text h2.tl-headline,
  1307. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-text h2.tl-headline p {
  1308. display: -webkit-box;
  1309. line-clamp: 2;
  1310. -webkit-line-clamp: 2;
  1311. box-orient: vertical;
  1312. -webkit-box-orient: vertical;
  1313. text-overflow: ellipsis;
  1314. font-size: 12px;
  1315. line-height: 12px;
  1316. height: 100%;
  1317. overflow: hidden;
  1318. font-weight: normal;
  1319. margin: 0;
  1320. color: #bfbfbf;
  1321. position: relative;
  1322. }
  1323. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-text h2.tl-headline.tl-headline-fadeout:after,
  1324. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-text h2.tl-headline p.tl-headline-fadeout:after {
  1325. content: "";
  1326. text-align: right;
  1327. position: absolute;
  1328. bottom: 0;
  1329. right: 0;
  1330. width: 100%;
  1331. height: 50%;
  1332. background: -moz-linear-gradient(top, rgba(229, 229, 229, 0) 0%, #e5e5e5 50%);
  1333. /* FF3.6+ */
  1334. background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(229, 229, 229, 0)), color-stop(50%, #e5e5e5));
  1335. /* Chrome,Safari4+ */
  1336. background: -webkit-linear-gradient(top, rgba(229, 229, 229, 0) 0%, #e5e5e5 50%);
  1337. /* Chrome10+,Safari5.1+ */
  1338. background: -o-linear-gradient(top, rgba(229, 229, 229, 0) 0%, #e5e5e5 50%);
  1339. /* Opera 11.10+ */
  1340. background: -ms-linear-gradient(top, rgba(229, 229, 229, 0) 0%, #e5e5e5 50%);
  1341. /* IE10+ */
  1342. background: linear-gradient(to bottom, rgba(229, 229, 229, 0) 0%, #e5e5e5 50%);
  1343. /* W3C */
  1344. filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='@thecolor', endColorstr='@thecolor', GradientType=0);
  1345. /* IE6-9 */
  1346. }
  1347. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container {
  1348. float: left;
  1349. max-width: 24px;
  1350. max-height: 24px;
  1351. overflow: hidden;
  1352. margin-right: 5px;
  1353. height: 100%;
  1354. -webkit-box-sizing: border-box;
  1355. /* Safari/Chrome, other WebKit */
  1356. -moz-box-sizing: border-box;
  1357. /* Firefox, other Gecko */
  1358. box-sizing: border-box;
  1359. }
  1360. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container .tl-timemarker-media {
  1361. max-width: 24px;
  1362. max-height: 100%;
  1363. opacity: 0.25;
  1364. }
  1365. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container [class^="tl-icon-"],
  1366. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container [class*=" tl-icon-"] {
  1367. display: block;
  1368. font-size: 24px;
  1369. color: #bfbfbf;
  1370. margin-top: 0px;
  1371. }
  1372. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container .tl-icon-wikipedia {
  1373. font-size: 16px;
  1374. }
  1375. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content.tl-timemarker-content-small .tl-timemarker-text h2.tl-headline {
  1376. display: block;
  1377. white-space: nowrap;
  1378. text-overflow: ellipsis;
  1379. }
  1380. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content.tl-timemarker-content-small .tl-timemarker-media-container [class^="tl-icon-"],
  1381. .tl-timemarker .tl-timemarker-content-container .tl-timemarker-content.tl-timemarker-content-small .tl-timemarker-media-container [class*=" tl-icon-"] {
  1382. font-size: 12px;
  1383. }
  1384. .tl-timemarker:hover .tl-timemarker-timespan {
  1385. background-color: rgba(191, 191, 191, 0.15);
  1386. }
  1387. .tl-timemarker:hover .tl-timemarker-timespan .tl-timemarker-timespan-content {
  1388. background-color: #bfbfbf;
  1389. }
  1390. .tl-timemarker:hover .tl-timemarker-timespan .tl-timemarker-line-left,
  1391. .tl-timemarker:hover .tl-timemarker-timespan .tl-timemarker-line-right {
  1392. border-color: #a6a6a6;
  1393. }
  1394. .tl-timemarker:hover .tl-timemarker-timespan .tl-timemarker-line-left:after,
  1395. .tl-timemarker:hover .tl-timemarker-timespan .tl-timemarker-line-right:after {
  1396. background-color: #3d3d3d;
  1397. }
  1398. .tl-timemarker:hover .tl-timemarker-content-container {
  1399. background-color: #bfbfbf;
  1400. border-color: #a6a6a6;
  1401. -webkit-transition: height 250ms cubic-bezier(0.77, 0, 0.175, 1), width 250ms cubic-bezier(0.77, 0, 0.175, 1);
  1402. -moz-transition: height 250ms cubic-bezier(0.77, 0, 0.175, 1), width 250ms cubic-bezier(0.77, 0, 0.175, 1);
  1403. -o-transition: height 250ms cubic-bezier(0.77, 0, 0.175, 1), width 250ms cubic-bezier(0.77, 0, 0.175, 1);
  1404. transition: height 250ms cubic-bezier(0.77, 0, 0.175, 1), width 250ms cubic-bezier(0.77, 0, 0.175, 1);
  1405. }
  1406. .tl-timemarker:hover .tl-timemarker-content-container.tl-timemarker-content-container-small {
  1407. width: 200px;
  1408. }
  1409. .tl-timemarker:hover .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-text h2.tl-headline {
  1410. color: #FFF;
  1411. }
  1412. .tl-timemarker:hover .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-text h2.tl-headline.tl-headline-fadeout:after {
  1413. background: -moz-linear-gradient(top, rgba(191, 191, 191, 0) 0%, #bfbfbf 80%);
  1414. /* FF3.6+ */
  1415. background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(191, 191, 191, 0)), color-stop(80%, #bfbfbf));
  1416. /* Chrome,Safari4+ */
  1417. background: -webkit-linear-gradient(top, rgba(191, 191, 191, 0) 0%, #bfbfbf 80%);
  1418. /* Chrome10+,Safari5.1+ */
  1419. background: -o-linear-gradient(top, rgba(191, 191, 191, 0) 0%, #bfbfbf 80%);
  1420. /* Opera 11.10+ */
  1421. background: -ms-linear-gradient(top, rgba(191, 191, 191, 0) 0%, #bfbfbf 80%);
  1422. /* IE10+ */
  1423. background: linear-gradient(to bottom, rgba(191, 191, 191, 0) 0%, #bfbfbf 80%);
  1424. /* W3C */
  1425. filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='@thecolor', endColorstr='@thecolor', GradientType=0);
  1426. /* IE6-9 */
  1427. }
  1428. .tl-timemarker:hover .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container .tl-timemarker-media {
  1429. opacity: 1;
  1430. }
  1431. .tl-timemarker:hover .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container [class^="tl-icon-"],
  1432. .tl-timemarker:hover .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container [class*=" tl-icon-"] {
  1433. color: #FFF;
  1434. }
  1435. .tl-timemarker:hover.tl-timemarker-active .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-text h2.tl-headline.tl-headline-fadeout:after {
  1436. background: -moz-linear-gradient(top, rgba(255, 255, 255, 0) 0%, #FFF 80%);
  1437. /* FF3.6+ */
  1438. background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(255, 255, 255, 0)), color-stop(80%, #FFF));
  1439. /* Chrome,Safari4+ */
  1440. background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0) 0%, #FFF 80%);
  1441. /* Chrome10+,Safari5.1+ */
  1442. background: -o-linear-gradient(top, rgba(255, 255, 255, 0) 0%, #FFF 80%);
  1443. /* Opera 11.10+ */
  1444. background: -ms-linear-gradient(top, rgba(255, 255, 255, 0) 0%, #FFF 80%);
  1445. /* IE10+ */
  1446. background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, #FFF 80%);
  1447. /* W3C */
  1448. filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='@thecolor', endColorstr='@thecolor', GradientType=0);
  1449. /* IE6-9 */
  1450. }
  1451. .tl-timemarker:hover.tl-timemarker-active .tl-timemarker-line-left,
  1452. .tl-timemarker:hover.tl-timemarker-active .tl-timemarker-line-right {
  1453. border-color: #000;
  1454. }
  1455. .tl-timemarker:hover.tl-timemarker-active .tl-timemarker-line-left:after,
  1456. .tl-timemarker:hover.tl-timemarker-active .tl-timemarker-line-right:after {
  1457. background-color: #000;
  1458. }
  1459. .tl-timemarker.tl-timemarker-active .tl-timemarker-timespan {
  1460. background-color: rgba(255, 255, 255, 0.5);
  1461. z-index: 8;
  1462. }
  1463. .tl-timemarker.tl-timemarker-active .tl-timemarker-timespan .tl-timemarker-timespan-content {
  1464. background-color: #333;
  1465. }
  1466. .tl-timemarker.tl-timemarker-active .tl-timemarker-line-left,
  1467. .tl-timemarker.tl-timemarker-active .tl-timemarker-line-right {
  1468. border-color: rgba(51, 51, 51, 0.5);
  1469. border-width: 1px;
  1470. z-index: 8;
  1471. box-shadow: 0px 1px 3px rgba(145, 145, 145, 0.5);
  1472. }
  1473. .tl-timemarker.tl-timemarker-active .tl-timemarker-line-left:after,
  1474. .tl-timemarker.tl-timemarker-active .tl-timemarker-line-right:after {
  1475. background-color: #333;
  1476. }
  1477. .tl-timemarker.tl-timemarker-active .tl-timemarker-content-container {
  1478. background-color: #FFF;
  1479. color: #333;
  1480. z-index: 9;
  1481. border-color: rgba(51, 51, 51, 0.5);
  1482. box-shadow: 1px 1px 3px rgba(145, 145, 145, 0.5);
  1483. }
  1484. .tl-timemarker.tl-timemarker-active .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-text h2.tl-headline {
  1485. color: #333;
  1486. }
  1487. .tl-timemarker.tl-timemarker-active .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-text h2.tl-headline.tl-headline-fadeout:after {
  1488. background: -moz-linear-gradient(top, rgba(255, 255, 255, 0) 0%, #FFF 80%);
  1489. /* FF3.6+ */
  1490. background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(255, 255, 255, 0)), color-stop(80%, #FFF));
  1491. /* Chrome,Safari4+ */
  1492. background: -webkit-linear-gradient(top, rgba(255, 255, 255, 0) 0%, #FFF 80%);
  1493. /* Chrome10+,Safari5.1+ */
  1494. background: -o-linear-gradient(top, rgba(255, 255, 255, 0) 0%, #FFF 80%);
  1495. /* Opera 11.10+ */
  1496. background: -ms-linear-gradient(top, rgba(255, 255, 255, 0) 0%, #FFF 80%);
  1497. /* IE10+ */
  1498. background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, #FFF 80%);
  1499. /* W3C */
  1500. filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='@thecolor', endColorstr='@thecolor', GradientType=0);
  1501. /* IE6-9 */
  1502. }
  1503. .tl-timemarker.tl-timemarker-active .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container .tl-timemarker-media {
  1504. opacity: 1;
  1505. }
  1506. .tl-timemarker.tl-timemarker-active .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container [class^="tl-icon-"],
  1507. .tl-timemarker.tl-timemarker-active .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container [class*=" tl-icon-"] {
  1508. color: #333;
  1509. }
  1510. .tl-timemarker.tl-timemarker-with-end .tl-timemarker-timespan .tl-timemarker-timespan-content {
  1511. display: block;
  1512. }
  1513. .tl-timemarker.tl-timemarker-with-end .tl-timemarker-timespan .tl-timemarker-line-left,
  1514. .tl-timemarker.tl-timemarker-with-end .tl-timemarker-timespan .tl-timemarker-line-right {
  1515. z-index: 5;
  1516. }
  1517. .tl-timemarker.tl-timemarker-with-end .tl-timemarker-timespan:after {
  1518. display: block;
  1519. content: " ";
  1520. position: absolute;
  1521. left: 0px;
  1522. bottom: -7px;
  1523. height: 6px;
  1524. width: 100%;
  1525. background-color: rgba(115, 115, 115, 0.15);
  1526. z-index: 6;
  1527. -webkit-border-radius: 7px;
  1528. -moz-border-radius: 7px;
  1529. border-radius: 7px;
  1530. }
  1531. .tl-timemarker.tl-timemarker-with-end .tl-timemarker-content-container.tl-timemarker-content-container-long {
  1532. box-shadow: none;
  1533. }
  1534. .tl-timemarker.tl-timemarker-with-end .tl-timemarker-line-right {
  1535. display: block;
  1536. }
  1537. .tl-timemarker.tl-timemarker-with-end .tl-timemarker-line-left {
  1538. box-shadow: none;
  1539. }
  1540. .tl-timemarker.tl-timemarker-with-end:hover .tl-timemarker-timespan:after {
  1541. background-color: rgba(0, 0, 0, 0.25);
  1542. }
  1543. .tl-timemarker.tl-timemarker-with-end.tl-timemarker-active .tl-timemarker-timespan:after {
  1544. background-color: rgba(51, 51, 51, 0.5);
  1545. }
  1546. .tl-timemarker.tl-timemarker-with-end.tl-timemarker-active .tl-timemarker-line-left,
  1547. .tl-timemarker.tl-timemarker-with-end.tl-timemarker-active .tl-timemarker-line-right {
  1548. border-width: 1px;
  1549. }
  1550. .tl-timemarker.tl-timemarker-with-end.tl-timemarker-active .tl-timemarker-line-left:after,
  1551. .tl-timemarker.tl-timemarker-with-end.tl-timemarker-active .tl-timemarker-line-right:after {
  1552. background-color: #333 !important;
  1553. }
  1554. .tl-timemarker.tl-timemarker-with-end.tl-timemarker-active .tl-timemarker-line-left {
  1555. box-shadow: none;
  1556. }
  1557. .tl-timemarker.tl-timemarker-with-end.tl-timemarker-active:hover .tl-timemarker-timespan:after {
  1558. background-color: rgba(51, 51, 51, 0.5);
  1559. }
  1560. /* Skinny
  1561. ================================================== */
  1562. /* Mobile, iPhone
  1563. ================================================== */
  1564. /* Mobile, iPhone and skinny
  1565. ================================================== */
  1566. /* TL.TimeMarker
  1567. ================================================== */
  1568. .tl-timeera {
  1569. height: 100%;
  1570. height: 40px;
  1571. position: absolute;
  1572. bottom: 0;
  1573. left: 0;
  1574. pointer-events: none;
  1575. z-index: 3;
  1576. /* Animate Left Width and Top
  1577. ================================================== */
  1578. -webkit-transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1579. -moz-transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1580. -o-transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1581. transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1582. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1583. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1584. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1585. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1586. /* Timespan
  1587. ================================================== */
  1588. /* Content
  1589. ================================================== */
  1590. }
  1591. .tl-timeera.tl-timeera-fast {
  1592. -webkit-transition: top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1593. -moz-transition: top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1594. -o-transition: top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1595. transition: top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 500ms cubic-bezier(0.77, 0, 0.175, 1);
  1596. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1597. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1598. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1599. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1600. }
  1601. .tl-timeera .tl-timeera-background {
  1602. position: absolute;
  1603. background-color: #28a6c3;
  1604. width: 100%;
  1605. height: 100%;
  1606. opacity: 1;
  1607. }
  1608. .tl-timeera.tl-timeera-color0 .tl-timeera-background {
  1609. background-color: #c34528;
  1610. }
  1611. .tl-timeera.tl-timeera-color1 .tl-timeera-background {
  1612. background-color: #28a6c3;
  1613. }
  1614. .tl-timeera.tl-timeera-color2 .tl-timeera-background {
  1615. background-color: #2832c3;
  1616. }
  1617. .tl-timeera.tl-timeera-color3 .tl-timeera-background {
  1618. background-color: #28c36c;
  1619. }
  1620. .tl-timeera.tl-timeera-color4 .tl-timeera-background {
  1621. background-color: #286dc3;
  1622. }
  1623. .tl-timeera.tl-timeera-color5 .tl-timeera-background {
  1624. background-color: #28c3a7;
  1625. }
  1626. .tl-timeera .tl-timeera-content-container {
  1627. position: absolute;
  1628. border: 0;
  1629. border-top-left-radius: 4px;
  1630. border-top-right-radius: 4px;
  1631. border-bottom-right-radius: 4px;
  1632. height: 100%;
  1633. width: 100px;
  1634. overflow: hidden;
  1635. -webkit-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1636. -moz-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1637. -o-transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1638. transition: height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1639. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1640. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1641. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1642. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1643. box-sizing: border-box;
  1644. border: 1px solid #d9d9d9;
  1645. }
  1646. .tl-timeera .tl-timeera-content-container .tl-timeera-content {
  1647. position: relative;
  1648. overflow: hidden;
  1649. height: 100%;
  1650. padding: 5px;
  1651. -webkit-box-sizing: border-box;
  1652. /* Safari/Chrome, other WebKit */
  1653. -moz-box-sizing: border-box;
  1654. /* Firefox, other Gecko */
  1655. box-sizing: border-box;
  1656. }
  1657. .tl-timeera .tl-timeera-content-container .tl-timeera-content .tl-timeera-text {
  1658. overflow: hidden;
  1659. position: relative;
  1660. height: 100%;
  1661. }
  1662. .tl-timeera .tl-timeera-content-container .tl-timeera-content .tl-timeera-text h2.tl-headline {
  1663. bottom: 0px;
  1664. position: absolute;
  1665. display: -webkit-box;
  1666. line-clamp: 4;
  1667. -webkit-line-clamp: 4;
  1668. box-orient: vertical;
  1669. -webkit-box-orient: vertical;
  1670. text-overflow: ellipsis;
  1671. font-size: 10px;
  1672. line-height: 10px;
  1673. overflow: hidden;
  1674. font-weight: normal;
  1675. margin: 0;
  1676. color: #FFF;
  1677. margin-left: 10px;
  1678. }
  1679. .tl-timeera .tl-timeera-content-container .tl-timeera-content .tl-timeera-text h2.tl-headline.tl-headline-fadeout:after {
  1680. content: "";
  1681. text-align: right;
  1682. position: absolute;
  1683. bottom: 0;
  1684. right: 0;
  1685. width: 100%;
  1686. height: 50%;
  1687. background: -moz-linear-gradient(top, rgba(229, 229, 229, 0) 0%, #e5e5e5 50%);
  1688. /* FF3.6+ */
  1689. background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(229, 229, 229, 0)), color-stop(50%, #e5e5e5));
  1690. /* Chrome,Safari4+ */
  1691. background: -webkit-linear-gradient(top, rgba(229, 229, 229, 0) 0%, #e5e5e5 50%);
  1692. /* Chrome10+,Safari5.1+ */
  1693. background: -o-linear-gradient(top, rgba(229, 229, 229, 0) 0%, #e5e5e5 50%);
  1694. /* Opera 11.10+ */
  1695. background: -ms-linear-gradient(top, rgba(229, 229, 229, 0) 0%, #e5e5e5 50%);
  1696. /* IE10+ */
  1697. background: linear-gradient(to bottom, rgba(229, 229, 229, 0) 0%, #e5e5e5 50%);
  1698. /* W3C */
  1699. filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='@thecolor', endColorstr='@thecolor', GradientType=0);
  1700. /* IE6-9 */
  1701. }
  1702. /* Skinny
  1703. ================================================== */
  1704. /* Mobile, iPhone
  1705. ================================================== */
  1706. /* Mobile, iPhone and skinny
  1707. ================================================== */
  1708. /* TL.TimeMarker
  1709. ================================================== */
  1710. .tl-timegroup {
  1711. width: 100%;
  1712. position: absolute;
  1713. top: 0;
  1714. left: 0;
  1715. background-color: #f2f2f2;
  1716. display: -ms-flexbox;
  1717. display: -webkit-flex;
  1718. display: flex;
  1719. align-items: center;
  1720. -ms-flex-align: center;
  1721. -webkit-align-items: center;
  1722. -webkit-box-align: center;
  1723. /* Animate Left Width and Top
  1724. ================================================== */
  1725. -webkit-transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1726. -moz-transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1727. -o-transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1728. transition: left 1000ms cubic-bezier(0.77, 0, 0.175, 1), top 500ms cubic-bezier(0.77, 0, 0.175, 1), height 500ms cubic-bezier(0.77, 0, 0.175, 1), width 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1729. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1730. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1731. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1732. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1733. }
  1734. .tl-timegroup .tl-timegroup-message {
  1735. color: #e0e0e0;
  1736. text-shadow: #FFF 0px 2px 2px;
  1737. margin-left: 80px;
  1738. }
  1739. .tl-timegroup.tl-timegroup-alternate {
  1740. background-color: #fafafa;
  1741. }
  1742. .tl-timegroup.tl-timegroup-hidden {
  1743. display: none;
  1744. }
  1745. /* Skinny
  1746. ================================================== */
  1747. /* Mobile, iPhone
  1748. ================================================== */
  1749. /* Mobile, iPhone and skinny
  1750. ================================================== */
  1751. /* TL.TimeAxis
  1752. ================================================== */
  1753. .tl-timeaxis-background {
  1754. height: 39px;
  1755. width: 100%;
  1756. position: absolute;
  1757. bottom: 0;
  1758. left: 0;
  1759. background-color: #FFF;
  1760. border-top: 1px solid #e5e5e5;
  1761. z-index: 2;
  1762. }
  1763. .tl-timeaxis {
  1764. height: 39px;
  1765. width: 100%;
  1766. position: absolute;
  1767. bottom: 0;
  1768. left: 0;
  1769. z-index: 3;
  1770. }
  1771. .tl-timeaxis .tl-timeaxis-content-container {
  1772. position: relative;
  1773. bottom: 0;
  1774. height: 39px;
  1775. }
  1776. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-major,
  1777. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor {
  1778. opacity: 0;
  1779. position: absolute;
  1780. }
  1781. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-major .tl-timeaxis-tick,
  1782. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor .tl-timeaxis-tick {
  1783. position: absolute;
  1784. display: block;
  1785. top: 0;
  1786. left: 0;
  1787. text-align: center;
  1788. font-weight: normal;
  1789. }
  1790. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-major .tl-timeaxis-tick .tl-timeaxis-tick-text,
  1791. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor .tl-timeaxis-tick .tl-timeaxis-tick-text {
  1792. display: inline-block;
  1793. white-space: nowrap;
  1794. text-overflow: ellipsis;
  1795. overflow: hidden;
  1796. }
  1797. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-major .tl-timeaxis-tick:before,
  1798. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor .tl-timeaxis-tick:before {
  1799. content: "|";
  1800. display: block;
  1801. color: #FFF;
  1802. width: 1px;
  1803. overflow: hidden;
  1804. border-left: 1px solid #bfbfbf;
  1805. text-align: center;
  1806. }
  1807. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-major.tl-timeaxis-animate .tl-timeaxis-tick,
  1808. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor.tl-timeaxis-animate .tl-timeaxis-tick {
  1809. -webkit-transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1810. -moz-transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1811. -o-transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1812. transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1813. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1814. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1815. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1816. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1817. }
  1818. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-major.tl-timeaxis-animate-opacity .tl-timeaxis-tick,
  1819. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor.tl-timeaxis-animate-opacity .tl-timeaxis-tick {
  1820. -webkit-transition: opacity 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1821. -moz-transition: opacity 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1822. -o-transition: opacity 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1823. transition: opacity 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1824. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1825. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1826. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1827. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1828. }
  1829. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-major {
  1830. z-index: 1;
  1831. background-color: #FFF;
  1832. }
  1833. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-major .tl-timeaxis-tick {
  1834. font-size: 12px;
  1835. line-height: 14px;
  1836. color: #737373;
  1837. width: 100px;
  1838. margin-left: -50px;
  1839. }
  1840. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-major .tl-timeaxis-tick:before {
  1841. border-color: #a6a6a6;
  1842. font-size: 18px;
  1843. line-height: 18px;
  1844. margin-bottom: 2px;
  1845. margin-left: 50px;
  1846. }
  1847. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor .tl-timeaxis-tick {
  1848. font-size: 11px;
  1849. line-height: 13px;
  1850. color: #bfbfbf;
  1851. width: 50px;
  1852. margin-left: -25px;
  1853. }
  1854. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor .tl-timeaxis-tick .tl-timeaxis-tick-text {
  1855. opacity: 0;
  1856. white-space: normal;
  1857. padding-left: 2px;
  1858. padding-right: 2px;
  1859. }
  1860. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor .tl-timeaxis-tick .tl-timeaxis-tick-text span {
  1861. display: block;
  1862. font-size: 9px;
  1863. line-height: 9px;
  1864. margin-top: -2px;
  1865. color: #e6e6e6;
  1866. }
  1867. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor .tl-timeaxis-tick:before {
  1868. font-size: 9px;
  1869. line-height: 9px;
  1870. margin-left: 25px;
  1871. }
  1872. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor .tl-timeaxis-tick.tl-timeaxis-tick-hidden .tl-timeaxis-tick-text {
  1873. opacity: 0 !important;
  1874. }
  1875. .tl-timeaxis .tl-timeaxis-content-container .tl-timeaxis-minor .tl-timeaxis-tick.tl-timeaxis-tick-hidden:before {
  1876. opacity: 0.33;
  1877. }
  1878. /* Skinny
  1879. ================================================== */
  1880. /* Mobile, iPhone
  1881. ================================================== */
  1882. /* Mobile, iPhone and skinny
  1883. ================================================== */
  1884. .tlanimate {
  1885. -webkit-transform: translateZ(0);
  1886. -webkit-perspective: 1000;
  1887. -webkit-backface-visibility: hidden;
  1888. }
  1889. .tl-animate {
  1890. -webkit-transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1891. -moz-transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1892. -o-transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1893. transition: all 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1894. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1895. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1896. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1897. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1898. }
  1899. .tl-animate-opacity {
  1900. -webkit-transition: opacity 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1901. -moz-transition: opacity 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1902. -o-transition: opacity 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1903. transition: opacity 1000ms cubic-bezier(0.77, 0, 0.175, 1);
  1904. -webkit-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1905. -moz-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1906. -o-transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1907. transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
  1908. }
  1909. /* SLIDE
  1910. ================================================== */
  1911. .tl-slide {
  1912. position: absolute;
  1913. width: 100%;
  1914. height: 100%;
  1915. padding: 0px;
  1916. margin: 0px;
  1917. overflow-x: hidden;
  1918. overflow-y: auto;
  1919. }
  1920. .tl-slide .tl-slide-background {
  1921. position: absolute;
  1922. left: 0;
  1923. top: 0;
  1924. width: 100%;
  1925. height: 100%;
  1926. z-index: -1;
  1927. overflow: hidden;
  1928. display: none;
  1929. filter: alpha(opacity=50);
  1930. -khtml-opacity: 0.5;
  1931. -moz-opacity: 0.5;
  1932. opacity: 0.5;
  1933. background: no-repeat center center;
  1934. -webkit-background-size: cover;
  1935. -moz-background-size: cover;
  1936. -o-background-size: cover;
  1937. background-size: cover;
  1938. }
  1939. .tl-slide .tl-slide-scrollable-container {
  1940. display: table;
  1941. table-layout: fixed;
  1942. height: 100%;
  1943. z-index: 1;
  1944. }
  1945. .tl-slide .tl-slide-content-container {
  1946. display: table-cell;
  1947. vertical-align: middle;
  1948. position: relative;
  1949. width: 100%;
  1950. height: 100%;
  1951. z-index: 3;
  1952. }
  1953. .tl-slide .tl-slide-content-container .tl-slide-content {
  1954. display: table;
  1955. vertical-align: middle;
  1956. padding-left: 100px;
  1957. padding-right: 100px;
  1958. position: relative;
  1959. max-width: 100%;
  1960. user-select: text;
  1961. }
  1962. .tl-slide .tl-slide-content-container .tl-slide-content .tl-media {
  1963. position: relative;
  1964. width: 100%;
  1965. min-width: 50%;
  1966. float: left;
  1967. margin-top: auto;
  1968. margin-bottom: auto;
  1969. }
  1970. .tl-slide .tl-slide-content-container .tl-slide-content .tl-text {
  1971. width: 50%;
  1972. max-width: 50%;
  1973. min-width: 120px;
  1974. padding: 0 20px 0 20px;
  1975. display: table-cell;
  1976. vertical-align: middle;
  1977. text-align: left;
  1978. }
  1979. /* Only Media (no text)
  1980. ================================================== */
  1981. .tl-slide-media-only .tl-slide-content-container .tl-slide-content {
  1982. text-align: center;
  1983. }
  1984. .tl-slide-media-only .tl-slide-content-container .tl-slide-content .tl-media {
  1985. text-align: center;
  1986. position: relative;
  1987. width: 100%;
  1988. min-width: 50%;
  1989. max-width: 100%;
  1990. float: none;
  1991. margin-top: auto;
  1992. margin-bottom: auto;
  1993. }
  1994. .tl-slide-media-only .tl-slide-content-container .tl-slide-content .tl-text {
  1995. width: 100%;
  1996. max-width: 100%;
  1997. display: block;
  1998. margin-left: auto;
  1999. margin-right: auto;
  2000. text-align: center;
  2001. }
  2002. /* Only Text (no media)
  2003. ================================================== */
  2004. .tl-slide-text-only .tl-slide-content-container .tl-slide-content {
  2005. text-align: center;
  2006. }
  2007. .tl-slide-text-only .tl-slide-content-container .tl-slide-content .tl-text {
  2008. max-width: 80%;
  2009. width: 80%;
  2010. display: block;
  2011. margin-left: auto;
  2012. margin-right: auto;
  2013. }
  2014. /* Background
  2015. ================================================== */
  2016. .tl-slide.tl-full-image-background,
  2017. .tl-slide.tl-full-color-background {
  2018. color: #FFF !important;
  2019. text-shadow: 1px 1px 1px #000 !important;
  2020. }
  2021. .tl-slide.tl-full-image-background p,
  2022. .tl-slide.tl-full-color-background p,
  2023. .tl-slide.tl-full-image-background h1,
  2024. .tl-slide.tl-full-color-background h1,
  2025. .tl-slide.tl-full-image-background h2,
  2026. .tl-slide.tl-full-color-background h2,
  2027. .tl-slide.tl-full-image-background h3,
  2028. .tl-slide.tl-full-color-background h3,
  2029. .tl-slide.tl-full-image-background h4,
  2030. .tl-slide.tl-full-color-background h4,
  2031. .tl-slide.tl-full-image-background h5,
  2032. .tl-slide.tl-full-color-background h5,
  2033. .tl-slide.tl-full-image-background h6,
  2034. .tl-slide.tl-full-color-background h6 {
  2035. color: #FFF !important;
  2036. text-shadow: 1px 1px 1px #000 !important;
  2037. }
  2038. .tl-slide.tl-full-image-background a,
  2039. .tl-slide.tl-full-color-background a,
  2040. .tl-slide.tl-full-image-background b,
  2041. .tl-slide.tl-full-color-background b,
  2042. .tl-slide.tl-full-image-background i,
  2043. .tl-slide.tl-full-color-background i,
  2044. .tl-slide.tl-full-image-background blockquote,
  2045. .tl-slide.tl-full-color-background blockquote,
  2046. .tl-slide.tl-full-image-background blockquote p,
  2047. .tl-slide.tl-full-color-background blockquote p {
  2048. text-shadow: 1px 1px 1px #000;
  2049. color: #ffffff;
  2050. }
  2051. .tl-slide.tl-full-image-background a:hover,
  2052. .tl-slide.tl-full-color-background a:hover {
  2053. text-decoration: underline;
  2054. color: #c34528;
  2055. }
  2056. .tl-slide.tl-full-image-background .tl-caption,
  2057. .tl-slide.tl-full-color-background .tl-caption,
  2058. .tl-slide.tl-full-image-background .tl-credit,
  2059. .tl-slide.tl-full-color-background .tl-credit {
  2060. color: #FFF !important;
  2061. text-shadow: 1px 1px 1px #000 !important;
  2062. }
  2063. .tl-slide.tl-full-image-background .tl-media-twitter blockquote,
  2064. .tl-slide.tl-full-color-background .tl-media-twitter blockquote,
  2065. .tl-slide.tl-full-image-background .tl-media-blockquote blockquote,
  2066. .tl-slide.tl-full-color-background .tl-media-blockquote blockquote {
  2067. color: #FFF !important;
  2068. text-shadow: 1px 1px 1px #000 !important;
  2069. }
  2070. .tl-slide.tl-full-image-background .tl-media-twitter blockquote p,
  2071. .tl-slide.tl-full-color-background .tl-media-twitter blockquote p,
  2072. .tl-slide.tl-full-image-background .tl-media-blockquote blockquote p,
  2073. .tl-slide.tl-full-color-background .tl-media-blockquote blockquote p {
  2074. color: #FFF !important;
  2075. text-shadow: 1px 1px 1px #000 !important;
  2076. }
  2077. .tl-slide.tl-full-image-background .vcard a,
  2078. .tl-slide.tl-full-color-background .vcard a,
  2079. .tl-slide.tl-full-image-background .vcard .nickname,
  2080. .tl-slide.tl-full-color-background .vcard .nickname {
  2081. color: #FFF !important;
  2082. text-shadow: 1px 1px 1px #000 !important;
  2083. }
  2084. /* Full Image Background
  2085. ================================================== */
  2086. .tl-slide.tl-full-image-background {
  2087. background: no-repeat center center;
  2088. -webkit-background-size: cover;
  2089. -moz-background-size: cover;
  2090. -o-background-size: cover;
  2091. background-size: cover;
  2092. background-position: center 25%;
  2093. text-shadow: 1px 1px 2px #000;
  2094. }
  2095. .tl-slide.tl-full-image-background p,
  2096. .tl-slide.tl-full-image-background h1,
  2097. .tl-slide.tl-full-image-background h2,
  2098. .tl-slide.tl-full-image-background h3,
  2099. .tl-slide.tl-full-image-background h4,
  2100. .tl-slide.tl-full-image-background h5,
  2101. .tl-slide.tl-full-image-background h6 {
  2102. text-shadow: 1px 1px 2px #000;
  2103. }
  2104. .tl-slide.tl-full-image-background .tl-caption,
  2105. .tl-slide.tl-full-image-background .tl-credit {
  2106. text-shadow: 1px 1px 2px #000;
  2107. }
  2108. .tl-slide.tl-full-image-background .tl-media-twitter blockquote,
  2109. .tl-slide.tl-full-image-background .tl-media-blockquote blockquote {
  2110. text-shadow: 1px 1px 2px #000 !important;
  2111. }
  2112. .tl-slide.tl-full-image-background .tl-media-twitter blockquote p,
  2113. .tl-slide.tl-full-image-background .tl-media-blockquote blockquote p {
  2114. text-shadow: 1px 1px 2px #000 !important;
  2115. }
  2116. /* Color Background
  2117. ================================================== */
  2118. /* Text Background
  2119. ================================================== */
  2120. .tl-slide.tl-text-background .tl-text .tl-text-content-container {
  2121. padding: 20px;
  2122. /* Fallback for web browsers that doesn't support RGBa */
  2123. background: #000000 transparent;
  2124. /* RGBa with 0.6 opacity */
  2125. background: rgba(0, 0, 0, 0.6);
  2126. -webkit-border-radius: 7px;
  2127. -moz-border-radius: 7px;
  2128. border-radius: 7px;
  2129. }
  2130. .tl-slide.tl-text-background .tl-text .tl-text-content-container h2 {
  2131. margin-top: 5px;
  2132. }
  2133. /* Skinny
  2134. ================================================== */
  2135. .tl-skinny .tl-slide {
  2136. display: block;
  2137. padding-top: 10px;
  2138. }
  2139. .tl-skinny .tl-slide .tl-slide-content-container {
  2140. display: block;
  2141. position: static;
  2142. height: auto;
  2143. height: 100%;
  2144. display: -webkit-flex;
  2145. /* Safari */
  2146. display: flex;
  2147. align-items: center;
  2148. -webkit-align-items: center;
  2149. /* Safari 7.0+ */
  2150. }
  2151. .tl-skinny .tl-slide .tl-slide-content-container .tl-slide-content {
  2152. display: block;
  2153. display: -webkit-flex;
  2154. /* Safari */
  2155. display: flex;
  2156. flex-direction: column-reverse;
  2157. -webkit-flex-direction: column-reverse;
  2158. /* Safari */
  2159. position: static;
  2160. height: auto;
  2161. padding-left: 50px;
  2162. padding-right: 50px;
  2163. }
  2164. .tl-skinny .tl-slide .tl-slide-content-container .tl-slide-content .tl-media {
  2165. position: static;
  2166. width: 100%;
  2167. height: auto;
  2168. float: none;
  2169. display: block;
  2170. padding-top: 20px;
  2171. border-top: 1px solid #e6e6e6;
  2172. }
  2173. .tl-skinny .tl-slide .tl-slide-content-container .tl-slide-content .tl-text {
  2174. display: block;
  2175. height: auto;
  2176. vertical-align: initial;
  2177. position: static;
  2178. width: 100%;
  2179. max-width: 100%;
  2180. min-width: 0;
  2181. float: none;
  2182. padding: 0;
  2183. }
  2184. .tl-skinny .tl-slide .tl-slide-content-container .tl-slide-content .tl-text .tl-text-content-container {
  2185. padding-left: 10px;
  2186. padding-right: 10px;
  2187. padding-bottom: 10px;
  2188. }
  2189. .tl-skinny .tl-slide.tl-slide.tl-full-color-background .tl-slide-content-container .tl-slide-content .tl-media,
  2190. .tl-skinny .tl-slide.tl-full-image-background .tl-slide-content-container .tl-slide-content .tl-media {
  2191. border-color: rgba(230, 230, 230, 0.25);
  2192. }
  2193. .tl-skinny .tl-slide.tl-slide-media-only .tl-slide-content-container .tl-slide-content {
  2194. flex-direction: column;
  2195. -webkit-flex-direction: column;
  2196. /* Safari */
  2197. }
  2198. .tl-skinny .tl-slide.tl-slide-media-only .tl-slide-content-container .tl-slide-content .tl-media {
  2199. border-top: none;
  2200. padding-top: 0px;
  2201. }
  2202. /* Mobile, iPhone
  2203. ================================================== */
  2204. /* Mobile, iPhone and skinny
  2205. ================================================== */
  2206. .tl-mobile.tl-skinny .tl-slide .tl-slide-content-container .tl-slide-content .tl-media img,
  2207. .tl-mobile.tl-skinny .tl-slide .tl-slide-content-container .tl-slide-content .tl-media embed,
  2208. .tl-mobile.tl-skinny .tl-slide .tl-slide-content-container .tl-slide-content .tl-media object,
  2209. .tl-mobile.tl-skinny .tl-slide .tl-slide-content-container .tl-slide-content .tl-media video,
  2210. .tl-mobile.tl-skinny .tl-slide .tl-slide-content-container .tl-slide-content .tl-media iframe {
  2211. max-height: 175px;
  2212. }
  2213. /* SlideNav
  2214. ================================================== */
  2215. /* NAVIGATION
  2216. ================================================== */
  2217. .tl-slidenav-previous,
  2218. .tl-slidenav-next {
  2219. position: absolute;
  2220. top: 45%;
  2221. z-index: 10;
  2222. cursor: pointer;
  2223. }
  2224. .tl-slidenav-previous .tl-slidenav-content-container,
  2225. .tl-slidenav-next .tl-slidenav-content-container {
  2226. height: 200px;
  2227. width: 100px;
  2228. position: absolute;
  2229. }
  2230. .tl-slidenav-previous .tl-slidenav-title,
  2231. .tl-slidenav-next .tl-slidenav-title,
  2232. .tl-slidenav-previous .tl-slidenav-description,
  2233. .tl-slidenav-next .tl-slidenav-description {
  2234. width: 80px;
  2235. -webkit-line-clamp: 2;
  2236. line-clamp: 2;
  2237. text-overflow: ellipsis;
  2238. /* Non standard for webkit */
  2239. /*
  2240. -webkit-hyphens: auto;
  2241. -moz-hyphens: auto;
  2242. -ms-hyphens: auto;
  2243. hyphens: auto;
  2244. */
  2245. }
  2246. .tl-slidenav-previous .tl-slidenav-title small,
  2247. .tl-slidenav-next .tl-slidenav-title small,
  2248. .tl-slidenav-previous .tl-slidenav-description small,
  2249. .tl-slidenav-next .tl-slidenav-description small {
  2250. display: block;
  2251. }
  2252. .tl-slidenav-previous .tl-slidenav-title,
  2253. .tl-slidenav-next .tl-slidenav-title {
  2254. margin-top: 10px;
  2255. filter: alpha(opacity=15);
  2256. -khtml-opacity: 0.15;
  2257. -moz-opacity: 0.15;
  2258. opacity: 0.15;
  2259. font-size: 11px;
  2260. line-height: 11px;
  2261. }
  2262. .tl-slidenav-previous .tl-slidenav-description,
  2263. .tl-slidenav-next .tl-slidenav-description {
  2264. font-size: 11px;
  2265. margin-top: 5px;
  2266. filter: alpha(opacity=0);
  2267. -khtml-opacity: 0;
  2268. -moz-opacity: 0;
  2269. opacity: 0;
  2270. }
  2271. .tl-slidenav-previous .tl-slidenav-description small,
  2272. .tl-slidenav-next .tl-slidenav-description small {
  2273. display: none;
  2274. }
  2275. /* NAVIGATION COLOR
  2276. ================================================== */
  2277. .tl-slidenav-previous .tl-slidenav-content-container .tl-slidenav-icon,
  2278. .tl-slidenav-next .tl-slidenav-content-container .tl-slidenav-icon,
  2279. .tl-slidenav-previous .tl-slidenav-content-container .tl-slidenav-title,
  2280. .tl-slidenav-next .tl-slidenav-content-container .tl-slidenav-title,
  2281. .tl-slidenav-previous .tl-slidenav-content-container .tl-slidenav-description,
  2282. .tl-slidenav-next .tl-slidenav-content-container .tl-slidenav-description {
  2283. text-shadow: 1px 1px 1px #FFF;
  2284. color: #333;
  2285. }
  2286. .tl-slidenav-previous .tl-slidenav-content-container.tl-slidenav-inverted .tl-slidenav-icon,
  2287. .tl-slidenav-next .tl-slidenav-content-container.tl-slidenav-inverted .tl-slidenav-icon,
  2288. .tl-slidenav-previous .tl-slidenav-content-container.tl-slidenav-inverted .tl-slidenav-title,
  2289. .tl-slidenav-next .tl-slidenav-content-container.tl-slidenav-inverted .tl-slidenav-title,
  2290. .tl-slidenav-previous .tl-slidenav-content-container.tl-slidenav-inverted .tl-slidenav-description,
  2291. .tl-slidenav-next .tl-slidenav-content-container.tl-slidenav-inverted .tl-slidenav-description {
  2292. color: #f2f2f2;
  2293. text-shadow: 1px 1px 1px #333;
  2294. }
  2295. /* ICONS
  2296. ================================================== */
  2297. .tl-slidenav-next .tl-slidenav-icon,
  2298. .tl-slidenav-previous .tl-slidenav-icon {
  2299. font-family: 'tl-icons';
  2300. speak: none;
  2301. font-style: normal;
  2302. font-weight: normal;
  2303. font-variant: normal;
  2304. text-transform: none;
  2305. line-height: 1;
  2306. -webkit-font-smoothing: antialiased;
  2307. -moz-osx-font-smoothing: grayscale;
  2308. font-size: 32px;
  2309. margin-bottom: 5px;
  2310. }
  2311. .tl-slidenav-next {
  2312. text-align: right;
  2313. margin-right: 10px;
  2314. right: 100px;
  2315. }
  2316. .tl-slidenav-next .tl-slidenav-title,
  2317. .tl-slidenav-next .tl-slidenav-description {
  2318. margin-left: 20px;
  2319. }
  2320. .tl-slidenav-next .tl-slidenav-icon {
  2321. margin-left: 76px;
  2322. }
  2323. .tl-slidenav-next .tl-slidenav-icon:before {
  2324. content: "\e64f";
  2325. }
  2326. .tl-slidenav-previous {
  2327. text-align: left;
  2328. margin-left: 10px;
  2329. }
  2330. .tl-slidenav-previous .tl-slidenav-icon {
  2331. margin-left: 0px;
  2332. }
  2333. .tl-slidenav-previous .tl-slidenav-icon:before {
  2334. content: "\e650";
  2335. }
  2336. /* NAVIGATION HOVER
  2337. ================================================== */
  2338. .tl-slidenav-previous:hover .tl-slidenav-title,
  2339. .tl-slidenav-next:hover .tl-slidenav-title {
  2340. filter: alpha(opacity=100);
  2341. -khtml-opacity: 1;
  2342. -moz-opacity: 1;
  2343. opacity: 1;
  2344. }
  2345. .tl-slidenav-previous:hover .tl-slidenav-description,
  2346. .tl-slidenav-next:hover .tl-slidenav-description {
  2347. filter: alpha(opacity=50);
  2348. -khtml-opacity: 0.5;
  2349. -moz-opacity: 0.5;
  2350. opacity: 0.5;
  2351. }
  2352. .tl-slidenav-next:hover .tl-slidenav-icon {
  2353. margin-left: 80px;
  2354. }
  2355. .tl-slidenav-previous:hover .tl-slidenav-icon {
  2356. margin-left: -4px;
  2357. }
  2358. .tl-skinny .tl-slidenav-next {
  2359. right: 32px;
  2360. }
  2361. .tl-skinny .tl-slidenav-next .tl-slidenav-icon {
  2362. margin-left: 8px;
  2363. }
  2364. .tl-skinny .tl-slidenav-previous .tl-slidenav-content-container,
  2365. .tl-skinny .tl-slidenav-next .tl-slidenav-content-container {
  2366. width: 32px;
  2367. height: 32px;
  2368. }
  2369. .tl-skinny .tl-slidenav-previous .tl-slidenav-title,
  2370. .tl-skinny .tl-slidenav-next .tl-slidenav-title,
  2371. .tl-skinny .tl-slidenav-previous .tl-slidenav-description,
  2372. .tl-skinny .tl-slidenav-next .tl-slidenav-description {
  2373. display: none;
  2374. }
  2375. .tl-skinny .tl-slidenav-previous .tl-slidenav-icon,
  2376. .tl-skinny .tl-slidenav-next .tl-slidenav-icon {
  2377. filter: alpha(opacity=33);
  2378. -khtml-opacity: 0.33;
  2379. -moz-opacity: 0.33;
  2380. opacity: 0.33;
  2381. }
  2382. .tl-skinny .tl-slidenav-next:hover .tl-slidenav-icon {
  2383. margin-left: 12px;
  2384. filter: alpha(opacity=100);
  2385. -khtml-opacity: 1;
  2386. -moz-opacity: 1;
  2387. opacity: 1;
  2388. }
  2389. .tl-skinny .tl-slidenav-previous:hover .tl-slidenav-icon {
  2390. margin-left: -4px;
  2391. filter: alpha(opacity=100);
  2392. -khtml-opacity: 1;
  2393. -moz-opacity: 1;
  2394. opacity: 1;
  2395. }
  2396. .tl-layout-landscape.tl-mobile .tl-slidenav-next:hover {
  2397. right: 70px;
  2398. }
  2399. .tl-layout-landscape.tl-mobile .tl-slidenav-next:hover .tl-slidenav-icon {
  2400. margin-left: 8px;
  2401. filter: alpha(opacity=100);
  2402. -khtml-opacity: 1;
  2403. -moz-opacity: 1;
  2404. opacity: 1;
  2405. }
  2406. .tl-layout-landscape.tl-mobile .tl-slidenav-next:active .tl-slidenav-icon {
  2407. margin-left: 0px;
  2408. filter: alpha(opacity=100);
  2409. -khtml-opacity: 1;
  2410. -moz-opacity: 1;
  2411. opacity: 1;
  2412. }
  2413. .tl-layout-landscape.tl-mobile .tl-slidenav-previous:hover .tl-slidenav-icon {
  2414. margin-left: 80px;
  2415. filter: alpha(opacity=100);
  2416. -khtml-opacity: 1;
  2417. -moz-opacity: 1;
  2418. opacity: 1;
  2419. }
  2420. .tl-layout-landscape.tl-mobile .tl-slidenav-previous:active .tl-slidenav-icon {
  2421. filter: alpha(opacity=100);
  2422. -khtml-opacity: 1;
  2423. -moz-opacity: 1;
  2424. opacity: 1;
  2425. margin-left: -4px;
  2426. }
  2427. .tl-layout-portrait.tl-mobile .tl-slidenav-next:hover .tl-slidenav-icon {
  2428. filter: alpha(opacity=33);
  2429. -khtml-opacity: 0.33;
  2430. -moz-opacity: 0.33;
  2431. opacity: 0.33;
  2432. }
  2433. .tl-layout-portrait.tl-mobile .tl-slidenav-next:active .tl-slidenav-icon {
  2434. filter: alpha(opacity=100);
  2435. -khtml-opacity: 1;
  2436. -moz-opacity: 1;
  2437. opacity: 1;
  2438. }
  2439. .tl-layout-portrait.tl-mobile .tl-slidenav-previous:hover .tl-slidenav-icon {
  2440. filter: alpha(opacity=33);
  2441. -khtml-opacity: 0.33;
  2442. -moz-opacity: 0.33;
  2443. opacity: 0.33;
  2444. }
  2445. .tl-layout-portrait.tl-mobile .tl-slidenav-previous:active .tl-slidenav-icon {
  2446. filter: alpha(opacity=100);
  2447. -khtml-opacity: 1;
  2448. -moz-opacity: 1;
  2449. opacity: 1;
  2450. }
  2451. .tl-mobile .tl-slidenav-previous,
  2452. .tl-skinny.tl-mobile .tl-slidenav-previous,
  2453. .tl-skinny.tl-layout-landscape.tl-mobile .tl-slidenav-previous,
  2454. .tl-skinny.tl-layout-portrait.tl-mobile .tl-slidenav-previous,
  2455. .tl-mobile .tl-slidenav-next,
  2456. .tl-skinny.tl-mobile .tl-slidenav-next,
  2457. .tl-skinny.tl-layout-landscape.tl-mobile .tl-slidenav-next,
  2458. .tl-skinny.tl-layout-portrait.tl-mobile .tl-slidenav-next {
  2459. display: none;
  2460. }
  2461. /* StorySlider
  2462. ================================================== */
  2463. /* SLIDER CONTAINERS
  2464. ================================================== */
  2465. .tl-storyslider {
  2466. width: 100%;
  2467. height: 100%;
  2468. overflow: hidden;
  2469. -webkit-user-select: none;
  2470. -moz-user-select: none;
  2471. -ms-user-select: none;
  2472. -o-user-select: none;
  2473. user-select: none;
  2474. position: relative;
  2475. box-sizing: content-box;
  2476. z-index: 8;
  2477. }
  2478. .tl-storyslider img,
  2479. .tl-storyslider embed,
  2480. .tl-storyslider object,
  2481. .tl-storyslider video,
  2482. .tl-storyslider iframe {
  2483. max-width: 100%;
  2484. position: relative;
  2485. }
  2486. .tl-storyslider .tl-slider-background {
  2487. position: absolute;
  2488. top: 0;
  2489. left: 0;
  2490. width: 100%;
  2491. height: 100%;
  2492. z-index: 1;
  2493. }
  2494. .tl-storyslider .tl-slider-touch-mask {
  2495. width: 100%;
  2496. height: 100%;
  2497. z-index: 25;
  2498. top: 0px;
  2499. left: 0px;
  2500. position: absolute;
  2501. }
  2502. .tl-storyslider .tl-slider-container-mask {
  2503. text-align: center;
  2504. width: 100%;
  2505. height: 100%;
  2506. position: relative;
  2507. z-index: 5;
  2508. }
  2509. .tl-storyslider .tl-slider-container-mask .tl-slider-container {
  2510. position: absolute;
  2511. top: 0px;
  2512. left: 0px;
  2513. width: 100%;
  2514. height: 100%;
  2515. text-align: center;
  2516. }
  2517. .tl-storyslider .tl-slider-container-mask .tl-slider-container .tl-slider-item-container {
  2518. width: 100%;
  2519. height: 100%;
  2520. display: table-cell;
  2521. vertical-align: middle;
  2522. }
  2523. /* Skinny
  2524. ================================================== */
  2525. /* Mobile, iPhone
  2526. ================================================== */
  2527. /* Mobile, iPhone and skinny
  2528. ================================================== */
  2529. /* Requires Variables.less
  2530. ================================================== */
  2531. .tl-media {
  2532. width: 100%;
  2533. min-width: 50%;
  2534. height: 100%;
  2535. float: left;
  2536. margin-top: auto;
  2537. margin-bottom: auto;
  2538. position: relative;
  2539. }
  2540. .tl-media .tl-media-content-container.tl-media-content-container-text {
  2541. border-right: 1px solid #e6e6e6;
  2542. padding-right: 20px;
  2543. }
  2544. .tl-media .tl-media-content-container .tl-media-content {
  2545. position: relative;
  2546. }
  2547. .tl-media .tl-media-content-container .tl-media-content:before,
  2548. .tl-media .tl-media-content-container .tl-media-content:after {
  2549. display: table;
  2550. content: "";
  2551. }
  2552. .tl-media .tl-media-content-container .tl-media-content:after {
  2553. clear: both;
  2554. }
  2555. .tl-media .tl-media-content-container .tl-media-content .tl-media-loaderror p {
  2556. color: #f2f2f2;
  2557. text-align: center;
  2558. }
  2559. .tl-media .tl-media-content-container .tl-media-content .tl-media-loaderror p span {
  2560. color: #f2f2f2;
  2561. }
  2562. .tl-media .tl-media-content-container .tl-media-content .tl-media-loaderror [class^="tl-icon-"],
  2563. .tl-media .tl-media-content-container .tl-media-content .tl-media-loaderror [class*=" tl-icon-"] {
  2564. font-size: 28px;
  2565. color: #f2f2f2;
  2566. text-align: center;
  2567. }
  2568. .tl-media .tl-media-content-container .tl-media-content img,
  2569. .tl-media .tl-media-content-container .tl-media-content embed,
  2570. .tl-media .tl-media-content-container .tl-media-content object,
  2571. .tl-media .tl-media-content-container .tl-media-content video {
  2572. max-width: 100%;
  2573. max-height: 100%;
  2574. }
  2575. /* Media Only Slides
  2576. ================================================== */
  2577. .tl-slide-media-only .tl-media .tl-media-content-container.tl-media-content-container-text {
  2578. border-right: none;
  2579. padding-right: 0;
  2580. }
  2581. /* Media Shodow
  2582. ================================================== */
  2583. .tl-media-shadow {
  2584. position: relative;
  2585. z-index: 1;
  2586. -webkit-box-shadow: 0 12px 10px -10px rgba(0, 0, 0, 0.6);
  2587. -moz-box-shadow: 0 12px 10px -10px rgba(0, 0, 0, 0.6);
  2588. box-shadow: 0 12px 10px -10px rgba(0, 0, 0, 0.6);
  2589. }
  2590. .tl-slide.tl-full-image-background a,
  2591. .tl-slide.tl-full-color-background a,
  2592. .tl-slide.tl-full-image-background .vcard a,
  2593. .tl-slide.tl-full-color-background .vcard a {
  2594. text-shadow: 1px 1px 1px #000;
  2595. color: #ffffff;
  2596. }
  2597. .tl-slide.tl-full-image-background a:hover,
  2598. .tl-slide.tl-full-color-background a:hover {
  2599. text-decoration: underline;
  2600. color: #c34528;
  2601. }
  2602. /* Credit
  2603. ================================================== */
  2604. .tl-credit {
  2605. color: #999999;
  2606. text-align: right;
  2607. display: block;
  2608. margin: 0 auto;
  2609. margin-top: 6px;
  2610. font-size: 10px;
  2611. line-height: 13px;
  2612. }
  2613. /* Caption
  2614. ================================================== */
  2615. .tl-caption {
  2616. text-align: left;
  2617. margin-right: auto;
  2618. margin-left: auto;
  2619. margin-top: 10px;
  2620. color: #666666;
  2621. font-size: 11px;
  2622. line-height: 14px;
  2623. text-rendering: optimizeLegibility;
  2624. word-wrap: break-word;
  2625. }
  2626. /* Full Image Background
  2627. ================================================== */
  2628. .tl-full-image-background .tl-media-shadow:before,
  2629. .tl-full-color-background .tl-media-shadow:before,
  2630. .tl-full-image-background .tl-media-shadow:after,
  2631. .tl-full-color-background .tl-media-shadow:after {
  2632. background: none;
  2633. -webkit-box-shadow: 0 0px 0px #000;
  2634. -moz-box-shadow: 0 0px 0px #000;
  2635. box-shadow: 0 0px 0px #000;
  2636. }
  2637. /* Skinny
  2638. ================================================== */
  2639. .tl-skinny .tl-media {
  2640. width: 100%;
  2641. height: auto;
  2642. float: none;
  2643. display: block;
  2644. }
  2645. .tl-skinny .tl-media .tl-media-content-container.tl-media-content-container-text {
  2646. border-right: 0;
  2647. padding-right: 0;
  2648. }
  2649. .tl-skinny .tl-media .tl-media-content-container .tl-credit,
  2650. .tl-skinny .tl-media .tl-media-content-container .tl-caption {
  2651. margin-top: 2px;
  2652. padding-left: 10px;
  2653. padding-right: 10px;
  2654. font-size: 8px;
  2655. }
  2656. .tl-skinny .tl-media .tl-media-content-container .tl-credit {
  2657. margin-top: 0px;
  2658. }
  2659. /* Mobile, iPhone
  2660. ================================================== */
  2661. /* Mobile, iPhone and skinny
  2662. ================================================== */
  2663. .tl-mobile.tl-skinny .tl-media {
  2664. width: 100%;
  2665. height: auto;
  2666. float: none;
  2667. display: block;
  2668. }
  2669. .tl-mobile.tl-skinny .tl-media .tl-media-content-container.tl-media-content-container-text {
  2670. border-right: 0;
  2671. padding-right: 0;
  2672. }
  2673. /* Requires Variables.less
  2674. ================================================== */
  2675. .tl-text {
  2676. width: 50%;
  2677. max-width: 50%;
  2678. min-width: 120px;
  2679. padding: 0 20px 0 20px;
  2680. display: table-cell;
  2681. vertical-align: middle;
  2682. text-align: left;
  2683. text-shadow: none;
  2684. color: #737373;
  2685. }
  2686. .tl-text p {
  2687. color: #737373;
  2688. }
  2689. .tl-text h2.tl-headline-title,
  2690. .tl-text h2.tl-headline {
  2691. margin-top: 0;
  2692. }
  2693. .tl-text .tl-headline-date,
  2694. .tl-text h3.tl-headline-date {
  2695. font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  2696. font-size: 15px;
  2697. line-height: 15px;
  2698. font-weight: normal;
  2699. margin: 0 0 3px 0;
  2700. color: #b3b3b3;
  2701. }
  2702. .tl-text .tl-headline-date small,
  2703. .tl-text h3.tl-headline-date small {
  2704. font-size: 15px;
  2705. line-height: 15px;
  2706. font-weight: normal;
  2707. color: #b3b3b3;
  2708. }
  2709. .tl-text .tl-text-date {
  2710. display: inline-block;
  2711. font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  2712. font-weight: normal;
  2713. margin-top: 10px;
  2714. font-size: 12px;
  2715. color: #b3b3b3;
  2716. }
  2717. .tl-full-image-background .tl-text,
  2718. .tl-full-color-background .tl-text,
  2719. .tl-full-image-background .tl-text p,
  2720. .tl-full-color-background .tl-text p {
  2721. color: #f2f2f2 !important;
  2722. text-shadow: 1px 1px 2px #000;
  2723. }
  2724. .tl-full-image-background .tl-text .tl-headline-date,
  2725. .tl-full-color-background .tl-text .tl-headline-date,
  2726. .tl-full-image-background .tl-text p .tl-headline-date,
  2727. .tl-full-color-background .tl-text p .tl-headline-date,
  2728. .tl-full-image-background .tl-text h3.tl-headline-date,
  2729. .tl-full-color-background .tl-text h3.tl-headline-date,
  2730. .tl-full-image-background .tl-text p h3.tl-headline-date,
  2731. .tl-full-color-background .tl-text p h3.tl-headline-date {
  2732. color: #f2f2f2 !important;
  2733. }
  2734. .tl-full-image-background .tl-text .tl-headline-date small,
  2735. .tl-full-color-background .tl-text .tl-headline-date small,
  2736. .tl-full-image-background .tl-text p .tl-headline-date small,
  2737. .tl-full-color-background .tl-text p .tl-headline-date small,
  2738. .tl-full-image-background .tl-text h3.tl-headline-date small,
  2739. .tl-full-color-background .tl-text h3.tl-headline-date small,
  2740. .tl-full-image-background .tl-text p h3.tl-headline-date small,
  2741. .tl-full-color-background .tl-text p h3.tl-headline-date small {
  2742. color: #f2f2f2 !important;
  2743. }
  2744. .tl-full-image-background .tl-text a:hover,
  2745. .tl-full-color-background .tl-text a:hover,
  2746. .tl-full-image-background .tl-text p a:hover,
  2747. .tl-full-color-background .tl-text p a:hover {
  2748. text-decoration: underline;
  2749. color: #c34528;
  2750. }
  2751. /* Skinny
  2752. ================================================== */
  2753. .tl-skinny .tl-text {
  2754. width: 100%;
  2755. max-width: 100%;
  2756. min-width: auto;
  2757. float: none;
  2758. margin-top: 20px;
  2759. }
  2760. .tl-skinny .tl-text h2.tl-headline-title,
  2761. .tl-skinny .tl-text h2.tl-headline {
  2762. font-size: 32px;
  2763. line-height: 36px;
  2764. }
  2765. /* Medium
  2766. ================================================== */
  2767. .tl-medium .tl-text h2.tl-headline-title,
  2768. .tl-medium .tl-text h2.tl-headline {
  2769. font-size: 32px;
  2770. line-height: 36px;
  2771. }
  2772. /* Mobile, iPhone
  2773. ================================================== */
  2774. /* Mobile, iPhone and skinny
  2775. ================================================== */
  2776. .tl-mobile.tl-skinny .tl-media .tl-media-image {
  2777. max-height: 250px !important;
  2778. }
  2779. .tl-media .tl-media-twitter {
  2780. text-align: left;
  2781. clear: both;
  2782. }
  2783. .tl-media .tl-media-twitter blockquote {
  2784. margin: 0;
  2785. margin-right: 15px;
  2786. font-size: 15px;
  2787. line-height: 21px;
  2788. color: #333;
  2789. }
  2790. .tl-media .tl-media-twitter blockquote p {
  2791. font-size: 28px;
  2792. line-height: 30px;
  2793. color: #000;
  2794. }
  2795. .tl-media .tl-media-twitter blockquote p:before {
  2796. display: none;
  2797. }
  2798. .tl-media .tl-media-twitter blockquote p:after {
  2799. display: none;
  2800. }
  2801. .tl-media .tl-media-twitter .tl-icon-twitter {
  2802. color: #55ACEE;
  2803. }
  2804. .tl-media .tl-media-twitter .vcard a:hover,
  2805. .tl-media .tl-media-twitter .vcard a.tl-date:hover {
  2806. text-decoration: none;
  2807. color: #55ACEE;
  2808. }
  2809. .tl-media .tl-media-twitter .vcard a:hover .fn,
  2810. .tl-media .tl-media-twitter .vcard a.tl-date:hover .fn,
  2811. .tl-media .tl-media-twitter .vcard a:hover .nickname,
  2812. .tl-media .tl-media-twitter .vcard a.tl-date:hover .nickname {
  2813. color: #55ACEE;
  2814. }
  2815. .tl-slide-media-only .tl-media .tl-media-twitter {
  2816. width: 80%;
  2817. margin-left: auto;
  2818. margin-right: auto;
  2819. }
  2820. .tl-mobile.tl-skinny .tl-media .tl-media-twitter blockquote p {
  2821. font-size: 15px;
  2822. line-height: 21px;
  2823. }
  2824. .tl-skinny .tl-media .tl-media-twitter {
  2825. margin-left: 10px;
  2826. margin-right: 10px;
  2827. }
  2828. .tl-skinny .tl-media .tl-media-twitter blockquote p {
  2829. font-size: 24px;
  2830. line-height: 26px;
  2831. }
  2832. .tl-media .tl-media-blockquote {
  2833. text-align: left;
  2834. clear: both;
  2835. }
  2836. .tl-media .tl-media-blockquote blockquote {
  2837. margin: 0;
  2838. margin-right: 15px;
  2839. text-align: left;
  2840. font-size: 28px;
  2841. line-height: 30px;
  2842. color: #333;
  2843. }
  2844. .tl-media .tl-media-blockquote blockquote p {
  2845. font-size: 28px;
  2846. line-height: 30px;
  2847. color: #333;
  2848. }
  2849. .tl-media .tl-media-blockquote blockquote p:before,
  2850. .tl-media .tl-media-blockquote blockquote p:after {
  2851. display: inline-block;
  2852. font-size: 36px;
  2853. }
  2854. .tl-media .tl-media-blockquote blockquote p:before {
  2855. content: open-quote;
  2856. margin-right: 5px;
  2857. }
  2858. .tl-media .tl-media-blockquote blockquote p:after {
  2859. content: close-quote;
  2860. margin-left: 3px;
  2861. }
  2862. .tl-media .tl-media-blockquote blockquote cite {
  2863. font-size: 15px;
  2864. line-height: 21px;
  2865. color: #999999;
  2866. text-align: right;
  2867. margin-top: 15px;
  2868. }
  2869. .tl-slide-media-only .tl-media .tl-media-blockquote {
  2870. border-right: 0;
  2871. width: 80%;
  2872. margin-left: auto;
  2873. margin-right: auto;
  2874. }
  2875. @media only screen and (max-width: 800px), only screen and (max-device-width: 800px) {
  2876. .tl-media .tl-media-blockquote blockquote p {
  2877. font-size: 24px;
  2878. line-height: 26px;
  2879. }
  2880. }
  2881. @media only screen and (max-width: 500px), only screen and (max-device-width: 500px) {
  2882. .tl-media .tl-media-blockquote blockquote p {
  2883. font-size: 15px;
  2884. line-height: 21px;
  2885. }
  2886. }
  2887. .tl-mobile.tl-skinny .tl-media .tl-media-instagram {
  2888. max-height: 250px !important;
  2889. }
  2890. .tl-media .tl-media-profile {
  2891. border-radius: 50%;
  2892. }
  2893. .tl-media .tl-media-iframe {
  2894. width: 100%;
  2895. height: 100%;
  2896. }
  2897. .tl-media .tl-media-iframe iframe {
  2898. width: 100%;
  2899. height: 100%;
  2900. }
  2901. .tl-media .tl-media-wikipedia {
  2902. text-align: left;
  2903. margin-left: auto;
  2904. margin-right: auto;
  2905. clear: both;
  2906. }
  2907. .tl-media .tl-media-wikipedia .tl-icon-wikipedia {
  2908. font-size: 32px;
  2909. margin-right: 10px;
  2910. float: left;
  2911. padding-top: 3px;
  2912. }
  2913. .tl-media .tl-media-wikipedia .tl-wikipedia-pageimage {
  2914. float: left;
  2915. margin-right: 10px;
  2916. margin-bottom: 5px;
  2917. margin-top: 5px;
  2918. }
  2919. .tl-media .tl-media-wikipedia .tl-wikipedia-title {
  2920. margin-left: 60px;
  2921. padding-left: 10px;
  2922. border-left: 1px solid #e6e6e6;
  2923. margin-bottom: 10px;
  2924. }
  2925. .tl-media .tl-media-wikipedia .tl-wikipedia-source {
  2926. font-size: 13px;
  2927. line-height: 15px;
  2928. font-style: italic;
  2929. margin-top: 3px;
  2930. display: block;
  2931. color: rgba(0, 0, 0, 0.5);
  2932. }
  2933. .tl-media .tl-media-wikipedia h4 {
  2934. margin-top: 0px;
  2935. margin-bottom: 0px;
  2936. }
  2937. .tl-media .tl-media-wikipedia h4 a {
  2938. color: #000;
  2939. text-decoration: none;
  2940. }
  2941. .tl-media .tl-media-wikipedia p {
  2942. font-size: 13px;
  2943. line-height: 19px;
  2944. }
  2945. .tl-slide-media-only .tl-media .tl-media-wikipedia {
  2946. border-right: 0;
  2947. border-top: 1px solid #e6e6e6;
  2948. width: 80%;
  2949. margin-left: auto;
  2950. margin-right: auto;
  2951. margin-top: 25px;
  2952. padding-top: 25px;
  2953. }
  2954. .tl-slide.tl-full-image-background .tl-media .tl-media-wikipedia,
  2955. .tl-slide.tl-full-color-background .tl-media .tl-media-wikipedia {
  2956. padding: 20px;
  2957. /* Fallback for web browsers that doesn't support RGBa */
  2958. background: #000000 transparent;
  2959. /* RGBa with 0.6 opacity */
  2960. background: rgba(0, 0, 0, 0.6);
  2961. -webkit-border-radius: 7px;
  2962. -moz-border-radius: 7px;
  2963. border-radius: 7px;
  2964. }
  2965. .tl-slide.tl-full-image-background .tl-media .tl-media-wikipedia h4 a,
  2966. .tl-slide.tl-full-color-background .tl-media .tl-media-wikipedia h4 a {
  2967. color: #FFF !important;
  2968. text-shadow: 1px 1px 1px #000 !important;
  2969. }
  2970. .tl-slide.tl-full-image-background .tl-media .tl-media-wikipedia a:hover,
  2971. .tl-slide.tl-full-color-background .tl-media .tl-media-wikipedia a:hover {
  2972. text-decoration: underline;
  2973. color: #c34528;
  2974. }
  2975. .tl-slide.tl-full-image-background .tl-media .tl-media-wikipedia .tl-wikipedia-title,
  2976. .tl-slide.tl-full-color-background .tl-media .tl-media-wikipedia .tl-wikipedia-title {
  2977. border-color: rgba(230, 230, 230, 0.25);
  2978. }
  2979. .tl-slide.tl-full-image-background .tl-media .tl-media-wikipedia .tl-wikipedia-source,
  2980. .tl-slide.tl-full-color-background .tl-media .tl-media-wikipedia .tl-wikipedia-source {
  2981. color: rgba(230, 230, 230, 0.85);
  2982. }
  2983. .tl-mobile.tl-skinny .tl-media .tl-media-wikipedia,
  2984. .tl-skinny .tl-media .tl-media-wikipedia {
  2985. margin-left: 10px;
  2986. margin-right: 10px;
  2987. }
  2988. .tl-media .tl-media-website {
  2989. text-align: left;
  2990. margin-left: auto;
  2991. margin-right: auto;
  2992. clear: both;
  2993. }
  2994. .tl-media .tl-media-website .tl-media-website-description {
  2995. font-size: 16px;
  2996. line-height: 19px;
  2997. font-style: italic;
  2998. margin-bottom: 10px;
  2999. text-transform: uppercase;
  3000. }
  3001. .tl-media .tl-media-website h4 {
  3002. margin-top: 0px;
  3003. margin-bottom: 0px;
  3004. line-height: 1;
  3005. }
  3006. .tl-media .tl-media-website h4 a {
  3007. color: #000;
  3008. text-decoration: none;
  3009. }
  3010. .tl-media .tl-media-website p {
  3011. font-size: 13px;
  3012. line-height: 19px;
  3013. }
  3014. .tl-media .tl-media-content-container .tl-media-content .tl-media-website img {
  3015. float: right;
  3016. max-width: 120px;
  3017. max-height: 120px;
  3018. margin: 4px 0 0 15px;
  3019. }
  3020. .tl-media .tl-media-content-container .tl-media-content .tl-media-website img.tl-media-website-icon {
  3021. max-width: 16px;
  3022. max-height: 16px;
  3023. float: none;
  3024. margin: 0;
  3025. margin-right: 3px;
  3026. }
  3027. .tl-slide.tl-full-image-background .tl-media .tl-media-website,
  3028. .tl-slide.tl-full-color-background .tl-media .tl-media-website {
  3029. padding: 20px;
  3030. /* Fallback for web browsers that doesn't support RGBa */
  3031. background: #000000 transparent;
  3032. /* RGBa with 0.6 opacity */
  3033. background: rgba(0, 0, 0, 0.6);
  3034. -webkit-border-radius: 7px;
  3035. -moz-border-radius: 7px;
  3036. border-radius: 7px;
  3037. }
  3038. .tl-slide.tl-full-image-background .tl-media .tl-media-website h4 a,
  3039. .tl-slide.tl-full-color-background .tl-media .tl-media-website h4 a {
  3040. color: #FFF !important;
  3041. text-shadow: 1px 1px 1px #000 !important;
  3042. }
  3043. .tl-slide.tl-full-image-background .tl-media .tl-media-website a:hover,
  3044. .tl-slide.tl-full-color-background .tl-media .tl-media-website a:hover {
  3045. text-decoration: underline;
  3046. color: #c34528;
  3047. }
  3048. .tl-mobile.tl-skinny .tl-media .tl-media-website {
  3049. margin-left: 10px;
  3050. margin-right: 10px;
  3051. }
  3052. /* Timeline
  3053. ================================================== */
  3054. .tl-timeline {
  3055. width: 100%;
  3056. height: 100%;
  3057. font-size: 16px;
  3058. line-height: normal;
  3059. overflow: hidden;
  3060. position: relative;
  3061. -webkit-user-select: none;
  3062. -moz-user-select: none;
  3063. -ms-user-select: none;
  3064. -o-user-select: none;
  3065. user-select: none;
  3066. background-color: #FFF;
  3067. color: #737373;
  3068. -webkit-box-sizing: content-box;
  3069. -moz-box-sizing: content-box;
  3070. box-sizing: content-box;
  3071. }
  3072. .tl-timeline.tl-timeline-embed {
  3073. box-sizing: border-box;
  3074. border-top: 1px solid #cccccc;
  3075. border-bottom: 1px solid #cccccc;
  3076. border-radius: 0;
  3077. }
  3078. .tl-timeline.tl-timeline-full-embed {
  3079. box-sizing: border-box;
  3080. border: 1px solid #cccccc;
  3081. border-radius: 8px;
  3082. }
  3083. /* Portrait
  3084. ================================================== */
  3085. .tl-layout-portrait .tl-storyslider {
  3086. -webkit-box-shadow: 0px -3px 6px rgba(0, 0, 0, 0.2);
  3087. -moz-box-shadow: 0px -3px 6px rgba(0, 0, 0, 0.2);
  3088. box-shadow: 0px -3px 6px rgba(0, 0, 0, 0.2);
  3089. }
  3090. .tl-rtl .tl-text-content,
  3091. .tl-rtl .tl-headline,
  3092. .tl-rtl .tl-media-blockquote,
  3093. .tl-rtl .tl-headline-date,
  3094. .tl-rtl .tl-timeline blockquote p,
  3095. .tl-rtl .tl-media-website,
  3096. .tl-rtl .tl-media-wikipedia,
  3097. .tl-rtl .tl-media .tl-media-blockquote blockquote,
  3098. .tl-rtl .blockquote,
  3099. .tl-rtl blockquote p,
  3100. .tl-rtl .tl-text-content p {
  3101. text-align: right;
  3102. direction: rtl;
  3103. }
  3104. .tl-rtl .tl-slide-media-only .tl-headline,
  3105. .tl-rtl .tl-slide-media-only .tl-headline-date {
  3106. text-align: center;
  3107. }
  3108. .tl-rtl .tl-timemarker-text {
  3109. margin-right: 35px;
  3110. }
  3111. .tl-rtl .tl-timemarker-content-container .tl-timemarker-content .tl-timemarker-media-container {
  3112. float: right;
  3113. }
  3114. .tl-rtl .tl-caption {
  3115. text-align: right;
  3116. }
  3117. .tl-rtl .tl-credit {
  3118. text-align: left;
  3119. }
  3120. /*# sourceMappingURL=timeline.css.map*/