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.
|
# 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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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' },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user