- 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>
331 lines
9.8 KiB
TypeScript
331 lines
9.8 KiB
TypeScript
/**
|
||
* 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 },
|
||
});
|