Files
avante-movil/src/screens/PhotoSettingsScreen.tsx
T
javierandClaude Sonnet 4.6 504bbf1a3d 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>
2026-07-01 11:34:38 +02:00

331 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 },
});