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:
2026-07-01 11:34:38 +02:00
co-authored by Claude Sonnet 4.6
parent e16f4585c2
commit 504bbf1a3d
9 changed files with 836 additions and 28 deletions
+244 -24
View File
@@ -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' },
});