feat(photo): pie de página georreferenciado + pantalla de configuración
- FooterOverlay: franja oscura (logo izquierda, campos derecha) superpuesta sobre la foto antes de encolarla; tamaños proporcionales a la resolución. - MediaStrip: capta coordenadas GPS (expo-location) al abrir el picker, estampa el footer con react-native-view-shot (view off-screen + onLoad), y añade icono ⚙ → PhotoSettings. Fallback: usa foto original si el stamp falla. - PhotoSettingsScreen (modal): toggle general, selector de logo cuadrado, activar/desactivar/editar etiqueta de cada campo (proyecto, fecha, coords) y añadir campos personalizados con etiqueta + valor estático. - footerConfig: persiste en meta table (clave 'photo_footer_config'). - repositories: añade getProject(id). - navigation: ruta PhotoSettings como modal. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+244
-24
@@ -1,17 +1,77 @@
|
||||
/**
|
||||
* Tira de fotos de un registro (feature/issue/issue_task/issue_comment).
|
||||
* Muestra las ya sincronizadas (tabla `media`, con url) y las locales en cola
|
||||
* (`media_outbox`), y permite añadir nuevas desde cámara o galería (offline).
|
||||
* 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.
|
||||
*
|
||||
* El pie de página es configurable desde PhotoSettingsScreen (icono ⚙).
|
||||
*/
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import * as Location from 'expo-location';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { captureRef } from 'react-native-view-shot';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Media, MediaParentEntity } from '../api/types';
|
||||
import { absoluteUrl } from '../config';
|
||||
import { enqueueMedia, getMediaOutboxFor, MediaOutboxRow } from '../db/outbox';
|
||||
import { getMediaFor } from '../db/repositories';
|
||||
import { getActiveProjectId, getMediaFor, getProject } from '../db/repositories';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import {
|
||||
FooterConfig,
|
||||
loadFooterConfig,
|
||||
resolveFieldValue,
|
||||
StampMeta,
|
||||
} from '../photo/footerConfig';
|
||||
import { FooterOverlay } from '../photo/FooterOverlay';
|
||||
import { COLORS } from './components';
|
||||
|
||||
// ─── tipos internos ────────────────────────────────────────────────────────
|
||||
|
||||
interface ComposingTask {
|
||||
uri: string;
|
||||
renderW: number;
|
||||
renderH: number;
|
||||
meta: StampMeta;
|
||||
config: FooterConfig;
|
||||
}
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const MAX_STAMP_W = 2048;
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function getCoords(): Promise<{ latitude: number; longitude: number } | null> {
|
||||
try {
|
||||
const { granted } = await Location.requestForegroundPermissionsAsync();
|
||||
if (!granted) return null;
|
||||
const loc = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
});
|
||||
return loc.coords;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCoords(lat: number, lng: number): string {
|
||||
const la = lat >= 0 ? 'N' : 'S';
|
||||
const lo = lng >= 0 ? 'E' : 'O';
|
||||
return `${Math.abs(lat).toFixed(5)}°${la}, ${Math.abs(lng).toFixed(5)}°${lo}`;
|
||||
}
|
||||
|
||||
// ─── componente ────────────────────────────────────────────────────────────
|
||||
|
||||
export function MediaStrip({
|
||||
parentEntity,
|
||||
parentId,
|
||||
@@ -21,8 +81,16 @@ export function MediaStrip({
|
||||
parentId: number;
|
||||
canUpload: boolean;
|
||||
}) {
|
||||
const [synced, setSynced] = useState<Media[]>([]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const [synced, setSynced] = useState<Media[]>([]);
|
||||
const [pending, setPending] = useState<MediaOutboxRow[]>([]);
|
||||
const [composing, setComposing] = useState<ComposingTask | null>(null);
|
||||
|
||||
const captureViewRef = useRef<View>(null);
|
||||
const resolveRef = useRef<((uri: string) => void) | null>(null);
|
||||
const rejectRef = useRef<((e: unknown) => void) | null>(null);
|
||||
|
||||
// ── cargar datos ──────────────────────────────────────────────────────────
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [s, p] = await Promise.all([
|
||||
@@ -33,11 +101,76 @@ export function MediaStrip({
|
||||
setPending(p);
|
||||
}, [parentEntity, parentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
const add = useCallback(
|
||||
// ── compositing (ViewShot) ────────────────────────────────────────────────
|
||||
|
||||
/** 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.
|
||||
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);
|
||||
} catch (e) {
|
||||
rejectRef.current?.(e);
|
||||
} finally {
|
||||
setComposing(null);
|
||||
resolveRef.current = null;
|
||||
rejectRef.current = null;
|
||||
}
|
||||
}, 80);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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;
|
||||
|
||||
let projectName: string | null = null;
|
||||
if (projectId != null) {
|
||||
const proj = await getProject(projectId);
|
||||
projectName = proj?.name ?? null;
|
||||
}
|
||||
|
||||
const coordsRaw = await getCoords();
|
||||
const meta: StampMeta = {
|
||||
projectName,
|
||||
date: new Date().toLocaleString('es-ES', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}),
|
||||
coordinates: coordsRaw ? formatCoords(coordsRaw.latitude, coordsRaw.longitude) : null,
|
||||
};
|
||||
|
||||
const scale = Math.min(1, MAX_STAMP_W / 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 });
|
||||
});
|
||||
}
|
||||
|
||||
// ── captura de foto ───────────────────────────────────────────────────────
|
||||
|
||||
const addPhoto = useCallback(
|
||||
async (source: 'camera' | 'library') => {
|
||||
const perm =
|
||||
source === 'camera'
|
||||
@@ -47,39 +180,67 @@ export function MediaStrip({
|
||||
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result =
|
||||
source === 'camera'
|
||||
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
|
||||
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7, mediaTypes: 'images' });
|
||||
? await ImagePicker.launchCameraAsync({ quality: 0.85 })
|
||||
: await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' });
|
||||
if (result.canceled || !result.assets?.length) return;
|
||||
|
||||
const asset = result.assets[0];
|
||||
|
||||
let finalUri: string;
|
||||
try {
|
||||
finalUri = await stamp(asset.uri, asset.width ?? 1920, asset.height ?? 1080);
|
||||
} catch {
|
||||
// Si el stamping falla, usar la foto original.
|
||||
finalUri = asset.uri;
|
||||
}
|
||||
|
||||
await enqueueMedia({
|
||||
parentEntity,
|
||||
parentId,
|
||||
localUri: asset.uri,
|
||||
localUri: finalUri,
|
||||
fileName: asset.fileName ?? undefined,
|
||||
mimeType: asset.mimeType ?? 'image/jpeg',
|
||||
category: 'image',
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[parentEntity, parentId, refresh],
|
||||
);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
Alert.alert('Añadir foto', undefined, [
|
||||
{ text: 'Cámara', onPress: () => void add('camera') },
|
||||
{ text: 'Galería', onPress: () => void add('library') },
|
||||
{ text: 'Cámara', onPress: () => void addPhoto('camera') },
|
||||
{ text: 'Galería', onPress: () => void addPhoto('library') },
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
]);
|
||||
}, [add]);
|
||||
}, [addPhoto]);
|
||||
|
||||
// ─── render ───────────────────────────────────────────────────────────────
|
||||
|
||||
if (synced.length === 0 && pending.length === 0 && !canUpload) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Tira de miniaturas */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
{canUpload && (
|
||||
<TouchableOpacity style={styles.addBtn} onPress={onAdd}>
|
||||
<Text style={styles.addPlus}>+</Text>
|
||||
@@ -89,8 +250,15 @@ export function MediaStrip({
|
||||
{pending.map((m) => (
|
||||
<View key={m.uuid} style={styles.thumbWrap}>
|
||||
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
|
||||
<View style={[styles.tag, { backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn }]}>
|
||||
<Text style={styles.tagText}>{m.status === 'error' ? 'error' : 'en cola'}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.tag,
|
||||
{ backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.tagText}>
|
||||
{m.status === 'error' ? 'error' : 'en cola'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -100,12 +268,51 @@ export function MediaStrip({
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Vista fuera de pantalla para compositar el pie de página */}
|
||||
{composing ? (
|
||||
<View
|
||||
ref={captureViewRef}
|
||||
collapsable={false}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: -(composing.renderW + 100),
|
||||
width: composing.renderW,
|
||||
height: composing.renderH,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: composing.uri }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="stretch"
|
||||
onLoad={onCaptureImageLoaded}
|
||||
/>
|
||||
<FooterOverlay
|
||||
config={composing.config}
|
||||
meta={composing.meta}
|
||||
imageWidth={composing.renderW}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── estilos ──────────────────────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { marginVertical: 8 },
|
||||
wrapper: { marginVertical: 8 },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 2,
|
||||
marginBottom: 6,
|
||||
},
|
||||
headerLabel: { fontSize: 13, fontWeight: '600', color: COLORS.muted },
|
||||
gear: { fontSize: 18, color: COLORS.muted, paddingHorizontal: 4 },
|
||||
row: { gap: 8, paddingRight: 8 },
|
||||
addBtn: {
|
||||
width: 72,
|
||||
@@ -119,8 +326,21 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' },
|
||||
addText: { color: COLORS.primary, fontSize: 11 },
|
||||
thumbWrap: { width: 72, height: 72, borderRadius: 8, overflow: 'hidden', backgroundColor: COLORS.bg },
|
||||
thumbWrap: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: COLORS.bg,
|
||||
},
|
||||
thumb: { width: '100%', height: '100%' },
|
||||
tag: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingVertical: 1, alignItems: 'center' },
|
||||
tag: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingVertical: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
tagText: { color: '#fff', fontSize: 9, fontWeight: '700' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user