diff --git a/.artifacts/08edce59-f9d0-43c1-9cf7-8b294aeca3f0/task.artifact.md b/.artifacts/08edce59-f9d0-43c1-9cf7-8b294aeca3f0/task.artifact.md new file mode 100644 index 0000000..0776594 --- /dev/null +++ b/.artifacts/08edce59-f9d0-43c1-9cf7-8b294aeca3f0/task.artifact.md @@ -0,0 +1,3 @@ +- [ ] Configure `gradle.user.home` and related properties in `gradle.properties` [ ] +- [ ] Verify Gradle Sync and Build [ ] +- [ ] Create Walkthrough [ ] diff --git a/android/gradle.properties b/android/gradle.properties index f23b18f..fff6691 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,11 +1,13 @@ # Project-wide Gradle settings. -org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 systemProp.android.prefs.root=C:/Android/home systemProp.android.user.home=C:/Android/home systemProp.android.sdk.home=C:/Android/home systemProp.user.home=C:/Android/home gradle.user.home=C:/Android/gradle +systemProp.gradle.user.home=C:/Android/gradle +android.overridePathCheck=true android.useAndroidX=true android.enablePngCrunchInReleaseBuilds=true reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 diff --git a/src/db/outbox.ts b/src/db/outbox.ts index 08f6ea4..f1ad19c 100644 --- a/src/db/outbox.ts +++ b/src/db/outbox.ts @@ -270,3 +270,9 @@ export async function deleteMediaOutbox(uuid: string): Promise { const db = await getDb(); await db.runAsync('DELETE FROM media_outbox WHERE uuid = ?', uuid); } + +/** Actualiza la ruta local de un fichero (usado tras añadir el sello en 2º plano). */ +export async function updateMediaLocalUri(uuid: string, localUri: string): Promise { + const db = await getDb(); + await db.runAsync('UPDATE media_outbox SET local_uri = ? WHERE uuid = ?', localUri, uuid); +} diff --git a/src/screens/CameraScreen.tsx b/src/screens/CameraScreen.tsx index 57187db..c754426 100644 --- a/src/screens/CameraScreen.tsx +++ b/src/screens/CameraScreen.tsx @@ -1,7 +1,8 @@ import { CameraView, useCameraPermissions } from 'expo-camera'; import * as Location from 'expo-location'; import * as ScreenOrientation from 'expo-screen-orientation'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import React, { useCallback, useRef, useState } from 'react'; import { StyleSheet, Text, @@ -30,66 +31,73 @@ export function CameraScreen({ route, navigation }: Props) { const [taking, setTaking] = useState(false); const [rotation, setRotation] = useState(0); - // Detectar orientación física para rotar el sello - useEffect(() => { - const updateOrientation = async () => { - const info = await ScreenOrientation.getOrientationAsync(); - handleOrientation(info); - }; + // Carga de datos cada vez que la pantalla gana el foco + useFocusEffect( + useCallback(() => { + let isMounted = true; - const handleOrientation = (o: ScreenOrientation.Orientation) => { - // Nota: LANDSCAPE_LEFT es rotar el móvil a la derecha (home button a la derecha) - // Queremos contrarrestar el giro para que el sello esté derecho. - if (o === ScreenOrientation.Orientation.LANDSCAPE_LEFT) setRotation(90); - else if (o === ScreenOrientation.Orientation.LANDSCAPE_RIGHT) setRotation(-90); - else if (o === ScreenOrientation.Orientation.PORTRAIT_UPSIDE_DOWN) setRotation(180); - else setRotation(0); - }; + const load = async () => { + const [cfg, projectId] = await Promise.all([ + loadFooterConfig(), + getActiveProjectId(), + ]); + if (!isMounted) return; + setConfig(cfg); - void updateOrientation(); - const sub = ScreenOrientation.addOrientationChangeListener((evt) => { - handleOrientation(evt.orientationInfo.orientation); - }); - return () => ScreenOrientation.removeOrientationChangeListener(sub); - }, []); + let projectName: string | null = null; + if (projectId != null) { + const proj = await getProject(projectId); + projectName = proj?.name ?? null; + } - const loadData = useCallback(async () => { - const [cfg, projectId] = await Promise.all([ - loadFooterConfig(), - getActiveProjectId(), - ]); - setConfig(cfg); + 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}`; + } - let projectName: string | null = null; - if (projectId != null) { - const proj = await getProject(projectId); - projectName = proj?.name ?? null; - } + if (isMounted) { + setMeta({ + projectName, + date: new Date().toLocaleString('es-ES', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }), + coordinates: coordsStr, + }); + } + }; - 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}`; - } + const syncOrientation = async () => { + const info = await ScreenOrientation.getOrientationAsync(); + handleOrientation(info); + }; - setMeta({ - projectName, - date: new Date().toLocaleString('es-ES', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', - }), - coordinates: coordsStr, - }); - }, []); + const handleOrientation = (o: ScreenOrientation.Orientation) => { + if (o === ScreenOrientation.Orientation.LANDSCAPE_LEFT) setRotation(90); + else if (o === ScreenOrientation.Orientation.LANDSCAPE_RIGHT) setRotation(-90); + else if (o === ScreenOrientation.Orientation.PORTRAIT_UPSIDE_DOWN) setRotation(180); + else setRotation(0); + }; - useEffect(() => { - void loadData(); - }, [loadData]); + void load(); + void syncOrientation(); + + const sub = ScreenOrientation.addOrientationChangeListener((evt) => { + handleOrientation(evt.orientationInfo.orientation); + }); + + return () => { + isMounted = false; + ScreenOrientation.removeOrientationChangeListener(sub); + }; + }, []) + ); if (!permission) { return ; @@ -111,9 +119,9 @@ export function CameraScreen({ route, navigation }: Props) { setTaking(true); try { const photo = await cameraRef.current.takePictureAsync({ - quality: config?.quality ?? 0.85, + quality: 0.9, // Máxima calidad para el raw base64: false, - exif: false, + exif: true, }); if (photo) { onCapture(photo.uri, photo.width, photo.height); @@ -135,7 +143,6 @@ export function CameraScreen({ route, navigation }: Props) { autofocus="on" /> - {/* Capa de UI sobre la cámara */} {/* Header con botón cerrar */} @@ -144,7 +151,7 @@ export function CameraScreen({ route, navigation }: Props) { - {/* Preview del sello (posicionado en su esquina con rotación) */} + {/* Preview del sello: posicionado por FooterOverlay, no por este contenedor */} {config && meta && ( )} - {/* Botón de disparo abajo al centro */} + {/* Botón de disparo */} (); + const { width: winW } = useWindowDimensions(); + const [synced, setSynced] = useState([]); const [pending, setPending] = useState([]); const [composing, setComposing] = useState(null); + // Estados para selección y visor + const [selectedUuids, setSelectedUuids] = useState>(new Set()); + const [viewerUri, setViewerUri] = useState(null); + const captureViewRef = useRef(null); - const resolveRef = useRef<((uri: string) => void) | null>(null); - const rejectRef = useRef<((e: unknown) => void) | null>(null); + + // Cola de tareas de procesado para no perder ninguna + const queueRef = useRef([]); // ── cargar datos ────────────────────────────────────────────────────────── @@ -102,43 +113,51 @@ export function MediaStrip({ useEffect(() => { void refresh(); }, [refresh]); - // ── compositing (ViewShot) ──────────────────────────────────────────────── + // ── compositing (Procesado en 2º plano) ─────────────────────────────────── + + /** Inicia el siguiente procesado de la cola si no hay uno en curso. */ + const processNext = useCallback(() => { + if (composing || queueRef.current.length === 0) return; + const next = queueRef.current.shift(); + if (next) setComposing(next); + }, [composing]); + + useEffect(() => { + processNext(); + }, [composing, processNext]); - /** Llamado por Image.onLoad cuando la imagen del composer ya está dibujada. */ const onCaptureImageLoaded = useCallback(() => { - // Un frame para que el layout nativo finalice antes de capturar. + if (!composing) return; + setTimeout(async () => { if (!captureViewRef.current) { - rejectRef.current?.(new Error('captureRef is null')); setComposing(null); return; } try { - const uri = await captureRef(captureViewRef, { format: 'jpg', quality: 0.85 }); - resolveRef.current?.(uri); + const stampedUri = await captureRef(captureViewRef, { + format: 'jpg', + quality: composing.config.quality + }); + // Actualizar el registro local con la imagen ya sellada + await updateMediaLocalUri(composing.uuid, stampedUri); + await refresh(); } catch (e) { - rejectRef.current?.(e); + console.error('Stamping failed:', e); } finally { - setComposing(null); - resolveRef.current = null; - rejectRef.current = null; + setComposing(null); // Esto disparará el siguiente en la cola vía useEffect } - }, 80); - }, []); + }, 100); + }, [composing, refresh]); - /** - * Estampa el pie de página sobre `rawUri`. - * Si el footer está desactivado o vacío devuelve el URI original intacto. - */ - async function stamp(rawUri: string, origW: number, origH: number): Promise { + /** Prepara una tarea de estampado y la mete en la cola. */ + async function scheduleStamp(uuid: string, rawUri: string, origW: number, origH: number) { const [config, projectId] = await Promise.all([ loadFooterConfig(), getActiveProjectId(), ]); - const hasContent = config.enabled && - (config.logoUri != null || config.fields.some((f) => f.enabled)); - if (!hasContent) return rawUri; + if (!config.enabled) return; let projectName: string | null = null; if (projectId != null) { @@ -158,145 +177,170 @@ export function MediaStrip({ 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); - return new Promise((resolve, reject) => { - resolveRef.current = resolve; - rejectRef.current = reject; - setComposing({ uri: rawUri, renderW, renderH, meta, config }); + queueRef.current.push({ + uuid, + uri: rawUri, + renderW: Math.round(origW * scale), + renderH: Math.round(origH * scale), + meta, + config, }); + processNext(); } - // ── captura de foto ─────────────────────────────────────────────────────── + // ── acciones ────────────────────────────────────────────────────────────── const handleCapture = async (uri: string, width: number, height: number) => { - let finalUri: string; - try { - finalUri = await stamp(uri, width, height); - } catch { - finalUri = uri; - } - - await enqueueMedia({ + // 1. Guardar la imagen original inmediatamente para respuesta instantánea + const uuid = await enqueueMedia({ parentEntity, parentId, - localUri: finalUri, + localUri: uri, mimeType: 'image/jpeg', category: 'image', }); - await refresh(); - }; - const addFromLibrary = async () => { - const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (!perm.granted) { - Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.'); - return; - } + await refresh(); // Mostrar en la galería ya - const result = await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' }); - if (result.canceled || !result.assets?.length) return; - - const asset = result.assets[0]; - await handleCapture(asset.uri, asset.width ?? 1920, asset.height ?? 1080); + // 2. Programar el estampado en segundo plano + void scheduleStamp(uuid, uri, width, height); }; const onAdd = useCallback(() => { - Alert.alert('Añadir foto', undefined, [ - { - text: 'Cámara', - onPress: () => navigation.navigate('Camera', { onCapture: handleCapture }) - }, - { text: 'Galería', onPress: () => void addFromLibrary() }, - { text: 'Cancelar', style: 'cancel' }, - ]); - }, [navigation, handleCapture, addFromLibrary]); + navigation.navigate('Camera', { onCapture: handleCapture }); + }, [navigation, handleCapture]); - const onDeletePending = useCallback( - async (m: MediaOutboxRow) => { - Alert.alert('Eliminar foto', '¿Deseas eliminar esta foto antes de sincronizar?', [ - { text: 'Cancelar', style: 'cancel' }, - { - text: 'Eliminar', - style: 'destructive', - onPress: async () => { - await deleteMediaOutbox(m.uuid); - await refresh(); - }, + const toggleSelect = (uuid: string) => { + setSelectedUuids((prev) => { + const next = new Set(prev); + if (next.has(uuid)) next.delete(uuid); + else next.add(uuid); + return next; + }); + }; + + const deleteSelected = async () => { + const count = selectedUuids.size; + if (count === 0) return; + + Alert.alert('Eliminar fotos', `¿Deseas eliminar las ${count} fotos seleccionadas?`, [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Eliminar', + style: 'destructive', + onPress: async () => { + for (const uuid of selectedUuids) { + await deleteMediaOutbox(uuid); + } + setSelectedUuids(new Set()); + await refresh(); }, - ]); - }, - [refresh], - ); + }, + ]); + }; // ─── render ─────────────────────────────────────────────────────────────── + const isSelectionMode = selectedUuids.size > 0; + const thumbSize = (winW - 32 - 16) / 3; + if (synced.length === 0 && pending.length === 0 && !canUpload) return null; return ( - {/* Cabecera con icono de configuración */} - {canUpload && ( - - Fotos - navigation.navigate('PhotoSettings')}> - - + + + {isSelectionMode ? `${selectedUuids.size} seleccionadas` : `Fotos (${synced.length + pending.length})`} + + + {isSelectionMode ? ( + + Borrar + + ) : ( + navigation.navigate('PhotoSettings')}> + + + )} - )} + - {/* Tira de miniaturas */} - + {canUpload && ( - + - Foto + Añadir )} - {pending.map((m) => ( - onDeletePending(m)} - activeOpacity={0.8} - > - - { + const isSelected = selectedUuids.has(m.uuid); + const isProcessing = composing?.uuid === m.uuid || queueRef.current.some(q => q.uuid === m.uuid); + + return ( + isSelectionMode ? toggleSelect(m.uuid) : setViewerUri(m.local_uri)} + onLongPress={() => toggleSelect(m.uuid)} + activeOpacity={0.8} > - - {m.status === 'error' ? 'error' : 'en cola'} - - - - - + + + {isProcessing && ( + + Sellando… + + )} + + {isSelected && ( + + + + )} + {m.status === 'error' && ( + + error + + )} + + ); + })} + + {synced.map((m) => ( + setViewerUri(absoluteUrl(m.url))} + > + ))} - {synced.map((m) => ( - - - - ))} - + - {/* Vista fuera de pantalla para compositar el pie de página */} - {composing ? ( + + + + setViewerUri(null)}> + Cerrar + + + + + {/* COMPOSER OCULTO: Solo renderiza si hay una tarea en la cola */} + {composing && ( - + - ) : null} + )} ); } -// ─── estilos ────────────────────────────────────────────────────────────── - const styles = StyleSheet.create({ wrapper: { marginVertical: 8 }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - paddingHorizontal: 2, - marginBottom: 6, + paddingHorizontal: 4, + marginBottom: 10, + }, + headerLabel: { fontSize: 14, fontWeight: '700', color: COLORS.muted }, + headerActions: { flexDirection: 'row', alignItems: 'center' }, + gear: { fontSize: 18, color: COLORS.muted, paddingHorizontal: 8 }, + deleteAction: { backgroundColor: COLORS.danger, paddingHorizontal: 12, paddingVertical: 4, borderRadius: 6 }, + deleteActionTxt: { color: '#fff', fontSize: 12, fontWeight: 'bold' }, + + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, }, - headerLabel: { fontSize: 13, fontWeight: '600', color: COLORS.muted }, - gear: { fontSize: 18, color: COLORS.muted, paddingHorizontal: 4 }, - row: { gap: 8, paddingRight: 8 }, addBtn: { - width: 72, - height: 72, borderRadius: 8, borderWidth: 1, borderColor: COLORS.primary, borderStyle: 'dashed', justifyContent: 'center', alignItems: 'center', + backgroundColor: '#fff', }, - addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' }, - addText: { color: COLORS.primary, fontSize: 11 }, + addPlus: { color: COLORS.primary, fontSize: 24, fontWeight: '700' }, + addText: { color: COLORS.primary, fontSize: 11, marginTop: 2 }, + thumbWrap: { - width: 72, - height: 72, borderRadius: 8, overflow: 'hidden', backgroundColor: COLORS.bg, + borderWidth: 2, + borderColor: 'transparent', }, + selectedThumb: { borderColor: COLORS.primary }, thumb: { width: '100%', height: '100%' }, + + checkOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(31, 111, 67, 0.4)', + justifyContent: 'center', + alignItems: 'center', + }, + checkIcon: { color: '#fff', fontSize: 32, fontWeight: 'bold' }, + + processingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + processingTxt: { color: '#fff', fontSize: 10, fontWeight: 'bold' }, + tag: { position: 'absolute', bottom: 0, @@ -362,16 +426,18 @@ const styles = StyleSheet.create({ alignItems: 'center', }, tagText: { color: '#fff', fontSize: 9, fontWeight: '700' }, - deleteOverlay: { + + /* Visor */ + viewerContainer: { flex: 1, backgroundColor: '#000', justifyContent: 'center' }, + viewerImg: { width: '100%', height: '80%' }, + viewerClose: { position: 'absolute', - top: 4, - right: 4, - backgroundColor: 'rgba(0,0,0,0.4)', - width: 20, - height: 20, - borderRadius: 10, - justifyContent: 'center', - alignItems: 'center', + bottom: 40, + alignSelf: 'center', + paddingHorizontal: 30, + paddingVertical: 12, + backgroundColor: 'rgba(255,255,255,0.2)', + borderRadius: 25, }, - deleteIcon: { color: '#fff', fontSize: 12, fontWeight: 'bold' }, + viewerCloseTxt: { color: '#fff', fontWeight: 'bold' }, });