Fix photo overlay positioning and rotation detection

This commit is contained in:
2026-07-29 00:35:45 +02:00
parent 6d11434061
commit a9ba966176
6 changed files with 107 additions and 46 deletions
+11
View File
@@ -18,6 +18,7 @@
"expo-file-system": "~56.0.8", "expo-file-system": "~56.0.8",
"expo-image-picker": "~56.0.18", "expo-image-picker": "~56.0.18",
"expo-location": "~56.0.18", "expo-location": "~56.0.18",
"expo-screen-orientation": "^57.0.1",
"expo-secure-store": "~56.0.4", "expo-secure-store": "~56.0.4",
"expo-sqlite": "~56.0.5", "expo-sqlite": "~56.0.5",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
@@ -5910,6 +5911,16 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-screen-orientation": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-57.0.1.tgz",
"integrity": "sha512-pvMaOf2N5mcJNiYijIj0+Anza6vrHvcnAfFeO2yjquvPbejiE8oEh9BP9+Hx8VRnzFMoxiO6bG9W/oOFhtFIDA==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-secure-store": { "node_modules/expo-secure-store": {
"version": "56.0.4", "version": "56.0.4",
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-56.0.4.tgz", "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-56.0.4.tgz",
+1
View File
@@ -13,6 +13,7 @@
"expo-file-system": "~56.0.8", "expo-file-system": "~56.0.8",
"expo-image-picker": "~56.0.18", "expo-image-picker": "~56.0.18",
"expo-location": "~56.0.18", "expo-location": "~56.0.18",
"expo-screen-orientation": "^57.0.1",
"expo-secure-store": "~56.0.4", "expo-secure-store": "~56.0.4",
"expo-sqlite": "~56.0.5", "expo-sqlite": "~56.0.5",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
+32 -37
View File
@@ -1,11 +1,10 @@
/** /**
* Pie de página superpuesto sobre la foto antes de capturarla con ViewShot. * Pie de página superpuesto sobre la foto.
* Se posiciona en la parte inferior de su contenedor con position:absolute. * Se posiciona en una de las cuatro esquinas según `config.overlayPosition`.
* Todos los tamaños son proporcionales al ancho de la imagen para que el pie * El diseño es compacto (recuadro con fondo semitransparente).
* sea legible independientemente de la resolución del sensor.
*/ */
import React from 'react'; import React from 'react';
import { Image, StyleSheet, Text, View } from 'react-native'; import { Image, StyleSheet, Text, View, ViewStyle } from 'react-native';
import { FooterConfig, resolveFieldValue, StampMeta } from './footerConfig'; import { FooterConfig, resolveFieldValue, StampMeta } from './footerConfig';
interface Props { interface Props {
@@ -20,31 +19,31 @@ export function FooterOverlay({ config, meta, imageWidth }: Props) {
const enabled = config.fields.filter((f) => f.enabled); const enabled = config.fields.filter((f) => f.enabled);
if (enabled.length === 0 && !config.logoUri) return null; if (enabled.length === 0 && !config.logoUri) return null;
const fontSize = Math.max(12, Math.round(imageWidth * 0.016)); const fontSize = Math.max(10, Math.round(imageWidth * 0.018));
const logoDim = Math.max(50, Math.round(imageWidth * 0.09)); const logoDim = Math.max(40, Math.round(imageWidth * 0.08));
const pad = Math.max(8, Math.round(imageWidth * 0.012)); const pad = Math.max(8, Math.round(imageWidth * 0.015));
const lineH = Math.round(fontSize * 1.4); const lineH = Math.round(fontSize * 1.3);
// Determinar posición
const posStyle: ViewStyle = {};
if (config.overlayPosition.includes('top')) posStyle.top = 20;
else posStyle.bottom = 20;
if (config.overlayPosition.includes('left')) posStyle.left = 20;
else posStyle.right = 20;
return ( return (
<View <View style={[styles.container, posStyle, { padding: pad }]}>
style={[
styles.container,
{ paddingHorizontal: pad, paddingVertical: Math.round(pad * 0.65) },
]}
>
{/* Logo */} {/* Logo */}
{config.logoUri ? ( {config.logoUri ? (
<> <View
<View style={[
style={[ styles.logoWrap,
styles.logoWrap, { width: logoDim, height: logoDim, borderRadius: Math.round(logoDim * 0.08), marginBottom: 6 },
{ width: logoDim, height: logoDim, borderRadius: Math.round(logoDim * 0.08) }, ]}
]} >
> <Image source={{ uri: config.logoUri }} style={styles.logoImg} resizeMode="contain" />
<Image source={{ uri: config.logoUri }} style={styles.logoImg} resizeMode="contain" /> </View>
</View>
<View style={[styles.divider, { marginHorizontal: pad, height: logoDim }]} />
</>
) : null} ) : null}
{/* Campos */} {/* Campos */}
@@ -60,7 +59,7 @@ export function FooterOverlay({ config, meta, imageWidth }: Props) {
{ fontSize, lineHeight: lineH }, { fontSize, lineHeight: lineH },
i === 0 ? styles.bold : null, i === 0 ? styles.bold : null,
]} ]}
numberOfLines={1} numberOfLines={2}
> >
{f.key === 'custom' ? `${f.label}: ${val}` : val} {f.key === 'custom' ? `${f.label}: ${val}` : val}
</Text> </Text>
@@ -74,23 +73,19 @@ export function FooterOverlay({ config, meta, imageWidth }: Props) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
position: 'absolute', position: 'absolute',
bottom: 0, backgroundColor: 'rgba(0,0,0,0.65)',
left: 0, borderRadius: 8,
right: 0, maxWidth: '50%', // Para no ocupar toda la pantalla
backgroundColor: 'rgba(0,0,0,0.75)', alignItems: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
}, },
logoWrap: { logoWrap: {
backgroundColor: '#fff', backgroundColor: '#fff',
overflow: 'hidden', overflow: 'hidden',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
flexShrink: 0,
}, },
logoImg: { width: '100%', height: '100%' }, logoImg: { width: '80%', height: '80%' },
divider: { width: 1, backgroundColor: 'rgba(255,255,255,0.35)', flexShrink: 0 }, textBlock: { width: '100%' },
textBlock: { flex: 1 },
line: { color: '#fff' }, line: { color: '#fff' },
bold: { fontWeight: '700' }, bold: { fontWeight: '700' },
}); });
+3
View File
@@ -4,6 +4,7 @@ import { getMeta, setMeta } from '../db/repositories';
export type FieldKey = 'project_name' | 'date' | 'coordinates' | 'custom'; export type FieldKey = 'project_name' | 'date' | 'coordinates' | 'custom';
export type PhotoResolution = 'low' | 'medium' | 'high'; export type PhotoResolution = 'low' | 'medium' | 'high';
export type OverlayPosition = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
export interface FooterField { export interface FooterField {
id: string; id: string;
@@ -20,6 +21,7 @@ export interface FooterConfig {
fields: FooterField[]; fields: FooterField[];
resolution: PhotoResolution; resolution: PhotoResolution;
quality: number; // 0.1 to 1.0 quality: number; // 0.1 to 1.0
overlayPosition: OverlayPosition;
} }
export interface StampMeta { export interface StampMeta {
@@ -42,6 +44,7 @@ function defaultConfig(): FooterConfig {
], ],
resolution: 'medium', resolution: 'medium',
quality: 0.85, quality: 0.85,
overlayPosition: 'bottom-left',
}; };
} }
+46 -9
View File
@@ -1,33 +1,55 @@
import { CameraView, useCameraPermissions } from 'expo-camera'; import { CameraView, useCameraPermissions } from 'expo-camera';
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import * as ScreenOrientation from 'expo-screen-orientation';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Dimensions,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
View, View,
ActivityIndicator, ActivityIndicator,
useWindowDimensions,
} from 'react-native'; } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types'; import { RootStackParamList } from '../navigation/types';
import { FooterConfig, loadFooterConfig, resolveFieldValue, StampMeta } from '../photo/footerConfig'; import { FooterConfig, loadFooterConfig, StampMeta } from '../photo/footerConfig';
import { getActiveProjectId, getProject } from '../db/repositories'; import { getActiveProjectId, getProject } from '../db/repositories';
import { COLORS } from '../ui/components'; import { COLORS } from '../ui/components';
import { FooterOverlay } from '../photo/FooterOverlay'; import { FooterOverlay } from '../photo/FooterOverlay';
type Props = NativeStackScreenProps<RootStackParamList, 'Camera'>; type Props = NativeStackScreenProps<RootStackParamList, 'Camera'>;
const { width: SCREEN_W, height: SCREEN_H } = Dimensions.get('window');
export function CameraScreen({ route, navigation }: Props) { export function CameraScreen({ route, navigation }: Props) {
const { onCapture } = route.params; const { onCapture } = route.params;
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null); const cameraRef = useRef<CameraView>(null);
const { width: winW, height: winH } = useWindowDimensions();
const [config, setConfig] = useState<FooterConfig | null>(null); const [config, setConfig] = useState<FooterConfig | null>(null);
const [meta, setMeta] = useState<StampMeta | null>(null); const [meta, setMeta] = useState<StampMeta | null>(null);
const [taking, setTaking] = useState(false); const [taking, setTaking] = useState(false);
const [rotation, setRotation] = useState(0);
// Detectar orientación física para rotar el sello
useEffect(() => {
const updateOrientation = async () => {
const info = await ScreenOrientation.getOrientationAsync();
handleOrientation(info);
};
const handleOrientation = (o: ScreenOrientation.Orientation) => {
if (o === ScreenOrientation.Orientation.LANDSCAPE_LEFT) setRotation(90);
else if (o === ScreenOrientation.Orientation.LANDSCAPE_RIGHT) setRotation(-90);
else if (o === ScreenOrientation.Orientation.PORTRAIT_UPSIDE_DOWN) setRotation(180);
else setRotation(0);
};
void updateOrientation();
const sub = ScreenOrientation.addOrientationChangeListener((evt) => {
handleOrientation(evt.orientationInfo.orientation);
});
return () => ScreenOrientation.removeOrientationChangeListener(sub);
}, []);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
const [cfg, projectId] = await Promise.all([ const [cfg, projectId] = await Promise.all([
@@ -118,10 +140,17 @@ export function CameraScreen({ route, navigation }: Props) {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Footer Preview */} {/* Footer Preview con rotación */}
{config && meta && ( {config && meta && (
<View style={styles.footerWrapper}> <View style={[
<FooterOverlay config={config} meta={meta} imageWidth={SCREEN_W} /> styles.footerWrapper,
{ transform: [{ rotate: `${rotation}deg` }] }
]}>
<FooterOverlay
config={config}
meta={meta}
imageWidth={winW > winH ? winH : winW}
/>
</View> </View>
)} )}
@@ -153,8 +182,16 @@ const styles = StyleSheet.create({
closeBtn: { width: 40, height: 40, justifyContent: 'center', alignItems: 'center' }, closeBtn: { width: 40, height: 40, justifyContent: 'center', alignItems: 'center' },
closeTxt: { color: '#fff', fontSize: 24 }, closeTxt: { color: '#fff', fontSize: 24 },
footerWrapper: { footerWrapper: {
height: 100, // Espacio para el footer position: 'absolute',
justifyContent: 'flex-end', // Ocupamos toda la pantalla para que el transform rote sobre el centro
// pero el FooterOverlay se posicionará internamente en su esquina.
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
pointerEvents: 'none',
}, },
controls: { controls: {
paddingBottom: 40, paddingBottom: 40,
+14
View File
@@ -22,6 +22,7 @@ import {
FooterConfig, FooterConfig,
FooterField, FooterField,
loadFooterConfig, loadFooterConfig,
OverlayPosition,
pickAndSaveLogo, pickAndSaveLogo,
PhotoResolution, PhotoResolution,
saveFooterConfig, saveFooterConfig,
@@ -36,6 +37,7 @@ const BUILT_IN_LABELS: Record<string, string> = {
const RESOLUTION_OPTIONS: PhotoResolution[] = ['low', 'medium', 'high']; const RESOLUTION_OPTIONS: PhotoResolution[] = ['low', 'medium', 'high'];
const QUALITY_OPTIONS = ['0.5', '0.7', '0.85', '1.0']; const QUALITY_OPTIONS = ['0.5', '0.7', '0.85', '1.0'];
const POSITION_OPTIONS: OverlayPosition[] = ['bottom-left', 'bottom-right', 'top-left', 'top-right'];
export function PhotoSettingsScreen() { export function PhotoSettingsScreen() {
const [config, setConfig] = useState<FooterConfig | null>(null); const [config, setConfig] = useState<FooterConfig | null>(null);
@@ -182,6 +184,18 @@ export function PhotoSettingsScreen() {
<Text style={styles.hint}> <Text style={styles.hint}>
Valores bajos reducen el tamaño del archivo pero pueden verse peor. Valores bajos reducen el tamaño del archivo pero pueden verse peor.
</Text> </Text>
<View style={{ height: 12 }} />
<ChipSelect
label="Posición del sello"
value={config.overlayPosition}
options={POSITION_OPTIONS}
onChange={(v) => void save({ ...config, overlayPosition: v })}
/>
<Text style={styles.hint}>
Esquina de la imagen donde aparecerán los datos.
</Text>
</View> </View>
{/* ── Campos ── */} {/* ── Campos ── */}