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": "0.85.3",
|
||||||
"react-native-maps": "1.27.2",
|
"react-native-maps": "1.27.2",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
@@ -3000,7 +3001,7 @@
|
|||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -4083,11 +4093,20 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dateformat": {
|
"node_modules/dateformat": {
|
||||||
@@ -6922,6 +6941,19 @@
|
|||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/http-call": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-call/-/http-call-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-call/-/http-call-5.3.0.tgz",
|
||||||
@@ -9476,6 +9508,19 @@
|
|||||||
"react-native": ">=0.82.0"
|
"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": {
|
"node_modules/react-native/node_modules/commander": {
|
||||||
"version": "12.1.0",
|
"version": "12.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
@@ -10509,6 +10554,15 @@
|
|||||||
"b4a": "^1.6.4"
|
"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": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
@@ -10744,7 +10798,7 @@
|
|||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -10909,6 +10963,15 @@
|
|||||||
"node": ">= 0.4.0"
|
"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": {
|
"node_modules/uuid": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.85.3",
|
"react-native": "0.85.3",
|
||||||
"react-native-maps": "1.27.2",
|
"react-native-maps": "1.27.2",
|
||||||
|
"react-native-view-shot": "^4.0.0",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2"
|
"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');
|
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). */
|
/** Persiste la lista que devuelve GET /projects (upsert sin tocar el resto). */
|
||||||
export async function saveProjectList(projects: Project[]): Promise<void> {
|
export async function saveProjectList(projects: Project[]): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { FeatureDetailScreen } from '../screens/FeatureDetailScreen';
|
|||||||
import { InspectionFormScreen } from '../screens/InspectionFormScreen';
|
import { InspectionFormScreen } from '../screens/InspectionFormScreen';
|
||||||
import { IssueCreateScreen } from '../screens/IssueCreateScreen';
|
import { IssueCreateScreen } from '../screens/IssueCreateScreen';
|
||||||
import { OutboxScreen } from '../screens/OutboxScreen';
|
import { OutboxScreen } from '../screens/OutboxScreen';
|
||||||
|
import { PhotoSettingsScreen } from '../screens/PhotoSettingsScreen';
|
||||||
import { RootStackParamList } from './types';
|
import { RootStackParamList } from './types';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
@@ -68,6 +69,11 @@ export function RootNavigator() {
|
|||||||
component={OutboxScreen}
|
component={OutboxScreen}
|
||||||
options={{ title: 'Cola de sincronización' }}
|
options={{ title: 'Cola de sincronización' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="PhotoSettings"
|
||||||
|
component={PhotoSettingsScreen}
|
||||||
|
options={{ title: 'Configuración de fotos', presentation: 'modal' }}
|
||||||
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export type RootStackParamList = {
|
|||||||
InspectionForm: { featureId: number; featureName: string; templateId?: number };
|
InspectionForm: { featureId: number; featureName: string; templateId?: number };
|
||||||
IssueCreate: { projectId: number; featureId?: number };
|
IssueCreate: { projectId: number; featureId?: number };
|
||||||
Outbox: undefined;
|
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).
|
* Tira de fotos de un registro. Muestra las ya sincronizadas (tabla `media`)
|
||||||
* Muestra las ya sincronizadas (tabla `media`, con url) y las locales en cola
|
* y las locales en cola (`media_outbox`). Permite añadir nuevas desde cámara
|
||||||
* (`media_outbox`), y permite añadir nuevas desde cámara o galería (offline).
|
* 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 * as ImagePicker from 'expo-image-picker';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import * as Location from 'expo-location';
|
||||||
import { Alert, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
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 { Media, MediaParentEntity } from '../api/types';
|
||||||
import { absoluteUrl } from '../config';
|
import { absoluteUrl } from '../config';
|
||||||
import { enqueueMedia, getMediaOutboxFor, MediaOutboxRow } from '../db/outbox';
|
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';
|
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({
|
export function MediaStrip({
|
||||||
parentEntity,
|
parentEntity,
|
||||||
parentId,
|
parentId,
|
||||||
@@ -21,8 +81,16 @@ export function MediaStrip({
|
|||||||
parentId: number;
|
parentId: number;
|
||||||
canUpload: boolean;
|
canUpload: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [synced, setSynced] = useState<Media[]>([]);
|
const navigation = useNavigation<Nav>();
|
||||||
|
const [synced, setSynced] = useState<Media[]>([]);
|
||||||
const [pending, setPending] = useState<MediaOutboxRow[]>([]);
|
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 refresh = useCallback(async () => {
|
||||||
const [s, p] = await Promise.all([
|
const [s, p] = await Promise.all([
|
||||||
@@ -33,11 +101,76 @@ export function MediaStrip({
|
|||||||
setPending(p);
|
setPending(p);
|
||||||
}, [parentEntity, parentId]);
|
}, [parentEntity, parentId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { void refresh(); }, [refresh]);
|
||||||
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') => {
|
async (source: 'camera' | 'library') => {
|
||||||
const perm =
|
const perm =
|
||||||
source === 'camera'
|
source === 'camera'
|
||||||
@@ -47,39 +180,67 @@ export function MediaStrip({
|
|||||||
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
|
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
source === 'camera'
|
source === 'camera'
|
||||||
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
|
? await ImagePicker.launchCameraAsync({ quality: 0.85 })
|
||||||
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7, mediaTypes: 'images' });
|
: await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' });
|
||||||
if (result.canceled || !result.assets?.length) return;
|
if (result.canceled || !result.assets?.length) return;
|
||||||
|
|
||||||
const asset = result.assets[0];
|
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({
|
await enqueueMedia({
|
||||||
parentEntity,
|
parentEntity,
|
||||||
parentId,
|
parentId,
|
||||||
localUri: asset.uri,
|
localUri: finalUri,
|
||||||
fileName: asset.fileName ?? undefined,
|
fileName: asset.fileName ?? undefined,
|
||||||
mimeType: asset.mimeType ?? 'image/jpeg',
|
mimeType: asset.mimeType ?? 'image/jpeg',
|
||||||
category: 'image',
|
category: 'image',
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
},
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[parentEntity, parentId, refresh],
|
[parentEntity, parentId, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onAdd = useCallback(() => {
|
const onAdd = useCallback(() => {
|
||||||
Alert.alert('Añadir foto', undefined, [
|
Alert.alert('Añadir foto', undefined, [
|
||||||
{ text: 'Cámara', onPress: () => void add('camera') },
|
{ text: 'Cámara', onPress: () => void addPhoto('camera') },
|
||||||
{ text: 'Galería', onPress: () => void add('library') },
|
{ text: 'Galería', onPress: () => void addPhoto('library') },
|
||||||
{ text: 'Cancelar', style: 'cancel' },
|
{ text: 'Cancelar', style: 'cancel' },
|
||||||
]);
|
]);
|
||||||
}, [add]);
|
}, [addPhoto]);
|
||||||
|
|
||||||
|
// ─── render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (synced.length === 0 && pending.length === 0 && !canUpload) return null;
|
if (synced.length === 0 && pending.length === 0 && !canUpload) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.wrapper}>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
|
{/* 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 && (
|
{canUpload && (
|
||||||
<TouchableOpacity style={styles.addBtn} onPress={onAdd}>
|
<TouchableOpacity style={styles.addBtn} onPress={onAdd}>
|
||||||
<Text style={styles.addPlus}>+</Text>
|
<Text style={styles.addPlus}>+</Text>
|
||||||
@@ -89,8 +250,15 @@ export function MediaStrip({
|
|||||||
{pending.map((m) => (
|
{pending.map((m) => (
|
||||||
<View key={m.uuid} style={styles.thumbWrap}>
|
<View key={m.uuid} style={styles.thumbWrap}>
|
||||||
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
|
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
|
||||||
<View style={[styles.tag, { backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn }]}>
|
<View
|
||||||
<Text style={styles.tagText}>{m.status === 'error' ? 'error' : 'en cola'}</Text>
|
style={[
|
||||||
|
styles.tag,
|
||||||
|
{ backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.tagText}>
|
||||||
|
{m.status === 'error' ? 'error' : 'en cola'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -100,12 +268,51 @@ export function MediaStrip({
|
|||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── estilos ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
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 },
|
row: { gap: 8, paddingRight: 8 },
|
||||||
addBtn: {
|
addBtn: {
|
||||||
width: 72,
|
width: 72,
|
||||||
@@ -119,8 +326,21 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' },
|
addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' },
|
||||||
addText: { color: COLORS.primary, fontSize: 11 },
|
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%' },
|
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' },
|
tagText: { color: '#fff', fontSize: 9, fontWeight: '700' },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user