Actividad 3 - Uso de funciones nativas en aplicaciones Ionic
Esta actividad consiste en crear una herramienta para que los usuarios informen de incidencias (desperfectos, problemas de mantenimiento, etc.) capturando evidencia visual y geográfica. Se utilizan plugins nativos de Capacitor para acceder a la cámara y a la geolocalización del dispositivo, y se almacenan los datos de forma persistente con localStorage.
1. Preparación del entorno
Section titled “1. Preparación del entorno”Se añaden las dependencias de Capacitor para las plataformas nativas y los plugins de geolocalización:
npm install @capacitor/androidnpm install @capacitor/iosnpm install @capacitor/geolocationSe actualiza la configuración de Capacitor en capacitor.config.ts con el appId y appName del proyecto, y se excluyen los directorios android/ e ios/ del control de versiones.
2. Geolocalización del dispositivo
Section titled “2. Geolocalización del dispositivo”Para obtener la geolocalización del dispositivo, se instala el plugin correspondiente y se sincroniza:
npm install @capacitor/geolocationnpx cap syncEl objetivo de npx cap sync es sincronizar los archivos de origen del proyecto con los archivos nativos de la plataforma objetivo (como iOS y Android). Esto puede incluir la configuración de permisos necesarios en Info.plist para iOS o AndroidManifest.xml para Android.
En un primer paso, se implementa la geolocalización directamente en la página principal, mostrando latitud, longitud y precisión en una tarjeta ion-card.
import { Injectable } from '@angular/core';import { Geolocation } from '@capacitor/geolocation';import { Capacitor } from '@capacitor/core';
@Injectable({ providedIn: 'root',})export class GeolocationService { async getCurrentPosition(): Promise<{ latitude: number; longitude: number }> { if (Capacitor.isNativePlatform()) { const position = await Geolocation.getCurrentPosition(); return { latitude: position.coords.latitude, longitude: position.coords.longitude, }; }
return new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition( (position) => resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude, }), (error) => reject(error), ); }); }}3. Cámara del dispositivo
Section titled “3. Cámara del dispositivo”Para utilizar la cámara del dispositivo, se instalan las dependencias necesarias:
npm install @ionic/pwa-elementsnpm install @capacitor/cameranpx cap syncEl plugin Camera de Capacitor, para poder ejecutarlo en una página web, requiere la instalación de PWA Elements. Además, se modifica src/main.ts para inicializar los custom elements:
import { bootstrapApplication } from '@angular/platform-browser';import { RouteReuseStrategy, provideRouter, withPreloading, PreloadAllModules,} from '@angular/router';import { IonicRouteStrategy, provideIonicAngular,} from '@ionic/angular/standalone';
import { routes } from './app/app.routes';import { AppComponent } from './app/app.component';
import { defineCustomElements } from '@ionic/pwa-elements/loader';
defineCustomElements(window);
bootstrapApplication(AppComponent, { providers: [ { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, provideIonicAngular(), provideRouter(routes, withPreloading(PreloadAllModules)), ],});El servicio de cámara envuelve la API de Capacitor Camera:
import { Injectable } from '@angular/core';import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
@Injectable({ providedIn: 'root',})export class CameraService { async takePicture(): Promise<string> { const image = await Camera.getPhoto({ quality: 90, allowEditing: false, resultType: CameraResultType.Uri, source: CameraSource.Camera, });
return image.webPath ?? ''; }}4. Refactorización de la estructura
Section titled “4. Refactorización de la estructura”Una vez implementadas las funcionalidades de geolocalización y cámara en la página principal, se refactoriza la aplicación separando la lógica en tres páginas independientes y eliminando la cámara y geolocalización del home para mayor claridad.
Se crean las rutas para las nuevas páginas en app.routes.ts:
import { Routes } from '@angular/router';
export const routes: Routes = [ { path: 'home', loadComponent: () => import('./features/home/home.page').then((m) => m.HomePage), }, { path: 'new-incidence', loadComponent: () => import('./features/new-incidence/new-incidence.page').then((m) => m.NewIncidencePage), }, { path: 'incidence-list', loadComponent: () => import('./features/incidence-list/incidence-list.page').then((m) => m.IncidenceListPage), }, { path: '', redirectTo: 'home', pathMatch: 'full', },];La página de inicio queda simplificada con un texto descriptivo y dos botones:
import { Component } from '@angular/core';import { RouterLink } from '@angular/router';import { IonHeader, IonToolbar, IonTitle, IonContent, IonButton, IonCard, IonCardHeader, IonCardTitle, IonCardContent,} from '@ionic/angular/standalone';
@Component({ selector: 'app-home', templateUrl: 'home.page.html', styleUrls: ['home.page.scss'], imports: [ RouterLink, IonHeader, IonToolbar, IonTitle, IonContent, IonButton, IonCard, IonCardHeader, IonCardTitle, IonCardContent, ],})export class HomePage {}<ion-header [translucent]="true"> <ion-toolbar> <ion-title>Reporta Incidencias</ion-title> </ion-toolbar></ion-header>
<ion-content [fullscreen]="true" class="ion-padding"> <ion-card> <ion-card-header> <ion-card-title>Bienvenido</ion-card-title> </ion-card-header> <ion-card-content> <p> Esta aplicación te permite informar de desperfectos y problemas de mantenimiento capturando evidencia visual y geográfica. </p> <ul> <li> <strong>Nueva Incidencia</strong>: Captura una foto, obtén tu ubicación automáticamente y describe el problema. </li> <li> <strong>Ver Incidencias</strong>: Consulta el historial de todas las incidencias que has reportado. </li> </ul> </ion-card-content> </ion-card>
<div class="ion-text-center ion-padding"> <ion-button expand="block" routerLink="/new-incidence" class="ion-margin-bottom"> Nueva Incidencia </ion-button> <ion-button expand="block" routerLink="/incidence-list" color="secondary"> Ver Incidencias </ion-button> </div></ion-content>5. Modelo de datos
Section titled “5. Modelo de datos”Se define la interfaz TypeScript que representa una incidencia:
export interface Incidence { id: string; photoUri: string; latitude: number; longitude: number; timestamp: number;}6. Servicio IncidenceService
Section titled “6. Servicio IncidenceService”Se crea un servicio que gestiona las operaciones CRUD de incidencias utilizando localStorage para persistencia local:
import { Injectable } from '@angular/core';import { Incidence } from '../models/incidence.model';
const STORAGE_KEY = 'incidences';
@Injectable({ providedIn: 'root',})export class IncidenceService { getAll(): Incidence[] { const data = localStorage.getItem(STORAGE_KEY); return data ? JSON.parse(data) : []; }
create(incidence: Incidence): void { const incidences = this.getAll(); incidences.push(incidence); localStorage.setItem(STORAGE_KEY, JSON.stringify(incidences)); }
delete(id: string): void { const incidences = this.getAll().filter((i) => i.id !== id); localStorage.setItem(STORAGE_KEY, JSON.stringify(incidences)); }}7. Nueva Incidencia
Section titled “7. Nueva Incidencia”Se implementa la página para dar de alta una nueva incidencia. Permite capturar una foto desde la cámara del dispositivo, obtener automáticamente la geolocalización y guardar la incidencia de forma persistente.
import { Component, inject } from '@angular/core';import { IonContent, IonHeader, IonTitle, IonToolbar, IonButtons, IonBackButton, IonButton, IonIcon, IonImg, IonCard, IonCardContent, IonText, IonSpinner,} from '@ionic/angular/standalone';import { CameraService } from '../../core/services/camera.service';import { GeolocationService } from '../../core/services/geolocation.service';import { IncidenceService } from '../../core/services/incidence.service';import { Incidence } from '../../core/models/incidence.model';import { camera, cameraOutline } from 'ionicons/icons';import { addIcons } from 'ionicons';import { Router } from '@angular/router';import { ToastController } from '@ionic/angular/standalone';
@Component({ selector: 'app-new-incidence', templateUrl: './new-incidence.page.html', styleUrls: ['./new-incidence.page.scss'], standalone: true, imports: [ IonContent, IonHeader, IonTitle, IonToolbar, IonButtons, IonBackButton, IonButton, IonIcon, IonImg, IonCard, IonCardContent, IonText, IonSpinner, ],})export class NewIncidencePage { public photoUri: string | null = null; public latitude: number | null = null; public longitude: number | null = null; public capturing = false; public saving = false;
private cameraService = inject(CameraService); private geolocationService = inject(GeolocationService); private incidenceService = inject(IncidenceService); private router = inject(Router); private toastController = inject(ToastController);
constructor() { addIcons({ camera, cameraOutline }); }
async capturePhoto(): Promise<void> { this.capturing = true; try { const [photoUri, position] = await Promise.all([ this.cameraService.takePicture(), this.geolocationService.getCurrentPosition(), ]);
this.photoUri = photoUri; this.latitude = position.latitude; this.longitude = position.longitude; } catch (error) { console.error('Error al capturar foto o geolocalización:', error); const toast = await this.toastController.create({ message: 'No se pudo capturar la foto o la ubicación.', duration: 3000, color: 'danger', }); await toast.present(); } finally { this.capturing = false; } }
async saveIncidence(): Promise<void> { if (!this.photoUri || this.latitude === null || this.longitude === null) { return; }
this.saving = true; try { const incidence: Incidence = { id: Date.now().toString(), photoUri: this.photoUri, latitude: this.latitude, longitude: this.longitude, timestamp: Date.now(), };
this.incidenceService.create(incidence);
const toast = await this.toastController.create({ message: 'Incidencia guardada correctamente.', duration: 2000, color: 'success', });
await toast.present();
toast.onDidDismiss().then(() => { this.router.navigate(['/incidence-list']); }); } catch (error) { console.error('Error al guardar la incidencia:', error);
const toast = await this.toastController.create({ message: 'No se pudo guardar la incidencia.', duration: 3000, color: 'danger', });
await toast.present(); } finally { this.saving = false; } }}<ion-header [translucent]="true"> <ion-toolbar> <ion-buttons slot="start"> <ion-back-button defaultHref="/home"></ion-back-button> </ion-buttons> <ion-title>Nueva Incidencia</ion-title> </ion-toolbar></ion-header>
<ion-content [fullscreen]="true" class="ion-padding"> <ion-card> <ion-card-content class="ion-text-center"> @if (!photoUri) { <div class="placeholder-container"> <ion-icon name="camera-outline" class="placeholder-icon"></ion-icon> <ion-text color="medium"> <p>No se ha capturado ninguna foto todavía.</p> </ion-text> </div> } @else { <ion-img [src]="photoUri" class="captured-photo"></ion-img> <ion-text color="dark"> <p class="geo-info"> <span>Latitud: {{ latitude }}</span><br /> <span>Longitud: {{ longitude }}</span> </p> </ion-text> } </ion-card-content> </ion-card>
<div class="ion-padding"> <ion-button expand="block" (click)="capturePhoto()" [disabled]="capturing" class="ion-margin-bottom" > <ion-icon name="camera" slot="start"></ion-icon> @if (!capturing) { Capturar Foto } @else { <ion-spinner name="crescent"></ion-spinner> } </ion-button>
<ion-button expand="block" color="success" (click)="saveIncidence()" [disabled]="!photoUri || capturing || saving" > @if (!saving) { Guardar Incidencia } @else { <ion-spinner name="crescent"></ion-spinner> } </ion-button> </div></ion-content>8. Listado de Incidencias
Section titled “8. Listado de Incidencias”Se implementa la página que muestra un listado de todas las incidencias reportadas. Si no hay incidencias guardadas, se muestran datos de prueba.
import { Component, inject, OnInit } from '@angular/core';import { DatePipe } from '@angular/common';import { IonContent, IonHeader, IonTitle, IonToolbar, IonButtons, IonBackButton, IonList, IonItem, IonThumbnail, IonImg, IonLabel, IonText, ToastController,} from '@ionic/angular/standalone';import { IncidenceService } from '../../core/services/incidence.service';import { Incidence } from '../../core/models/incidence.model';
const SAMPLE_INCIDENCES: Incidence[] = [ { id: '1', photoUri: 'https://i.imgur.com/kzuaLqT.jpeg', latitude: 37.3891, longitude: -5.9845, timestamp: Date.now() - 86400000, }, { id: '2', photoUri: 'https://i.imgur.com/cMeVPWO.jpeg', latitude: 40.4168, longitude: -3.7038, timestamp: Date.now() - 172800000, }, { id: '3', photoUri: 'https://i.imgur.com/KTTp2Km.jpeg', latitude: 41.3874, longitude: 2.1686, timestamp: Date.now() - 259200000, },];
@Component({ selector: 'app-incidence-list', templateUrl: './incidence-list.page.html', styleUrls: ['./incidence-list.page.scss'], standalone: true, imports: [ DatePipe, IonContent, IonHeader, IonTitle, IonToolbar, IonButtons, IonBackButton, IonList, IonItem, IonThumbnail, IonImg, IonLabel, IonText, ],})export class IncidenceListPage implements OnInit { public incidences: Incidence[] = [];
private incidenceService = inject(IncidenceService); private toastController = inject(ToastController);
ngOnInit() { const saved = this.incidenceService.getAll(); this.incidences = saved.length > 0 ? saved : SAMPLE_INCIDENCES; }
async showUnderConstruction(): Promise<void> { const toast = await this.toastController.create({ message: 'En construcción', duration: 2000, color: 'warning', }); await toast.present(); }}<ion-header [translucent]="true"> <ion-toolbar> <ion-buttons slot="start"> <ion-back-button defaultHref="/home"></ion-back-button> </ion-buttons> <ion-title>Listado de Incidencias</ion-title> </ion-toolbar></ion-header>
<ion-content [fullscreen]="true"> <ion-list> @for (incidence of incidences; track incidence.id) { <ion-item (click)="showUnderConstruction()" button detail="false"> <ion-thumbnail slot="start"> <ion-img [src]="incidence.photoUri" alt="Foto de la incidencia" ></ion-img> </ion-thumbnail> <ion-label> <ion-text> <p> <span >Lat: {{ incidence.latitude }} | Lon: {{ incidence.longitude }}</span > </p> </ion-text> <ion-text color="medium"> <p> <span>{{ incidence.timestamp | date: 'dd/MM/yyyy HH:mm' }}</span> </p> </ion-text> </ion-label> </ion-item> } </ion-list></ion-content>9. Ejecución del proyecto
Section titled “9. Ejecución del proyecto”-
Instalar dependencias
Terminal window npm install -
Ejecutar en el navegador
Terminal window ionic serve -
Esto abrirá la aplicación en el navegador por defecto en
http://localhost:8100.
10. Capturas del diseño
Section titled “10. Capturas del diseño”Pantalla de Inicio
Section titled “Pantalla de Inicio”
Nueva Incidencia
Section titled “Nueva Incidencia”
Listado de Incidencias
Section titled “Listado de Incidencias”