Implement custom camera with real-time overlay and resolution settings
This commit is contained in:
Generated
+92
-17
@@ -13,6 +13,7 @@
|
|||||||
"@react-navigation/native-stack": "^7.17.5",
|
"@react-navigation/native-stack": "^7.17.5",
|
||||||
"expo": "~56.0.12",
|
"expo": "~56.0.12",
|
||||||
"expo-build-properties": "~56.0.19",
|
"expo-build-properties": "~56.0.19",
|
||||||
|
"expo-camera": "^57.0.3",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-image-picker": "~56.0.18",
|
"expo-image-picker": "~56.0.18",
|
||||||
@@ -25,7 +26,8 @@
|
|||||||
"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"
|
"react-native-view-shot": "^4.0.0",
|
||||||
|
"react-native-webview": "^13.13.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
@@ -2958,6 +2960,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/emscripten": {
|
||||||
|
"version": "1.41.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||||
|
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/geojson": {
|
"node_modules/@types/geojson": {
|
||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
@@ -3432,6 +3440,15 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/barcode-detector": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-zLL7AbT9uNJBUzYpKg9v5tUA4yQGReExSi60q0g660Mj0wjUhKmN0GrUF3qELo8ITW9THzyJXdZQkXLMnsieiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"zxing-wasm": "3.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bare-events": {
|
"node_modules/bare-events": {
|
||||||
"version": "2.9.1",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz",
|
||||||
@@ -5418,22 +5435,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eas-cli/node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eas-cli/node_modules/uuid": {
|
"node_modules/eas-cli/node_modules/uuid": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||||
@@ -5792,6 +5793,26 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-camera": {
|
||||||
|
"version": "57.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.3.tgz",
|
||||||
|
"integrity": "sha512-Q+3aZ63eQCkdB6/FZrO/lfacNAg/j8JCeKQL2nBdf6vBeOo1Y2PKYx1/vK+U5LaRnIo/0tMGmCOzZ1JGhTeMIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"barcode-detector": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*",
|
||||||
|
"react-native-web": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-native-web": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-crypto": {
|
"node_modules/expo-crypto": {
|
||||||
"version": "56.0.4",
|
"version": "56.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz",
|
||||||
@@ -9525,6 +9546,20 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-webview": {
|
||||||
|
"version": "13.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.13.2.tgz",
|
||||||
|
"integrity": "sha512-zACPDTF0WnaEnKZ9mA/r/UpcOpV2gQM06AAIrOOexnO8UJvXL8Pjso0b/wTqKFxUZZnmjKuwd8gHVUosVOdVrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"escape-string-regexp": "^4.0.0",
|
||||||
|
"invariant": "2.2.4"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
@@ -10445,6 +10480,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tagged-tag": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.7",
|
"version": "7.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
|
||||||
@@ -11309,6 +11356,34 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zxing-wasm": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-g0sPJBIubO6zLcJh1jftLPIN6xziaqLsvLgtpGKwDrEhyXXqla3E3yjFrznlr78UHIOMzbJPi0HDWKs/KgaB7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/emscripten": "^1.41.5",
|
||||||
|
"type-fest": "^5.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/emscripten": ">=1.39.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zxing-wasm/node_modules/type-fest": {
|
||||||
|
"version": "5.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
|
||||||
|
"integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==",
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"tagged-tag": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -8,6 +8,7 @@
|
|||||||
"@react-navigation/native-stack": "^7.17.5",
|
"@react-navigation/native-stack": "^7.17.5",
|
||||||
"expo": "~56.0.12",
|
"expo": "~56.0.12",
|
||||||
"expo-build-properties": "~56.0.19",
|
"expo-build-properties": "~56.0.19",
|
||||||
|
"expo-camera": "^57.0.3",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-image-picker": "~56.0.18",
|
"expo-image-picker": "~56.0.18",
|
||||||
@@ -18,9 +19,10 @@
|
|||||||
"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",
|
||||||
|
"react-native-view-shot": "^4.0.0",
|
||||||
|
"react-native-webview": "^13.13.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { IssueCreateScreen } from '../screens/IssueCreateScreen';
|
|||||||
import { OutboxScreen } from '../screens/OutboxScreen';
|
import { OutboxScreen } from '../screens/OutboxScreen';
|
||||||
import { PhotoSettingsScreen } from '../screens/PhotoSettingsScreen';
|
import { PhotoSettingsScreen } from '../screens/PhotoSettingsScreen';
|
||||||
import { SettingsScreen } from '../screens/SettingsScreen';
|
import { SettingsScreen } from '../screens/SettingsScreen';
|
||||||
|
import { CameraScreen } from '../screens/CameraScreen';
|
||||||
import { RootStackParamList } from './types';
|
import { RootStackParamList } from './types';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
@@ -80,6 +81,11 @@ export function RootNavigator() {
|
|||||||
component={SettingsScreen}
|
component={SettingsScreen}
|
||||||
options={{ title: 'Configuración' }}
|
options={{ title: 'Configuración' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Camera"
|
||||||
|
component={CameraScreen}
|
||||||
|
options={{ headerShown: false, orientation: 'portrait' }}
|
||||||
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export type RootStackParamList = {
|
|||||||
Outbox: undefined;
|
Outbox: undefined;
|
||||||
PhotoSettings: undefined;
|
PhotoSettings: undefined;
|
||||||
Settings: undefined;
|
Settings: undefined;
|
||||||
|
Camera: { onCapture: (uri: string, width: number, height: number) => void };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as ImagePicker from 'expo-image-picker';
|
|||||||
import { getMeta, setMeta } from '../db/repositories';
|
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 interface FooterField {
|
export interface FooterField {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +18,8 @@ export interface FooterConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
logoUri: string | null;
|
logoUri: string | null;
|
||||||
fields: FooterField[];
|
fields: FooterField[];
|
||||||
|
resolution: PhotoResolution;
|
||||||
|
quality: number; // 0.1 to 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StampMeta {
|
export interface StampMeta {
|
||||||
@@ -37,9 +40,18 @@ function defaultConfig(): FooterConfig {
|
|||||||
{ id: 'date', key: 'date', label: 'Fecha', enabled: true },
|
{ id: 'date', key: 'date', label: 'Fecha', enabled: true },
|
||||||
{ id: 'coordinates', key: 'coordinates', label: 'Coordenadas GPS', 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> {
|
export async function loadFooterConfig(): Promise<FooterConfig> {
|
||||||
const raw = await getMeta(META_KEY);
|
const raw = await getMeta(META_KEY);
|
||||||
if (!raw) return defaultConfig();
|
if (!raw) return defaultConfig();
|
||||||
|
|||||||
@@ -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,
|
FooterField,
|
||||||
loadFooterConfig,
|
loadFooterConfig,
|
||||||
pickAndSaveLogo,
|
pickAndSaveLogo,
|
||||||
|
PhotoResolution,
|
||||||
saveFooterConfig,
|
saveFooterConfig,
|
||||||
} from '../photo/footerConfig';
|
} 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> = {
|
const BUILT_IN_LABELS: Record<string, string> = {
|
||||||
project_name: 'Nombre del proyecto',
|
project_name: 'Nombre del proyecto',
|
||||||
@@ -33,6 +34,9 @@ const BUILT_IN_LABELS: Record<string, string> = {
|
|||||||
coordinates: 'Coordenadas GPS',
|
coordinates: 'Coordenadas GPS',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RESOLUTION_OPTIONS: PhotoResolution[] = ['low', 'medium', 'high'];
|
||||||
|
const QUALITY_OPTIONS = ['0.5', '0.7', '0.85', '1.0'];
|
||||||
|
|
||||||
export function PhotoSettingsScreen() {
|
export function PhotoSettingsScreen() {
|
||||||
const [config, setConfig] = useState<FooterConfig | null>(null);
|
const [config, setConfig] = useState<FooterConfig | null>(null);
|
||||||
const [newLabel, setNewLabel] = useState('');
|
const [newLabel, setNewLabel] = useState('');
|
||||||
@@ -154,6 +158,32 @@ export function PhotoSettingsScreen() {
|
|||||||
Recorte cuadrado. Se recomienda logo con fondo blanco.
|
Recorte cuadrado. Se recomienda logo con fondo blanco.
|
||||||
</Text>
|
</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 ── */}
|
{/* ── Campos ── */}
|
||||||
<SectionTitle>Campos</SectionTitle>
|
<SectionTitle>Campos</SectionTitle>
|
||||||
|
|
||||||
@@ -277,6 +307,13 @@ const styles = StyleSheet.create({
|
|||||||
logoActions: { gap: 8 },
|
logoActions: { gap: 8 },
|
||||||
hint: { fontSize: 11, color: COLORS.muted, marginBottom: 8 },
|
hint: { fontSize: 11, color: COLORS.muted, marginBottom: 8 },
|
||||||
|
|
||||||
|
card: {
|
||||||
|
backgroundColor: COLORS.bg,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
|
||||||
/* Campos */
|
/* Campos */
|
||||||
fieldRow: {
|
fieldRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
+109
-74
@@ -1,32 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Mapa de features. Dibuja la geometría GeoJSON del proyecto (puntos, líneas,
|
* Mapa de features usando OpenStreetMap via Leaflet y WebView.
|
||||||
* polígonos) sobre Google Maps y permite seleccionar una feature tocándola.
|
* Elimina la dependencia de la API Key de Google Maps.
|
||||||
*
|
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
import * as Location from 'expo-location';
|
|
||||||
import React, { useMemo, useRef } from 'react';
|
import React, { useMemo, useRef } from 'react';
|
||||||
import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { StyleSheet, View } from 'react-native';
|
||||||
import MapView, { Marker, Polygon, Polyline, PROVIDER_DEFAULT, PROVIDER_GOOGLE } from 'react-native-maps';
|
import { WebView } from 'react-native-webview';
|
||||||
|
|
||||||
// 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 { Feature } from '../api/types';
|
import { Feature } from '../api/types';
|
||||||
import { COLORS } from './components';
|
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({
|
export function FeatureMap({
|
||||||
features,
|
features,
|
||||||
@@ -37,66 +17,121 @@ export function FeatureMap({
|
|||||||
selectedId: number | null;
|
selectedId: number | null;
|
||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const mapRef = useRef<MapView>(null);
|
const webViewRef = useRef<WebView>(null);
|
||||||
|
|
||||||
const { shaped, region } = useMemo(() => {
|
// Convertimos las features a un objeto GeoJSON simple para Leaflet
|
||||||
const all: LatLng[] = [];
|
const geoData = useMemo(() => {
|
||||||
const shaped = features.map((f) => {
|
return {
|
||||||
const shapes = geometryToShapes(f.geometry);
|
type: 'FeatureCollection',
|
||||||
all.push(...shapes.points, ...shapes.lines.flat(), ...shapes.polygons.flat());
|
features: features.map((f) => ({
|
||||||
return { feature: f, shapes };
|
type: 'Feature',
|
||||||
});
|
id: f.id,
|
||||||
return { shaped, region: regionFor(all) };
|
geometry: typeof f.geometry === 'string' ? JSON.parse(f.geometry) : f.geometry,
|
||||||
}, [features]);
|
properties: {
|
||||||
|
name: f.name,
|
||||||
|
status: f.status,
|
||||||
|
selected: f.id === selectedId,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [features, selectedId]);
|
||||||
|
|
||||||
const recenter = async () => {
|
const htmlContent = useMemo(() => `
|
||||||
const perm = await Location.requestForegroundPermissionsAsync();
|
<!DOCTYPE html>
|
||||||
if (!perm.granted) return;
|
<html>
|
||||||
const pos = await Location.getCurrentPositionAsync({});
|
<head>
|
||||||
mapRef.current?.animateToRegion({
|
<meta charset="utf-8">
|
||||||
latitude: pos.coords.latitude,
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
longitude: pos.coords.longitude,
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
latitudeDelta: 0.01,
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
longitudeDelta: 0.01,
|
<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: '© OpenStreetMap'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const geoData = ${JSON.stringify(geoData)};
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
pending: '#9aa0a6',
|
||||||
|
in_progress: '#1f6f43',
|
||||||
|
completed: '#2e7d32',
|
||||||
|
blocked: '#b00020'
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!region) {
|
const geoLayer = L.geoJSON(geoData, {
|
||||||
return (
|
style: (feature) => ({
|
||||||
<View style={styles.empty}>
|
color: feature.properties.selected ? '#000' : (STATUS_COLORS[feature.properties.status] || '#1f6f43'),
|
||||||
<Text style={{ color: COLORS.muted }}>Las features no tienen geometría.</Text>
|
weight: feature.properties.selected ? 4 : 2,
|
||||||
</View>
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: '#f0f0f0', justifyContent: 'center', alignItems: 'center' }]}>
|
<View style={styles.container}>
|
||||||
<Text style={{ color: COLORS.muted, textAlign: 'center' }}>
|
<WebView
|
||||||
Mapa desactivado temporalmente para pruebas.{"\n"}
|
ref={webViewRef}
|
||||||
Requiere API Key de Google Maps válida.
|
originWhitelist={['*']}
|
||||||
</Text>
|
source={{ html: htmlContent }}
|
||||||
|
onMessage={onMessage}
|
||||||
|
style={styles.map}
|
||||||
|
javaScriptEnabled={true}
|
||||||
|
domStorageEnabled={true}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1 },
|
container: { flex: 1, backgroundColor: '#f0f0f0' },
|
||||||
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
map: { flex: 1 },
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
+28
-34
@@ -29,6 +29,7 @@ import { RootStackParamList } from '../navigation/types';
|
|||||||
import {
|
import {
|
||||||
FooterConfig,
|
FooterConfig,
|
||||||
loadFooterConfig,
|
loadFooterConfig,
|
||||||
|
RESOLUTION_WIDTHS,
|
||||||
resolveFieldValue,
|
resolveFieldValue,
|
||||||
StampMeta,
|
StampMeta,
|
||||||
} from '../photo/footerConfig';
|
} from '../photo/footerConfig';
|
||||||
@@ -47,8 +48,6 @@ interface ComposingTask {
|
|||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
const MAX_STAMP_W = 2048;
|
|
||||||
|
|
||||||
// ─── helpers ───────────────────────────────────────────────────────────────
|
// ─── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function getCoords(): Promise<{ latitude: number; longitude: number } | null> {
|
async function getCoords(): Promise<{ latitude: number; longitude: number } | null> {
|
||||||
@@ -157,7 +156,8 @@ export function MediaStrip({
|
|||||||
coordinates: coordsRaw ? formatCoords(coordsRaw.latitude, coordsRaw.longitude) : null,
|
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 renderW = Math.round(origW * scale);
|
||||||
const renderH = Math.round(origH * scale);
|
const renderH = Math.round(origH * scale);
|
||||||
|
|
||||||
@@ -170,54 +170,48 @@ export function MediaStrip({
|
|||||||
|
|
||||||
// ── captura de foto ───────────────────────────────────────────────────────
|
// ── captura de foto ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const addPhoto = useCallback(
|
const handleCapture = async (uri: string, width: number, height: number) => {
|
||||||
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 result =
|
|
||||||
source === 'camera'
|
|
||||||
? await ImagePicker.launchCameraAsync({ quality: 0.85 })
|
|
||||||
: await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' });
|
|
||||||
if (result.canceled || !result.assets?.length) return;
|
|
||||||
|
|
||||||
const asset = result.assets[0];
|
|
||||||
|
|
||||||
let finalUri: string;
|
let finalUri: string;
|
||||||
try {
|
try {
|
||||||
finalUri = await stamp(asset.uri, asset.width ?? 1920, asset.height ?? 1080);
|
finalUri = await stamp(uri, width, height);
|
||||||
} catch {
|
} catch {
|
||||||
// Si el stamping falla, usar la foto original.
|
finalUri = uri;
|
||||||
finalUri = asset.uri;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await enqueueMedia({
|
await enqueueMedia({
|
||||||
parentEntity,
|
parentEntity,
|
||||||
parentId,
|
parentId,
|
||||||
localUri: finalUri,
|
localUri: finalUri,
|
||||||
fileName: asset.fileName ?? undefined,
|
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],
|
const addFromLibrary = async () => {
|
||||||
);
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!perm.granted) {
|
||||||
|
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({ quality: 0.85, mediaTypes: 'images' });
|
||||||
|
if (result.canceled || !result.assets?.length) return;
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
await handleCapture(asset.uri, asset.width ?? 1920, asset.height ?? 1080);
|
||||||
|
};
|
||||||
|
|
||||||
const onAdd = useCallback(() => {
|
const onAdd = useCallback(() => {
|
||||||
Alert.alert('Añadir foto', undefined, [
|
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' },
|
{ text: 'Cancelar', style: 'cancel' },
|
||||||
]);
|
]);
|
||||||
}, [addPhoto]);
|
}, [navigation, handleCapture, addFromLibrary]);
|
||||||
|
|
||||||
const onDeletePending = useCallback(
|
const onDeletePending = useCallback(
|
||||||
async (m: MediaOutboxRow) => {
|
async (m: MediaOutboxRow) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user