Finalize background photo processing and orientation fixes
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
- [ ] Configure `gradle.user.home` and related properties in `gradle.properties` [ ]
|
||||
- [ ] Verify Gradle Sync and Build [ ]
|
||||
- [ ] Create Walkthrough [ ]
|
||||
@@ -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
|
||||
|
||||
@@ -270,3 +270,9 @@ export async function deleteMediaOutbox(uuid: string): Promise<void> {
|
||||
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<void> {
|
||||
const db = await getDb();
|
||||
await db.runAsync('UPDATE media_outbox SET local_uri = ? WHERE uuid = ?', localUri, uuid);
|
||||
}
|
||||
|
||||
@@ -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 <View style={styles.center}><ActivityIndicator size="large" /></View>;
|
||||
@@ -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 */}
|
||||
<View style={styles.overlay}>
|
||||
{/* Header con botón cerrar */}
|
||||
<View style={styles.header}>
|
||||
@@ -144,7 +151,7 @@ export function CameraScreen({ route, navigation }: Props) {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Preview del sello (posicionado en su esquina con rotación) */}
|
||||
{/* Preview del sello: posicionado por FooterOverlay, no por este contenedor */}
|
||||
{config && meta && (
|
||||
<View style={styles.fullOverlay} pointerEvents="none">
|
||||
<FooterOverlay
|
||||
@@ -156,7 +163,7 @@ export function CameraScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Botón de disparo abajo al centro */}
|
||||
{/* Botón de disparo */}
|
||||
<View style={styles.footerControls}>
|
||||
<TouchableOpacity
|
||||
style={[styles.captureBtn, taking && styles.disabled]}
|
||||
@@ -176,14 +183,15 @@ const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, justifyContent: 'space-between', zIndex: 10 },
|
||||
fullOverlay: {
|
||||
...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' },
|
||||
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', 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 },
|
||||
footerControls: {
|
||||
paddingBottom: 40,
|
||||
@@ -191,18 +199,18 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
},
|
||||
captureBtn: {
|
||||
width: 76,
|
||||
height: 70, // Un poco ovalado para que se note
|
||||
borderRadius: 38,
|
||||
width: 74,
|
||||
height: 74,
|
||||
borderRadius: 37,
|
||||
borderWidth: 5,
|
||||
borderColor: '#fff',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureInner: {
|
||||
width: 54,
|
||||
height: 54,
|
||||
borderRadius: 27,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
disabled: { opacity: 0.5 },
|
||||
|
||||
+225
-159
@@ -1,21 +1,26 @@
|
||||
/**
|
||||
* 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
|
||||
* o galería; antes de encolarlas les estampa un pie de página georreferenciado
|
||||
* (logo + proyecto + fecha + coordenadas) capturado con react-native-view-shot.
|
||||
* y las locales en cola (`media_outbox`). Permite añadir nuevas desde cámara;
|
||||
* antes de encolarlas les estampa un pie de página georreferenciado.
|
||||
*
|
||||
* 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 React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native';
|
||||
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 { Media, MediaParentEntity } from '../api/types';
|
||||
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 { RootStackParamList } from '../navigation/types';
|
||||
import {
|
||||
@@ -39,6 +44,7 @@ import { COLORS } from './components';
|
||||
// ─── tipos internos ────────────────────────────────────────────────────────
|
||||
|
||||
interface ComposingTask {
|
||||
uuid: string; // Para saber qué registro actualizar al terminar
|
||||
uri: string;
|
||||
renderW: number;
|
||||
renderH: number;
|
||||
@@ -54,9 +60,7 @@ async function getCoords(): Promise<{ latitude: number; longitude: number } | nu
|
||||
try {
|
||||
const { granted } = await Location.requestForegroundPermissionsAsync();
|
||||
if (!granted) return null;
|
||||
const loc = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
});
|
||||
const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
|
||||
return loc.coords;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -81,13 +85,20 @@ export function MediaStrip({
|
||||
canUpload: boolean;
|
||||
}) {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { width: winW } = useWindowDimensions();
|
||||
|
||||
const [synced, setSynced] = useState<Media[]>([]);
|
||||
const [pending, setPending] = useState<MediaOutboxRow[]>([]);
|
||||
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 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<string> {
|
||||
/** 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<string>((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 (
|
||||
<View style={styles.wrapper}>
|
||||
{/* Cabecera con icono de configuración */}
|
||||
{canUpload && (
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerLabel}>Fotos</Text>
|
||||
<TouchableOpacity onPress={() => navigation.navigate('PhotoSettings')}>
|
||||
<Text style={styles.gear}>⚙</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerLabel}>
|
||||
{isSelectionMode ? `${selectedUuids.size} seleccionadas` : `Fotos (${synced.length + pending.length})`}
|
||||
</Text>
|
||||
<View style={styles.headerActions}>
|
||||
{isSelectionMode ? (
|
||||
<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>
|
||||
|
||||
{/* Tira de miniaturas */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
<View style={styles.grid}>
|
||||
{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.addText}>Foto</Text>
|
||||
<Text style={styles.addText}>Añadir</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{pending.map((m) => (
|
||||
<TouchableOpacity
|
||||
key={m.uuid}
|
||||
style={styles.thumbWrap}
|
||||
onPress={() => onDeletePending(m)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
|
||||
<View
|
||||
style={[
|
||||
styles.tag,
|
||||
{ backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn },
|
||||
]}
|
||||
|
||||
{pending.map((m) => {
|
||||
const isSelected = selectedUuids.has(m.uuid);
|
||||
const isProcessing = composing?.uuid === m.uuid || queueRef.current.some(q => q.uuid === m.uuid);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={m.uuid}
|
||||
style={[styles.thumbWrap, { width: thumbSize, height: thumbSize }, isSelected && styles.selectedThumb]}
|
||||
onPress={() => isSelectionMode ? toggleSelect(m.uuid) : setViewerUri(m.local_uri)}
|
||||
onLongPress={() => toggleSelect(m.uuid)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.tagText}>
|
||||
{m.status === 'error' ? 'error' : 'en cola'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.deleteOverlay}>
|
||||
<Text style={styles.deleteIcon}>✕</Text>
|
||||
</View>
|
||||
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
|
||||
|
||||
{isProcessing && (
|
||||
<View style={styles.processingOverlay}>
|
||||
<Text style={styles.processingTxt}>Sellando…</Text>
|
||||
</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>
|
||||
))}
|
||||
{synced.map((m) => (
|
||||
<View key={`s${m.id}`} style={styles.thumbWrap}>
|
||||
<Image source={{ uri: absoluteUrl(m.url) }} style={styles.thumb} />
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Vista fuera de pantalla para compositar el pie de página */}
|
||||
{composing ? (
|
||||
<Modal visible={!!viewerUri} transparent={false} animationType="fade">
|
||||
<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
|
||||
ref={captureViewRef}
|
||||
collapsable={false}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: -(composing.renderW + 100),
|
||||
left: -(composing.renderW + 500),
|
||||
width: composing.renderW,
|
||||
height: composing.renderH,
|
||||
overflow: 'hidden',
|
||||
@@ -304,55 +348,75 @@ export function MediaStrip({
|
||||
>
|
||||
<Image
|
||||
source={{ uri: composing.uri }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
style={{ width: composing.renderW, height: composing.renderH }}
|
||||
resizeMode="stretch"
|
||||
onLoad={onCaptureImageLoaded}
|
||||
/>
|
||||
<FooterOverlay
|
||||
config={composing.config}
|
||||
meta={composing.meta}
|
||||
imageWidth={composing.renderW}
|
||||
/>
|
||||
<FooterOverlay config={composing.config} meta={composing.meta} imageWidth={composing.renderW} />
|
||||
</View>
|
||||
) : null}
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user