From 6d1143406198740e3bcb9cfa88997c0f09dccb79 Mon Sep 17 00:00:00 2001 From: javier Date: Tue, 28 Jul 2026 23:55:24 +0200 Subject: [PATCH] Implement custom camera with real-time overlay and resolution settings --- package-lock.json | 109 +++++++++++++--- package.json | 6 +- src/navigation/index.tsx | 6 + src/navigation/types.ts | 1 + src/photo/footerConfig.ts | 12 ++ src/screens/CameraScreen.tsx | 179 ++++++++++++++++++++++++++ src/screens/PhotoSettingsScreen.tsx | 39 +++++- src/ui/FeatureMap.tsx | 187 +++++++++++++++++----------- src/ui/MediaStrip.tsx | 78 ++++++------ 9 files changed, 479 insertions(+), 138 deletions(-) create mode 100644 src/screens/CameraScreen.tsx diff --git a/package-lock.json b/package-lock.json index 97604f5..60ce28e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@react-navigation/native-stack": "^7.17.5", "expo": "~56.0.12", "expo-build-properties": "~56.0.19", + "expo-camera": "^57.0.3", "expo-crypto": "~56.0.4", "expo-file-system": "~56.0.8", "expo-image-picker": "~56.0.18", @@ -25,7 +26,8 @@ "react-native-maps": "1.27.2", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", - "react-native-view-shot": "^4.0.0" + "react-native-view-shot": "^4.0.0", + "react-native-webview": "^13.13.2" }, "devDependencies": { "@types/react": "~19.2.2", @@ -2958,6 +2960,12 @@ "@types/node": "*" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -3432,6 +3440,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/barcode-detector": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.1.tgz", + "integrity": "sha512-zLL7AbT9uNJBUzYpKg9v5tUA4yQGReExSi60q0g660Mj0wjUhKmN0GrUF3qELo8ITW9THzyJXdZQkXLMnsieiw==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.1.1" + } + }, "node_modules/bare-events": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", @@ -5418,22 +5435,6 @@ "node": ">=4" } }, - "node_modules/eas-cli/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/eas-cli/node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -5792,6 +5793,26 @@ "expo": "*" } }, + "node_modules/expo-camera": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.3.tgz", + "integrity": "sha512-Q+3aZ63eQCkdB6/FZrO/lfacNAg/j8JCeKQL2nBdf6vBeOo1Y2PKYx1/vK+U5LaRnIo/0tMGmCOzZ1JGhTeMIw==", + "license": "MIT", + "dependencies": { + "barcode-detector": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-crypto": { "version": "56.0.4", "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz", @@ -9525,6 +9546,20 @@ "react-native": "*" } }, + "node_modules/react-native-webview": { + "version": "13.13.2", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.13.2.tgz", + "integrity": "sha512-zACPDTF0WnaEnKZ9mA/r/UpcOpV2gQM06AAIrOOexnO8UJvXL8Pjso0b/wTqKFxUZZnmjKuwd8gHVUosVOdVrw==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -10445,6 +10480,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "7.5.7", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", @@ -11309,6 +11356,34 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zxing-wasm": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.1.tgz", + "integrity": "sha512-g0sPJBIubO6zLcJh1jftLPIN6xziaqLsvLgtpGKwDrEhyXXqla3E3yjFrznlr78UHIOMzbJPi0HDWKs/KgaB7A==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.8.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 78e0ffe..c7096d4 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@react-navigation/native-stack": "^7.17.5", "expo": "~56.0.12", "expo-build-properties": "~56.0.19", + "expo-camera": "^57.0.3", "expo-crypto": "~56.0.4", "expo-file-system": "~56.0.8", "expo-image-picker": "~56.0.18", @@ -18,9 +19,10 @@ "react": "19.2.3", "react-native": "0.85.3", "react-native-maps": "1.27.2", - "react-native-view-shot": "^4.0.0", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2" + "react-native-screens": "4.25.2", + "react-native-view-shot": "^4.0.0", + "react-native-webview": "^13.13.2" }, "devDependencies": { "@types/react": "~19.2.2", diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index b9b63e2..6bee882 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -13,6 +13,7 @@ import { IssueCreateScreen } from '../screens/IssueCreateScreen'; import { OutboxScreen } from '../screens/OutboxScreen'; import { PhotoSettingsScreen } from '../screens/PhotoSettingsScreen'; import { SettingsScreen } from '../screens/SettingsScreen'; +import { CameraScreen } from '../screens/CameraScreen'; import { RootStackParamList } from './types'; const Stack = createNativeStackNavigator(); @@ -80,6 +81,11 @@ export function RootNavigator() { component={SettingsScreen} options={{ title: 'Configuración' }} /> + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 038b935..26e1ddb 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -8,4 +8,5 @@ export type RootStackParamList = { Outbox: undefined; PhotoSettings: undefined; Settings: undefined; + Camera: { onCapture: (uri: string, width: number, height: number) => void }; }; diff --git a/src/photo/footerConfig.ts b/src/photo/footerConfig.ts index f39dedd..c79abcd 100644 --- a/src/photo/footerConfig.ts +++ b/src/photo/footerConfig.ts @@ -3,6 +3,7 @@ import * as ImagePicker from 'expo-image-picker'; import { getMeta, setMeta } from '../db/repositories'; export type FieldKey = 'project_name' | 'date' | 'coordinates' | 'custom'; +export type PhotoResolution = 'low' | 'medium' | 'high'; export interface FooterField { id: string; @@ -17,6 +18,8 @@ export interface FooterConfig { enabled: boolean; logoUri: string | null; fields: FooterField[]; + resolution: PhotoResolution; + quality: number; // 0.1 to 1.0 } export interface StampMeta { @@ -37,9 +40,18 @@ function defaultConfig(): FooterConfig { { id: 'date', key: 'date', label: 'Fecha', enabled: true }, { id: 'coordinates', key: 'coordinates', label: 'Coordenadas GPS', enabled: true }, ], + resolution: 'medium', + quality: 0.85, }; } +/** Mapa de resoluciones a píxeles (ancho). El alto se calcula según el aspect ratio. */ +export const RESOLUTION_WIDTHS: Record = { + low: 1280, // 720p approx + medium: 1920, // 1080p + high: 3840, // 4K approx +}; + export async function loadFooterConfig(): Promise { const raw = await getMeta(META_KEY); if (!raw) return defaultConfig(); diff --git a/src/screens/CameraScreen.tsx b/src/screens/CameraScreen.tsx new file mode 100644 index 0000000..0be8534 --- /dev/null +++ b/src/screens/CameraScreen.tsx @@ -0,0 +1,179 @@ +import { CameraView, useCameraPermissions } from 'expo-camera'; +import * as Location from 'expo-location'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Dimensions, + StyleSheet, + Text, + TouchableOpacity, + View, + ActivityIndicator, +} from 'react-native'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../navigation/types'; +import { FooterConfig, loadFooterConfig, resolveFieldValue, StampMeta } from '../photo/footerConfig'; +import { getActiveProjectId, getProject } from '../db/repositories'; +import { COLORS } from '../ui/components'; +import { FooterOverlay } from '../photo/FooterOverlay'; + +type Props = NativeStackScreenProps; + +const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window'); + +export function CameraScreen({ route, navigation }: Props) { + const { onCapture } = route.params; + const [permission, requestPermission] = useCameraPermissions(); + const cameraRef = useRef(null); + + const [config, setConfig] = useState(null); + const [meta, setMeta] = useState(null); + const [taking, setTaking] = useState(false); + + const loadData = useCallback(async () => { + const [cfg, projectId] = await Promise.all([ + loadFooterConfig(), + getActiveProjectId(), + ]); + setConfig(cfg); + + let projectName: string | null = null; + if (projectId != null) { + const proj = await getProject(projectId); + projectName = proj?.name ?? null; + } + + const { granted } = await Location.requestForegroundPermissionsAsync(); + let coordsStr: string | null = null; + if (granted) { + const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }); + const lat = loc.coords.latitude; + const lng = loc.coords.longitude; + const la = lat >= 0 ? 'N' : 'S'; + const lo = lng >= 0 ? 'E' : 'O'; + coordsStr = `${Math.abs(lat).toFixed(5)}°${la}, ${Math.abs(lng).toFixed(5)}°${lo}`; + } + + setMeta({ + projectName, + date: new Date().toLocaleString('es-ES', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }), + coordinates: coordsStr, + }); + }, []); + + useEffect(() => { + void loadData(); + }, [loadData]); + + if (!permission) { + return ; + } + + if (!permission.granted) { + return ( + + Necesitamos permiso para usar la cámara + + Conceder permiso + + + ); + } + + const capture = async () => { + if (!cameraRef.current || taking) return; + setTaking(true); + try { + const photo = await cameraRef.current.takePictureAsync({ + quality: config?.quality ?? 0.85, + base64: false, + exif: false, + }); + if (photo) { + onCapture(photo.uri, photo.width, photo.height); + navigation.goBack(); + } + } catch (e) { + console.error('Error taking picture:', e); + } finally { + setTaking(false); + } + }; + + return ( + + + + {/* Header */} + + navigation.goBack()} style={styles.closeBtn}> + + + + + {/* Footer Preview */} + {config && meta && ( + + + + )} + + {/* Controls */} + + void capture()} + disabled={taking} + > + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#000' }, + camera: { flex: 1 }, + overlay: { flex: 1, justifyContent: 'space-between' }, + center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }, + message: { color: '#fff', textAlign: 'center', marginBottom: 20 }, + btn: { backgroundColor: COLORS.primary, padding: 12, borderRadius: 8 }, + btnText: { color: '#fff', fontWeight: 'bold' }, + header: { padding: 20, paddingTop: 50 }, + closeBtn: { width: 40, height: 40, justifyContent: 'center', alignItems: 'center' }, + closeTxt: { color: '#fff', fontSize: 24 }, + footerWrapper: { + height: 100, // Espacio para el footer + justifyContent: 'flex-end', + }, + controls: { + paddingBottom: 40, + alignItems: 'center', + }, + captureBtn: { + width: 70, + height: 70, + borderRadius: 35, + borderWidth: 4, + borderColor: '#fff', + justifyContent: 'center', + alignItems: 'center', + }, + captureInner: { + width: 54, + height: 54, + borderRadius: 27, + backgroundColor: '#fff', + }, + disabled: { opacity: 0.5 }, +}); diff --git a/src/screens/PhotoSettingsScreen.tsx b/src/screens/PhotoSettingsScreen.tsx index d895544..d04cd12 100644 --- a/src/screens/PhotoSettingsScreen.tsx +++ b/src/screens/PhotoSettingsScreen.tsx @@ -23,9 +23,10 @@ import { FooterField, loadFooterConfig, pickAndSaveLogo, + PhotoResolution, saveFooterConfig, } from '../photo/footerConfig'; -import { COLORS, PrimaryButton, SectionTitle } from '../ui/components'; +import { ChipSelect, COLORS, PrimaryButton, SectionTitle } from '../ui/components'; const BUILT_IN_LABELS: Record = { project_name: 'Nombre del proyecto', @@ -33,6 +34,9 @@ const BUILT_IN_LABELS: Record = { coordinates: 'Coordenadas GPS', }; +const RESOLUTION_OPTIONS: PhotoResolution[] = ['low', 'medium', 'high']; +const QUALITY_OPTIONS = ['0.5', '0.7', '0.85', '1.0']; + export function PhotoSettingsScreen() { const [config, setConfig] = useState(null); const [newLabel, setNewLabel] = useState(''); @@ -154,6 +158,32 @@ export function PhotoSettingsScreen() { Recorte cuadrado. Se recomienda logo con fondo blanco. + {/* ── Resolución y Calidad ── */} + Calidad de imagen + + void save({ ...config, resolution: v })} + /> + + Low (~720p), Medium (~1080p), High (~4K). + + + + + void save({ ...config, quality: parseFloat(v) })} + /> + + Valores bajos reducen el tamaño del archivo pero pueden verse peor. + + + {/* ── Campos ── */} Campos @@ -277,6 +307,13 @@ const styles = StyleSheet.create({ logoActions: { gap: 8 }, hint: { fontSize: 11, color: COLORS.muted, marginBottom: 8 }, + card: { + backgroundColor: COLORS.bg, + borderRadius: 8, + padding: 12, + marginBottom: 8, + }, + /* Campos */ fieldRow: { flexDirection: 'row', diff --git a/src/ui/FeatureMap.tsx b/src/ui/FeatureMap.tsx index bbce1e3..b45f27d 100644 --- a/src/ui/FeatureMap.tsx +++ b/src/ui/FeatureMap.tsx @@ -1,32 +1,12 @@ /** - * Mapa de features. Dibuja la geometría GeoJSON del proyecto (puntos, líneas, - * polígonos) sobre Google Maps y permite seleccionar una feature tocándola. - * - * Nota: las tiles de Google Maps requieren conexión; la geometría sí se dibuja - * sin red. Requiere una API key de Google Maps (ver app.config.js / README). + * Mapa de features usando OpenStreetMap via Leaflet y WebView. + * Elimina la dependencia de la API Key de Google Maps. */ -import * as Location from 'expo-location'; import React, { useMemo, useRef } from 'react'; -import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import MapView, { Marker, Polygon, Polyline, PROVIDER_DEFAULT, PROVIDER_GOOGLE } from 'react-native-maps'; - -// Android usa Google Maps (requiere GOOGLE_MAPS_API_KEY). -// iOS usa Apple Maps por defecto (sin key, PROVIDER_DEFAULT). -const MAP_PROVIDER = Platform.OS === 'android' ? PROVIDER_GOOGLE : PROVIDER_DEFAULT; +import { StyleSheet, View } from 'react-native'; +import { WebView } from 'react-native-webview'; import { Feature } from '../api/types'; import { COLORS } from './components'; -import { geometryToShapes, LatLng, regionFor } from './geojson'; - -const STATUS_COLOR: Record = { - pending: '#9aa0a6', - in_progress: '#1f6f43', - completed: '#2e7d32', - blocked: '#b00020', -}; - -function colorFor(status?: string): string { - return (status && STATUS_COLOR[status]) || COLORS.primary; -} export function FeatureMap({ features, @@ -37,66 +17,121 @@ export function FeatureMap({ selectedId: number | null; onSelect: (id: number) => void; }) { - const mapRef = useRef(null); + const webViewRef = useRef(null); - const { shaped, region } = useMemo(() => { - const all: LatLng[] = []; - const shaped = features.map((f) => { - const shapes = geometryToShapes(f.geometry); - all.push(...shapes.points, ...shapes.lines.flat(), ...shapes.polygons.flat()); - return { feature: f, shapes }; - }); - return { shaped, region: regionFor(all) }; - }, [features]); + // Convertimos las features a un objeto GeoJSON simple para Leaflet + const geoData = useMemo(() => { + return { + type: 'FeatureCollection', + features: features.map((f) => ({ + type: 'Feature', + id: f.id, + geometry: typeof f.geometry === 'string' ? JSON.parse(f.geometry) : f.geometry, + properties: { + name: f.name, + status: f.status, + selected: f.id === selectedId, + }, + })), + }; + }, [features, selectedId]); - const recenter = async () => { - const perm = await Location.requestForegroundPermissionsAsync(); - if (!perm.granted) return; - const pos = await Location.getCurrentPositionAsync({}); - mapRef.current?.animateToRegion({ - latitude: pos.coords.latitude, - longitude: pos.coords.longitude, - latitudeDelta: 0.01, - longitudeDelta: 0.01, - }); + const htmlContent = useMemo(() => ` + + + + + + + + + + +
+ + + + `, [geoData]); + + const onMessage = (event: any) => { + try { + const data = JSON.parse(event.nativeEvent.data); + if (data.type === 'select') { + onSelect(data.id); + } + } catch (e) { + console.warn('Error parsing WebView message:', e); + } }; - if (!region) { - return ( - - Las features no tienen geometría. - - ); - } - return ( - - - Mapa desactivado temporalmente para pruebas.{"\n"} - Requiere API Key de Google Maps válida. - + + ); } const styles = StyleSheet.create({ - container: { flex: 1 }, - empty: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 }, - locBtn: { - position: 'absolute', - right: 16, - bottom: 16, - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: '#fff', - justifyContent: 'center', - alignItems: 'center', - elevation: 4, - shadowColor: '#000', - shadowOpacity: 0.2, - shadowRadius: 4, - shadowOffset: { width: 0, height: 2 }, - }, - locIcon: { fontSize: 22, color: COLORS.primary }, + container: { flex: 1, backgroundColor: '#f0f0f0' }, + map: { flex: 1 }, }); diff --git a/src/ui/MediaStrip.tsx b/src/ui/MediaStrip.tsx index 683fe49..cc8f003 100644 --- a/src/ui/MediaStrip.tsx +++ b/src/ui/MediaStrip.tsx @@ -29,6 +29,7 @@ import { RootStackParamList } from '../navigation/types'; import { FooterConfig, loadFooterConfig, + RESOLUTION_WIDTHS, resolveFieldValue, StampMeta, } from '../photo/footerConfig'; @@ -47,8 +48,6 @@ interface ComposingTask { type Nav = NativeStackNavigationProp; -const MAX_STAMP_W = 2048; - // ─── helpers ─────────────────────────────────────────────────────────────── async function getCoords(): Promise<{ latitude: number; longitude: number } | null> { @@ -157,7 +156,8 @@ export function MediaStrip({ coordinates: coordsRaw ? formatCoords(coordsRaw.latitude, coordsRaw.longitude) : null, }; - const scale = Math.min(1, MAX_STAMP_W / origW); + const targetW = RESOLUTION_WIDTHS[config.resolution] || 1920; + const scale = Math.min(1, targetW / origW); const renderW = Math.round(origW * scale); const renderH = Math.round(origH * scale); @@ -170,54 +170,48 @@ export function MediaStrip({ // ── captura de foto ─────────────────────────────────────────────────────── - const addPhoto = useCallback( - async (source: 'camera' | 'library') => { - const perm = - source === 'camera' - ? await ImagePicker.requestCameraPermissionsAsync() - : await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (!perm.granted) { - Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.'); - return; - } + const handleCapture = async (uri: string, width: number, height: number) => { + let finalUri: string; + try { + finalUri = await stamp(uri, width, height); + } catch { + finalUri = uri; + } - const result = - source === 'camera' - ? await ImagePicker.launchCameraAsync({ quality: 0.85 }) - : await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' }); - if (result.canceled || !result.assets?.length) return; + await enqueueMedia({ + parentEntity, + parentId, + localUri: finalUri, + mimeType: 'image/jpeg', + category: 'image', + }); + await refresh(); + }; - const asset = result.assets[0]; + const addFromLibrary = async () => { + const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!perm.granted) { + Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.'); + return; + } - let finalUri: string; - try { - finalUri = await stamp(asset.uri, asset.width ?? 1920, asset.height ?? 1080); - } catch { - // Si el stamping falla, usar la foto original. - finalUri = asset.uri; - } + const result = await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' }); + if (result.canceled || !result.assets?.length) return; - await enqueueMedia({ - parentEntity, - parentId, - localUri: finalUri, - fileName: asset.fileName ?? undefined, - mimeType: asset.mimeType ?? 'image/jpeg', - category: 'image', - }); - await refresh(); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [parentEntity, parentId, refresh], - ); + const asset = result.assets[0]; + await handleCapture(asset.uri, asset.width ?? 1920, asset.height ?? 1080); + }; const onAdd = useCallback(() => { Alert.alert('Añadir foto', undefined, [ - { text: 'Cámara', onPress: () => void addPhoto('camera') }, - { text: 'Galería', onPress: () => void addPhoto('library') }, + { + text: 'Cámara', + onPress: () => navigation.navigate('Camera', { onCapture: handleCapture }) + }, + { text: 'Galería', onPress: () => void addFromLibrary() }, { text: 'Cancelar', style: 'cancel' }, ]); - }, [addPhoto]); + }, [navigation, handleCapture, addFromLibrary]); const onDeletePending = useCallback( async (m: MediaOutboxRow) => {