Fix photo overlay positioning and rotation detection
This commit is contained in:
Generated
+11
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ── */}
|
||||||
|
|||||||
Reference in New Issue
Block a user