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
+330
View File
@@ -0,0 +1,330 @@
/**
* Configuración del pie de página en fotos.
* Permite activar/desactivar el footer, cambiar el logo, activar/desactivar
* cada campo (proyecto, fecha, coordenadas) y añadir campos personalizados
* con etiqueta y valor estático.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
Image,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { newUuid } from '../sync/uuid';
import {
clearLogo,
FooterConfig,
FooterField,
loadFooterConfig,
pickAndSaveLogo,
saveFooterConfig,
} from '../photo/footerConfig';
import { COLORS, PrimaryButton, SectionTitle } from '../ui/components';
const BUILT_IN_LABELS: Record<string, string> = {
project_name: 'Nombre del proyecto',
date: 'Fecha y hora',
coordinates: 'Coordenadas GPS',
};
export function PhotoSettingsScreen() {
const [config, setConfig] = useState<FooterConfig | null>(null);
const [newLabel, setNewLabel] = useState('');
const [newValue, setNewValue] = useState('');
const [showAddForm, setShowAddForm] = useState(false);
const load = useCallback(async () => {
setConfig(await loadFooterConfig());
}, []);
useEffect(() => { void load(); }, [load]);
const save = useCallback(async (next: FooterConfig) => {
setConfig(next);
await saveFooterConfig(next);
}, []);
const toggleEnabled = useCallback((val: boolean) => {
if (!config) return;
void save({ ...config, enabled: val });
}, [config, save]);
const toggleField = useCallback((id: string, val: boolean) => {
if (!config) return;
void save({
...config,
fields: config.fields.map((f) => (f.id === id ? { ...f, enabled: val } : f)),
});
}, [config, save]);
const editFieldLabel = useCallback((id: string, label: string) => {
if (!config) return;
const next = {
...config,
fields: config.fields.map((f) => (f.id === id ? { ...f, label } : f)),
};
void save(next);
}, [config, save]);
const deleteCustomField = useCallback((id: string) => {
if (!config) return;
void save({ ...config, fields: config.fields.filter((f) => f.id !== id) });
}, [config, save]);
const addCustomField = useCallback(() => {
if (!config) return;
const label = newLabel.trim();
const value = newValue.trim();
if (!label) { Alert.alert('', 'La etiqueta no puede estar vacía.'); return; }
const field: FooterField = {
id: newUuid(),
key: 'custom',
label,
enabled: true,
customValue: value || label,
};
void save({ ...config, fields: [...config.fields, field] });
setNewLabel('');
setNewValue('');
setShowAddForm(false);
}, [config, newLabel, newValue, save]);
const onChangeLogo = useCallback(async () => {
if (!config) return;
const uri = await pickAndSaveLogo();
if (uri) void save({ ...config, logoUri: uri });
}, [config, save]);
const onRemoveLogo = useCallback(() => {
if (!config) return;
Alert.alert('Quitar logo', '¿Eliminar el logo del pie de página?', [
{
text: 'Quitar', style: 'destructive', onPress: () => {
void clearLogo();
void save({ ...config, logoUri: null });
},
},
{ text: 'Cancelar', style: 'cancel' },
]);
}, [config, save]);
if (!config) return null;
return (
<ScrollView contentContainerStyle={styles.body} keyboardShouldPersistTaps="handled">
{/* Master toggle */}
<View style={styles.row}>
<Text style={styles.masterLabel}>Añadir pie de página a las fotos</Text>
<Switch
value={config.enabled}
onValueChange={toggleEnabled}
trackColor={{ true: COLORS.primary }}
/>
</View>
{/* ── Logo ── */}
<SectionTitle>Logo</SectionTitle>
<View style={styles.logoRow}>
{config.logoUri ? (
<Image source={{ uri: config.logoUri }} style={styles.logoPreview} resizeMode="contain" />
) : (
<View style={styles.logoPlaceholder}>
<Text style={styles.logoPlaceholderText}>Sin logo</Text>
</View>
)}
<View style={styles.logoActions}>
<PrimaryButton
title={config.logoUri ? 'Cambiar' : 'Seleccionar'}
variant="ghost"
onPress={() => void onChangeLogo()}
/>
{config.logoUri ? (
<PrimaryButton title="Quitar" variant="danger" onPress={onRemoveLogo} />
) : null}
</View>
</View>
<Text style={styles.hint}>
Recorte cuadrado. Se recomienda logo con fondo blanco.
</Text>
{/* ── Campos ── */}
<SectionTitle>Campos</SectionTitle>
{config.fields.map((f) => {
const isBuiltIn = f.key !== 'custom';
return (
<View key={f.id} style={styles.fieldRow}>
<Switch
value={f.enabled}
onValueChange={(v) => toggleField(f.id, v)}
trackColor={{ true: COLORS.primary }}
/>
<View style={styles.fieldInfo}>
<Text style={styles.fieldKey}>
{isBuiltIn ? BUILT_IN_LABELS[f.key] ?? f.label : ''}
</Text>
<TextInput
style={styles.fieldLabel}
value={f.label}
onChangeText={(t) => editFieldLabel(f.id, t)}
placeholder="Etiqueta…"
returnKeyType="done"
/>
{f.key === 'custom' && (
<TextInput
style={[styles.fieldLabel, styles.fieldValue]}
value={f.customValue ?? ''}
onChangeText={(t) => {
if (!config) return;
void save({
...config,
fields: config.fields.map((x) =>
x.id === f.id ? { ...x, customValue: t } : x,
),
});
}}
placeholder="Valor…"
returnKeyType="done"
/>
)}
</View>
{!isBuiltIn && (
<TouchableOpacity
onPress={() => deleteCustomField(f.id)}
style={styles.deleteBtn}
>
<Text style={styles.deleteTxt}></Text>
</TouchableOpacity>
)}
</View>
);
})}
{/* Añadir campo personalizado */}
{showAddForm ? (
<View style={styles.addForm}>
<TextInput
style={styles.addInput}
placeholder="Etiqueta (p. ej. Empresa)"
value={newLabel}
onChangeText={setNewLabel}
returnKeyType="next"
/>
<TextInput
style={styles.addInput}
placeholder="Valor (p. ej. Constructora ABC)"
value={newValue}
onChangeText={setNewValue}
returnKeyType="done"
/>
<View style={styles.addFormBtns}>
<View style={{ flex: 1 }}>
<PrimaryButton title="Añadir" onPress={addCustomField} />
</View>
<View style={{ flex: 1 }}>
<PrimaryButton title="Cancelar" variant="ghost" onPress={() => setShowAddForm(false)} />
</View>
</View>
</View>
) : (
<TouchableOpacity style={styles.addFieldBtn} onPress={() => setShowAddForm(true)}>
<Text style={styles.addFieldTxt}> Añadir campo personalizado</Text>
</TouchableOpacity>
)}
<View style={{ height: 40 }} />
</ScrollView>
);
}
// ─── estilos ──────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
body: { padding: 16 },
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
marginBottom: 8,
},
masterLabel: { fontSize: 16, fontWeight: '600', flex: 1, marginRight: 12 },
/* Logo */
logoRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 4 },
logoPreview: { width: 72, height: 72, borderRadius: 8, backgroundColor: COLORS.bg },
logoPlaceholder: {
width: 72,
height: 72,
borderRadius: 8,
backgroundColor: COLORS.bg,
justifyContent: 'center',
alignItems: 'center',
borderWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
},
logoPlaceholderText: { fontSize: 11, color: COLORS.muted },
logoActions: { gap: 8 },
hint: { fontSize: 11, color: COLORS.muted, marginBottom: 8 },
/* Campos */
fieldRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
},
fieldInfo: { flex: 1 },
fieldKey: { fontSize: 11, color: COLORS.muted, marginBottom: 2 },
fieldLabel: {
fontSize: 14,
color: '#111',
borderBottomWidth: 1,
borderColor: COLORS.border,
paddingVertical: 2,
paddingHorizontal: 0,
},
fieldValue: { marginTop: 4, color: COLORS.muted },
deleteBtn: { padding: 8 },
deleteTxt: { color: COLORS.danger, fontSize: 16 },
/* Nuevo campo */
addFieldBtn: {
marginTop: 16,
paddingVertical: 12,
alignItems: 'center',
borderWidth: 1,
borderStyle: 'dashed',
borderColor: COLORS.primary,
borderRadius: 8,
},
addFieldTxt: { color: COLORS.primary, fontWeight: '600' },
addForm: {
marginTop: 12,
padding: 12,
backgroundColor: COLORS.bg,
borderRadius: 8,
gap: 10,
},
addInput: {
borderWidth: 1,
borderColor: COLORS.border,
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
fontSize: 14,
},
addFormBtns: { flexDirection: 'row', gap: 8 },
});