Finalize background photo processing and orientation fixes

This commit is contained in:
2026-07-29 12:29:01 +02:00
parent f2b359e515
commit a12ec6ae2f
5 changed files with 312 additions and 227 deletions
@@ -0,0 +1,3 @@
- [ ] Configure `gradle.user.home` and related properties in `gradle.properties` [ ]
- [ ] Verify Gradle Sync and Build [ ]
- [ ] Create Walkthrough [ ]
+3 -1
View File
@@ -1,11 +1,13 @@
# Project-wide Gradle settings. # 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.prefs.root=C:/Android/home
systemProp.android.user.home=C:/Android/home systemProp.android.user.home=C:/Android/home
systemProp.android.sdk.home=C:/Android/home systemProp.android.sdk.home=C:/Android/home
systemProp.user.home=C:/Android/home systemProp.user.home=C:/Android/home
gradle.user.home=C:/Android/gradle gradle.user.home=C:/Android/gradle
systemProp.gradle.user.home=C:/Android/gradle
android.overridePathCheck=true
android.useAndroidX=true android.useAndroidX=true
android.enablePngCrunchInReleaseBuilds=true android.enablePngCrunchInReleaseBuilds=true
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
+6
View File
@@ -270,3 +270,9 @@ export async function deleteMediaOutbox(uuid: string): Promise<void> {
const db = await getDb(); const db = await getDb();
await db.runAsync('DELETE FROM media_outbox WHERE uuid = ?', uuid); 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<void> {
const db = await getDb();
await db.runAsync('UPDATE media_outbox SET local_uri = ? WHERE uuid = ?', localUri, uuid);
}
+75 -67
View File
@@ -1,7 +1,8 @@
import { CameraView, useCameraPermissions } from 'expo-camera'; import { CameraView, useCameraPermissions } from 'expo-camera';
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import * as ScreenOrientation from 'expo-screen-orientation'; 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 { import {
StyleSheet, StyleSheet,
Text, Text,
@@ -30,66 +31,73 @@ export function CameraScreen({ route, navigation }: Props) {
const [taking, setTaking] = useState(false); const [taking, setTaking] = useState(false);
const [rotation, setRotation] = useState(0); const [rotation, setRotation] = useState(0);
// Detectar orientación física para rotar el sello // Carga de datos cada vez que la pantalla gana el foco
useEffect(() => { useFocusEffect(
const updateOrientation = async () => { useCallback(() => {
const info = await ScreenOrientation.getOrientationAsync(); let isMounted = true;
handleOrientation(info);
};
const handleOrientation = (o: ScreenOrientation.Orientation) => { const load = async () => {
// Nota: LANDSCAPE_LEFT es rotar el móvil a la derecha (home button a la derecha) const [cfg, projectId] = await Promise.all([
// Queremos contrarrestar el giro para que el sello esté derecho. loadFooterConfig(),
if (o === ScreenOrientation.Orientation.LANDSCAPE_LEFT) setRotation(90); getActiveProjectId(),
else if (o === ScreenOrientation.Orientation.LANDSCAPE_RIGHT) setRotation(-90); ]);
else if (o === ScreenOrientation.Orientation.PORTRAIT_UPSIDE_DOWN) setRotation(180); if (!isMounted) return;
else setRotation(0); setConfig(cfg);
};
void updateOrientation(); let projectName: string | null = null;
const sub = ScreenOrientation.addOrientationChangeListener((evt) => { if (projectId != null) {
handleOrientation(evt.orientationInfo.orientation); const proj = await getProject(projectId);
}); projectName = proj?.name ?? null;
return () => ScreenOrientation.removeOrientationChangeListener(sub); }
}, []);
const loadData = useCallback(async () => { const { granted } = await Location.requestForegroundPermissionsAsync();
const [cfg, projectId] = await Promise.all([ let coordsStr: string | null = null;
loadFooterConfig(), if (granted) {
getActiveProjectId(), const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
]); const lat = loc.coords.latitude;
setConfig(cfg); 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 (isMounted) {
if (projectId != null) { setMeta({
const proj = await getProject(projectId); projectName,
projectName = proj?.name ?? null; 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(); const syncOrientation = async () => {
let coordsStr: string | null = null; const info = await ScreenOrientation.getOrientationAsync();
if (granted) { handleOrientation(info);
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({ const handleOrientation = (o: ScreenOrientation.Orientation) => {
projectName, if (o === ScreenOrientation.Orientation.LANDSCAPE_LEFT) setRotation(90);
date: new Date().toLocaleString('es-ES', { else if (o === ScreenOrientation.Orientation.LANDSCAPE_RIGHT) setRotation(-90);
year: 'numeric', month: '2-digit', day: '2-digit', else if (o === ScreenOrientation.Orientation.PORTRAIT_UPSIDE_DOWN) setRotation(180);
hour: '2-digit', minute: '2-digit', else setRotation(0);
}), };
coordinates: coordsStr,
});
}, []);
useEffect(() => { void load();
void loadData(); void syncOrientation();
}, [loadData]);
const sub = ScreenOrientation.addOrientationChangeListener((evt) => {
handleOrientation(evt.orientationInfo.orientation);
});
return () => {
isMounted = false;
ScreenOrientation.removeOrientationChangeListener(sub);
};
}, [])
);
if (!permission) { if (!permission) {
return <View style={styles.center}><ActivityIndicator size="large" /></View>; return <View style={styles.center}><ActivityIndicator size="large" /></View>;
@@ -111,9 +119,9 @@ export function CameraScreen({ route, navigation }: Props) {
setTaking(true); setTaking(true);
try { try {
const photo = await cameraRef.current.takePictureAsync({ const photo = await cameraRef.current.takePictureAsync({
quality: config?.quality ?? 0.85, quality: 0.9, // Máxima calidad para el raw
base64: false, base64: false,
exif: false, exif: true,
}); });
if (photo) { if (photo) {
onCapture(photo.uri, photo.width, photo.height); onCapture(photo.uri, photo.width, photo.height);
@@ -135,7 +143,6 @@ export function CameraScreen({ route, navigation }: Props) {
autofocus="on" autofocus="on"
/> />
{/* Capa de UI sobre la cámara */}
<View style={styles.overlay}> <View style={styles.overlay}>
{/* Header con botón cerrar */} {/* Header con botón cerrar */}
<View style={styles.header}> <View style={styles.header}>
@@ -144,7 +151,7 @@ export function CameraScreen({ route, navigation }: Props) {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Preview del sello (posicionado en su esquina con rotación) */} {/* Preview del sello: posicionado por FooterOverlay, no por este contenedor */}
{config && meta && ( {config && meta && (
<View style={styles.fullOverlay} pointerEvents="none"> <View style={styles.fullOverlay} pointerEvents="none">
<FooterOverlay <FooterOverlay
@@ -156,7 +163,7 @@ export function CameraScreen({ route, navigation }: Props) {
</View> </View>
)} )}
{/* Botón de disparo abajo al centro */} {/* Botón de disparo */}
<View style={styles.footerControls}> <View style={styles.footerControls}>
<TouchableOpacity <TouchableOpacity
style={[styles.captureBtn, taking && styles.disabled]} style={[styles.captureBtn, taking && styles.disabled]}
@@ -176,14 +183,15 @@ const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'space-between', zIndex: 10 }, overlay: { flex: 1, justifyContent: 'space-between', zIndex: 10 },
fullOverlay: { fullOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
// No centramos aquí, dejamos que FooterOverlay use sus offsets top/bottom/left/right // IMPORTANTE: Eliminado justifyContent: center.
// Ahora FooterOverlay se anclará a las esquinas correctamente.
}, },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }, center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' },
message: { color: '#fff', textAlign: 'center', marginBottom: 20 }, message: { color: '#fff', textAlign: 'center', marginBottom: 20 },
btn: { backgroundColor: COLORS.primary, padding: 12, borderRadius: 8 }, btn: { backgroundColor: COLORS.primary, padding: 12, borderRadius: 8 },
btnText: { color: '#fff', fontWeight: 'bold' }, btnText: { color: '#fff', fontWeight: 'bold' },
header: { padding: 20, paddingTop: 50 }, header: { padding: 20, paddingTop: 50 },
closeBtn: { width: 40, height: 40, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.3)', borderRadius: 20 }, closeBtn: { width: 44, height: 44, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.4)', borderRadius: 22 },
closeTxt: { color: '#fff', fontSize: 24 }, closeTxt: { color: '#fff', fontSize: 24 },
footerControls: { footerControls: {
paddingBottom: 40, paddingBottom: 40,
@@ -191,18 +199,18 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
}, },
captureBtn: { captureBtn: {
width: 76, width: 74,
height: 70, // Un poco ovalado para que se note height: 74,
borderRadius: 38, borderRadius: 37,
borderWidth: 5, borderWidth: 5,
borderColor: '#fff', borderColor: '#fff',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
captureInner: { captureInner: {
width: 54, width: 56,
height: 54, height: 56,
borderRadius: 27, borderRadius: 28,
backgroundColor: '#fff', backgroundColor: '#fff',
}, },
disabled: { opacity: 0.5 }, disabled: { opacity: 0.5 },
+225 -159
View File
@@ -1,21 +1,26 @@
/** /**
* Tira de fotos de un registro. Muestra las ya sincronizadas (tabla `media`) * Tira de fotos de un registro. Muestra las ya sincronizadas (tabla `media`)
* y las locales en cola (`media_outbox`). Permite añadir nuevas desde cámara * y las locales en cola (`media_outbox`). Permite añadir nuevas desde cámara;
* o galería; antes de encolarlas les estampa un pie de página georreferenciado * antes de encolarlas les estampa un pie de página georreferenciado.
* (logo + proyecto + fecha + coordenadas) capturado con react-native-view-shot.
* *
* El pie de página es configurable desde PhotoSettingsScreen (icono ⚙). * Mejoras:
* - Acceso directo a cámara.
* - Layout en cuadrícula (grid).
* - Selección múltiple para borrado masivo.
* - Visor de imagen integrado.
* - Procesado de sello en segundo plano (no bloquea).
*/ */
import * as ImagePicker from 'expo-image-picker';
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Alert, Alert,
Image, Image,
Modal,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
useWindowDimensions,
View, View,
} from 'react-native'; } from 'react-native';
import { captureRef } from 'react-native-view-shot'; import { captureRef } from 'react-native-view-shot';
@@ -23,7 +28,7 @@ import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Media, MediaParentEntity } from '../api/types'; import { Media, MediaParentEntity } from '../api/types';
import { absoluteUrl } from '../config'; import { absoluteUrl } from '../config';
import { deleteMediaOutbox, enqueueMedia, getMediaOutboxFor, MediaOutboxRow } from '../db/outbox'; import { deleteMediaOutbox, enqueueMedia, getMediaOutboxFor, MediaOutboxRow, updateMediaLocalUri } from '../db/outbox';
import { getActiveProjectId, getMediaFor, getProject } from '../db/repositories'; import { getActiveProjectId, getMediaFor, getProject } from '../db/repositories';
import { RootStackParamList } from '../navigation/types'; import { RootStackParamList } from '../navigation/types';
import { import {
@@ -39,6 +44,7 @@ import { COLORS } from './components';
// ─── tipos internos ──────────────────────────────────────────────────────── // ─── tipos internos ────────────────────────────────────────────────────────
interface ComposingTask { interface ComposingTask {
uuid: string; // Para saber qué registro actualizar al terminar
uri: string; uri: string;
renderW: number; renderW: number;
renderH: number; renderH: number;
@@ -54,9 +60,7 @@ async function getCoords(): Promise<{ latitude: number; longitude: number } | nu
try { try {
const { granted } = await Location.requestForegroundPermissionsAsync(); const { granted } = await Location.requestForegroundPermissionsAsync();
if (!granted) return null; if (!granted) return null;
const loc = await Location.getCurrentPositionAsync({ const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
accuracy: Location.Accuracy.Balanced,
});
return loc.coords; return loc.coords;
} catch { } catch {
return null; return null;
@@ -81,13 +85,20 @@ export function MediaStrip({
canUpload: boolean; canUpload: boolean;
}) { }) {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const { width: winW } = useWindowDimensions();
const [synced, setSynced] = useState<Media[]>([]); const [synced, setSynced] = useState<Media[]>([]);
const [pending, setPending] = useState<MediaOutboxRow[]>([]); const [pending, setPending] = useState<MediaOutboxRow[]>([]);
const [composing, setComposing] = useState<ComposingTask | null>(null); const [composing, setComposing] = useState<ComposingTask | null>(null);
// Estados para selección y visor
const [selectedUuids, setSelectedUuids] = useState<Set<string>>(new Set());
const [viewerUri, setViewerUri] = useState<string | null>(null);
const captureViewRef = useRef<View>(null); const captureViewRef = useRef<View>(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<ComposingTask[]>([]);
// ── cargar datos ────────────────────────────────────────────────────────── // ── cargar datos ──────────────────────────────────────────────────────────
@@ -102,43 +113,51 @@ export function MediaStrip({
useEffect(() => { void refresh(); }, [refresh]); 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(() => { const onCaptureImageLoaded = useCallback(() => {
// Un frame para que el layout nativo finalice antes de capturar. if (!composing) return;
setTimeout(async () => { setTimeout(async () => {
if (!captureViewRef.current) { if (!captureViewRef.current) {
rejectRef.current?.(new Error('captureRef is null'));
setComposing(null); setComposing(null);
return; return;
} }
try { try {
const uri = await captureRef(captureViewRef, { format: 'jpg', quality: 0.85 }); const stampedUri = await captureRef(captureViewRef, {
resolveRef.current?.(uri); format: 'jpg',
quality: composing.config.quality
});
// Actualizar el registro local con la imagen ya sellada
await updateMediaLocalUri(composing.uuid, stampedUri);
await refresh();
} catch (e) { } catch (e) {
rejectRef.current?.(e); console.error('Stamping failed:', e);
} finally { } finally {
setComposing(null); setComposing(null); // Esto disparará el siguiente en la cola vía useEffect
resolveRef.current = null;
rejectRef.current = null;
} }
}, 80); }, 100);
}, []); }, [composing, refresh]);
/** /** Prepara una tarea de estampado y la mete en la cola. */
* Estampa el pie de página sobre `rawUri`. async function scheduleStamp(uuid: string, rawUri: string, origW: number, origH: number) {
* Si el footer está desactivado o vacío devuelve el URI original intacto.
*/
async function stamp(rawUri: string, origW: number, origH: number): Promise<string> {
const [config, projectId] = await Promise.all([ const [config, projectId] = await Promise.all([
loadFooterConfig(), loadFooterConfig(),
getActiveProjectId(), getActiveProjectId(),
]); ]);
const hasContent = config.enabled && if (!config.enabled) return;
(config.logoUri != null || config.fields.some((f) => f.enabled));
if (!hasContent) return rawUri;
let projectName: string | null = null; let projectName: string | null = null;
if (projectId != null) { if (projectId != null) {
@@ -158,145 +177,170 @@ export function MediaStrip({
const targetW = RESOLUTION_WIDTHS[config.resolution] || 1920; const targetW = RESOLUTION_WIDTHS[config.resolution] || 1920;
const scale = Math.min(1, targetW / origW); const scale = Math.min(1, targetW / origW);
const renderW = Math.round(origW * scale);
const renderH = Math.round(origH * scale);
return new Promise<string>((resolve, reject) => { queueRef.current.push({
resolveRef.current = resolve; uuid,
rejectRef.current = reject; uri: rawUri,
setComposing({ uri: rawUri, renderW, renderH, meta, config }); 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) => { const handleCapture = async (uri: string, width: number, height: number) => {
let finalUri: string; // 1. Guardar la imagen original inmediatamente para respuesta instantánea
try { const uuid = await enqueueMedia({
finalUri = await stamp(uri, width, height);
} catch {
finalUri = uri;
}
await enqueueMedia({
parentEntity, parentEntity,
parentId, parentId,
localUri: finalUri, localUri: uri,
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
category: 'image', category: 'image',
}); });
await refresh();
};
const addFromLibrary = async () => { await refresh(); // Mostrar en la galería ya
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' }); // 2. Programar el estampado en segundo plano
if (result.canceled || !result.assets?.length) return; void scheduleStamp(uuid, uri, width, height);
const asset = result.assets[0];
await handleCapture(asset.uri, asset.width ?? 1920, asset.height ?? 1080);
}; };
const onAdd = useCallback(() => { const onAdd = useCallback(() => {
Alert.alert('Añadir foto', undefined, [ navigation.navigate('Camera', { onCapture: handleCapture });
{ }, [navigation, handleCapture]);
text: 'Cámara',
onPress: () => navigation.navigate('Camera', { onCapture: handleCapture })
},
{ text: 'Galería', onPress: () => void addFromLibrary() },
{ text: 'Cancelar', style: 'cancel' },
]);
}, [navigation, handleCapture, addFromLibrary]);
const onDeletePending = useCallback( const toggleSelect = (uuid: string) => {
async (m: MediaOutboxRow) => { setSelectedUuids((prev) => {
Alert.alert('Eliminar foto', '¿Deseas eliminar esta foto antes de sincronizar?', [ const next = new Set(prev);
{ text: 'Cancelar', style: 'cancel' }, if (next.has(uuid)) next.delete(uuid);
{ else next.add(uuid);
text: 'Eliminar', return next;
style: 'destructive', });
onPress: async () => { };
await deleteMediaOutbox(m.uuid);
await refresh(); 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 ─────────────────────────────────────────────────────────────── // ─── render ───────────────────────────────────────────────────────────────
const isSelectionMode = selectedUuids.size > 0;
const thumbSize = (winW - 32 - 16) / 3;
if (synced.length === 0 && pending.length === 0 && !canUpload) return null; if (synced.length === 0 && pending.length === 0 && !canUpload) return null;
return ( return (
<View style={styles.wrapper}> <View style={styles.wrapper}>
{/* Cabecera con icono de configuración */} <View style={styles.header}>
{canUpload && ( <Text style={styles.headerLabel}>
<View style={styles.header}> {isSelectionMode ? `${selectedUuids.size} seleccionadas` : `Fotos (${synced.length + pending.length})`}
<Text style={styles.headerLabel}>Fotos</Text> </Text>
<TouchableOpacity onPress={() => navigation.navigate('PhotoSettings')}> <View style={styles.headerActions}>
<Text style={styles.gear}></Text> {isSelectionMode ? (
</TouchableOpacity> <TouchableOpacity onPress={deleteSelected} style={styles.deleteAction}>
<Text style={styles.deleteActionTxt}>Borrar</Text>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={() => navigation.navigate('PhotoSettings')}>
<Text style={styles.gear}></Text>
</TouchableOpacity>
)}
</View> </View>
)} </View>
{/* Tira de miniaturas */} <View style={styles.grid}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.row}
>
{canUpload && ( {canUpload && (
<TouchableOpacity style={styles.addBtn} onPress={onAdd}> <TouchableOpacity
style={[styles.addBtn, { width: thumbSize, height: thumbSize }]}
onPress={onAdd}
disabled={isSelectionMode}
>
<Text style={styles.addPlus}></Text> <Text style={styles.addPlus}></Text>
<Text style={styles.addText}>Foto</Text> <Text style={styles.addText}>Añadir</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
{pending.map((m) => (
<TouchableOpacity {pending.map((m) => {
key={m.uuid} const isSelected = selectedUuids.has(m.uuid);
style={styles.thumbWrap} const isProcessing = composing?.uuid === m.uuid || queueRef.current.some(q => q.uuid === m.uuid);
onPress={() => onDeletePending(m)}
activeOpacity={0.8} return (
> <TouchableOpacity
<Image source={{ uri: m.local_uri }} style={styles.thumb} /> key={m.uuid}
<View style={[styles.thumbWrap, { width: thumbSize, height: thumbSize }, isSelected && styles.selectedThumb]}
style={[ onPress={() => isSelectionMode ? toggleSelect(m.uuid) : setViewerUri(m.local_uri)}
styles.tag, onLongPress={() => toggleSelect(m.uuid)}
{ backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn }, activeOpacity={0.8}
]}
> >
<Text style={styles.tagText}> <Image source={{ uri: m.local_uri }} style={styles.thumb} />
{m.status === 'error' ? 'error' : 'en cola'}
</Text> {isProcessing && (
</View> <View style={styles.processingOverlay}>
<View style={styles.deleteOverlay}> <Text style={styles.processingTxt}>Sellando</Text>
<Text style={styles.deleteIcon}></Text> </View>
</View> )}
{isSelected && (
<View style={styles.checkOverlay}>
<Text style={styles.checkIcon}></Text>
</View>
)}
{m.status === 'error' && (
<View style={[styles.tag, { backgroundColor: COLORS.danger }]}>
<Text style={styles.tagText}>error</Text>
</View>
)}
</TouchableOpacity>
);
})}
{synced.map((m) => (
<TouchableOpacity
key={`s${m.id}`}
style={[styles.thumbWrap, { width: thumbSize, height: thumbSize }]}
onPress={() => setViewerUri(absoluteUrl(m.url))}
>
<Image source={{ uri: absoluteUrl(m.url) }} style={styles.thumb} />
</TouchableOpacity> </TouchableOpacity>
))} ))}
{synced.map((m) => ( </View>
<View key={`s${m.id}`} style={styles.thumbWrap}>
<Image source={{ uri: absoluteUrl(m.url) }} style={styles.thumb} />
</View>
))}
</ScrollView>
{/* Vista fuera de pantalla para compositar el pie de página */} <Modal visible={!!viewerUri} transparent={false} animationType="fade">
{composing ? ( <View style={styles.viewerContainer}>
<Image source={{ uri: viewerUri ?? undefined }} style={styles.viewerImg} resizeMode="contain" />
<TouchableOpacity style={styles.viewerClose} onPress={() => setViewerUri(null)}>
<Text style={styles.viewerCloseTxt}>Cerrar</Text>
</TouchableOpacity>
</View>
</Modal>
{/* COMPOSER OCULTO: Solo renderiza si hay una tarea en la cola */}
{composing && (
<View <View
ref={captureViewRef} ref={captureViewRef}
collapsable={false} collapsable={false}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: -(composing.renderW + 100), left: -(composing.renderW + 500),
width: composing.renderW, width: composing.renderW,
height: composing.renderH, height: composing.renderH,
overflow: 'hidden', overflow: 'hidden',
@@ -304,55 +348,75 @@ export function MediaStrip({
> >
<Image <Image
source={{ uri: composing.uri }} source={{ uri: composing.uri }}
style={StyleSheet.absoluteFill} style={{ width: composing.renderW, height: composing.renderH }}
resizeMode="stretch" resizeMode="stretch"
onLoad={onCaptureImageLoaded} onLoad={onCaptureImageLoaded}
/> />
<FooterOverlay <FooterOverlay config={composing.config} meta={composing.meta} imageWidth={composing.renderW} />
config={composing.config}
meta={composing.meta}
imageWidth={composing.renderW}
/>
</View> </View>
) : null} )}
</View> </View>
); );
} }
// ─── estilos ──────────────────────────────────────────────────────────────
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrapper: { marginVertical: 8 }, wrapper: { marginVertical: 8 },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 2, paddingHorizontal: 4,
marginBottom: 6, 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: { addBtn: {
width: 72,
height: 72,
borderRadius: 8, borderRadius: 8,
borderWidth: 1, borderWidth: 1,
borderColor: COLORS.primary, borderColor: COLORS.primary,
borderStyle: 'dashed', borderStyle: 'dashed',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#fff',
}, },
addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' }, addPlus: { color: COLORS.primary, fontSize: 24, fontWeight: '700' },
addText: { color: COLORS.primary, fontSize: 11 }, addText: { color: COLORS.primary, fontSize: 11, marginTop: 2 },
thumbWrap: { thumbWrap: {
width: 72,
height: 72,
borderRadius: 8, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: COLORS.bg, backgroundColor: COLORS.bg,
borderWidth: 2,
borderColor: 'transparent',
}, },
selectedThumb: { borderColor: COLORS.primary },
thumb: { width: '100%', height: '100%' }, 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: { tag: {
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 0,
@@ -362,16 +426,18 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
tagText: { color: '#fff', fontSize: 9, fontWeight: '700' }, tagText: { color: '#fff', fontSize: 9, fontWeight: '700' },
deleteOverlay: {
/* Visor */
viewerContainer: { flex: 1, backgroundColor: '#000', justifyContent: 'center' },
viewerImg: { width: '100%', height: '80%' },
viewerClose: {
position: 'absolute', position: 'absolute',
top: 4, bottom: 40,
right: 4, alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.4)', paddingHorizontal: 30,
width: 20, paddingVertical: 12,
height: 20, backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: 10, borderRadius: 25,
justifyContent: 'center',
alignItems: 'center',
}, },
deleteIcon: { color: '#fff', fontSize: 12, fontWeight: 'bold' }, viewerCloseTxt: { color: '#fff', fontWeight: 'bold' },
}); });