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:
Generated
+67
-4
@@ -24,7 +24,8 @@
|
||||
"react-native": "0.85.3",
|
||||
"react-native-maps": "1.27.2",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2"
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-view-shot": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
@@ -3000,7 +3001,7 @@
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3446,6 +3447,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -4083,11 +4093,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dateformat": {
|
||||
@@ -6922,6 +6941,19 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-call": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/http-call/-/http-call-5.3.0.tgz",
|
||||
@@ -9476,6 +9508,19 @@
|
||||
"react-native": ">=0.82.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-view-shot": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz",
|
||||
"integrity": "sha512-USNjYmED7C0me02c1DxKA0074Hw+y/nxo+xJKlffMvfUWWzL5ELh/TJA/pTnVqFurIrzthZDPtDM7aBFJuhrHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native/node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
@@ -10509,6 +10554,15 @@
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -10744,7 +10798,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -10909,6 +10963,15 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-maps": "1.27.2",
|
||||
"react-native-view-shot": "^4.0.0",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2"
|
||||
},
|
||||
|
||||
@@ -274,6 +274,11 @@ export async function getProjects(): Promise<Project[]> {
|
||||
return db.getAllAsync<Project>('SELECT * FROM projects ORDER BY name');
|
||||
}
|
||||
|
||||
export async function getProject(id: number): Promise<Project | null> {
|
||||
const db = await getDb();
|
||||
return db.getFirstAsync<Project>('SELECT * FROM projects WHERE id = ?', id);
|
||||
}
|
||||
|
||||
/** Persiste la lista que devuelve GET /projects (upsert sin tocar el resto). */
|
||||
export async function saveProjectList(projects: Project[]): Promise<void> {
|
||||
const db = await getDb();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FeatureDetailScreen } from '../screens/FeatureDetailScreen';
|
||||
import { InspectionFormScreen } from '../screens/InspectionFormScreen';
|
||||
import { IssueCreateScreen } from '../screens/IssueCreateScreen';
|
||||
import { OutboxScreen } from '../screens/OutboxScreen';
|
||||
import { PhotoSettingsScreen } from '../screens/PhotoSettingsScreen';
|
||||
import { RootStackParamList } from './types';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
@@ -68,6 +69,11 @@ export function RootNavigator() {
|
||||
component={OutboxScreen}
|
||||
options={{ title: 'Cola de sincronización' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PhotoSettings"
|
||||
component={PhotoSettingsScreen}
|
||||
options={{ title: 'Configuración de fotos', presentation: 'modal' }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
||||
@@ -6,4 +6,5 @@ export type RootStackParamList = {
|
||||
InspectionForm: { featureId: number; featureName: string; templateId?: number };
|
||||
IssueCreate: { projectId: number; featureId?: number };
|
||||
Outbox: undefined;
|
||||
PhotoSettings: undefined;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Pie de página superpuesto sobre la foto antes de capturarla con ViewShot.
|
||||
* Se posiciona en la parte inferior de su contenedor con position:absolute.
|
||||
* Todos los tamaños son proporcionales al ancho de la imagen para que el pie
|
||||
* sea legible independientemente de la resolución del sensor.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Image, StyleSheet, Text, View } from 'react-native';
|
||||
import { FooterConfig, resolveFieldValue, StampMeta } from './footerConfig';
|
||||
|
||||
interface Props {
|
||||
config: FooterConfig;
|
||||
meta: StampMeta;
|
||||
imageWidth: number;
|
||||
}
|
||||
|
||||
export function FooterOverlay({ config, meta, imageWidth }: Props) {
|
||||
if (!config.enabled) return null;
|
||||
|
||||
const enabled = config.fields.filter((f) => f.enabled);
|
||||
if (enabled.length === 0 && !config.logoUri) return null;
|
||||
|
||||
const fontSize = Math.max(12, Math.round(imageWidth * 0.016));
|
||||
const logoDim = Math.max(50, Math.round(imageWidth * 0.09));
|
||||
const pad = Math.max(8, Math.round(imageWidth * 0.012));
|
||||
const lineH = Math.round(fontSize * 1.4);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ paddingHorizontal: pad, paddingVertical: Math.round(pad * 0.65) },
|
||||
]}
|
||||
>
|
||||
{/* Logo */}
|
||||
{config.logoUri ? (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.logoWrap,
|
||||
{ width: logoDim, height: logoDim, borderRadius: Math.round(logoDim * 0.08) },
|
||||
]}
|
||||
>
|
||||
<Image source={{ uri: config.logoUri }} style={styles.logoImg} resizeMode="contain" />
|
||||
</View>
|
||||
<View style={[styles.divider, { marginHorizontal: pad, height: logoDim }]} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Campos */}
|
||||
<View style={styles.textBlock}>
|
||||
{enabled.map((f, i) => {
|
||||
const val = resolveFieldValue(f, meta);
|
||||
if (!val) return null;
|
||||
return (
|
||||
<Text
|
||||
key={f.id}
|
||||
style={[
|
||||
styles.line,
|
||||
{ fontSize, lineHeight: lineH },
|
||||
i === 0 ? styles.bold : null,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{f.key === 'custom' ? `${f.label}: ${val}` : val}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.75)',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoWrap: {
|
||||
backgroundColor: '#fff',
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
logoImg: { width: '100%', height: '100%' },
|
||||
divider: { width: 1, backgroundColor: 'rgba(255,255,255,0.35)', flexShrink: 0 },
|
||||
textBlock: { flex: 1 },
|
||||
line: { color: '#fff' },
|
||||
bold: { fontWeight: '700' },
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { getMeta, setMeta } from '../db/repositories';
|
||||
|
||||
export type FieldKey = 'project_name' | 'date' | 'coordinates' | 'custom';
|
||||
|
||||
export interface FooterField {
|
||||
id: string;
|
||||
key: FieldKey;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
/** Solo para key === 'custom': texto estático que aparece en el pie. */
|
||||
customValue?: string;
|
||||
}
|
||||
|
||||
export interface FooterConfig {
|
||||
enabled: boolean;
|
||||
logoUri: string | null;
|
||||
fields: FooterField[];
|
||||
}
|
||||
|
||||
export interface StampMeta {
|
||||
projectName: string | null;
|
||||
date: string;
|
||||
coordinates: string | null;
|
||||
}
|
||||
|
||||
const META_KEY = 'photo_footer_config';
|
||||
const LOGO_DEST = (FileSystem.documentDirectory ?? '') + 'footer_logo.jpg';
|
||||
|
||||
function defaultConfig(): FooterConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
logoUri: null,
|
||||
fields: [
|
||||
{ id: 'project_name', key: 'project_name', label: 'Proyecto', enabled: true },
|
||||
{ id: 'date', key: 'date', label: 'Fecha', enabled: true },
|
||||
{ id: 'coordinates', key: 'coordinates', label: 'Coordenadas GPS', enabled: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadFooterConfig(): Promise<FooterConfig> {
|
||||
const raw = await getMeta(META_KEY);
|
||||
if (!raw) return defaultConfig();
|
||||
try {
|
||||
return JSON.parse(raw) as FooterConfig;
|
||||
} catch {
|
||||
return defaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveFooterConfig(config: FooterConfig): Promise<void> {
|
||||
await setMeta(META_KEY, JSON.stringify(config));
|
||||
}
|
||||
|
||||
/** Abre la galería, copia la imagen al almacenamiento de la app y devuelve la ruta. */
|
||||
export async function pickAndSaveLogo(): Promise<string | null> {
|
||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!perm.granted) return null;
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
quality: 0.9,
|
||||
mediaTypes: 'images',
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
});
|
||||
if (result.canceled || !result.assets?.length) return null;
|
||||
await FileSystem.copyAsync({ from: result.assets[0].uri, to: LOGO_DEST });
|
||||
// Añadir timestamp para que Image no use caché de la versión anterior.
|
||||
return `${LOGO_DEST}?t=${Date.now()}`;
|
||||
}
|
||||
|
||||
export async function clearLogo(): Promise<void> {
|
||||
try { await FileSystem.deleteAsync(LOGO_DEST, { idempotent: true }); } catch { /* */ }
|
||||
}
|
||||
|
||||
/** Resuelve el texto que aparece en la línea de un campo. */
|
||||
export function resolveFieldValue(field: FooterField, meta: StampMeta): string {
|
||||
switch (field.key) {
|
||||
case 'project_name': return meta.projectName ?? '';
|
||||
case 'date': return meta.date;
|
||||
case 'coordinates': return meta.coordinates ?? 'Sin GPS';
|
||||
case 'custom': return field.customValue ?? field.label;
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
+244
-24
@@ -1,17 +1,77 @@
|
||||
/**
|
||||
* Tira de fotos de un registro (feature/issue/issue_task/issue_comment).
|
||||
* Muestra las ya sincronizadas (tabla `media`, con url) y las locales en cola
|
||||
* (`media_outbox`), y permite añadir nuevas desde cámara o galería (offline).
|
||||
* Tira de fotos de un registro. Muestra las ya sincronizadas (tabla `media`)
|
||||
* y las locales en cola (`media_outbox`). Permite añadir nuevas desde cámara
|
||||
* o galería; antes de encolarlas les estampa un pie de página georreferenciado
|
||||
* (logo + proyecto + fecha + coordenadas) capturado con react-native-view-shot.
|
||||
*
|
||||
* El pie de página es configurable desde PhotoSettingsScreen (icono ⚙).
|
||||
*/
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import * as Location from 'expo-location';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { captureRef } from 'react-native-view-shot';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Media, MediaParentEntity } from '../api/types';
|
||||
import { absoluteUrl } from '../config';
|
||||
import { enqueueMedia, getMediaOutboxFor, MediaOutboxRow } from '../db/outbox';
|
||||
import { getMediaFor } from '../db/repositories';
|
||||
import { getActiveProjectId, getMediaFor, getProject } from '../db/repositories';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
import {
|
||||
FooterConfig,
|
||||
loadFooterConfig,
|
||||
resolveFieldValue,
|
||||
StampMeta,
|
||||
} from '../photo/footerConfig';
|
||||
import { FooterOverlay } from '../photo/FooterOverlay';
|
||||
import { COLORS } from './components';
|
||||
|
||||
// ─── tipos internos ────────────────────────────────────────────────────────
|
||||
|
||||
interface ComposingTask {
|
||||
uri: string;
|
||||
renderW: number;
|
||||
renderH: number;
|
||||
meta: StampMeta;
|
||||
config: FooterConfig;
|
||||
}
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const MAX_STAMP_W = 2048;
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function getCoords(): Promise<{ latitude: number; longitude: number } | null> {
|
||||
try {
|
||||
const { granted } = await Location.requestForegroundPermissionsAsync();
|
||||
if (!granted) return null;
|
||||
const loc = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
});
|
||||
return loc.coords;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCoords(lat: number, lng: number): string {
|
||||
const la = lat >= 0 ? 'N' : 'S';
|
||||
const lo = lng >= 0 ? 'E' : 'O';
|
||||
return `${Math.abs(lat).toFixed(5)}°${la}, ${Math.abs(lng).toFixed(5)}°${lo}`;
|
||||
}
|
||||
|
||||
// ─── componente ────────────────────────────────────────────────────────────
|
||||
|
||||
export function MediaStrip({
|
||||
parentEntity,
|
||||
parentId,
|
||||
@@ -21,8 +81,16 @@ export function MediaStrip({
|
||||
parentId: number;
|
||||
canUpload: boolean;
|
||||
}) {
|
||||
const [synced, setSynced] = useState<Media[]>([]);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const [synced, setSynced] = useState<Media[]>([]);
|
||||
const [pending, setPending] = useState<MediaOutboxRow[]>([]);
|
||||
const [composing, setComposing] = useState<ComposingTask | null>(null);
|
||||
|
||||
const captureViewRef = useRef<View>(null);
|
||||
const resolveRef = useRef<((uri: string) => void) | null>(null);
|
||||
const rejectRef = useRef<((e: unknown) => void) | null>(null);
|
||||
|
||||
// ── cargar datos ──────────────────────────────────────────────────────────
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [s, p] = await Promise.all([
|
||||
@@ -33,11 +101,76 @@ export function MediaStrip({
|
||||
setPending(p);
|
||||
}, [parentEntity, parentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
const add = useCallback(
|
||||
// ── compositing (ViewShot) ────────────────────────────────────────────────
|
||||
|
||||
/** Llamado por Image.onLoad cuando la imagen del composer ya está dibujada. */
|
||||
const onCaptureImageLoaded = useCallback(() => {
|
||||
// Un frame para que el layout nativo finalice antes de capturar.
|
||||
setTimeout(async () => {
|
||||
if (!captureViewRef.current) {
|
||||
rejectRef.current?.(new Error('captureRef is null'));
|
||||
setComposing(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const uri = await captureRef(captureViewRef, { format: 'jpg', quality: 0.85 });
|
||||
resolveRef.current?.(uri);
|
||||
} catch (e) {
|
||||
rejectRef.current?.(e);
|
||||
} finally {
|
||||
setComposing(null);
|
||||
resolveRef.current = null;
|
||||
rejectRef.current = null;
|
||||
}
|
||||
}, 80);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Estampa el pie de página sobre `rawUri`.
|
||||
* Si el footer está desactivado o vacío devuelve el URI original intacto.
|
||||
*/
|
||||
async function stamp(rawUri: string, origW: number, origH: number): Promise<string> {
|
||||
const [config, projectId] = await Promise.all([
|
||||
loadFooterConfig(),
|
||||
getActiveProjectId(),
|
||||
]);
|
||||
|
||||
const hasContent = config.enabled &&
|
||||
(config.logoUri != null || config.fields.some((f) => f.enabled));
|
||||
if (!hasContent) return rawUri;
|
||||
|
||||
let projectName: string | null = null;
|
||||
if (projectId != null) {
|
||||
const proj = await getProject(projectId);
|
||||
projectName = proj?.name ?? null;
|
||||
}
|
||||
|
||||
const coordsRaw = await getCoords();
|
||||
const meta: StampMeta = {
|
||||
projectName,
|
||||
date: new Date().toLocaleString('es-ES', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}),
|
||||
coordinates: coordsRaw ? formatCoords(coordsRaw.latitude, coordsRaw.longitude) : null,
|
||||
};
|
||||
|
||||
const scale = Math.min(1, MAX_STAMP_W / origW);
|
||||
const renderW = Math.round(origW * scale);
|
||||
const renderH = Math.round(origH * scale);
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
resolveRef.current = resolve;
|
||||
rejectRef.current = reject;
|
||||
setComposing({ uri: rawUri, renderW, renderH, meta, config });
|
||||
});
|
||||
}
|
||||
|
||||
// ── captura de foto ───────────────────────────────────────────────────────
|
||||
|
||||
const addPhoto = useCallback(
|
||||
async (source: 'camera' | 'library') => {
|
||||
const perm =
|
||||
source === 'camera'
|
||||
@@ -47,39 +180,67 @@ export function MediaStrip({
|
||||
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result =
|
||||
source === 'camera'
|
||||
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
|
||||
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7, mediaTypes: 'images' });
|
||||
? await ImagePicker.launchCameraAsync({ quality: 0.85 })
|
||||
: await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' });
|
||||
if (result.canceled || !result.assets?.length) return;
|
||||
|
||||
const asset = result.assets[0];
|
||||
|
||||
let finalUri: string;
|
||||
try {
|
||||
finalUri = await stamp(asset.uri, asset.width ?? 1920, asset.height ?? 1080);
|
||||
} catch {
|
||||
// Si el stamping falla, usar la foto original.
|
||||
finalUri = asset.uri;
|
||||
}
|
||||
|
||||
await enqueueMedia({
|
||||
parentEntity,
|
||||
parentId,
|
||||
localUri: asset.uri,
|
||||
localUri: finalUri,
|
||||
fileName: asset.fileName ?? undefined,
|
||||
mimeType: asset.mimeType ?? 'image/jpeg',
|
||||
category: 'image',
|
||||
});
|
||||
await refresh();
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[parentEntity, parentId, refresh],
|
||||
);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
Alert.alert('Añadir foto', undefined, [
|
||||
{ text: 'Cámara', onPress: () => void add('camera') },
|
||||
{ text: 'Galería', onPress: () => void add('library') },
|
||||
{ text: 'Cámara', onPress: () => void addPhoto('camera') },
|
||||
{ text: 'Galería', onPress: () => void addPhoto('library') },
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
]);
|
||||
}, [add]);
|
||||
}, [addPhoto]);
|
||||
|
||||
// ─── render ───────────────────────────────────────────────────────────────
|
||||
|
||||
if (synced.length === 0 && pending.length === 0 && !canUpload) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
|
||||
<View style={styles.wrapper}>
|
||||
{/* Cabecera con icono de configuración */}
|
||||
{canUpload && (
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerLabel}>Fotos</Text>
|
||||
<TouchableOpacity onPress={() => navigation.navigate('PhotoSettings')}>
|
||||
<Text style={styles.gear}>⚙</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tira de miniaturas */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.row}
|
||||
>
|
||||
{canUpload && (
|
||||
<TouchableOpacity style={styles.addBtn} onPress={onAdd}>
|
||||
<Text style={styles.addPlus}>+</Text>
|
||||
@@ -89,8 +250,15 @@ export function MediaStrip({
|
||||
{pending.map((m) => (
|
||||
<View key={m.uuid} style={styles.thumbWrap}>
|
||||
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
|
||||
<View style={[styles.tag, { backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn }]}>
|
||||
<Text style={styles.tagText}>{m.status === 'error' ? 'error' : 'en cola'}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.tag,
|
||||
{ backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.tagText}>
|
||||
{m.status === 'error' ? 'error' : 'en cola'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -100,12 +268,51 @@ export function MediaStrip({
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Vista fuera de pantalla para compositar el pie de página */}
|
||||
{composing ? (
|
||||
<View
|
||||
ref={captureViewRef}
|
||||
collapsable={false}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: -(composing.renderW + 100),
|
||||
width: composing.renderW,
|
||||
height: composing.renderH,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: composing.uri }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="stretch"
|
||||
onLoad={onCaptureImageLoaded}
|
||||
/>
|
||||
<FooterOverlay
|
||||
config={composing.config}
|
||||
meta={composing.meta}
|
||||
imageWidth={composing.renderW}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── estilos ──────────────────────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { marginVertical: 8 },
|
||||
wrapper: { marginVertical: 8 },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 2,
|
||||
marginBottom: 6,
|
||||
},
|
||||
headerLabel: { fontSize: 13, fontWeight: '600', color: COLORS.muted },
|
||||
gear: { fontSize: 18, color: COLORS.muted, paddingHorizontal: 4 },
|
||||
row: { gap: 8, paddingRight: 8 },
|
||||
addBtn: {
|
||||
width: 72,
|
||||
@@ -119,8 +326,21 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' },
|
||||
addText: { color: COLORS.primary, fontSize: 11 },
|
||||
thumbWrap: { width: 72, height: 72, borderRadius: 8, overflow: 'hidden', backgroundColor: COLORS.bg },
|
||||
thumbWrap: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: COLORS.bg,
|
||||
},
|
||||
thumb: { width: '100%', height: '100%' },
|
||||
tag: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingVertical: 1, alignItems: 'center' },
|
||||
tag: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingVertical: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
tagText: { color: '#fff', fontSize: 9, fontWeight: '700' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user