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