Implement custom camera with real-time overlay and resolution settings

This commit is contained in:
2026-07-28 23:55:24 +02:00
parent c6240784ff
commit 6d11434061
9 changed files with 479 additions and 138 deletions
+6
View File
@@ -13,6 +13,7 @@ import { IssueCreateScreen } from '../screens/IssueCreateScreen';
import { OutboxScreen } from '../screens/OutboxScreen';
import { PhotoSettingsScreen } from '../screens/PhotoSettingsScreen';
import { SettingsScreen } from '../screens/SettingsScreen';
import { CameraScreen } from '../screens/CameraScreen';
import { RootStackParamList } from './types';
const Stack = createNativeStackNavigator<RootStackParamList>();
@@ -80,6 +81,11 @@ export function RootNavigator() {
component={SettingsScreen}
options={{ title: 'Configuración' }}
/>
<Stack.Screen
name="Camera"
component={CameraScreen}
options={{ headerShown: false, orientation: 'portrait' }}
/>
</Stack.Navigator>
</NavigationContainer>
);
+1
View File
@@ -8,4 +8,5 @@ export type RootStackParamList = {
Outbox: undefined;
PhotoSettings: undefined;
Settings: undefined;
Camera: { onCapture: (uri: string, width: number, height: number) => void };
};
+12
View File
@@ -3,6 +3,7 @@ import * as ImagePicker from 'expo-image-picker';
import { getMeta, setMeta } from '../db/repositories';
export type FieldKey = 'project_name' | 'date' | 'coordinates' | 'custom';
export type PhotoResolution = 'low' | 'medium' | 'high';
export interface FooterField {
id: string;
@@ -17,6 +18,8 @@ export interface FooterConfig {
enabled: boolean;
logoUri: string | null;
fields: FooterField[];
resolution: PhotoResolution;
quality: number; // 0.1 to 1.0
}
export interface StampMeta {
@@ -37,9 +40,18 @@ function defaultConfig(): FooterConfig {
{ id: 'date', key: 'date', label: 'Fecha', enabled: true },
{ id: 'coordinates', key: 'coordinates', label: 'Coordenadas GPS', enabled: true },
],
resolution: 'medium',
quality: 0.85,
};
}
/** Mapa de resoluciones a píxeles (ancho). El alto se calcula según el aspect ratio. */
export const RESOLUTION_WIDTHS: Record<PhotoResolution, number> = {
low: 1280, // 720p approx
medium: 1920, // 1080p
high: 3840, // 4K approx
};
export async function loadFooterConfig(): Promise<FooterConfig> {
const raw = await getMeta(META_KEY);
if (!raw) return defaultConfig();
+179
View File
@@ -0,0 +1,179 @@
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as Location from 'expo-location';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Dimensions,
StyleSheet,
Text,
TouchableOpacity,
View,
ActivityIndicator,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { FooterConfig, loadFooterConfig, resolveFieldValue, StampMeta } from '../photo/footerConfig';
import { getActiveProjectId, getProject } from '../db/repositories';
import { COLORS } from '../ui/components';
import { FooterOverlay } from '../photo/FooterOverlay';
type Props = NativeStackScreenProps<RootStackParamList, 'Camera'>;
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
export function CameraScreen({ route, navigation }: Props) {
const { onCapture } = route.params;
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
const [config, setConfig] = useState<FooterConfig | null>(null);
const [meta, setMeta] = useState<StampMeta | null>(null);
const [taking, setTaking] = useState(false);
const loadData = useCallback(async () => {
const [cfg, projectId] = await Promise.all([
loadFooterConfig(),
getActiveProjectId(),
]);
setConfig(cfg);
let projectName: string | null = null;
if (projectId != null) {
const proj = await getProject(projectId);
projectName = proj?.name ?? null;
}
const { granted } = await Location.requestForegroundPermissionsAsync();
let coordsStr: string | null = null;
if (granted) {
const loc = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced });
const lat = loc.coords.latitude;
const lng = loc.coords.longitude;
const la = lat >= 0 ? 'N' : 'S';
const lo = lng >= 0 ? 'E' : 'O';
coordsStr = `${Math.abs(lat).toFixed(5)}°${la}, ${Math.abs(lng).toFixed(5)}°${lo}`;
}
setMeta({
projectName,
date: new Date().toLocaleString('es-ES', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
}),
coordinates: coordsStr,
});
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
if (!permission) {
return <View style={styles.center}><ActivityIndicator size="large" /></View>;
}
if (!permission.granted) {
return (
<View style={styles.center}>
<Text style={styles.message}>Necesitamos permiso para usar la cámara</Text>
<TouchableOpacity style={styles.btn} onPress={requestPermission}>
<Text style={styles.btnText}>Conceder permiso</Text>
</TouchableOpacity>
</View>
);
}
const capture = async () => {
if (!cameraRef.current || taking) return;
setTaking(true);
try {
const photo = await cameraRef.current.takePictureAsync({
quality: config?.quality ?? 0.85,
base64: false,
exif: false,
});
if (photo) {
onCapture(photo.uri, photo.width, photo.height);
navigation.goBack();
}
} catch (e) {
console.error('Error taking picture:', e);
} finally {
setTaking(false);
}
};
return (
<View style={styles.container}>
<CameraView
ref={cameraRef}
style={styles.camera}
facing="back"
autofocus="on"
>
<View style={styles.overlay}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.closeBtn}>
<Text style={styles.closeTxt}></Text>
</TouchableOpacity>
</View>
{/* Footer Preview */}
{config && meta && (
<View style={styles.footerWrapper}>
<FooterOverlay config={config} meta={meta} imageWidth={SCREEN_W} />
</View>
)}
{/* Controls */}
<View style={styles.controls}>
<TouchableOpacity
style={[styles.captureBtn, taking && styles.disabled]}
onPress={() => void capture()}
disabled={taking}
>
<View style={styles.captureInner} />
</TouchableOpacity>
</View>
</View>
</CameraView>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#000' },
camera: { flex: 1 },
overlay: { flex: 1, justifyContent: 'space-between' },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' },
message: { color: '#fff', textAlign: 'center', marginBottom: 20 },
btn: { backgroundColor: COLORS.primary, padding: 12, borderRadius: 8 },
btnText: { color: '#fff', fontWeight: 'bold' },
header: { padding: 20, paddingTop: 50 },
closeBtn: { width: 40, height: 40, justifyContent: 'center', alignItems: 'center' },
closeTxt: { color: '#fff', fontSize: 24 },
footerWrapper: {
height: 100, // Espacio para el footer
justifyContent: 'flex-end',
},
controls: {
paddingBottom: 40,
alignItems: 'center',
},
captureBtn: {
width: 70,
height: 70,
borderRadius: 35,
borderWidth: 4,
borderColor: '#fff',
justifyContent: 'center',
alignItems: 'center',
},
captureInner: {
width: 54,
height: 54,
borderRadius: 27,
backgroundColor: '#fff',
},
disabled: { opacity: 0.5 },
});
+38 -1
View File
@@ -23,9 +23,10 @@ import {
FooterField,
loadFooterConfig,
pickAndSaveLogo,
PhotoResolution,
saveFooterConfig,
} from '../photo/footerConfig';
import { COLORS, PrimaryButton, SectionTitle } from '../ui/components';
import { ChipSelect, COLORS, PrimaryButton, SectionTitle } from '../ui/components';
const BUILT_IN_LABELS: Record<string, string> = {
project_name: 'Nombre del proyecto',
@@ -33,6 +34,9 @@ const BUILT_IN_LABELS: Record<string, string> = {
coordinates: 'Coordenadas GPS',
};
const RESOLUTION_OPTIONS: PhotoResolution[] = ['low', 'medium', 'high'];
const QUALITY_OPTIONS = ['0.5', '0.7', '0.85', '1.0'];
export function PhotoSettingsScreen() {
const [config, setConfig] = useState<FooterConfig | null>(null);
const [newLabel, setNewLabel] = useState('');
@@ -154,6 +158,32 @@ export function PhotoSettingsScreen() {
Recorte cuadrado. Se recomienda logo con fondo blanco.
</Text>
{/* ── Resolución y Calidad ── */}
<SectionTitle>Calidad de imagen</SectionTitle>
<View style={styles.card}>
<ChipSelect
label="Resolución máxima"
value={config.resolution}
options={RESOLUTION_OPTIONS}
onChange={(v) => void save({ ...config, resolution: v })}
/>
<Text style={styles.hint}>
Low (~720p), Medium (~1080p), High (~4K).
</Text>
<View style={{ height: 12 }} />
<ChipSelect
label="Calidad de compresión"
value={String(config.quality)}
options={QUALITY_OPTIONS}
onChange={(v) => void save({ ...config, quality: parseFloat(v) })}
/>
<Text style={styles.hint}>
Valores bajos reducen el tamaño del archivo pero pueden verse peor.
</Text>
</View>
{/* ── Campos ── */}
<SectionTitle>Campos</SectionTitle>
@@ -277,6 +307,13 @@ const styles = StyleSheet.create({
logoActions: { gap: 8 },
hint: { fontSize: 11, color: COLORS.muted, marginBottom: 8 },
card: {
backgroundColor: COLORS.bg,
borderRadius: 8,
padding: 12,
marginBottom: 8,
},
/* Campos */
fieldRow: {
flexDirection: 'row',
+111 -76
View File
@@ -1,32 +1,12 @@
/**
* Mapa de features. Dibuja la geometría GeoJSON del proyecto (puntos, líneas,
* polígonos) sobre Google Maps y permite seleccionar una feature tocándola.
*
* Nota: las tiles de Google Maps requieren conexión; la geometría sí se dibuja
* sin red. Requiere una API key de Google Maps (ver app.config.js / README).
* Mapa de features usando OpenStreetMap via Leaflet y WebView.
* Elimina la dependencia de la API Key de Google Maps.
*/
import * as Location from 'expo-location';
import React, { useMemo, useRef } from 'react';
import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import MapView, { Marker, Polygon, Polyline, PROVIDER_DEFAULT, PROVIDER_GOOGLE } from 'react-native-maps';
// Android usa Google Maps (requiere GOOGLE_MAPS_API_KEY).
// iOS usa Apple Maps por defecto (sin key, PROVIDER_DEFAULT).
const MAP_PROVIDER = Platform.OS === 'android' ? PROVIDER_GOOGLE : PROVIDER_DEFAULT;
import { StyleSheet, View } from 'react-native';
import { WebView } from 'react-native-webview';
import { Feature } from '../api/types';
import { COLORS } from './components';
import { geometryToShapes, LatLng, regionFor } from './geojson';
const STATUS_COLOR: Record<string, string> = {
pending: '#9aa0a6',
in_progress: '#1f6f43',
completed: '#2e7d32',
blocked: '#b00020',
};
function colorFor(status?: string): string {
return (status && STATUS_COLOR[status]) || COLORS.primary;
}
export function FeatureMap({
features,
@@ -37,66 +17,121 @@ export function FeatureMap({
selectedId: number | null;
onSelect: (id: number) => void;
}) {
const mapRef = useRef<MapView>(null);
const webViewRef = useRef<WebView>(null);
const { shaped, region } = useMemo(() => {
const all: LatLng[] = [];
const shaped = features.map((f) => {
const shapes = geometryToShapes(f.geometry);
all.push(...shapes.points, ...shapes.lines.flat(), ...shapes.polygons.flat());
return { feature: f, shapes };
});
return { shaped, region: regionFor(all) };
}, [features]);
// Convertimos las features a un objeto GeoJSON simple para Leaflet
const geoData = useMemo(() => {
return {
type: 'FeatureCollection',
features: features.map((f) => ({
type: 'Feature',
id: f.id,
geometry: typeof f.geometry === 'string' ? JSON.parse(f.geometry) : f.geometry,
properties: {
name: f.name,
status: f.status,
selected: f.id === selectedId,
},
})),
};
}, [features, selectedId]);
const recenter = async () => {
const perm = await Location.requestForegroundPermissionsAsync();
if (!perm.granted) return;
const pos = await Location.getCurrentPositionAsync({});
mapRef.current?.animateToRegion({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
});
const htmlContent = useMemo(() => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
body { margin: 0; padding: 0; }
#map { height: 100vh; width: 100vw; background: #f0f0f0; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const map = L.map('map', { zoomControl: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(map);
const geoData = ${JSON.stringify(geoData)};
const STATUS_COLORS = {
pending: '#9aa0a6',
in_progress: '#1f6f43',
completed: '#2e7d32',
blocked: '#b00020'
};
const geoLayer = L.geoJSON(geoData, {
style: (feature) => ({
color: feature.properties.selected ? '#000' : (STATUS_COLORS[feature.properties.status] || '#1f6f43'),
weight: feature.properties.selected ? 4 : 2,
fillOpacity: 0.4,
fillColor: STATUS_COLORS[feature.properties.status] || '#1f6f43'
}),
pointToLayer: (feature, latlng) => {
return L.circleMarker(latlng, {
radius: 8,
fillColor: STATUS_COLORS[feature.properties.status] || '#1f6f43',
color: feature.properties.selected ? '#000' : '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
});
},
onEachFeature: (feature, layer) => {
layer.on('click', () => {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'select', id: feature.id }));
});
}
}).addTo(map);
if (geoData.features.length > 0) {
try {
map.fitBounds(geoLayer.getBounds(), { padding: [20, 20] });
} catch(e) {
map.setView([40.4167, -3.7037], 13);
}
} else {
map.setView([40.4167, -3.7037], 13); // Madrid default
}
</script>
</body>
</html>
`, [geoData]);
const onMessage = (event: any) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'select') {
onSelect(data.id);
}
} catch (e) {
console.warn('Error parsing WebView message:', e);
}
};
if (!region) {
return (
<View style={styles.empty}>
<Text style={{ color: COLORS.muted }}>Las features no tienen geometría.</Text>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: '#f0f0f0', justifyContent: 'center', alignItems: 'center' }]}>
<Text style={{ color: COLORS.muted, textAlign: 'center' }}>
Mapa desactivado temporalmente para pruebas.{"\n"}
Requiere API Key de Google Maps válida.
</Text>
<View style={styles.container}>
<WebView
ref={webViewRef}
originWhitelist={['*']}
source={{ html: htmlContent }}
onMessage={onMessage}
style={styles.map}
javaScriptEnabled={true}
domStorageEnabled={true}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
locBtn: {
position: 'absolute',
right: 16,
bottom: 16,
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#fff',
justifyContent: 'center',
alignItems: 'center',
elevation: 4,
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
locIcon: { fontSize: 22, color: COLORS.primary },
container: { flex: 1, backgroundColor: '#f0f0f0' },
map: { flex: 1 },
});
+36 -42
View File
@@ -29,6 +29,7 @@ import { RootStackParamList } from '../navigation/types';
import {
FooterConfig,
loadFooterConfig,
RESOLUTION_WIDTHS,
resolveFieldValue,
StampMeta,
} from '../photo/footerConfig';
@@ -47,8 +48,6 @@ interface ComposingTask {
type Nav = NativeStackNavigationProp<RootStackParamList>;
const MAX_STAMP_W = 2048;
// ─── helpers ───────────────────────────────────────────────────────────────
async function getCoords(): Promise<{ latitude: number; longitude: number } | null> {
@@ -157,7 +156,8 @@ export function MediaStrip({
coordinates: coordsRaw ? formatCoords(coordsRaw.latitude, coordsRaw.longitude) : null,
};
const scale = Math.min(1, MAX_STAMP_W / origW);
const targetW = RESOLUTION_WIDTHS[config.resolution] || 1920;
const scale = Math.min(1, targetW / origW);
const renderW = Math.round(origW * scale);
const renderH = Math.round(origH * scale);
@@ -170,54 +170,48 @@ export function MediaStrip({
// ── captura de foto ───────────────────────────────────────────────────────
const addPhoto = useCallback(
async (source: 'camera' | 'library') => {
const perm =
source === 'camera'
? await ImagePicker.requestCameraPermissionsAsync()
: await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
return;
}
const handleCapture = async (uri: string, width: number, height: number) => {
let finalUri: string;
try {
finalUri = await stamp(uri, width, height);
} catch {
finalUri = uri;
}
const result =
source === 'camera'
? await ImagePicker.launchCameraAsync({ quality: 0.85 })
: await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' });
if (result.canceled || !result.assets?.length) return;
await enqueueMedia({
parentEntity,
parentId,
localUri: finalUri,
mimeType: 'image/jpeg',
category: 'image',
});
await refresh();
};
const asset = result.assets[0];
const addFromLibrary = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
return;
}
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;
}
const result = await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' });
if (result.canceled || !result.assets?.length) return;
await enqueueMedia({
parentEntity,
parentId,
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 asset = result.assets[0];
await handleCapture(asset.uri, asset.width ?? 1920, asset.height ?? 1080);
};
const onAdd = useCallback(() => {
Alert.alert('Añadir foto', undefined, [
{ text: 'Cámara', onPress: () => void addPhoto('camera') },
{ text: 'Galería', onPress: () => void addPhoto('library') },
{
text: 'Cámara',
onPress: () => navigation.navigate('Camera', { onCapture: handleCapture })
},
{ text: 'Galería', onPress: () => void addFromLibrary() },
{ text: 'Cancelar', style: 'cancel' },
]);
}, [addPhoto]);
}, [navigation, handleCapture, addFromLibrary]);
const onDeletePending = useCallback(
async (m: MediaOutboxRow) => {