Nav apraksta

fields.tsx 22KB

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