Skip to content

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.


Se añaden las dependencias de Capacitor para las plataformas nativas y los plugins de geolocalización:

Terminal window
npm install @capacitor/android
npm install @capacitor/ios
npm install @capacitor/geolocation

Se 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.


Para obtener la geolocalización del dispositivo, se instala el plugin correspondiente y se sincroniza:

Terminal window
npm install @capacitor/geolocation
npx cap sync

El 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.

src/app/core/services/geolocation.service.ts
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),
);
});
}
}

Para utilizar la cámara del dispositivo, se instalan las dependencias necesarias:

Terminal window
npm install @ionic/pwa-elements
npm install @capacitor/camera
npx cap sync

El 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:

src/main.ts
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:

src/app/core/services/camera.service.ts
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 ?? '';
}
}

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:

src/app/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:

src/app/features/home/home.page.ts
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 {}
src/app/features/home/home.page.html
<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>

Se define la interfaz TypeScript que representa una incidencia:

src/app/core/models/incidence.model.ts
export interface Incidence {
id: string;
photoUri: string;
latitude: number;
longitude: number;
timestamp: number;
}

Se crea un servicio que gestiona las operaciones CRUD de incidencias utilizando localStorage para persistencia local:

src/app/core/services/incidence.service.ts
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));
}
}

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.

src/app/features/new-incidence/new-incidence.page.ts
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;
}
}
}
src/app/features/new-incidence/new-incidence.page.html
<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>

Se implementa la página que muestra un listado de todas las incidencias reportadas. Si no hay incidencias guardadas, se muestran datos de prueba.

src/app/features/incidence-list/incidence-list.page.ts
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();
}
}
src/app/features/incidence-list/incidence-list.page.html
<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>

  1. Instalar dependencias

    Terminal window
    npm install
  2. Ejecutar en el navegador

    Terminal window
    ionic serve
  3. Esto abrirá la aplicación en el navegador por defecto en http://localhost:8100.


Pantalla de inicio con descripción y botones Página de nueva incidencia con captura de foto Listado de incidencias con thumbnails