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
+6
View File
@@ -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);
}
+75 -67
View File
@@ -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
View File
@@ -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' },
});