Implement custom camera with real-time overlay and resolution settings
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user