| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234 |
- import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
- import {
- ActivityIndicator,
- Alert,
- FlatList,
- Image,
- InteractionManager,
- KeyboardAvoidingView,
- Modal,
- Pressable,
- Platform,
- ScrollView,
- StyleSheet,
- TextInput,
- View,
- } from 'react-native';
- import * as ImagePicker from 'expo-image-picker';
- import { ResizeMode, Video } from 'expo-av';
- import { useLocalSearchParams, useRouter } from 'expo-router';
- import { useFocusEffect, useNavigation } from '@react-navigation/native';
- import { ThemedText } from '@/components/themed-text';
- import { ThemedView } from '@/components/themed-view';
- import { IconSymbol } from '@/components/ui/icon-symbol';
- import { ThemedButton } from '@/components/themed-button';
- import { IconButton } from '@/components/icon-button';
- import { Colors, Fonts } from '@/constants/theme';
- import { useTranslation } from '@/localization/i18n';
- import { dbPromise, initCoreTables } from '@/services/db';
- import { useColorScheme } from '@/hooks/use-color-scheme';
- import { ZoomImageModal } from '@/components/zoom-image-modal';
- type FieldRow = {
- id: number;
- name: string | null;
- area_ha: number | null;
- notes: string | null;
- photo_uri: string | null;
- created_at: string | null;
- updated_at: string | null;
- };
- type FieldMediaRow = {
- uri: string | null;
- };
- export default function FieldsScreen() {
- const { t } = useTranslation();
- const router = useRouter();
- const navigation = useNavigation();
- const { from } = useLocalSearchParams<{ from?: string }>();
- const theme = useColorScheme() ?? 'light';
- const palette = Colors[theme];
- useLayoutEffect(() => {
- if (from === 'home') {
- navigation.setOptions({
- headerLeft: () => (
- <Pressable onPress={() => router.replace('/')} hitSlop={10} style={{ paddingHorizontal: 8 }}>
- <IconSymbol size={20} name="chevron.left" color={palette.text} />
- </Pressable>
- ),
- });
- }
- }, [from, navigation, palette.text, router]);
- const pageSize = 12;
- const [fields, setFields] = useState<FieldRow[]>([]);
- const [status, setStatus] = useState(t('fields.loading'));
- const [name, setName] = useState('');
- const [areaHa, setAreaHa] = useState('');
- const [notes, setNotes] = useState('');
- const [photoUri, setPhotoUri] = useState<string | null>(null);
- const [mediaUris, setMediaUris] = useState<string[]>([]);
- const [newModalVisible, setNewModalVisible] = useState(false);
- const [editingId, setEditingId] = useState<number | null>(null);
- const [editModalVisible, setEditModalVisible] = useState(false);
- const [editName, setEditName] = useState('');
- const [editAreaHa, setEditAreaHa] = useState('');
- const [editNotes, setEditNotes] = useState('');
- const [editPhotoUri, setEditPhotoUri] = useState<string | null>(null);
- const [editMediaUris, setEditMediaUris] = useState<string[]>([]);
- const [zoomUri, setZoomUri] = useState<string | null>(null);
- const [pendingZoomUri, setPendingZoomUri] = useState<string | null>(null);
- const [sheetZoomUri, setSheetZoomUri] = useState<string | null>(null);
- function closeSheetViewer() {
- setSheetZoomUri(null);
- setEditModalVisible(false);
- setNewModalVisible(false);
- setEditingId(null);
- setEditErrors({});
- setNewErrors({});
- }
- const [newErrors, setNewErrors] = useState<{ name?: string; area?: string }>({});
- const [editErrors, setEditErrors] = useState<{ name?: string; area?: string }>({});
- const [page, setPage] = useState(1);
- const [hasMore, setHasMore] = useState(true);
- const [loadingMore, setLoadingMore] = useState(false);
- useEffect(() => {
- let isActive = true;
- async function loadFields() {
- await fetchFieldsPage(1, true, isActive);
- }
- loadFields();
- return () => {
- isActive = false;
- };
- }, [t]);
- useFocusEffect(
- useCallback(() => {
- fetchFieldsPage(1, true);
- }, [])
- );
- useEffect(() => {
- if (!editModalVisible && !newModalVisible && pendingZoomUri) {
- const uri = pendingZoomUri;
- setPendingZoomUri(null);
- InteractionManager.runAfterInteractions(() => {
- setTimeout(() => setZoomUri(uri), 150);
- });
- }
- }, [editModalVisible, newModalVisible, pendingZoomUri]);
- async function fetchFieldsPage(pageToLoad: number, replace: boolean, isActive = true) {
- try {
- await initCoreTables();
- const db = await dbPromise;
- const rows = await db.getAllAsync<FieldRow>(
- 'SELECT id, name, area_ha, notes, photo_uri, created_at, updated_at FROM fields ORDER BY id DESC LIMIT ? OFFSET ?;',
- pageSize,
- (pageToLoad - 1) * pageSize
- );
- if (!isActive) return;
- setFields((prev) => (replace ? rows : [...prev, ...rows]));
- setHasMore(rows.length === pageSize);
- setPage(pageToLoad);
- if (replace) {
- setStatus(rows.length === 0 ? t('fields.empty') : '');
- }
- } catch (error) {
- if (isActive) setStatus(`Error: ${String(error)}`);
- } finally {
- if (isActive) setLoadingMore(false);
- }
- }
- async function handleLoadMore() {
- if (loadingMore || !hasMore) return;
- setLoadingMore(true);
- const nextPage = page + 1;
- await fetchFieldsPage(nextPage, false);
- }
- async function handleSave() {
- const trimmedName = name.trim();
- const area = areaHa.trim() ? Number(areaHa) : null;
- const nextErrors: { name?: string; area?: string } = {};
- if (!trimmedName) {
- nextErrors.name = t('fields.nameRequired');
- }
- if (areaHa.trim() && !Number.isFinite(area)) {
- nextErrors.area = t('fields.areaInvalid');
- }
- setNewErrors(nextErrors);
- if (Object.keys(nextErrors).length > 0) {
- setStatus(nextErrors.name ?? nextErrors.area ?? t('fields.nameRequired'));
- return false;
- }
- try {
- const db = await dbPromise;
- const now = new Date().toISOString();
- const primaryUri = mediaUris[0] ?? normalizeMediaUri(photoUri);
- const result = await db.runAsync(
- 'INSERT INTO fields (name, area_ha, notes, photo_uri, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?);',
- trimmedName,
- area,
- notes.trim() || null,
- primaryUri ?? null,
- now,
- now
- );
- const fieldId = Number(result.lastInsertRowId);
- if (fieldId) {
- const mediaToInsert = uniqueMediaUris([
- ...mediaUris,
- ...(normalizeMediaUri(photoUri) ? [normalizeMediaUri(photoUri) as string] : []),
- ]);
- for (const uri of mediaToInsert) {
- await db.runAsync(
- 'INSERT INTO field_media (field_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
- fieldId,
- uri,
- isVideoUri(uri) ? 'video' : 'image',
- now
- );
- }
- }
- setName('');
- setAreaHa('');
- setNotes('');
- setPhotoUri(null);
- setMediaUris([]);
- setNewErrors({});
- await fetchFieldsPage(1, true);
- setStatus(t('fields.saved'));
- return true;
- } catch (error) {
- setStatus(`Error: ${String(error)}`);
- return false;
- }
- }
- async function handleDelete(id: number) {
- try {
- const db = await dbPromise;
- await db.runAsync('DELETE FROM field_media WHERE field_id = ?;', id);
- await db.runAsync('DELETE FROM fields WHERE id = ?;', id);
- await fetchFieldsPage(1, true);
- } catch (error) {
- setStatus(`Error: ${String(error)}`);
- }
- }
- function confirmDelete(id: number) {
- Alert.alert(
- t('fields.deleteTitle'),
- t('fields.deleteMessage'),
- [
- { text: t('fields.cancel'), style: 'cancel' },
- { text: t('fields.delete'), style: 'destructive', onPress: () => handleDelete(id) },
- ]
- );
- }
- async function startEdit(field: FieldRow) {
- router.push(`/logbook/fields/${field.id}`);
- }
- function cancelEdit() {
- setEditingId(null);
- setEditName('');
- setEditAreaHa('');
- setEditNotes('');
- setEditPhotoUri(null);
- setEditMediaUris([]);
- setEditErrors({});
- setSheetZoomUri(null);
- setEditModalVisible(false);
- setStatus('');
- }
- async function handleUpdate() {
- if (!editingId) return;
- const trimmedName = editName.trim();
- const area = editAreaHa.trim() ? Number(editAreaHa) : null;
- const nextErrors: { name?: string; area?: string } = {};
- if (!trimmedName) {
- nextErrors.name = t('fields.nameRequired');
- }
- if (editAreaHa.trim() && !Number.isFinite(area)) {
- nextErrors.area = t('fields.areaInvalid');
- }
- setEditErrors(nextErrors);
- if (Object.keys(nextErrors).length > 0) {
- setStatus(nextErrors.name ?? nextErrors.area ?? t('fields.nameRequired'));
- return;
- }
- try {
- const db = await dbPromise;
- const now = new Date().toISOString();
- const primaryUri = editMediaUris[0] ?? normalizeMediaUri(editPhotoUri);
- await db.runAsync(
- 'UPDATE fields SET name = ?, area_ha = ?, notes = ?, photo_uri = ?, updated_at = ? WHERE id = ?;',
- trimmedName,
- area,
- editNotes.trim() || null,
- primaryUri ?? null,
- now,
- editingId
- );
- await db.runAsync('DELETE FROM field_media WHERE field_id = ?;', editingId);
- const mediaToInsert = uniqueMediaUris([
- ...editMediaUris,
- ...(normalizeMediaUri(editPhotoUri) ? [normalizeMediaUri(editPhotoUri) as string] : []),
- ]);
- for (const uri of mediaToInsert) {
- await db.runAsync(
- 'INSERT INTO field_media (field_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);',
- editingId,
- uri,
- isVideoUri(uri) ? 'video' : 'image',
- now
- );
- }
- setEditModalVisible(false);
- setEditingId(null);
- setEditErrors({});
- await fetchFieldsPage(1, true);
- setStatus(t('fields.saved'));
- } catch (error) {
- setStatus(`Error: ${String(error)}`);
- }
- }
- const inputStyle = [
- styles.input,
- {
- borderColor: palette.border,
- backgroundColor: palette.input,
- color: palette.text,
- },
- ];
- return (
- <>
- <FlatList
- data={fields}
- keyExtractor={(item) => String(item.id)}
- extraData={[photoUri, editModalVisible, editPhotoUri, name, areaHa, notes, status]}
- onEndReached={handleLoadMore}
- onEndReachedThreshold={0.4}
- renderItem={({ item }) => {
- const mediaUri = normalizeMediaUri(item.photo_uri);
- return (
- <Pressable onPress={() => startEdit(item)}>
- <ThemedView style={[styles.card, { backgroundColor: palette.card, borderColor: palette.border }]}>
- <ThemedText type="subtitle">{item.name || t('fields.unnamed')}</ThemedText>
- {item.area_ha !== null ? (
- <ThemedText style={styles.meta}>
- {t('fields.areaLabel')} {item.area_ha}
- </ThemedText>
- ) : null}
- {mediaUri ? (
- isVideoUri(mediaUri) ? (
- <Video
- source={{ uri: mediaUri }}
- style={styles.videoPreview}
- useNativeControls
- resizeMode={ResizeMode.CONTAIN}
- isMuted
- />
- ) : (
- <Pressable onPress={() => setZoomUri(mediaUri)}>
- <Image
- source={{ uri: mediaUri }}
- style={styles.photoPreview}
- resizeMode="contain"
- onError={(error) =>
- console.log('[Fields] List image error:', mediaUri, error.nativeEvent)
- }
- onLoad={() => console.log('[Fields] List image loaded:', mediaUri)}
- />
- </Pressable>
- )
- ) : null}
- {item.notes ? <ThemedText>{item.notes}</ThemedText> : null}
- <View style={styles.buttonRow}>
- <IconButton
- name="trash"
- onPress={() => confirmDelete(item.id)}
- accessibilityLabel={t('fields.delete')}
- variant="danger"
- />
- {item.updated_at ? (
- <ThemedText style={styles.metaEnd}>{formatDate(item.updated_at)}</ThemedText>
- ) : null}
- </View>
- </ThemedView>
- </Pressable>
- );
- }}
- ItemSeparatorComponent={() => <View style={styles.separator} />}
- ListHeaderComponent={
- <View>
- <ThemedView style={styles.hero}>
- <Image source={require('@/assets/images/fields.jpg')} style={styles.heroImage} />
- </ThemedView>
- <ThemedView style={styles.titleContainer}>
- <ThemedText type="title" style={{ fontFamily: Fonts.rounded }}>
- {t('fields.title')}
- </ThemedText>
- </ThemedView>
- {status ? (
- <ThemedView style={styles.section}>
- <ThemedText>{status}</ThemedText>
- </ThemedView>
- ) : null}
- <ThemedView style={styles.section}>
- <Pressable
- style={styles.newButton}
- onPress={() => {
- setNewErrors({});
- setNewModalVisible(true);
- }}>
- <IconSymbol size={18} name="plus.circle.fill" color="#2F7D4F" />
- <ThemedText style={styles.newButtonText}>{t('fields.new')}</ThemedText>
- </Pressable>
- </ThemedView>
- </View>
- }
- ListFooterComponent={
- <View style={styles.footer}>
- {loadingMore ? <ActivityIndicator /> : null}
- </View>
- }
- />
- <Modal
- visible={editModalVisible}
- animationType="slide"
- transparent
- onRequestClose={cancelEdit}>
- <View style={styles.sheetOverlay}>
- <Pressable
- style={styles.sheetBackdrop}
- pointerEvents={sheetZoomUri ? 'none' : 'auto'}
- onPress={() => {
- if (sheetZoomUri) {
- setSheetZoomUri(null);
- return;
- }
- cancelEdit();
- }}
- />
- <KeyboardAvoidingView
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
- keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
- style={styles.keyboardAvoid}
- pointerEvents={sheetZoomUri ? 'none' : 'auto'}>
- <Pressable
- style={[
- styles.sheet,
- { backgroundColor: palette.card, borderColor: palette.border, paddingBottom: 0 },
- ]}
- onPress={(event) => {
- event.stopPropagation();
- }}
- onStartShouldSetResponder={() => true}
- pointerEvents={sheetZoomUri ? 'none' : 'auto'}>
- <ScrollView
- keyboardShouldPersistTaps="handled"
- contentContainerStyle={styles.sheetContent}>
- <ThemedText type="subtitle">{t('fields.edit')}</ThemedText>
- <ThemedText>
- {t('fields.name')}
- <ThemedText style={styles.requiredMark}> *</ThemedText>
- </ThemedText>
- <TextInput
- value={editName}
- onChangeText={(value) => {
- setEditName(value);
- if (editErrors.name) {
- setEditErrors((prev) => ({ ...prev, name: undefined }));
- }
- }}
- placeholder={t('fields.name')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- />
- {editErrors.name ? <ThemedText style={styles.errorText}>{editErrors.name}</ThemedText> : null}
- <ThemedText>{t('fields.area')}</ThemedText>
- <TextInput
- value={editAreaHa}
- onChangeText={(value) => {
- setEditAreaHa(value);
- if (editErrors.area) {
- setEditErrors((prev) => ({ ...prev, area: undefined }));
- }
- }}
- placeholder={t('fields.areaPlaceholder')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- keyboardType="decimal-pad"
- />
- {editErrors.area ? <ThemedText style={styles.errorText}>{editErrors.area}</ThemedText> : null}
- <ThemedText>{t('fields.notes')}</ThemedText>
- <TextInput
- value={editNotes}
- onChangeText={setEditNotes}
- placeholder={t('fields.notesPlaceholder')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- multiline
- />
- <ThemedText>{t('fields.addMedia')}</ThemedText>
- {editMediaUris.length > 0 ? (
- isVideoUri(normalizeMediaUri(editPhotoUri) as string) ? (
- <Pressable
- onPress={(event) => {
- event.stopPropagation();
- setSheetZoomUri(normalizeMediaUri(editPhotoUri) as string);
- }}>
- <Video
- source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
- style={styles.videoPreview}
- useNativeControls
- resizeMode={ResizeMode.CONTAIN}
- isMuted
- />
- </Pressable>
- ) : (
- <Pressable
- onPress={(event) => {
- event.stopPropagation();
- setSheetZoomUri(normalizeMediaUri(editPhotoUri) as string);
- }}>
- <Image
- key={normalizeMediaUri(editPhotoUri) as string}
- source={{ uri: normalizeMediaUri(editPhotoUri) as string }}
- style={styles.photoPreview}
- resizeMode="contain"
- onError={(error) =>
- console.log(
- '[Fields] Edit image error:',
- normalizeMediaUri(editPhotoUri) as string,
- error.nativeEvent
- )
- }
- onLoad={() =>
- console.log(
- '[Fields] Edit image loaded:',
- normalizeMediaUri(editPhotoUri) as string
- )
- }
- />
- </Pressable>
- )
- ) : (
- <ThemedText style={styles.photoPlaceholder}>{t('fields.noPhoto')}</ThemedText>
- )}
- {editMediaUris.length > 0 ? (
- <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
- {editMediaUris.map((uri) => (
- <Pressable
- key={uri}
- style={styles.mediaChip}
- onPress={(event) => {
- event.stopPropagation();
- setSheetZoomUri(uri);
- }}>
- {isVideoUri(uri) ? (
- <View style={styles.videoThumb}>
- <ThemedText style={styles.videoThumbText}>▶</ThemedText>
- </View>
- ) : (
- <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
- )}
- <Pressable
- style={styles.removeBadge}
- onPress={(event) => {
- event.stopPropagation();
- setEditMediaUris((prev) => {
- const next = prev.filter((item) => item !== uri);
- setEditPhotoUri((current) => (current === uri ? next[0] ?? null : current));
- return next;
- });
- }}>
- <ThemedText style={styles.removeBadgeText}>×</ThemedText>
- </Pressable>
- </Pressable>
- ))}
- </ScrollView>
- ) : null}
- <View style={styles.photoRow}>
- <ThemedButton
- title={t('fields.pickFromGallery')}
- onPress={() =>
- handlePickPhoto(
- (uri) => {
- if (!uri) return;
- setEditPhotoUri((prev) => prev ?? uri);
- },
- (uris) => {
- if (uris.length === 0) return;
- setEditMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
- setEditPhotoUri((prev) => prev ?? uris[0]);
- }
- )
- }
- variant="secondary"
- />
- <ThemedButton
- title={t('fields.takeMedia')}
- onPress={() =>
- handleTakePhoto(
- (uri) => {
- if (!uri) return;
- setEditPhotoUri((prev) => prev ?? uri);
- setEditMediaUris((prev) => uniqueMediaUris([...prev, uri]));
- },
- (code) =>
- setStatus(
- code === 'cameraDenied'
- ? t('tasks.cameraDenied')
- : t('tasks.cameraError')
- )
- )
- }
- variant="secondary"
- />
- </View>
- <View style={styles.modalActions}>
- <ThemedButton title={t('fields.cancel')} onPress={cancelEdit} variant="secondary" />
- <ThemedButton title={t('fields.update')} onPress={handleUpdate} />
- </View>
- </ScrollView>
- </Pressable>
- </KeyboardAvoidingView>
- {sheetZoomUri ? (
- <View style={styles.sheetZoomOverlay} pointerEvents="box-none">
- <Pressable
- style={styles.sheetZoomBackdrop}
- onPress={() => setSheetZoomUri(null)}
- />
- <Pressable
- style={styles.sheetZoomClose}
- onPress={closeSheetViewer}
- hitSlop={12}
- pointerEvents="auto">
- <ThemedText style={styles.sheetZoomCloseText}>{t('common.close')}</ThemedText>
- </Pressable>
- <View style={styles.sheetZoomContent} pointerEvents="auto">
- {isVideoUri(sheetZoomUri) ? (
- <Video
- source={{ uri: sheetZoomUri }}
- style={styles.sheetZoomVideo}
- useNativeControls
- resizeMode={ResizeMode.CONTAIN}
- />
- ) : (
- <ScrollView
- style={styles.sheetZoomScroll}
- contentContainerStyle={styles.sheetZoomScrollContent}
- minimumZoomScale={1}
- maximumZoomScale={3}
- centerContent
- showsHorizontalScrollIndicator={false}
- showsVerticalScrollIndicator={false}>
- <Image source={{ uri: sheetZoomUri }} style={styles.sheetZoomImage} resizeMode="contain" />
- </ScrollView>
- )}
- </View>
- </View>
- ) : null}
- </View>
- </Modal>
- <Modal
- visible={newModalVisible}
- animationType="slide"
- transparent
- onRequestClose={() => setNewModalVisible(false)}>
- <View style={styles.sheetOverlay}>
- <Pressable
- style={styles.sheetBackdrop}
- pointerEvents={sheetZoomUri ? 'none' : 'auto'}
- onPress={() => {
- if (sheetZoomUri) {
- setSheetZoomUri(null);
- return;
- }
- setNewModalVisible(false);
- }}
- />
- <KeyboardAvoidingView
- behavior={Platform.OS === 'ios' ? 'padding' : undefined}
- keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
- style={styles.keyboardAvoid}
- pointerEvents={sheetZoomUri ? 'none' : 'auto'}>
- <Pressable
- style={[
- styles.sheet,
- { backgroundColor: palette.card, borderColor: palette.border, paddingBottom: 0 },
- ]}
- onPress={(event) => {
- event.stopPropagation();
- }}
- onStartShouldSetResponder={() => true}
- pointerEvents={sheetZoomUri ? 'none' : 'auto'}>
- <ScrollView
- keyboardShouldPersistTaps="handled"
- contentContainerStyle={styles.sheetContent}>
- <ThemedText type="subtitle">{t('fields.new')}</ThemedText>
- <ThemedText>
- {t('fields.name')}
- <ThemedText style={styles.requiredMark}> *</ThemedText>
- </ThemedText>
- <TextInput
- value={name}
- onChangeText={(value) => {
- setName(value);
- if (newErrors.name) {
- setNewErrors((prev) => ({ ...prev, name: undefined }));
- }
- }}
- placeholder={t('fields.name')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- />
- {newErrors.name ? <ThemedText style={styles.errorText}>{newErrors.name}</ThemedText> : null}
- <ThemedText>{t('fields.area')}</ThemedText>
- <TextInput
- value={areaHa}
- onChangeText={(value) => {
- setAreaHa(value);
- if (newErrors.area) {
- setNewErrors((prev) => ({ ...prev, area: undefined }));
- }
- }}
- placeholder={t('fields.areaPlaceholder')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- keyboardType="decimal-pad"
- />
- {newErrors.area ? <ThemedText style={styles.errorText}>{newErrors.area}</ThemedText> : null}
- <ThemedText>{t('fields.notes')}</ThemedText>
- <TextInput
- value={notes}
- onChangeText={setNotes}
- placeholder={t('fields.notesPlaceholder')}
- placeholderTextColor={palette.placeholder}
- style={inputStyle}
- multiline
- />
- <ThemedText>{t('fields.addMedia')}</ThemedText>
- {mediaUris.length > 0 ? (
- isVideoUri(normalizeMediaUri(photoUri) as string) ? (
- <Pressable
- onPress={(event) => {
- event.stopPropagation();
- setSheetZoomUri(normalizeMediaUri(photoUri) as string);
- }}>
- <Video
- source={{ uri: normalizeMediaUri(photoUri) as string }}
- style={styles.videoPreview}
- useNativeControls
- resizeMode={ResizeMode.CONTAIN}
- isMuted
- />
- </Pressable>
- ) : (
- <Pressable
- onPress={(event) => {
- event.stopPropagation();
- setSheetZoomUri(normalizeMediaUri(photoUri) as string);
- }}>
- <Image
- key={normalizeMediaUri(photoUri) as string}
- source={{ uri: normalizeMediaUri(photoUri) as string }}
- style={styles.photoPreview}
- resizeMode="contain"
- onError={(error) =>
- console.log(
- '[Fields] New image error:',
- normalizeMediaUri(photoUri) as string,
- error.nativeEvent
- )
- }
- onLoad={() =>
- console.log(
- '[Fields] New image loaded:',
- normalizeMediaUri(photoUri) as string
- )
- }
- />
- </Pressable>
- )
- ) : (
- <ThemedText style={styles.photoPlaceholder}>{t('fields.noPhoto')}</ThemedText>
- )}
- {mediaUris.length > 0 ? (
- <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
- {mediaUris.map((uri) => (
- <Pressable
- key={uri}
- style={styles.mediaChip}
- onPress={(event) => {
- event.stopPropagation();
- setSheetZoomUri(uri);
- }}>
- {isVideoUri(uri) ? (
- <View style={styles.videoThumb}>
- <ThemedText style={styles.videoThumbText}>▶</ThemedText>
- </View>
- ) : (
- <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
- )}
- <Pressable
- style={styles.removeBadge}
- onPress={(event) => {
- event.stopPropagation();
- setMediaUris((prev) => {
- const next = prev.filter((item) => item !== uri);
- setPhotoUri((current) => (current === uri ? next[0] ?? null : current));
- return next;
- });
- }}>
- <ThemedText style={styles.removeBadgeText}>×</ThemedText>
- </Pressable>
- </Pressable>
- ))}
- </ScrollView>
- ) : null}
- <View style={styles.photoRow}>
- <ThemedButton
- title={t('fields.pickFromGallery')}
- onPress={() =>
- handlePickPhoto(
- (uri) => {
- if (!uri) return;
- setPhotoUri((prev) => prev ?? uri);
- },
- (uris) => {
- if (uris.length === 0) return;
- setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
- setPhotoUri((prev) => prev ?? uris[0]);
- }
- )
- }
- variant="secondary"
- />
- <ThemedButton
- title={t('fields.takeMedia')}
- onPress={() =>
- handleTakePhoto(
- (uri) => {
- if (!uri) return;
- setPhotoUri((prev) => prev ?? uri);
- setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
- },
- (code) =>
- setStatus(
- code === 'cameraDenied'
- ? t('tasks.cameraDenied')
- : t('tasks.cameraError')
- )
- )
- }
- variant="secondary"
- />
- </View>
- <View style={styles.modalActions}>
- <ThemedButton
- title={t('fields.cancel')}
- onPress={() => setNewModalVisible(false)}
- variant="secondary"
- />
- <ThemedButton
- title={t('fields.save')}
- onPress={async () => {
- const ok = await handleSave();
- if (ok) setNewModalVisible(false);
- }}
- />
- </View>
- </ScrollView>
- </Pressable>
- </KeyboardAvoidingView>
- {sheetZoomUri ? (
- <View style={styles.sheetZoomOverlay} pointerEvents="box-none">
- <Pressable
- style={styles.sheetZoomBackdrop}
- onPress={() => setSheetZoomUri(null)}
- />
- <Pressable
- style={styles.sheetZoomClose}
- onPress={closeSheetViewer}
- hitSlop={12}
- pointerEvents="auto">
- <ThemedText style={styles.sheetZoomCloseText}>{t('common.close')}</ThemedText>
- </Pressable>
- <View style={styles.sheetZoomContent} pointerEvents="auto">
- {isVideoUri(sheetZoomUri) ? (
- <Video
- source={{ uri: sheetZoomUri }}
- style={styles.sheetZoomVideo}
- useNativeControls
- resizeMode={ResizeMode.CONTAIN}
- />
- ) : (
- <ScrollView
- style={styles.sheetZoomScroll}
- contentContainerStyle={styles.sheetZoomScrollContent}
- minimumZoomScale={1}
- maximumZoomScale={3}
- centerContent
- showsHorizontalScrollIndicator={false}
- showsVerticalScrollIndicator={false}>
- <Image source={{ uri: sheetZoomUri }} style={styles.sheetZoomImage} resizeMode="contain" />
- </ScrollView>
- )}
- </View>
- </View>
- ) : null}
- </View>
- </Modal>
- <ZoomImageModal
- uri={zoomUri}
- visible={Boolean(zoomUri)}
- onClose={() => setZoomUri(null)}
- />
- </>
- );
- }
- function formatDate(value: string) {
- try {
- return new Date(value).toLocaleString();
- } catch {
- return value;
- }
- }
- async function handlePickPhoto(
- setter: (value: string | null) => void,
- onAdd?: (uris: string[]) => void
- ) {
- const result = await ImagePicker.launchImageLibraryAsync({
- mediaTypes: getMediaTypes(),
- quality: 1,
- allowsMultipleSelection: true,
- selectionLimit: 0,
- });
- if (result.canceled) return;
- const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
- if (uris.length === 0) return;
- console.log('[Fields] Picked media:', uris);
- setter(uris[0]);
- onAdd?.(uris);
- }
- async function handleTakePhoto(
- setter: (value: string | null) => void,
- onError?: (msg: string) => void
- ) {
- try {
- const permission = await ImagePicker.requestCameraPermissionsAsync();
- if (!permission.granted) {
- onError?.('cameraDenied');
- return;
- }
- const result = await ImagePicker.launchCameraAsync({
- mediaTypes: getMediaTypes(),
- quality: 1,
- });
- if (result.canceled) return;
- const asset = result.assets[0];
- console.log('[Fields] Captured media:', asset.uri);
- setter(asset.uri);
- } catch {
- onError?.('cameraError');
- }
- }
- function getMediaTypes() {
- const mediaType = (ImagePicker as {
- MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
- }).MediaType;
- const imageType = mediaType?.Image ?? mediaType?.Images;
- const videoType = mediaType?.Video ?? mediaType?.Videos;
- if (imageType && videoType) {
- return [imageType, videoType];
- }
- return imageType ?? videoType ?? ['images', 'videos'];
- }
- function isVideoUri(uri: string) {
- return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
- }
- function normalizeMediaUri(uri?: string | null) {
- if (typeof uri !== 'string') return null;
- const trimmed = uri.trim();
- return trimmed ? trimmed : null;
- }
- function uniqueMediaUris(uris: string[]) {
- const seen = new Set<string>();
- const result: string[] = [];
- for (const uri of uris) {
- if (!uri || seen.has(uri)) continue;
- seen.add(uri);
- result.push(uri);
- }
- return result;
- }
- const styles = StyleSheet.create({
- hero: {
- backgroundColor: '#E8E6DA',
- aspectRatio: 16 / 9,
- width: '100%',
- },
- heroImage: {
- width: '100%',
- height: '100%',
- },
- titleContainer: {
- gap: 8,
- paddingHorizontal: 16,
- paddingVertical: 12,
- },
- section: {
- gap: 8,
- marginBottom: 16,
- paddingHorizontal: 16,
- },
- newButton: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- borderRadius: 10,
- borderWidth: 1,
- borderColor: '#B9B9B9',
- paddingHorizontal: 12,
- paddingVertical: 10,
- alignSelf: 'flex-start',
- },
- newButtonText: {
- fontSize: 15,
- fontWeight: '600',
- },
- card: {
- borderRadius: 12,
- borderWidth: 1,
- borderColor: '#C6C6C6',
- padding: 12,
- marginHorizontal: 16,
- gap: 6,
- backgroundColor: '#FFFFFF',
- },
- meta: {
- opacity: 0.7,
- },
- input: {
- borderRadius: 10,
- borderWidth: 1,
- borderColor: '#B9B9B9',
- paddingHorizontal: 12,
- paddingVertical: 10,
- fontSize: 15,
- },
- requiredMark: {
- color: '#C0392B',
- fontWeight: '700',
- },
- errorText: {
- color: '#C0392B',
- fontSize: 12,
- },
- photoPreview: {
- width: '100%',
- height: 200,
- borderRadius: 12,
- },
- buttonRow: {
- alignSelf: 'flex-start',
- flexDirection: 'row',
- gap: 8,
- alignItems: 'center',
- width: '100%',
- },
- metaEnd: {
- marginLeft: 'auto',
- opacity: 0.7,
- fontSize: 12,
- },
- cancelRow: {
- marginTop: 8,
- },
- modalActions: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- gap: 12,
- },
- sheetOverlay: {
- flex: 1,
- backgroundColor: 'rgba(0,0,0,0.3)',
- justifyContent: 'flex-end',
- },
- sheetBackdrop: {
- ...StyleSheet.absoluteFillObject,
- },
- sheet: {
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
- borderWidth: 1,
- borderColor: '#C6C6C6',
- padding: 16,
- backgroundColor: '#FFFFFF',
- gap: 10,
- maxHeight: '85%',
- },
- sheetContent: {
- gap: 10,
- paddingBottom: 80,
- },
- sheetZoomOverlay: {
- position: 'absolute',
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: 'rgba(0,0,0,0.85)',
- justifyContent: 'center',
- alignItems: 'center',
- zIndex: 10,
- elevation: 10,
- },
- sheetZoomBackdrop: {
- ...StyleSheet.absoluteFillObject,
- zIndex: 0,
- },
- sheetZoomContent: {
- width: '100%',
- height: '100%',
- paddingHorizontal: 16,
- paddingTop: 24,
- paddingBottom: 24,
- justifyContent: 'center',
- zIndex: 1,
- },
- sheetZoomClose: {
- position: 'absolute',
- top: 24,
- right: 16,
- zIndex: 2,
- backgroundColor: 'rgba(0,0,0,0.6)',
- paddingHorizontal: 12,
- paddingVertical: 6,
- borderRadius: 14,
- },
- sheetZoomCloseText: {
- color: '#FFFFFF',
- fontWeight: '600',
- },
- sheetZoomScroll: {
- flex: 1,
- },
- sheetZoomScrollContent: {
- flexGrow: 1,
- justifyContent: 'center',
- alignItems: 'center',
- },
- sheetZoomImage: {
- width: '100%',
- height: '100%',
- },
- sheetZoomVideo: {
- width: '100%',
- height: '100%',
- },
- keyboardAvoid: {
- width: '100%',
- flex: 1,
- justifyContent: 'flex-end',
- },
- separator: {
- height: 12,
- },
- footer: {
- height: 24,
- },
- photoRow: {
- flexDirection: 'row',
- gap: 8,
- },
- photoPlaceholder: {
- opacity: 0.6,
- },
- mediaPlaceholder: {
- opacity: 0.8,
- fontStyle: 'italic',
- },
- videoPreview: {
- width: '100%',
- height: 200,
- borderRadius: 12,
- backgroundColor: '#1C1C1C',
- },
- mediaStrip: {
- marginTop: 6,
- },
- mediaChip: {
- width: 72,
- height: 72,
- borderRadius: 10,
- marginRight: 8,
- overflow: 'hidden',
- backgroundColor: '#E6E1D4',
- alignItems: 'center',
- justifyContent: 'center',
- },
- mediaThumb: {
- width: '100%',
- height: '100%',
- },
- removeBadge: {
- position: 'absolute',
- top: 4,
- right: 4,
- backgroundColor: 'rgba(0,0,0,0.6)',
- width: 18,
- height: 18,
- borderRadius: 9,
- alignItems: 'center',
- justifyContent: 'center',
- },
- removeBadgeText: {
- color: '#FFFFFF',
- fontSize: 12,
- lineHeight: 14,
- fontWeight: '700',
- },
- videoThumb: {
- width: '100%',
- height: '100%',
- backgroundColor: '#1C1C1C',
- alignItems: 'center',
- justifyContent: 'center',
- },
- videoThumbText: {
- color: '#FFFFFF',
- fontSize: 18,
- fontWeight: '700',
- },
- });
|