Práctica: Desarrolla una App de Fotos con Ubicación

Video
30 min~11 min lectura
Objetivo de la lección

Este proyecto va más allá de un simple tutorial; es una simulación de un caso de uso real que enfrentarás como desarrollador profesional.

Puntos de control
  • Introducción: Construyendo una Aplicación de Fotos Contextual
  • Concepto Clave: La Integración de Sensores y Datos Persistentes
  • Cómo Funciona en la Práctica: Arquitectura del Proyecto
  • Código en Acción: El Servicio de Cámara y Ubicación

Reproductor de video

Introducción: Construyendo una Aplicación de Fotos Contextual

En esta lección práctica, consolidaremos conocimientos avanzados de React Native y Expo integrando múltiples APIs nativas para crear una aplicación funcional y lista para producción. Desarrollaremos una aplicación de fotos con ubicación que no solo captura imágenes, sino que también las enriquece con metadatos geográficos, las almacena localmente y permite visualizarlas en un mapa interactivo. Este proyecto va más allá de un simple tutorial; es una simulación de un caso de uso real que enfrentarás como desarrollador profesional.

La aplicación, que llamaremos GeoSnap, requerirá el manejo de permisos, la interacción con la cámara del dispositivo, el acceso a la ubicación en primer y segundo plano, el almacenamiento persistente de datos estructurados y la renderización de componentes complejos como mapas. Utilizaremos Expo por su capacidad para simplificar el acceso a APIs nativas con un API JavaScript unificada, pero siempre con la mentalidad de entender lo que sucede bajo el capó para preparar la app para un despliegue en las tiendas de aplicaciones.

El flujo de la aplicación será el siguiente: el usuario abre la app, concede los permisos necesarios. En la pantalla principal, una vista de cámara le permite tomar una foto. Al capturar la imagen, la aplicación obtiene automáticamente las coordenadas GPS actuales, guarda ambos datos (la imagen como archivo y la ubicación como metadato) en el almacenamiento local del dispositivo y luego navega a una galería donde las fotos se muestran en una lista y también como marcadores en un mapa. Este flujo integra varias tecnologías de manera cohesiva.

Concepto Clave: La Integración de Sensores y Datos Persistentes

El núcleo de GeoSnap reside en la integración de dos flujos de datos independientes pero concurrentes: los datos de sensor en tiempo real (cámara y GPS) y el almacenamiento persistente estructurado. Piensa en esto como un periodista moderno que cubre una noticia. El periodista (nuestra app) utiliza herramientas (sensores) para capturar información: una cámara para la foto y un GPS para la ubicación exacta. Inmediatamente, esa información cruda debe ser anotada, catalogada y archivada en un sistema de archivos (almacenamiento local) para su posterior recuperación y análisis. La magia no está en cada herramienta individual, sino en el proceso automatizado que las vincula.

En el contexto técnico, las APIs nativas de Cámara y Ubicación son asíncronas y basadas en permisos. No podemos asumir que el dato estará disponible al instante; debemos solicitarlo y manejar los estados de carga, éxito y error. Una vez obtenidos, estos datos transitorios (la URI de la imagen y el objeto de coordenadas) deben transformarse y guardarse en un estado global y en una base de datos local como SQLite o AsyncStorage para estructuras más complejas. Esta persistencia es lo que permite cerrar la aplicación y volver a encontrar nuestras fotos geolocalizadas intactas.

Tip Profesional: Nunca confíes únicamente en el estado de React (useState, useReducer) para datos que deben sobrevivir a un reinicio de la aplicación. El estado es volátil y está destinado a la UI. Para la persistencia de datos de la aplicación, siempre utiliza una capa de almacenamiento dedicada (SQLite, MMKV, AsyncStorage para datos simples o un contexto persistido con bibliotecas como @react-native-async-storage/async-storage).

Cómo Funciona en la Práctica: Arquitectura del Proyecto

Vamos a estructurar nuestro proyecto de Expo (Managed Workflow) con una claridad que facilite el mantenimiento y la escalabilidad. Crearemos los siguientes directorios y archivos clave: components/ para componentes reutilizables (como un botón de cámara personalizado), screens/ para nuestras pantallas principales (CameraScreen, GalleryScreen, MapScreen), utils/ para lógica de negocio y manejo de permisos, y services/ para la interacción con APIs nativas y la base de datos. Utilizaremos React Navigation para la navegación entre pantallas y Zustand o React Context para la gestión del estado global de las fotos.

El primer paso práctico es la configuración de los permisos. En app.json, debemos declarar las características que nuestra aplicación utilizará. Esto es crucial para iOS y para la construcción de APKs/AABs en Android. Luego, en el código, construiremos un módulo de servicio que centralice la solicitud y verificación de permisos para la cámara y la ubicación. Este servicio será invocado antes de que cualquier pantalla que dependa de estas funcionalidades intente utilizarlas, mostrando alertas amigables al usuario si los permisos son denegados.

El flujo de datos se inicia en la CameraScreen. Al presionar el botón de captura, se ejecuta una función que: 1) Toma la foto usando expo-camera y recibe una URI temporal. 2) Simultáneamente (o inmediatamente después), solicita la ubicación actual de alta precisión usando expo-location. 3) Con ambos datos en mano, genera un objeto con un ID único, la URI de la imagen, las coordenadas, una marca de tiempo y quizás una nota del usuario. 4) Guarda este objeto en la base de datos SQLite y actualiza el estado global de la aplicación. 5) Finalmente, navega a la GalleryScreen, que al montarse consulta la base de datos y muestra los datos.

Código en Acción: El Servicio de Cámara y Ubicación

A continuación, implementaremos el corazón de la funcionalidad: el módulo que orquesta la captura de la foto y su geolocalización. Este código debe ser robusto y manejar errores elegante.

// services/PhotoService.js
import * as Camera from 'expo-camera';
import * as Location from 'expo-location';
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
import { db } from './DatabaseService'; // Servicio hipotético para SQLite

class PhotoService {
  static async requestPermissions() {
    const cameraPermission = await Camera.requestCameraPermissionsAsync();
    const locationPermission = await Location.requestForegroundPermissionsAsync();

    return {
      cameraGranted: cameraPermission.status === 'granted',
      locationGranted: locationPermission.status === 'granted',
    };
  }

  static async captureAndGeotag(cameraRef) {
    if (!cameraRef.current) {
      throw new Error('Cámara no disponible');
    }

    // 1. Capturar la foto
    const photo = await cameraRef.current.takePictureAsync({
      quality: 0.7,
      exif: true, // Incluir metadatos EXIF
    });

    // 2. Obtener la ubicación actual
    let location;
    try {
      location = await Location.getCurrentPositionAsync({
        accuracy: Location.Accuracy.High,
        timeout: 5000, // 5 segundos de timeout
      });
    } catch (locationError) {
      console.warn('No se pudo obtener la ubicación:', locationError);
      // Podemos decidir guardar la foto sin ubicación o lanzar un error.
      location = { coords: { latitude: null, longitude: null } };
    }

    // 3. (Opcional) Redimensionar la imagen para ahorrar espacio
    const manipulatedImage = await manipulateAsync(
      photo.uri,
      [{ resize: { width: 1080 } }], // Redimensionar a ancho 1080px manteniendo aspecto
      { compress: 0.8, format: SaveFormat.JPEG }
    );

    // 4. Crear el objeto de foto geolocalizada
    const geotaggedPhoto = {
      id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
      uri: manipulatedImage.uri,
      originalUri: photo.uri,
      latitude: location.coords.latitude,
      longitude: location.coords.longitude,
      timestamp: new Date().toISOString(),
      address: null, // Podríamos geocodificar esto después
    };

    // 5. Guardar en la base de datos
    await db.savePhoto(geotaggedPhoto);

    return geotaggedPhoto;
  }
}

export default PhotoService;

Código en Acción: Componente de la Galería y Mapa Integrado

Ahora, veamos cómo mostrar los datos. Crearemos una pantalla que use un TabNavigator para alternar entre una vista de lista y una vista de mapa.

// screens/GalleryScreen.js
import React, { useState, useEffect } from 'react';
import { View, FlatList, Image, Text, StyleSheet, TouchableOpacity } from 'react-native';
import MapView, { Marker } from 'react-native-maps';
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
import { db } from '../services/DatabaseService';

const Tab = createMaterialTopTabNavigator();

function ListView({ photos }) {
  const renderItem = ({ item }) => (
    
      
      
        
          {new Date(item.timestamp).toLocaleDateString()}
        
        
          Lat: {item.latitude?.toFixed(4)}, Lng: {item.longitude?.toFixed(4)}
        
      
    
  );

  return (
     item.id}
      contentContainerStyle={styles.listContainer}
    />
  );
}

function MapViewComponent({ photos }) {
  const [region, setRegion] = useState({
    latitude: 40.4168,
    longitude: -3.7038,
    latitudeDelta: 10,
    longitudeDelta: 10,
  });

  // Si hay fotos, centrar el mapa en la primera
  useEffect(() => {
    if (photos.length > 0 && photos[0].latitude) {
      setRegion({
        latitude: photos[0].latitude,
        longitude: photos[0].longitude,
        latitudeDelta: 0.1,
        longitudeDelta: 0.1,
      });
    }
  }, [photos]);

  return (
    
      {photos
        .filter((photo) => photo.latitude && photo.longitude)
        .map((photo) => (
          
            
          
        ))}
    
  );
}

export default function GalleryScreen() {
  const [photos, setPhotos] = useState([]);

  useEffect(() => {
    loadPhotos();
  }, []);

  const loadPhotos = async () => {
    const savedPhotos = await db.getPhotos();
    setPhotos(savedPhotos);
  };

  return (
    
      
        {() => }
      
      
        {() => }
      
    
  );
}

const styles = StyleSheet.create({
  listContainer: { padding: 10 },
  listItem: {
    flexDirection: 'row',
    backgroundColor: 'white',
    marginBottom: 10,
    borderRadius: 8,
    overflow: 'hidden',
    elevation: 2,
  },
  thumbnail: { width: 80, height: 80 },
  info: { padding: 10, justifyContent: 'center' },
  date: { fontWeight: 'bold' },
  coords: { color: 'gray', fontSize: 12 },
  map: { flex: 1 },
  markerImage: { width: 40, height: 40, borderRadius: 20 },
});

Código en Acción: Configuración de la Base de Datos SQLite

Para persistencia robusta, usaremos SQLite. Aquí hay un ejemplo del servicio de base de datos.

// services/DatabaseService.js
import * as SQLite from 'expo-sqlite';

const db = SQLite.openDatabase('geosnap.db');

export const initDatabase = () => {
  return new Promise((resolve, reject) => {
    db.transaction((tx) => {
      tx.executeSql(
        `CREATE TABLE IF NOT EXISTS photos (
          id TEXT PRIMARY KEY NOT NULL,
          uri TEXT NOT NULL,
          originalUri TEXT,
          latitude REAL,
          longitude REAL,
          timestamp TEXT NOT NULL,
          address TEXT
        );`,
        [],
        () => {
          console.log('Tabla "photos" inicializada/verificada.');
          resolve();
        },
        (_, error) => {
          console.error('Error al crear la tabla:', error);
          reject(error);
        }
      );
    });
  });
};

export const savePhoto = (photo) => {
  return new Promise((resolve, reject) => {
    db.transaction((tx) => {
      tx.executeSql(
        `INSERT INTO photos (id, uri, originalUri, latitude, longitude, timestamp, address)
         VALUES (?, ?, ?, ?, ?, ?, ?);`,
        [
          photo.id,
          photo.uri,
          photo.originalUri,
          photo.latitude,
          photo.longitude,
          photo.timestamp,
          photo.address,
        ],
        (_, result) => resolve(result),
        (_, error) => reject(error)
      );
    });
  });
};

export const getPhotos = () => {
  return new Promise((resolve, reject) => {
    db.transaction((tx) => {
      tx.executeSql(
        'SELECT * FROM photos ORDER BY timestamp DESC;',
        [],
        (_, { rows }) => resolve(rows._array),
        (_, error) => reject(error)
      );
    });
  });
};

// Exportar un objeto con los métodos para mayor claridad
export const db = {
  initDatabase,
  savePhoto,
  getPhotos,
};

Errores Comunes y Cómo Evitarlos

1. No Manejar Estados de Permisos Denegados: Asumir que los permisos siempre serán concedidos es un error crítico. Si el usuario deniega el permiso de ubicación y tu código intenta llamar a `getCurrentPositionAsync`, la aplicación puede fallar. Solución: Siempre verifica el estado del permiso con `getPermissionsAsync` antes de usar la API y guía al usuario a la configuración de la app si es necesario, usando `Linking.openSettings()`.

2. Bloquear el Hilo Principal con Operaciones Síncronas Pesadas: Procesar imágenes (redimensionar, comprimir) o realizar muchas operaciones de base de datos en el hilo principal puede causar "jank" (tirones) en la UI. Solución: Utiliza `expo-image-manipulator` de forma asíncrona como mostramos, y considera el uso de `InteractionManager.runAfterInteractions` para operaciones post-captura que no son críticas para la UI inmediata.

3. Almacenar URIs de Imágenes que Dejan de ser Válidas: En iOS, las URIs devueltas por `takePictureAsync` pueden ser temporales y apuntar a un directorio de caché que el sistema puede limpiar. Solución: Copia o mueve el archivo a un directorio persistente de tu aplicación usando `expo-file-system`. Por ejemplo, `FileSystem.copyAsync({ from: uriTemporal, to: uriPersistente })`. La URI en nuestro objeto de foto debe ser la persistente.

4. Sobrecalentar la Batería con Escuchas de Ubicación Continuas: Si en la pantalla del mapa añades un listener de ubicación con `watchPositionAsync` y no lo limpias adecuadamente al desmontar el componente, seguirá consumiendo recursos en segundo plano. Solución: Usa el patrón de efecto de limpieza de React: `const subscription = await Location.watchPositionAsync(...); return () => subscription.remove();` dentro de `useEffect`.

5. No Probar en Dispositivos Reales con Diferentes Condiciones: Probar solo en el simulador de iOS y el emulador de Android es insuficiente. El GPS del simulador puede dar ubicaciones perfectas, pero en un dispositivo real con poca cobertura, la precisión y el tiempo de respuesta varían. Solución: Prueba siempre en al menos un dispositivo físico de cada plataforma. Usa el modo "Location Debug" en el simulador para simular diferentes escenarios (sin cobertura, ciudad, etc.).

Checklist de Dominio

Antes de considerar esta lección completa, asegúrate de poder verificar cada uno de los siguientes puntos:

  • Puedo configurar correctamente los permisos en app.json y solicitar/manejar su estado en tiempo de ejecución usando las APIs de Expo.
  • He implementado una pantalla de cámara funcional que captura una foto y, en paralelo o secuencia, obtiene la ubicación GPS del dispositivo.
  • He creado un esquema de base de datos SQLite para almacenar de forma estructurada los metadatos de las fotos (URI, coordenadas, timestamp) y puedo realizar operaciones CRUD básicas.
  • Puedo mostrar una lista de fotos capturadas, incluyendo una miniatura y sus metadatos, usando un FlatList optimizado.
  • He integrado un componente MapView que muestra marcadores personalizados (con la miniatura de la foto) en las coordenadas guardadas.
  • Comprendo y he implementado el manejo de errores para los casos comunes: permisos denegados, GPS no disponible, errores de la cámara.
  • He probado el flujo completo en un dispositivo físico, verificando que las fotos y la ubicación se persisten tras cerrar y reabrir la aplicación.
  • Puedo explicar la diferencia entre almacenar datos en el estado de React, en AsyncStorage y en una base de datos SQLite, y cuándo es apropiado usar cada uno.
Falar no WhatsApp
De lección a portfolio

Convertí esta lección en una prueba técnica visible.

Una app pequeña publicada, con README y decisiones explicadas, funciona mejor que una lista de tecnologías sueltas.

Paso 1

Creá una demo mínima que use el concepto de la lección.

Paso 2

Escribí un README corto con objetivo, stack, decisión técnica y mejora futura.

Paso 3

Publicá la demo y enlazala desde tu perfil profesional.

Newsletter Cursalo

Recibí rutas y cursos nuevos

Sumate para recibir recursos orientados a empleo y portfolio.

  • Rutas de empleo
  • Cursos prácticos
  • Portfolio y entrevistas

Sin spam. También podés entrar con tu cuenta para guardar progreso. Iniciá sesión

Práctica: Desarrolla una App de Fotos con Ubica... | Cursalo