Нема описа

new.tsx 21KB

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