2026-07-01 11:34:38 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
2026-07-29 00:35:45 +02:00
|
|
|
|
OverlayPosition,
|
2026-07-01 11:34:38 +02:00
|
|
|
|
pickAndSaveLogo,
|
2026-07-28 23:55:24 +02:00
|
|
|
|
PhotoResolution,
|
2026-07-01 11:34:38 +02:00
|
|
|
|
saveFooterConfig,
|
|
|
|
|
|
} from '../photo/footerConfig';
|
2026-07-28 23:55:24 +02:00
|
|
|
|
import { ChipSelect, COLORS, PrimaryButton, SectionTitle } from '../ui/components';
|
2026-07-01 11:34:38 +02:00
|
|
|
|
|
|
|
|
|
|
const BUILT_IN_LABELS: Record<string, string> = {
|
|
|
|
|
|
project_name: 'Nombre del proyecto',
|
|
|
|
|
|
date: 'Fecha y hora',
|
|
|
|
|
|
coordinates: 'Coordenadas GPS',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-28 23:55:24 +02:00
|
|
|
|
const RESOLUTION_OPTIONS: PhotoResolution[] = ['low', 'medium', 'high'];
|
|
|
|
|
|
const QUALITY_OPTIONS = ['0.5', '0.7', '0.85', '1.0'];
|
2026-07-29 00:35:45 +02:00
|
|
|
|
const POSITION_OPTIONS: OverlayPosition[] = ['bottom-left', 'bottom-right', 'top-left', 'top-right'];
|
2026-07-28 23:55:24 +02:00
|
|
|
|
|
2026-07-01 11:34:38 +02:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-07-28 23:55:24 +02:00
|
|
|
|
{/* ── Resolución y Calidad ── */}
|
|
|
|
|
|
<SectionTitle>Calidad de imagen</SectionTitle>
|
|
|
|
|
|
<View style={styles.card}>
|
|
|
|
|
|
<ChipSelect
|
|
|
|
|
|
label="Resolución máxima"
|
|
|
|
|
|
value={config.resolution}
|
|
|
|
|
|
options={RESOLUTION_OPTIONS}
|
|
|
|
|
|
onChange={(v) => void save({ ...config, resolution: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text style={styles.hint}>
|
|
|
|
|
|
Low (~720p), Medium (~1080p), High (~4K).
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
|
|
|
|
|
|
<View style={{ height: 12 }} />
|
|
|
|
|
|
|
|
|
|
|
|
<ChipSelect
|
|
|
|
|
|
label="Calidad de compresión"
|
|
|
|
|
|
value={String(config.quality)}
|
|
|
|
|
|
options={QUALITY_OPTIONS}
|
|
|
|
|
|
onChange={(v) => void save({ ...config, quality: parseFloat(v) })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text style={styles.hint}>
|
|
|
|
|
|
Valores bajos reducen el tamaño del archivo pero pueden verse peor.
|
|
|
|
|
|
</Text>
|
2026-07-29 00:35:45 +02:00
|
|
|
|
|
|
|
|
|
|
<View style={{ height: 12 }} />
|
|
|
|
|
|
|
|
|
|
|
|
<ChipSelect
|
|
|
|
|
|
label="Posición del sello"
|
|
|
|
|
|
value={config.overlayPosition}
|
|
|
|
|
|
options={POSITION_OPTIONS}
|
|
|
|
|
|
onChange={(v) => void save({ ...config, overlayPosition: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text style={styles.hint}>
|
|
|
|
|
|
Esquina de la imagen donde aparecerán los datos.
|
|
|
|
|
|
</Text>
|
2026-07-28 23:55:24 +02:00
|
|
|
|
</View>
|
|
|
|
|
|
|
2026-07-01 11:34:38 +02:00
|
|
|
|
{/* ── 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 },
|
|
|
|
|
|
|
2026-07-28 23:55:24 +02:00
|
|
|
|
card: {
|
|
|
|
|
|
backgroundColor: COLORS.bg,
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
marginBottom: 8,
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-07-01 11:34:38 +02:00
|
|
|
|
/* 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 },
|
|
|
|
|
|
});
|