# Taller: Desarrollo con Angular e Ionic ## Capítulo 1: Introducción a Angular e Ionic ## 1. ¿Qué es Angular? Angular es un framework de desarrollo front-end mantenido por Google, diseñado para crear aplicaciones web de una sola página (SPA) y aplicaciones móviles. Algunas características clave: - **Basado en componentes**: Toda la interfaz se construye mediante componentes reutilizables - **TypeScript**: Utiliza TypeScript como lenguaje principal, añadiendo tipos estáticos a JavaScript - **Completo**: Ofrece soluciones integradas para enrutamiento, formularios, HTTP, animaciones, etc. - **Modular**: Su arquitectura permite dividir la aplicación en módulos funcionales - **Reactivo**: Facilita la programación reactiva mediante RxJS Angular utiliza un sistema de "detección de cambios" para mantener sincronizada la interfaz de usuario con el estado de la aplicación, lo que permite crear interfaces dinámicas y reactivas. ## 2. Angular CLI: La Herramienta de Línea de Comandos El Angular CLI (Command Line Interface) es una herramienta oficial que simplifica enormemente el desarrollo con Angular: ```bash # Instalación global npm install -g @angular/cli # Crear nuevo proyecto ng new mi-proyecto # Generar componentes, servicios, etc. ng generate component mi-componente ng generate service mi-servicio # Iniciar servidor de desarrollo ng serve # Construir para producción ng build --prod ``` Beneficios del CLI: - **Scaffolding**: Generación de código con estructura y configuración correctas - **Herramientas de desarrollo**: Servidor local con recarga en vivo (live reload) - **Optimización**: Empaquetado y minificación para producción - **Testing**: Configuración automática para pruebas unitarias e integración - **Actualización**: Facilita la actualización entre versiones de Angular ## 3. Angular vs Desarrollo Web Tradicional Angular se asemeja al desarrollo de páginas web normales, pero con una estructura y enfoque diferentes: | Desarrollo Web Tradicional | Desarrollo con Angular | |---------------------------|------------------------| | Páginas HTML separadas | Aplicación de una sola página (SPA) | | jQuery para manipular DOM | Vinculación de datos bidireccional | | JavaScript vanilla | TypeScript con tipos estáticos | | Recarga completa entre páginas | Navegación sin recarga (enrutamiento SPA) | | Mezcla de lógica y presentación | Separación modelo-vista-controlador | | Scripts y estilos globales | Encapsulación de componentes | A pesar de las diferencias, los conocimientos de HTML, CSS y JavaScript son totalmente aplicables en Angular, ya que seguimos trabajando con estos lenguajes fundamentales pero de manera estructurada. ## 4. Componentes Básicos de Angular #### Componentes El elemento fundamental de las aplicaciones Angular: ```typescript import { Component } from '@angular/core'; @Component({ selector: 'app-contador', template: `

Contador: {{ contador }}

`, styles: [` div { text-align: center; } button { margin: 0 5px; } `] }) export class ContadorComponent { contador = 0; incrementar() { this.contador++; } decrementar() { this.contador--; } } ``` #### Módulos Organizan la aplicación en bloques funcionales: ```typescript import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppComponent } from './app.component'; import { ContadorComponent } from './contador/contador.component'; @NgModule({ declarations: [ AppComponent, ContadorComponent ], imports: [ BrowserModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { } ``` #### Servicios Encapsulan la lógica de negocio y son inyectables en componentes: ```typescript import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class DatosService { private datos = ['Primer elemento', 'Segundo elemento']; getDatos() { return this.datos; } agregarDato(dato: string) { this.datos.push(dato); } } ``` #### Directivas Modifican el comportamiento del DOM: ```html
Este contenido es condicional

Texto con estilo dinámico

``` #### Pipes Transforman datos para su visualización: ```html

{{ fecha | date:'dd/MM/yyyy' }}

{{ precio | currency:'EUR' }}

{{ nombre | uppercase }}

{{ textoLargo | slice:0:50 }}...

``` ## 5. ¿Qué es Ionic? Ionic es un framework de desarrollo de aplicaciones móviles híbridas que se construye sobre Angular: - **Multiplataforma**: Una única base de código para iOS, Android y web - **Componentes nativos**: UI con apariencia y comportamiento nativo - **Capacitor/Cordova**: Acceso a APIs nativas del dispositivo - **Rendimiento optimizado**: Aplicaciones rápidas y responsivas ## 6. Componentes UI de Ionic Ionic ofrece una amplia biblioteca de componentes que siguen las directrices de diseño de iOS y Android: #### Navegación y Estructura ```html Inicio Buscar Perfil Menú Inicio Perfil Configuración ``` > **Nota:** Para ver ejemplos interactivos de estos componentes, visite la documentación oficial: > - [Ionic Tabs](https://ionicframework.com/docs/api/tabs) > - [Ionic Menu](https://ionicframework.com/docs/api/menu) #### Componentes Básicos ```html Botón Estándar Botón Outline Botón Block Subtítulo Título Principal Contenido detallado de la tarjeta que puede incluir texto, imágenes y otros elementos. Usuarios

Juan Pérez

Desarrollador

María García

Diseñadora

``` > **Nota:** Puede visualizar estos componentes en la documentación oficial: > - [Ionic Buttons](https://ionicframework.com/docs/api/button) > - [Ionic Cards](https://ionicframework.com/docs/api/card) > - [Ionic Lists](https://ionicframework.com/docs/api/list) #### Formularios y Entrada ```html Nombre Email Categoría Deportes Música Tecnología Notificaciones Acepto términos ``` > **Nota:** Consulte la documentación oficial para ver ejemplos interactivos: > - [Ionic Input](https://ionicframework.com/docs/api/input) > - [Ionic Select](https://ionicframework.com/docs/api/select) > - [Ionic Toggle](https://ionicframework.com/docs/api/toggle) > - [Ionic Checkbox](https://ionicframework.com/docs/api/checkbox) #### Feedback y Alertas ```typescript import { AlertController, ToastController, LoadingController } from '@ionic/angular'; constructor( private alertCtrl: AlertController, private toastCtrl: ToastController, private loadingCtrl: LoadingController ) {} async mostrarAlerta() { const alert = await this.alertCtrl.create({ header: 'Alerta', subHeader: 'Información importante', message: '¿Estás seguro de realizar esta acción?', buttons: ['Cancelar', 'Aceptar'] }); await alert.present(); } async mostrarToast() { const toast = await this.toastCtrl.create({ message: 'Operación completada con éxito', duration: 2000, position: 'bottom', color: 'success' }); toast.present(); } async mostrarCargando() { const loading = await this.loadingCtrl.create({ message: 'Cargando datos...', duration: 2000 }); await loading.present(); } ``` > **Nota:** Para más información sobre alertas y elementos interactivos, consulte: > - [Ionic Alert](https://ionicframework.com/docs/api/alert) > - [Ionic Toast](https://ionicframework.com/docs/api/toast) > - [Ionic Loading](https://ionicframework.com/docs/api/loading) ## 7. Capacitor: El Puente Nativo Capacitor es el framework que permite a Ionic acceder a las capacidades nativas del dispositivo: - Cámara y galería de fotos - Geolocalización - Almacenamiento persistente - Notificaciones push - Sensores del dispositivo - Archivos y sistema de archivos Ejemplo básico de uso de Capacitor: ```typescript import { Camera, CameraResultType } from '@capacitor/camera'; async function tomarFoto() { const imagen = await Camera.getPhoto({ quality: 90, allowEditing: true, resultType: CameraResultType.Uri }); // Usar la imagen (imagen.webPath) const imagenUrl = imagen.webPath; } ``` ## 8. Ciclo de Desarrollo con Ionic y Angular El proceso típico de desarrollo con Ionic y Angular incluye: 1. **Creación del proyecto**: ```bash # Crear un nuevo proyecto (por defecto usa Angular) ionic start mi-proyecto-ionic # Especificar el framework (angular, react, vue) ionic start mi-proyecto-ionic --type=angular ``` 2. **Desarrollo en navegador web**: ```bash # Iniciar servidor de desarrollo con recarga en vivo ionic serve ``` 3. **Pruebas en dispositivo real**: ```bash # Añadir plataforma Android ionic capacitor add android # Ejecutar en dispositivo con recarga en vivo ionic capacitor run android --livereload ``` 4. **Compilación para producción**: ```bash # Construir la aplicación optimizada ionic build --prod # Copiar los archivos a las plataformas nativas npx cap copy # Abrir el proyecto en Android Studio para ajustes finales npx cap open android ``` ## Resumen Angular e Ionic forman una poderosa combinación para desarrollar aplicaciones móviles multiplataforma: - Angular proporciona la estructura, organización y lógica - Ionic aporta componentes UI con aspecto nativo - Capacitor permite acceder a características nativas del dispositivo - El desarrollo es similar al web tradicional, pero más estructurado y optimizado En los siguientes capítulos, exploraremos más a fondo cada uno de estos conceptos y construiremos paso a paso una aplicación completa de reservas para un gimnasio, además de la creación básica de un proyecto vacío. ## Recursos y Documentación - [Documentación oficial de Angular](https://angular.io/docs) - [Documentación de Ionic Framework](https://ionicframework.com/docs) - [Capacitor Docs](https://capacitorjs.com/docs) - [Angular CLI](https://cli.angular.io/) - [TypeScript](https://www.typescriptlang.org/docs/) # Taller: Desarrollo con Angular e Ionic ## Capítulo 2: Componentes y Estructura de una Aplicación Angular/Ionic ## 1. Estructura de una Aplicación Angular/Ionic Al crear un nuevo proyecto de Ionic con Angular mediante el comando `ionic start`, se genera una estructura de carpetas organizada: ``` mi-app/ ├── src/ # Código fuente principal │ ├── app/ # Lógica y componentes de la aplicación │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ ├── app-routing.module.ts │ ├── assets/ # Recursos estáticos (imágenes, fuentes, etc.) │ ├── environments/ # Configuraciones por entorno (dev, prod) │ ├── theme/ # Variables globales de estilo (colores, etc.) │ ├── global.scss # Estilos globales │ ├── index.html # Archivo HTML raíz │ ├── main.ts # Punto de entrada de la aplicación ├── angular.json # Configuración de Angular ├── capacitor.config.ts # Configuración de Capacitor ├── package.json # Dependencias y scripts ├── tsconfig.json # Configuración de TypeScript ``` ### Archivos Clave: - **app.module.ts**: Módulo principal que configura la aplicación - **app-routing.module.ts**: Define las rutas de navegación - **app.component.ts**: Componente raíz que contiene toda la aplicación - **index.html**: Archivo HTML base donde se monta la aplicación - **main.ts**: Punto de entrada que arranca la aplicación Angular ## 2. Fundamentos de los Componentes Angular ### Anatomía de un Componente Cada componente Angular consta de: 1. **Clase TypeScript**: Contiene la lógica y datos 2. **Plantilla HTML**: Define la estructura visual 3. **Estilos CSS**: Define la apariencia (opcional) 4. **Metadatos**: Configuración mediante decorador @Component ```typescript import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-producto', // Cómo se referencia en HTML templateUrl: './producto.component.html', // Plantilla HTML styleUrls: ['./producto.component.scss'] // Estilos }) export class ProductoComponent implements OnInit { nombre: string = 'Smartphone'; precio: number = 599.99; disponible: boolean = true; constructor() { } ngOnInit() { // Inicialización del componente } aplicarDescuento(porcentaje: number): void { this.precio = this.precio * (1 - porcentaje/100); } } ``` ### Ciclo de Vida de los Componentes Los componentes tienen un ciclo de vida gestionado por Angular: 1. **ngOnChanges**: Cuando cambian las propiedades de entrada (@Input) 2. **ngOnInit**: Después de la primera inicialización 3. **ngAfterViewInit**: Cuando la vista se ha inicializado 4. **ngOnDestroy**: Justo antes de que Angular destruya el componente ```typescript import { Component, OnInit, OnDestroy } from '@angular/core'; import { Subscription } from 'rxjs'; import { DatosService } from './datos.service'; @Component({ selector: 'app-ejemplo', template: '
{{datos}}
' }) export class EjemploComponent implements OnInit, OnDestroy { datos: any[] = []; private suscripcion!: Subscription; constructor(private datosService: DatosService) { } ngOnInit() { // Perfecto para inicializar datos, suscripciones, etc. this.suscripcion = this.datosService.obtenerDatos() .subscribe(datos => { this.datos = datos; }); } ngOnDestroy() { // Limpieza antes de destruir el componente if (this.suscripcion) { this.suscripcion.unsubscribe(); } } } ``` ## 3. Vinculación de Datos (Data Binding) Angular ofrece cuatro formas de vinculación de datos: ### Interpolación {{ }} Muestra valores de propiedades en la plantilla: ```html

{{ titulo }}

Precio: {{ precio | currency }}

Estado: {{ disponible ? 'En stock' : 'Agotado' }}
``` ### Property Binding [ ] Vincula propiedades HTML con valores del componente: ```html
Contenido con clases dinámicas
``` ### Event Binding ( ) Responde a eventos del usuario: ```html
``` ### Two-Way Binding [( )] Combina property binding y event binding para actualizar datos en ambas direcciones: ```html ``` > Nota: Para usar ngModel, debes importar FormsModule en tu módulo Angular. ## 4. Directivas en Angular Las directivas son clases que extienden HTML con nueva funcionalidad. ### Directivas Estructurales Modifican el DOM añadiendo o quitando elementos: ```html
El producto está disponible
Panel de administrador
Panel de editor
Panel de usuario
``` ### Directivas de Atributo Modifican la apariencia o comportamiento de elementos existentes: ```html
Elemento con clases dinámicas
Texto con estilo dinámico
``` ## 5. Componentes UI de Ionic - Visión General Ionic proporciona una amplia gama de componentes UI pre-diseñados que siguen las guías de diseño de iOS y Android. A continuación, se muestra una visión general de las categorías principales: ### Categorías de Componentes 1. **Navegación** - `ion-header`, `ion-toolbar`, `ion-buttons` - `ion-tabs` - `ion-menu` - `ion-back-button` 2. **Contenido y Presentación** - `ion-card` y componentes relacionados - `ion-list`, `ion-item` - `ion-grid`, `ion-row`, `ion-col` - `ion-avatar`, `ion-thumbnail` 3. **Formularios e Inputs** - `ion-input`, `ion-textarea` - `ion-select`, `ion-radio`, `ion-checkbox` - `ion-toggle`, `ion-range` - `ion-datetime` 4. **Feedback al Usuario** - `ion-loading` - `ion-toast` - `ion-alert` - `ion-action-sheet` ### Ejemplo Básico de Uso ```html Mi Aplicación Bienvenido Contenido de ejemplo para mostrar un card básico. Elemento 1 Elemento 2 ``` ### Controladores de Componentes Para componentes interactivos como alertas, modales o toasts, se utilizan controladores: ```typescript import { Component } from '@angular/core'; import { AlertController } from '@ionic/angular'; @Component({ selector: 'app-ejemplo', templateUrl: './ejemplo.page.html', }) export class EjemploPage { constructor(private alertController: AlertController) {} async mostrarAlerta() { const alert = await this.alertController.create({ header: 'Información', message: 'Esta es una alerta de ejemplo', buttons: ['OK'] }); await alert.present(); } } ``` > **Nota:** Para más detalles sobre cada componente, consulta la [documentación oficial de Ionic](https://ionicframework.com/docs/components), donde encontrarás ejemplos completos de uso y todas las propiedades disponibles. ## 6. Servicios y Dependency Injection Los servicios en Angular son clases que encapsulan lógica de negocio y compartir estado entre componentes. La inyección de dependencias facilita su uso. ### Creación de un Servicio ```bash ionic generate service services/productos ``` ```typescript import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; export interface Producto { id: number; nombre: string; precio: number; imagen: string; descripcion: string; } @Injectable({ providedIn: 'root' // Disponible en toda la aplicación }) export class ProductosService { private apiUrl = 'https://mi-api.com/productos'; constructor(private http: HttpClient) { } obtenerProductos(): Observable { return this.http.get(this.apiUrl); } obtenerProductoPorId(id: number): Observable { return this.http.get(`${this.apiUrl}/${id}`); } buscarProductos(termino: string): Observable { return this.http.get(this.apiUrl).pipe( map(productos => productos.filter(p => p.nombre.toLowerCase().includes(termino.toLowerCase()) )) ); } agregarProducto(producto: Producto): Observable { return this.http.post(this.apiUrl, producto); } actualizarProducto(producto: Producto): Observable { return this.http.put(`${this.apiUrl}/${producto.id}`, producto); } eliminarProducto(id: number): Observable { return this.http.delete(`${this.apiUrl}/${id}`); } } ``` ### Uso del Servicio en un Componente ```typescript import { Component, OnInit } from '@angular/core'; import { ProductosService, Producto } from '../../services/productos.service'; import { LoadingController } from '@ionic/angular'; @Component({ selector: 'app-lista-productos', templateUrl: './lista-productos.page.html', styleUrls: ['./lista-productos.page.scss'], }) export class ListaProductosPage implements OnInit { productos: Producto[] = []; cargando = false; error = false; constructor( private productosService: ProductosService, private loadingController: LoadingController ) { } async ngOnInit() { await this.cargarProductos(); } async cargarProductos() { this.cargando = true; const loading = await this.loadingController.create({ message: 'Cargando productos...' }); await loading.present(); this.productosService.obtenerProductos().subscribe({ next: (data) => { this.productos = data; this.error = false; }, error: (error) => { console.error('Error al cargar productos', error); this.error = true; }, complete: () => { this.cargando = false; loading.dismiss(); } }); } async refrescarProductos(event: any) { this.productosService.obtenerProductos().subscribe({ next: (data) => { this.productos = data; this.error = false; event.target.complete(); }, error: (error) => { console.error('Error al refrescar productos', error); this.error = true; event.target.complete(); } }); } buscarProductos(event: any) { const termino = event.detail.value; if (termino && termino.trim() !== '') { this.productosService.buscarProductos(termino).subscribe(productos => { this.productos = productos; }); } else { this.cargarProductos(); } } } ``` ## 7. Navegación y Enrutamiento Angular Router permite definir rutas para navegar entre páginas: ```typescript // app-routing.module.ts import { NgModule } from '@angular/core'; import { PreloadAllModules, RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { path: '', redirectTo: 'inicio', pathMatch: 'full' }, { path: 'inicio', loadChildren: () => import('./pages/inicio/inicio.module').then(m => m.InicioPageModule) }, { path: 'productos', loadChildren: () => import('./pages/productos/productos.module').then(m => m.ProductosPageModule) }, { path: 'producto/:id', loadChildren: () => import('./pages/detalle-producto/detalle-producto.module').then(m => m.DetalleProductoPageModule) }, { path: 'carrito', loadChildren: () => import('./pages/carrito/carrito.module').then(m => m.CarritoPageModule) }, { path: 'perfil', loadChildren: () => import('./pages/perfil/perfil.module').then(m => m.PerfilPageModule) }, { path: '**', loadChildren: () => import('./pages/not-found/not-found.module').then(m => m.NotFoundPageModule) } ]; @NgModule({ imports: [ RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }) ], exports: [RouterModule] }) export class AppRoutingModule { } ``` ### Navegación Declarativa ```html Ver productos {{ producto.nombre }} Ver electrónica ``` ### Navegación Programática ```typescript import { Component } from '@angular/core'; import { Router, NavigationExtras } from '@angular/router'; @Component({ selector: 'app-navegacion', templateUrl: './navegacion.page.html', }) export class NavegacionPage { constructor(private router: Router) { } irAProductos() { this.router.navigate(['/productos']); } verProducto(id: number) { this.router.navigate(['/producto', id]); } filtrarProductos() { const navigationExtras: NavigationExtras = { queryParams: { categoria: 'electronica', orden: 'precio' } }; this.router.navigate(['/productos'], navigationExtras); } } ``` ### Recibir Parámetros ```typescript import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { ProductosService, Producto } from '../../services/productos.service'; @Component({ selector: 'app-detalle-producto', templateUrl: './detalle-producto.page.html', }) export class DetalleProductoPage implements OnInit { producto: Producto | undefined; constructor( private route: ActivatedRoute, private productosService: ProductosService ) { } ngOnInit() { // Parámetros de ruta this.route.paramMap.subscribe(params => { const id = Number(params.get('id')); if (id) { this.cargarProducto(id); } }); // Parámetros de consulta this.route.queryParamMap.subscribe(params => { const vista = params.get('vista'); console.log('Vista:', vista); }); } cargarProducto(id: number) { this.productosService.obtenerProductoPorId(id).subscribe(producto => { this.producto = producto; }); } } ``` ## 8. Integración con Capacitor Para utilizar capacidades nativas con Capacitor, necesitas instalar y configurar plugins específicos. ### Ejemplo: Cámara ```bash npm install @capacitor/camera npx cap sync ``` ```typescript import { Component } from '@angular/core'; import { Camera, CameraResultType, CameraSource, Photo } from '@capacitor/camera'; @Component({ selector: 'app-camara', template: ` Cámara Tomar Foto
Foto tomada
` }) export class CamaraPage { photoUrl: string | undefined; constructor() { } async tomarFoto() { try { const photo = await Camera.getPhoto({4. Las directivas extienden HTML con funcionalidades dinámicas quality: 90, allowEditing: true, resultType: CameraResultType.Uri, source: CameraSource.Camera }); // La propiedad webPath es la URL que se puede usar para mostrar la foto this.photoUrl = photo.webPath; } catch (error) { console.error('Error al tomar la foto', error); } } } ``` ### Ejemplo: Geolocalización ```bash npm install @capacitor/geolocation npx cap sync ``` ```typescript import { Component } from '@angular/core'; import { Geolocation, Position } from '@capacitor/geolocation'; @Component({ selector: 'app-geolocalizacion', template: ` Ubicación Obtener Ubicación Tu ubicación actual

Latitud: {{ coordenadas.latitude }}

Longitud: {{ coordenadas.longitude }}

Precisión: {{ coordenadas.accuracy }} metros

` }) export class GeolocalizacionPage { coordenadas: { latitude: number; longitude: number; accuracy: number; } | undefined; constructor() { } async obtenerUbicacion() { try { // Solicitar permisos primero const permisos = await Geolocation.checkPermissions(); if (permisos.location !== 'granted') { const solicitado = await Geolocation.requestPermissions(); if (solicitado.location !== 'granted') { throw new Error('Permiso de ubicación denegado'); } } const position = await Geolocation.getCurrentPosition({ enableHighAccuracy: true }); this.coordenadas = { latitude: position.coords.latitude, longitude: position.coords.longitude, accuracy: position.coords.accuracy }; } catch (error) { console.error('Error al obtener ubicación', error); } } } ``` ## Resumen En este capítulo hemos explorado a fondo los componentes y estructura de una aplicación Angular/Ionic: 1. La estructura de carpetas sigue una organización lógica que separa código, estilos y recursos 2. Los componentes Angular encapsulan HTML, CSS y lógica en unidades coherentes 3. El enlace de datos permite la comunicación bidireccional entre vistas y lógica 4. Las directivas extienden HTML con funcionalidades dinámicas 5. Ionic proporciona componentes UI que se adaptan a iOS y Android automáticamente 6. Los servicios permiten compartir lógica y estado entre componentes 7. El enrutamiento facilita la navegación entre diferentes vistas 8. Capacitor extiende las aplicaciones con acceso a funcionalidades nativas Con estos fundamentos, estamos listos para combinar todos estos conceptos en una aplicación completa en el siguiente capítulo. ## Recursos Adicionales - [Guía de Componentes Angular](https://angular.io/guide/component-overview) - [Ciclo de Vida en Angular](https://angular.io/guide/lifecycle-hooks) - [Directivas en Angular](https://angular.io/guide/attribute-directives) - [Componentes UI de Ionic](https://ionicframework.com/docs/components) - [Servicios e Inyección de Dependencias](https://angular.io/guide/dependency-injection) - [Router de Angular](https://angular.io/guide/router) - [Documentación de Capacitor](https://capacitorjs.com/docs/apis) # Taller: Desarrollo con Angular e Ionic ## Capítulo 3: Estructura de la Aplicación de Reservas para Gimnasio En este capítulo, detallaremos la estructura y planificación de nuestra aplicación de reservas para gimnasio. Esto nos servirá como guía para el desarrollo que realizaremos en los siguientes capítulos. ## 1. Definición de Requisitos Nuestra aplicación de reservas para gimnasio debe cumplir con los siguientes requisitos: ### Funcionalidades Principales - **Ver clases disponibles**: Listado de clases con información sobre horarios, instructores y plazas - **Reservar clases**: Posibilidad de reservar una clase si hay plazas disponibles - **Gestionar reservas**: Ver, modificar y cancelar reservas existentes - **Perfil de usuario**: Información básica del usuario y preferencias - **Notificaciones**: Recordatorios de clases reservadas ### Tipos de Usuario - **Clientes**: Pueden ver clases, hacer reservas y gestionar su perfil - **Administradores**: Pueden gestionar clases, instructores y ver estadísticas (para una versión futura) ## 2. Planificación de la Estructura ### Entidades Principales 1. **Clases (GymClass)** - ID - Nombre - Descripción - Instructor - Horario (Inicio/Fin) - Capacidad máxima - Reservas actuales 2. **Reservas (Booking)** - ID - ID de Usuario - ID de Clase - Fecha - Estado (Confirmada, Cancelada, Pendiente) 3. **Usuario (User)** - ID - Nombre - Email - Foto de perfil - Preferencias ### Páginas Principales 1. **Inicio/Login** - Autenticación de usuarios - Pantalla de bienvenida 2. **Lista de Clases** - Mostrar todas las clases disponibles - Filtros por día, tipo, instructor 3. **Detalle de Clase** - Información completa de la clase - Botón para reservar - Lista de participantes (opcional) 4. **Mis Reservas** - Lista de reservas del usuario - Opciones para cancelar/modificar 5. **Perfil** - Datos del usuario - Preferencias - Estadísticas (opcional) ## 3. Diseño de la Arquitectura ### Estructura de Carpetas ``` gym-reservation-app/ ├── src/ │ ├── app/ │ │ ├── core/ # Servicios core, guards, interceptores │ │ │ ├── services/ │ │ │ ├── guards/ │ │ │ └── interceptors/ │ │ ├── shared/ # Componentes compartidos │ │ │ ├── components/ │ │ │ ├── directives/ │ │ │ └── pipes/ │ │ ├── models/ # Interfaces y modelos │ │ ├── pages/ # Páginas principales │ │ │ ├── auth/ │ │ │ ├── classes/ │ │ │ ├── class-detail/ │ │ │ ├── bookings/ │ │ │ └── profile/ │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ └── app-routing.module.ts │ ├── assets/ │ │ ├── icon/ │ │ └── images/ │ ├── environments/ │ └── theme/ └── capacitor.config.ts ``` ### Arquitectura y Patrones Utilizaremos una arquitectura basada en: 1. **Componentes**: Encapsulan partes de la UI y su lógica asociada 2. **Servicios**: Manejan la lógica de negocio y el acceso a datos 3. **Modelos**: Definen las estructuras de datos 4. **Enrutamiento**: Gestiona la navegación entre páginas 5. **Patrón Observable**: Para gestionar flujos de datos asincrónicos ## 4. Diseño de la Base de Datos Para esta versión inicial, utilizaremos almacenamiento local mediante Capacitor/Storage. En una versión futura, podríamos migrar a Firebase o una API REST. **Estructura del almacenamiento local**: ```json { "classes": [ { "id": "1", "name": "Yoga", "description": "Clase de yoga para todos los niveles", "instructor": "María López", "startTime": "2025-04-22T08:00:00", "endTime": "2025-04-22T09:00:00", "maxCapacity": 15, "currentBookings": 8 }, // Más clases... ], "bookings": [ { "id": "1", "userId": "user123", "classId": "1", "className": "Yoga", "date": "2025-04-22T08:00:00", "status": "confirmed" }, // Más reservas... ], "users": [ { "id": "user123", "name": "Usuario Demo", "email": "usuario@ejemplo.com", "profilePic": "avatar.jpg", "preferences": { "notifications": true, "favoriteClasses": ["1", "3"] } }, // Más usuarios... ] } ``` ## 5. Flujos de Navegación ### Flujo Principal 1. **Inicio**: El usuario abre la aplicación 2. **Autenticación**: El usuario inicia sesión (simulado en esta versión) 3. **Tabs**: Navegación principal con tabs - Tab 1: Clases disponibles - Tab 2: Mis reservas - Tab 3: Perfil ### Flujo de Reserva 1. Usuario navega a "Clases disponibles" 2. Usuario selecciona una clase específica 3. En la pantalla de detalle, pulsa "Reservar Clase" 4. Se muestra confirmación y se redirige a "Mis Reservas" ### Flujo de Cancelación 1. Usuario navega a "Mis Reservas" 2. Usuario desliza una reserva hacia la izquierda 3. Pulsa en "Cancelar" 4. Se muestra confirmación y se actualiza la lista ## 6. Integración con Capacitor Para mejorar la experiencia de usuario y aprovechar las capacidades nativas, integraremos los siguientes plugins de Capacitor: 1. **@capacitor/local-notifications** - Enviar recordatorios de clases - Notificaciones de confirmación de reservas 2. **@capacitor/camera** - Permitir a los usuarios actualizar su foto de perfil - Opción de tomar foto o seleccionar de la galería 3. **@capacitor/preferences (antes Storage)** - Almacenar datos de usuario - Guardar preferencias - Persistir información de clases y reservas 4. **@capacitor/device** (opcional) - Obtener información del dispositivo para personalizar la experiencia ## 7. Plan de Implementación Para desarrollar la aplicación seguiremos estos pasos: 1. **Configuración del proyecto** - Crear proyecto Ionic con Angular - Instalar dependencias necesarias 2. **Implementación de los modelos de datos** - Definir interfaces TypeScript para las entidades 3. **Servicios Core** - Implementar servicios para gestionar clases - Implementar servicios para gestionar reservas - Implementar servicio de autenticación (mock) 4. **Componentes Shared** - Crear componentes reutilizables (tarjetas, listas, etc.) 5. **Páginas principales** - Implementar página de listado de clases - Implementar página de detalle de clase - Implementar página de mis reservas - Implementar página de perfil 6. **Navegación y enrutamiento** - Configurar las rutas - Implementar navegación entre páginas 7. **Capacitor y funcionalidades nativas** - Integrar notificaciones - Integrar cámara - Implementar almacenamiento persistente 8. **Pruebas y ajustes** - Probar funcionalidades - Ajustar estilos y mejorar UX ## 8. Consideraciones para Versiones Futuras En futuras versiones se podrían incluir: 1. **Autenticación real** - Integración con Firebase Auth o similares - Roles de usuario (cliente/admin) 2. **Backend real** - API REST o Firebase para datos en tiempo real - Sincronización entre dispositivos 3. **Funcionalidades avanzadas** - Pagos para clases premium - Calendarios personalizados - Estadísticas de asistencia - Chat con instructores 4. **Mejoras UX/UI** - Temas personalizables - Animaciones - Modo oscuro/claro ## Conclusión En este capítulo hemos definido la estructura de nuestra aplicación de reservas para gimnasio, estableciendo una base sólida para el desarrollo. Con esta planificación, podemos proceder a la implementación paso a paso, siguiendo las mejores prácticas de Angular e Ionic. En el siguiente capítulo, comenzaremos con la implementación práctica, creando el proyecto base y desarrollando los modelos y servicios esenciales. # Capítulo 4: Desarrollo Práctico de la Aplicación de Reservas En este capítulo comenzaremos el desarrollo práctico de nuestra aplicación de reservas para gimnasio utilizando Ionic y Angular. Seguiremos paso a paso la implementación desde cero. ## 1. Creación y Configuración del Proyecto ### Crear el proyecto Lo primero es crear el proyecto mediante el CLI de Ionic: > Es necesario recordar de que se tiene que tener node instalado, en este caso yo personalmente estoy usando la versión LTS de node (22.14) ```bash # Asegúrate de tener instalado Ionic CLI y Angular CLI npm install -g @ionic/cli @angular/cli # Crear el proyecto con el template de tabs ionic start taller tabs --type=angular # Entrar al directorio del proyecto cd taller ``` ![\[img\]https://i.ibb.co/Xx1kxbMn/instalar-cli.png\[/img\]](https://i.ibb.co/Xx1kxbMn/instalar-cli.png) ![enter image description here](https://i.ibb.co/Q3mcpwKt/imagen.png) > Últimamente Angular impulsa mucho el uso de los conocidos como `componentes independientes`, personalmente no me gustan mucho, así que para efectos de la demostración lo haré con la versión clásica con módulos ### Estructura inicial del proyecto Veamos la estructura de carpetas generada: ``` taller/ ├── src/ │ ├── app/ │ │ ├── tab1/ # Tab 1 generado automáticamente │ │ ├── tab2/ # Tab 2 generado automáticamente │ │ ├── tab3/ # Tab 3 generado automáticamente │ │ ├── tabs/ # Controlador de tabs │ │ ├── app-routing.module.ts │ │ ├── app.component.ts │ │ └── app.module.ts │ ├── assets/ │ ├── environments/ │ ├── theme/ │ ├── global.scss │ ├── index.html │ └── main.ts ├── angular.json ├── capacitor.config.ts ├── package.json └── ... ``` ### Ejecutar el proyecto Iniciemos la aplicación para verificar que todo funciona correctamente: ```bash ionic serve ``` > Literalmente usa exactamente el mismo servidor de desarrollo que una típica aplicación de Angular, aunque lo único que cambia en este punto es el puerto por defecto, ahora es el 8100 en vez del 4200, de igual manera se puede cambiar sin problemas ![enter image description here](https://i.ibb.co/VWc1Nfx9/imagen.png) ![proyecto,corriendo](https://i.ibb.co/gLfFxtVv/imagen.png) Deberías ver la aplicación por defecto en tu navegador, con tres tabs básicos. ## 2. Organización del Proyecto Vamos a reorganizar la estructura para adaptarla a nuestras necesidades: ```bash # Crear directorios para nuestra estructura, puedes crearlos manualmente, # o en mi caso que estoy usando una distribución linux los haré con estos comandos mkdir -p src/app/pages mkdir -p src/app/services mkdir -p src/app/models mkdir -p src/app/shared/components ``` ## 3. Definición de Modelos Creemos los modelos de datos que necesitaremos: ```bash # Crear archivos para los modelos # lo mismo de arriba, puedes crearlos manualmente, en mi caso usaré estos comandos touch src/app/models/gym-class.model.ts touch src/app/models/booking.model.ts touch src/app/models/user.model.ts ``` Ahora implementemos las interfaces para cada modelo: **gym-class.model.ts**: ```typescript export interface GymClass { id: string; name: string; description: string; instructor: string; startTime: Date; endTime: Date; maxCapacity: number; currentBookings: number; category?: string; imageUrl?: string; } ``` **booking.model.ts**: ```typescript export interface Booking { id: string; userId: string; classId: string; className: string; date: Date; status: 'confirmed' | 'cancelled' | 'pending'; } ``` **user.model.ts**: ```typescript export interface User { id: string; name: string; email: string; profilePic?: string; profilePicUrl?: string; // Para compatibilidad con el backend notificationsEnabled?: boolean; // Para compatibilidad con el backend preferences?: { notifications: boolean; favoriteClasses?: string[]; }; } ``` ## 4. Creación de Servicios Ahora, implementaremos los servicios que gestionarán los datos: ```bash # Generar servicios usando el CLI de Ionic ionic generate service services/classes ionic generate service services/bookings ionic generate service services/auth ionic generate service services/storage ionic generate service services/upload ionic generate service services/notification ``` ### Implementación del Servicio de Almacenamiento Primero vamos a instalar el plugin de Preferences de Capacitor: > Este plugin es básicamente para guardar datos o caché de nuestra app, en caso de que la exportemos para móviles como Android o iOS ```bash npm install @capacitor/preferences ``` Ahora implementemos el servicio de almacenamiento: **services/storage.service.ts**: ```typescript import { Injectable } from '@angular/core'; import { Preferences } from '@capacitor/preferences'; @Injectable({ providedIn: 'root' }) export class StorageService { constructor() { } async set(key: string, value: any): Promise { await Preferences.set({ key, value: JSON.stringify(value) }); } async get(key: string): Promise { const { value } = await Preferences.get({ key }); if (value) { return JSON.parse(value); } return null; } async remove(key: string): Promise { await Preferences.remove({ key }); } async clear(): Promise { await Preferences.clear(); } } ``` ## 5. Configuración de Entornos Configuremos los archivos de entorno para facilitar el cambio entre desarrollo y producción: **src/environments/environment.ts**: ```typescript export const environment = { production: false, apiUrl: 'http://localhost:3000' // URL de la API de desarrollo }; ``` **src/environments/environment.prod.ts**: ```typescript export const environment = { production: true, apiUrl: 'https://taller.ionic.lcespedes.dev' // URL de la API de producción }; ``` ## 6. Integración con Backend Nuestra aplicación se conectará a un backend real en lugar de utilizar datos mockeados. Instalemos las dependencias necesarias: ```bash npm install @angular/common/http ``` ### Configuración del Módulo HTTP Agreguemos el módulo HTTP al app.module.ts: ```typescript import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouteReuseStrategy } from '@angular/router'; import { HttpClientModule } from '@angular/common/http'; import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; @NgModule({ declarations: [AppComponent], imports: [ BrowserModule, IonicModule.forRoot(), AppRoutingModule, HttpClientModule ], providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }], bootstrap: [AppComponent], }) export class AppModule {} ``` ## 7. Implementación de Autenticación Crearemos un módulo de autenticación completo: ```bash ionic generate page pages/auth ionic generate component pages/auth/components/login ionic generate component pages/auth/components/register ionic generate guard pages/auth/guards/auth ``` ### Implementación del Guard de Autenticación **src/app/pages/auth/guards/auth.guard.ts**: ```typescript import { Injectable } from '@angular/core'; import { CanActivate, Router } from '@angular/router'; import { AuthService } from '../../../services/auth.service'; @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanActivate { constructor( private authService: AuthService, private router: Router ) {} async canActivate(): Promise { // Esperar a que el servicio de autenticación esté inicializado await this.authService.waitForInitialization(); if (this.authService.isLoggedIn()) { return true; } // Redirigir al login si no hay sesión this.router.navigateByUrl('/auth'); return false; } } ``` ### Configuración de Rutas Principales Actualizaremos el app-routing.module.ts para implementar las rutas correctas: ```typescript import { NgModule } from '@angular/core'; import { PreloadAllModules, RouterModule, Routes } from '@angular/router'; import { AuthGuard } from './pages/auth/guards/auth.guard'; const routes: Routes = [ { path: '', redirectTo: 'auth', pathMatch: 'full' }, { path: 'app', loadChildren: () => import('./tabs/tabs.module').then(m => m.TabsPageModule), canActivate: [AuthGuard] }, { path: 'auth', loadChildren: () => import('./pages/auth/auth.module').then(m => m.AuthPageModule) }, { path: 'classes', loadChildren: () => import('./pages/classes/classes.module').then(m => m.ClassesPageModule) }, { path: 'class-detail', loadChildren: () => import('./pages/class-detail/class-detail.module').then(m => m.ClassDetailPageModule) }, { path: 'bookings', loadChildren: () => import('./pages/bookings/bookings.module').then(m => m.BookingsPageModule) }, { path: 'profile', loadChildren: () => import('./pages/profile/profile.module').then(m => m.ProfilePageModule) } ]; @NgModule({ imports: [ RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }) ], exports: [RouterModule] }) export class AppRoutingModule {} ``` ## 8. Creación de Páginas Principales Vamos a crear las páginas principales de nuestra aplicación: ```bash # Eliminar las páginas de tabs generadas automáticamente rm -rf src/app/tab1 rm -rf src/app/tab2 rm -rf src/app/tab3 # Generar nuestras propias páginas ionic generate page pages/classes ionic generate page pages/class-detail ionic generate page pages/bookings ionic generate page pages/profile ``` ### Configuración de las rutas y tabs Actualizamos el archivo de rutas de tabs: **src/app/tabs/tabs-routing.module.ts**: ```typescript import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { TabsPage } from './tabs.page'; const routes: Routes = [ { path: '', component: TabsPage, children: [ { path: 'classes', loadChildren: () => import('../pages/classes/classes.module').then(m => m.ClassesPageModule) }, { path: 'bookings', loadChildren: () => import('../pages/bookings/bookings.module').then(m => m.BookingsPageModule) }, { path: 'profile', loadChildren: () => import('../pages/profile/profile.module').then(m => m.ProfilePageModule) }, { path: '', redirectTo: '/app/classes', pathMatch: 'full' } ] }, { path: '', redirectTo: '/app/classes', pathMatch: 'full' } ]; @NgModule({ imports: [RouterModule.forChild(routes)], }) export class TabsPageRoutingModule {} ``` Actualizamos el template de tabs: **src/app/tabs/tabs.page.html**: ```html Clases Mis Reservas Perfil ``` ## 9. Implementar Servicio de Autenticación Ahora implementaremos el servicio de autenticación real que se comunica con el backend: **services/auth.service.ts**: ```typescript import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { catchError, map, tap } from 'rxjs/operators'; import { User } from '../models/user.model'; import { environment } from '../../environments/environment'; import { StorageService } from './storage.service'; // Clave para almacenar información de sesión const AUTH_TOKEN_KEY = 'auth_token'; const USER_KEY = 'user_data'; // Interfaz para credenciales de login interface LoginCredentials { email: string; password: string; } // Interfaz para datos de registro interface RegisterData { name: string; email: string; password: string; } @Injectable({ providedIn: 'root' }) export class AuthService { private apiUrl = `${environment.apiUrl}/api/users`; private authUrl = `${environment.apiUrl}/api/auth`; private currentUserSubject = new BehaviorSubject(null); public currentUser$ = this.currentUserSubject.asObservable(); private isInitialized = false; constructor( private http: HttpClient, private storageService: StorageService ) { // Intentar recuperar sesión anterior this.initializeSession(); } /** * Inicializa la sesión si hay datos guardados */ async initializeSession() { if (this.isInitialized) return; try { const userData = await this.storageService.get(USER_KEY) as User | null; if (userData) { console.log('Sesión recuperada del almacenamiento local:', userData); // Normalizar campos para compatibilidad this.normalizeUserData(userData); this.currentUserSubject.next(userData); } else { console.log('No hay sesión guardada, redirigiendo a login'); this.currentUserSubject.next(null); } } catch (error) { console.error('Error al inicializar sesión:', error); this.currentUserSubject.next(null); } this.isInitialized = true; } /** * Espera a que se complete la inicialización * Importante para evitar redirecciones erróneas cuando la app inicia */ async waitForInitialization(): Promise { if (this.isInitialized) return; // Si no se ha inicializado, iniciamos el proceso await this.initializeSession(); // Esperar hasta que se inicialice (con timeout) let attempts = 0; while (!this.isInitialized && attempts < 10) { await new Promise(resolve => setTimeout(resolve, 100)); attempts++; } } /** * Normaliza los datos de usuario para compatibilidad entre frontend/backend */ private normalizeUserData(user: User) { // Sincronizar campos de imagen if (user.profilePicUrl && !user.profilePic) { user.profilePic = user.profilePicUrl; } else if (user.profilePic && !user.profilePicUrl) { user.profilePicUrl = user.profilePic; } // Sincronizar preferencias de notificaciones if (user.notificationsEnabled !== undefined && !user.preferences) { user.preferences = { notifications: user.notificationsEnabled, favoriteClasses: [] }; } else if (user.preferences?.notifications !== undefined && user.notificationsEnabled === undefined) { user.notificationsEnabled = user.preferences.notifications; } } getCurrentUser(): User | null { return this.currentUserSubject.value; } /** * Iniciar sesión con email y contraseña */ login(credentials: LoginCredentials): Observable { console.log('Intentando login con:', credentials); return this.http.post(`${this.authUrl}/login`, credentials).pipe( tap(async (response) => { console.log('Respuesta de login:', response); // Guardar token si el backend lo devuelve if (response.token) { await this.storageService.set(AUTH_TOKEN_KEY, response.token); } // Verificar y normalizar datos del usuario const user = response.user || response; this.normalizeUserData(user); // Guardar datos del usuario await this.storageService.set(USER_KEY, user); // Actualizar estado de sesión this.currentUserSubject.next(user); }), map(response => response.user || response) ); } /** * Registrar un nuevo usuario */ register(userData: RegisterData): Observable { console.log('Registrando usuario:', userData); const registerData = { name: userData.name, email: userData.email, password: userData.password }; return this.http.post(`${this.authUrl}/register`, registerData).pipe( tap(async (response) => { console.log('Respuesta de registro:', response); // Guardar token si el backend lo devuelve if (response.token) { await this.storageService.set(AUTH_TOKEN_KEY, response.token); } // Verificar y normalizar datos del usuario const user = response.user || response; this.normalizeUserData(user); // Guardar datos del usuario await this.storageService.set(USER_KEY, user); // Actualizar estado de sesión this.currentUserSubject.next(user); }), map(response => response.user || response) ); } updateUserProfile(userData: Partial): Observable { const currentUser = this.getCurrentUser(); if (!currentUser) { return of(currentUser as unknown as User); } console.log('Actualizando perfil de usuario con:', userData); // Asegurar que ambos campos de imagen estén sincronizados if (userData.profilePic && !userData.profilePicUrl) { userData.profilePicUrl = userData.profilePic; } else if (userData.profilePicUrl && !userData.profilePic) { userData.profilePic = userData.profilePicUrl; } // Sincronizar notificaciones entre los dos formatos if (userData.preferences?.notifications !== undefined && userData.notificationsEnabled === undefined) { userData.notificationsEnabled = userData.preferences.notifications; } else if (userData.notificationsEnabled !== undefined && (!userData.preferences || userData.preferences.notifications === undefined)) { if (!userData.preferences) userData.preferences = { notifications: false, favoriteClasses: [] }; userData.preferences.notifications = userData.notificationsEnabled; } // Solo enviamos al servidor los campos que espera const backendUserData = { name: userData.name, profilePicUrl: userData.profilePicUrl, notificationsEnabled: userData.notificationsEnabled || (userData.preferences ? userData.preferences.notifications : undefined) }; return this.http.put(`${this.apiUrl}/${currentUser.id}`, backendUserData).pipe( tap(async (user) => { console.log('Usuario actualizado correctamente:', user); // Sincronizar campos para mantener compatibilidad if (user.profilePicUrl && !user.profilePic) { user.profilePic = user.profilePicUrl; } else if (user.profilePic && !user.profilePicUrl) { user.profilePicUrl = user.profilePic; } // Mantener los campos que espera el frontend if (user.notificationsEnabled !== undefined && !user.preferences) { user.preferences = { notifications: user.notificationsEnabled, favoriteClasses: currentUser.preferences?.favoriteClasses || [] }; } // Actualizar almacenamiento local await this.storageService.set(USER_KEY, user); this.currentUserSubject.next(user); }), catchError(error => { console.error('Error al actualizar usuario:', error); // Devolver el usuario actualizado localmente para que la UI no se rompa // en caso de error de red const updatedUser = { ...currentUser, ...userData }; this.currentUserSubject.next(updatedUser); return of(updatedUser); }) ); } /** * Cerrar sesión y eliminar datos */ async logout(): Promise { try { // En una aplicación real, enviaríamos petición al backend // this.http.post(`${this.authUrl}/logout`, {}).subscribe(); // Limpiar datos de sesión await this.storageService.clear(); // Limpiamos TODA la caché de preferencias // Actualizar estado this.currentUserSubject.next(null); this.isInitialized = false; // Permitir nueva inicialización return true; } catch (error) { console.error('Error al cerrar sesión:', error); return false; } } /** * Verificar si hay sesión activa */ isLoggedIn(): boolean { return !!this.currentUserSubject.value; } } ``` ## 10. Creación de Componentes de Login y Registro Implementemos los componentes de autenticación: **pages/auth/components/login/login.component.html**: ```html

Iniciar Sesión

Email Email es requerido Formato de email inválido Contraseña Contraseña es requerida La contraseña debe tener al menos 6 caracteres Iniciar Sesión
``` **pages/auth/components/register/register.component.html**: ```html

Crear Cuenta

Nombre Nombre es requerido Email Email es requerido Formato de email inválido Contraseña Contraseña es requerida La contraseña debe tener al menos 6 caracteres Registrarse
``` **pages/auth/auth.page.html**: ```html Gym Reservation

Gym Reservation

Tu app para reservar clases en tu gimnasio favorito

Iniciar Sesión Registrarse

¿Quieres probar la app? Usa estos datos:

Email: usuario@ejemplo.com

Contraseña: password123

``` ## 11. Servicio para Subir Imágenes Implementemos un servicio para manejar la carga de imágenes al backend: **services/upload.service.ts**: ```typescript import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class UploadService { private apiUrl = `${environment.apiUrl}/api/upload`; constructor(private http: HttpClient) { } /** * Sube una imagen al servidor * @param file Archivo a subir * @param type Tipo de imagen (avatar, class, etc) * @returns URL de la imagen subida */ uploadImage(file: File, type: 'avatar' | 'class' = 'avatar'): Observable<{ imageUrl: string }> { const formData = new FormData(); formData.append('image', file); formData.append('type', type); return this.http.post<{ imageUrl: string }>(`${this.apiUrl}`, formData); } /** * Convierte una imagen dataURL (base64) a un archivo File * @param dataUrl URL de datos base64 * @param filename Nombre del archivo */ dataURLtoFile(dataUrl: string, filename: string): File { const arr = dataUrl.split(','); const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/jpeg'; const bstr = atob(arr[1]); let n = bstr.length; const u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bstr.charCodeAt(n); } return new File([u8arr], filename, { type: mime }); } } ``` ## Resumen En este capítulo, hemos establecido las bases sólidas para nuestra aplicación de reservas para gimnasio: 1. Creamos la estructura del proyecto 2. Implementamos los modelos de datos 3. Configuramos los servicios básicos 4. Establecimos la autenticación con un backend real 5. Implementamos las rutas principales 6. Creamos las páginas y componentes esenciales 7. Integramos el sistema de subida de imágenes En el siguiente capítulo, completaremos la implementación con: - Página de listado de clases - Detalle de clase y reserva - Gestión de reservas - Perfil de usuario - Integración con capacidades nativas mediante Capacitor ## Disclaimer > **Nota importante:** Si durante el desarrollo encuentras errores o discrepancias en la ejecución del código, verifica la implementación correcta de cada módulo. El código completo de cada componente está disponible en el repositorio del curso. Asegúrate de revisar que los nombres de las clases, servicios e importaciones coincidan exactamente con lo especificado en este tutorial. > > Si tienes problemas con algún módulo específico, puedes verificar la estructura del proyecto y consultar la documentación oficial de Ionic y Angular para obtener más información sobre cómo resolver los errores más comunes. > Adicionalmente igual puedes descargar el repositorio completo desde este [link](https://git.lcespedes.dev/lxluxo23/taller-ionic) > Digo eso ya que es en ocasiones el ionic-cli funciona de manera distinta dependiendo de la versión de este mismo, además de la versión de node. # Capítulo 5: Implementando Funcionalidades y Componentes Principales En este capítulo completaremos nuestra aplicación de reservas para gimnasio implementando las páginas restantes y añadiendo funcionalidades para interactuar con nuestro backend. ## 1. Implementación de Servicios para Interactuar con el Backend Antes de implementar las páginas, necesitamos actualizar nuestros servicios para interactuar con el backend en lugar de usar datos de ejemplo. ### Servicio de Clases Actualizaremos el servicio de clases para obtener datos desde el backend: **services/classes.service.ts**: ```typescript import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { catchError, map, tap } from 'rxjs/operators'; import { GymClass } from '../models/gym-class.model'; import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class ClassesService { private apiUrl = `${environment.apiUrl}/api/classes`; private classesSubject = new BehaviorSubject([]); public classes$ = this.classesSubject.asObservable(); constructor(private http: HttpClient) { // Inicializar cargando clases this.loadClasses(); } private loadClasses(): void { this.http.get(this.apiUrl) .pipe( tap(classes => this.classesSubject.next(classes)), catchError(error => { console.error('Error al cargar clases:', error); return throwError(() => error); }) ) .subscribe(); } getClasses(): Observable { // Refrescar datos desde el servidor this.loadClasses(); return this.classes$; } getClassById(id: string): Observable { return this.http.get(`${this.apiUrl}/${id}`).pipe( catchError(error => { console.error(`Error al obtener clase con ID ${id}:`, error); return throwError(() => error); }) ); } searchClasses(term: string): Observable { return this.http.get(`${this.apiUrl}/search`, { params: { term } }).pipe( catchError(error => { console.error('Error al buscar clases:', error); return throwError(() => error); }) ); } filterByCategory(category: string): Observable { return this.http.get(`${this.apiUrl}`, { params: { category } }).pipe( catchError(error => { console.error('Error al filtrar clases por categoría:', error); return throwError(() => error); }) ); } // Este método actualiza el contador de reservas de una clase updateClassBookings(classId: string, change: number): Observable { const url = `${this.apiUrl}/${classId}/bookings`; return this.http.patch(url, { change }).pipe( tap(updatedClass => { // Actualizar la clase en la lista local const currentClasses = this.classesSubject.value; const index = currentClasses.findIndex(c => c.id === classId); if (index !== -1) { const updatedClasses = [...currentClasses]; updatedClasses[index] = updatedClass; this.classesSubject.next(updatedClasses); } }), catchError(error => { console.error('Error al actualizar reservas de clase:', error); return throwError(() => error); }) ); } } ``` ### Servicio de Reservas Ahora implementaremos el servicio de reservas para interactuar con el backend: **services/bookings.service.ts**: ```typescript import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, of, throwError } from 'rxjs'; import { catchError, map, switchMap, tap } from 'rxjs/operators'; import { Booking } from '../models/booking.model'; import { environment } from '../../environments/environment'; import { AuthService } from './auth.service'; import { ClassesService } from './classes.service'; import { NotificationService } from './notification.service'; @Injectable({ providedIn: 'root' }) export class BookingsService { private apiUrl = `${environment.apiUrl}/api/bookings`; private bookingsSubject = new BehaviorSubject([]); public bookings$ = this.bookingsSubject.asObservable(); constructor( private http: HttpClient, private authService: AuthService, private classesService: ClassesService, private notificationService: NotificationService ) { // Cargamos las reservas del usuario al inicializar el servicio this.loadUserBookings(); // Nos suscribimos a cambios de usuario para actualizar las reservas this.authService.currentUser$.subscribe(user => { if (user) { this.loadUserBookings(); } else { this.bookingsSubject.next([]); } }); } private loadUserBookings(): void { const currentUser = this.authService.getCurrentUser(); if (!currentUser) return; this.http.get(`${this.apiUrl}/user/${currentUser.id}`) .pipe( tap(bookings => this.bookingsSubject.next(bookings)), catchError(error => { console.error('Error al cargar reservas:', error); return throwError(() => error); }) ) .subscribe(); } getUserBookings(): Observable { // Refrescar datos this.loadUserBookings(); return this.bookings$; } getBookingById(id: string): Observable { return this.http.get(`${this.apiUrl}/${id}`).pipe( catchError(error => { console.error(`Error al obtener reserva con ID ${id}:`, error); return throwError(() => error); }) ); } addBooking(classId: string, className: string): Observable { const currentUser = this.authService.getCurrentUser(); if (!currentUser) { return throwError(() => new Error('No hay usuario autenticado')); } const bookingData = { userId: currentUser.id, classId, className, date: new Date(), status: 'confirmed' }; return this.http.post(this.apiUrl, bookingData).pipe( switchMap(booking => { // Actualizar el contador de la clase return this.classesService.updateClassBookings(classId, 1).pipe( switchMap(gymClass => { // Programar notificación para esta reserva this.notificationService.scheduleClassReminder(booking, gymClass); // Actualizar la lista local de reservas const bookings = [...this.bookingsSubject.value, booking]; this.bookingsSubject.next(bookings); return of(booking); }) ); }), catchError(error => { console.error('Error al crear reserva:', error); return throwError(() => error); }) ); } cancelBooking(bookingId: string): Observable { return this.http.patch(`${this.apiUrl}/${bookingId}/cancel`, {}).pipe( switchMap(booking => { // Actualizar contador de la clase return this.classesService.updateClassBookings(booking.classId, -1).pipe( map(() => booking) ); }), tap(booking => { // Cancelar notificación programada this.notificationService.cancelNotification(booking.id); // Actualizar lista local const bookings = this.bookingsSubject.value.map(b => b.id === bookingId ? { ...b, status: 'cancelled' } : b ); this.bookingsSubject.next(bookings); }), catchError(error => { console.error('Error al cancelar reserva:', error); return throwError(() => error); }) ); } } ``` ## 2. Implementación de la Página de Clases Vamos a implementar la página que muestra el listado de clases disponibles: **pages/classes/classes.page.html**: ```html Clases Disponibles Todas Mente/Cuerpo Cardio Fuerza

Cargando clases...

{{ gymClass.name }}

{{ gymClass.startTime | date:'EEE, d MMM, h:mm a' }}

{{ gymClass.instructor }}

{{ gymClass.currentBookings }}/{{ gymClass.maxCapacity }}
{{ gymClass.category }}

No se encontraron clases

Intenta con otros filtros o términos de búsqueda.

``` **pages/classes/classes.page.ts**: ```typescript import { Component, OnInit, OnDestroy } from '@angular/core'; import { Subscription } from 'rxjs'; import { GymClass } from '../../models/gym-class.model'; import { ClassesService } from '../../services/classes.service'; @Component({ selector: 'app-classes', templateUrl: './classes.page.html', styleUrls: ['./classes.page.scss'], }) export class ClassesPage implements OnInit, OnDestroy { clases: GymClass[] = []; clasesFiltradas: GymClass[] = []; cargando = true; terminoBusqueda = ''; categoriaSeleccionada = 'todas'; private subscription: Subscription = new Subscription(); constructor(private classesService: ClassesService) { } ngOnInit() { this.cargarClases(); } ngOnDestroy() { this.subscription.unsubscribe(); } ionViewWillEnter() { this.cargarClases(); } cargarClases() { this.cargando = true; const sub = this.classesService.getClasses().subscribe({ next: (classes) => { this.clases = classes; this.aplicarFiltros(); this.cargando = false; }, error: (error) => { console.error('Error al cargar clases', error); this.cargando = false; } }); this.subscription.add(sub); } refrescarClases(event: any) { const sub = this.classesService.getClasses().subscribe({ next: (classes) => { this.clases = classes; this.aplicarFiltros(); event.target.complete(); }, error: (error) => { console.error('Error al refrescar clases', error); event.target.complete(); } }); this.subscription.add(sub); } buscarClases(event: any) { this.terminoBusqueda = event.detail.value.toLowerCase(); this.aplicarFiltros(); } filtrarPorCategoria(event: any) { this.categoriaSeleccionada = event.detail.value; this.aplicarFiltros(); } private aplicarFiltros() { let resultado = [...this.clases]; // Filtrar por término de búsqueda if (this.terminoBusqueda) { resultado = resultado.filter(clase => clase.name.toLowerCase().includes(this.terminoBusqueda) || clase.instructor.toLowerCase().includes(this.terminoBusqueda) || clase.description.toLowerCase().includes(this.terminoBusqueda) ); } // Filtrar por categoría if (this.categoriaSeleccionada !== 'todas') { resultado = resultado.filter(clase => clase.category === this.categoriaSeleccionada ); } // Ordenar por fecha/hora resultado.sort((a, b) => { const dateA = new Date(a.startTime); const dateB = new Date(b.startTime); return dateA.getTime() - dateB.getTime(); }); this.clasesFiltradas = resultado; } } ``` ## 3. Implementación de la Página de Detalle de Clase Ahora crearemos la página de detalle que muestra la información de una clase y permite reservarla: **pages/class-detail/class-detail.page.html**: ```html Detalle de Clase

Cargando información...

{{ gymClass.category }} {{ gymClass.name }} Instructor: {{ gymClass.instructor }}

{{ gymClass.description }}

Horario

{{ gymClass.startTime | date:'EEEE, d MMM, h:mm a' }} - {{ gymClass.endTime | date:'h:mm a' }}

Capacidad

{{ gymClass.currentBookings }}/{{ gymClass.maxCapacity }} plazas ocupadas

Reservar Plaza

Lo sentimos, esta clase está completa.

``` **pages/class-detail/class-detail.page.ts**: ```typescript import { Component, OnInit, OnDestroy } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { NavController, ToastController, AlertController } from '@ionic/angular'; import { Subscription } from 'rxjs'; import { GymClass } from '../../models/gym-class.model'; import { ClassesService } from '../../services/classes.service'; import { BookingsService } from '../../services/bookings.service'; @Component({ selector: 'app-class-detail', templateUrl: './class-detail.page.html', styleUrls: ['./class-detail.page.scss'], }) export class ClassDetailPage implements OnInit, OnDestroy { gymClass?: GymClass; cargando = true; reservando = false; private subscription: Subscription = new Subscription(); constructor( private route: ActivatedRoute, private navCtrl: NavController, private classesService: ClassesService, private bookingsService: BookingsService, private toastCtrl: ToastController, private alertCtrl: AlertController ) { } ngOnInit() { this.cargarDatosClase(); } ngOnDestroy() { this.subscription.unsubscribe(); } ionViewWillEnter() { this.cargarDatosClase(); } cargarDatosClase() { const id = this.route.snapshot.paramMap.get('id'); if (!id) { this.navCtrl.navigateBack('/app/classes'); return; } this.cargando = true; const sub = this.classesService.getClassById(id).subscribe({ next: (gymClass) => { if (gymClass) { this.gymClass = gymClass; } else { this.navCtrl.navigateBack('/app/classes'); this.mostrarToast('Clase no encontrada', 'danger'); } this.cargando = false; }, error: (error) => { console.error('Error al cargar clase', error); this.cargando = false; this.navCtrl.navigateBack('/app/classes'); this.mostrarToast('Error al cargar la información', 'danger'); } }); this.subscription.add(sub); } isClassFull(): boolean { if (!this.gymClass) return true; return this.gymClass.currentBookings >= this.gymClass.maxCapacity; } getCapacityColor(): string { if (!this.gymClass) return 'primary'; const ratio = this.gymClass.currentBookings / this.gymClass.maxCapacity; if (ratio >= 0.9) return 'danger'; if (ratio >= 0.7) return 'warning'; return 'success'; } async reservarClase() { if (!this.gymClass || this.isClassFull() || this.reservando) return; this.reservando = true; const sub = this.bookingsService.addBooking(this.gymClass.id, this.gymClass.name).subscribe({ next: async (booking) => { this.mostrarToast(`¡Reserva confirmada para ${this.gymClass?.name}!`, 'success'); // Actualizar contador de la clase en local para mejor UX if (this.gymClass) { this.gymClass.currentBookings++; } this.reservando = false; // Mostrar alerta de confirmación const alert = await this.alertCtrl.create({ header: '¡Reserva Exitosa!', message: `Has reservado una plaza para ${this.gymClass?.name}. ¿Deseas ver tus reservas?`, buttons: [ { text: 'No, seguir explorando', role: 'cancel' }, { text: 'Ver Mis Reservas', handler: () => { this.navCtrl.navigateForward('/app/bookings'); } } ] }); await alert.present(); }, error: (error) => { console.error('Error al reservar', error); this.mostrarToast('Error al realizar la reserva', 'danger'); this.reservando = false; } }); this.subscription.add(sub); } async mostrarToast(mensaje: string, color: string = 'primary') { const toast = await this.toastCtrl.create({ message: mensaje, duration: 2000, position: 'bottom', color: color }); toast.present(); } compartir(medio: string) { if (!this.gymClass) return; const mensaje = `¡He encontrado una clase de ${this.gymClass.name} con ${this.gymClass.instructor}!`; // En una app real, aquí integraríamos con la API de Compartir nativa this.mostrarToast(`Compartiendo por ${medio}...`, 'success'); } } ``` ## 4. Implementación de la Página de Mis Reservas Ahora vamos a implementar la página para gestionar las reservas del usuario: **pages/bookings/bookings.page.html**: ```html Mis Reservas

Cargando tus reservas...

Próximas Clases

{{ booking.className }}

{{ obtenerFechaClase(booking) | date:'EEE, d MMM, h:mm a' }}

{{ getStatusText(booking.status) }}
Cancelar

No tienes reservas activas

Explora las clases disponibles y haz tu primera reserva

Ver Clases Disponibles
``` **pages/bookings/bookings.page.ts**: ```typescript import { Component, OnInit, OnDestroy } from '@angular/core'; import { AlertController, ToastController } from '@ionic/angular'; import { Subscription } from 'rxjs'; import { Booking } from '../../models/booking.model'; import { BookingsService } from '../../services/bookings.service'; import { ClassesService } from '../../services/classes.service'; @Component({ selector: 'app-bookings', templateUrl: './bookings.page.html', styleUrls: ['./bookings.page.scss'], }) export class BookingsPage implements OnInit, OnDestroy { reservas: Booking[] = []; cargando = true; private clasesCache: { [id: string]: any } = {}; private subscription: Subscription = new Subscription(); constructor( private bookingsService: BookingsService, private classesService: ClassesService, private alertController: AlertController, private toastController: ToastController ) { } ngOnInit() { this.cargarReservas(); } ngOnDestroy() { this.subscription.unsubscribe(); } ionViewWillEnter() { this.cargarReservas(); } cargarReservas() { this.cargando = true; const sub = this.bookingsService.getUserBookings().subscribe({ next: (bookings) => { // Ordenar por fecha y mostrar primero las confirmadas this.reservas = bookings.sort((a, b) => { // Primero ordenar por estado (confirmadas primero) if (a.status === 'confirmed' && b.status !== 'confirmed') return -1; if (a.status !== 'confirmed' && b.status === 'confirmed') return 1; // Luego ordenar por fecha const dateA = new Date(a.date); const dateB = new Date(b.date); return dateA.getTime() - dateB.getTime(); }); // Prellenar el cache de clases para obtener fechas reales this.precargarClases(); this.cargando = false; }, error: (error) => { console.error('Error al cargar reservas', error); this.cargando = false; } }); this.subscription.add(sub); } private precargarClases() { // Obtener solo IDs únicos de clases const classIds = [...new Set(this.reservas.map(b => b.classId))]; classIds.forEach(id => { const sub = this.classesService.getClassById(id).subscribe(gymClass => { if (gymClass) { this.clasesCache[id] = gymClass; } }); this.subscription.add(sub); }); } getClassImage(classId: string): string { return this.clasesCache[classId]?.imageUrl || 'assets/classes/default.jpg'; } obtenerFechaClase(booking: Booking): Date { // Si tenemos la clase en caché, usamos su fecha real if (this.clasesCache[booking.classId]) { return new Date(this.clasesCache[booking.classId].startTime); } // Si no, usamos la fecha de la reserva return new Date(booking.date); } refrescarReservas(event: any) { const sub = this.bookingsService.getUserBookings().subscribe({ next: (bookings) => { this.reservas = bookings; event.target.complete(); this.precargarClases(); }, error: (error) => { console.error('Error al refrescar reservas', error); event.target.complete(); } }); this.subscription.add(sub); } getStatusColor(status: string): string { switch (status) { case 'confirmed': return 'success'; case 'cancelled': return 'danger'; case 'pending': return 'warning'; default: return 'medium'; } } getStatusText(status: string): string { switch (status) { case 'confirmed': return 'Confirmada'; case 'cancelled': return 'Cancelada'; case 'pending': return 'Pendiente'; default: return status; } } async confirmarCancelacion(booking: Booking) { const alert = await this.alertController.create({ header: 'Confirmar Cancelación', message: `¿Estás seguro de que deseas cancelar tu reserva para la clase de ${booking.className}?`, buttons: [ { text: 'No', role: 'cancel' }, { text: 'Sí, Cancelar', handler: () => { this.cancelarReserva(booking.id); } } ] }); await alert.present(); } cancelarReserva(bookingId: string) { const sub = this.bookingsService.cancelBooking(bookingId).subscribe({ next: async () => { const toast = await this.toastController.create({ message: 'Reserva cancelada correctamente', duration: 2000, position: 'bottom', color: 'success' }); toast.present(); this.cargarReservas(); }, error: async (error) => { console.error('Error al cancelar reserva', error); const toast = await this.toastController.create({ message: 'Error al cancelar la reserva', duration: 2000, position: 'bottom', color: 'danger' }); toast.present(); } }); this.subscription.add(sub); } } ``` ## 5. Implementación de la Página de Perfil Implementemos la página de perfil de usuario con capacidad para subir imágenes: **pages/profile/profile.page.html**: ```html Mi Perfil
Foto de perfil

Toca para cambiar foto

{{ usuario?.name || 'Usuario' }}

{{ usuario?.email || 'usuario@ejemplo.com' }}

Editar Perfil Notificaciones Estadísticas

Clases Reservadas

{{ estadisticas.totalReservas }} reservas

Clases Completadas

{{ estadisticas.clasesCompletadas }} clases

Cuenta Ayuda y Soporte Cerrar Sesión

Versión 1.0.0

© 2025 Gym Reservations App

``` **pages/profile/profile.page.ts**: ```typescript import { Component, OnInit, OnDestroy } from '@angular/core'; import { AlertController, ToastController, NavController, ActionSheetController } from '@ionic/angular'; import { Subscription } from 'rxjs'; import { User } from '../../models/user.model'; import { AuthService } from '../../services/auth.service'; import { BookingsService } from '../../services/bookings.service'; import { UploadService } from '../../services/upload.service'; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; @Component({ selector: 'app-profile', templateUrl: './profile.page.html', styleUrls: ['./profile.page.scss'], }) export class ProfilePage implements OnInit, OnDestroy { usuario: User | null = null; profileImage: string | undefined; notificationsEnabled = true; estadisticas = { totalReservas: 0, clasesCompletadas: 0 }; private subscription: Subscription = new Subscription(); constructor( private authService: AuthService, private bookingsService: BookingsService, private uploadService: UploadService, private alertController: AlertController, private toastController: ToastController, private navController: NavController, private actionSheetController: ActionSheetController ) { } ngOnInit() { this.cargarDatosUsuario(); // Suscribirse a cambios en el usuario const userSub = this.authService.currentUser$.subscribe(user => { if (user) { this.usuario = user; this.profileImage = user.profilePic || user.profilePicUrl; this.notificationsEnabled = user.notificationsEnabled || (user.preferences ? user.preferences.notifications : true); } }); this.subscription.add(userSub); } ngOnDestroy() { this.subscription.unsubscribe(); } ionViewWillEnter() { this.cargarDatosUsuario(); this.cargarEstadisticas(); } cargarDatosUsuario() { this.usuario = this.authService.getCurrentUser(); if (this.usuario) { this.profileImage = this.usuario.profilePic || this.usuario.profilePicUrl; this.notificationsEnabled = this.usuario.notificationsEnabled || (this.usuario.preferences ? this.usuario.preferences.notifications : true); } } cargarEstadisticas() { const sub = this.bookingsService.getUserBookings().subscribe(bookings => { this.estadisticas.totalReservas = bookings.length; // Contar reservas completadas (las que no están canceladas y son pasadas) this.estadisticas.clasesCompletadas = bookings.filter(b => b.status === 'confirmed' && new Date(b.date) < new Date() ).length; }); this.subscription.add(sub); } async cambiarFotoPerfil() { const actionSheet = await this.actionSheetController.create({ header: 'Cambiar foto de perfil', buttons: [ { text: 'Tomar foto', icon: 'camera', handler: () => { this.tomarFoto(CameraSource.Camera); } }, { text: 'Elegir de la galería', icon: 'image', handler: () => { this.tomarFoto(CameraSource.Photos); } }, { text: 'Cancelar', icon: 'close', role: 'cancel' } ] }); await actionSheet.present(); } async tomarFoto(source: CameraSource) { try { const permisos = await Camera.requestPermissions(); if (permisos.photos === 'granted' || permisos.camera === 'granted') { const imagen = await Camera.getPhoto({ quality: 90, allowEditing: true, resultType: CameraResultType.Uri, source: source }); // Si tenemos la imagen, preparamos para subir if (imagen.webPath && imagen.path) { this.mostrarToast('Subiendo imagen...', 'primary'); // Convertir Uri a File const blob = await fetch(imagen.webPath).then(r => r.blob()); const file = new File([blob], `profile-${Date.now()}.${imagen.format || 'jpeg'}`, { type: `image/${imagen.format || 'jpeg'}` }); // Subir la imagen al servidor const sub = this.uploadService.uploadImage(file, 'avatar').subscribe({ next: (response) => { if (response && response.imageUrl) { // Actualizar perfil del usuario con la nueva imagen if (this.usuario) { this.authService.updateUserProfile({ ...this.usuario, profilePicUrl: response.imageUrl }).subscribe({ next: (updatedUser) => { this.profileImage = updatedUser.profilePicUrl || updatedUser.profilePic; this.mostrarToast('Foto de perfil actualizada', 'success'); }, error: (error) => { console.error('Error al actualizar perfil:', error); this.mostrarToast('Error al actualizar perfil', 'danger'); } }); } } }, error: (error) => { console.error('Error al subir imagen:', error); this.mostrarToast('Error al subir la imagen', 'danger'); } }); this.subscription.add(sub); } } else { this.mostrarToast('Necesitamos permiso para acceder a la cámara/galería', 'warning'); } } catch (error) { console.error('Error al tomar foto', error); this.mostrarToast('Error al procesar la imagen', 'danger'); } } async editarPerfil() { const alert = await this.alertController.create({ header: 'Editar Perfil', inputs: [ { name: 'name', type: 'text', placeholder: 'Nombre', value: this.usuario?.name } ], buttons: [ { text: 'Cancelar', role: 'cancel' }, { text: 'Guardar', handler: (data) => { if (this.usuario && data.name && data.name.trim() !== '') { this.authService.updateUserProfile({ ...this.usuario, name: data.name.trim() }).subscribe({ next: () => { this.mostrarToast('Perfil actualizado correctamente', 'success'); }, error: (error) => { console.error('Error al actualizar perfil:', error); this.mostrarToast('Error al actualizar perfil', 'danger'); } }); } } } ] }); await alert.present(); } toggleNotifications() { if (this.usuario) { // Preparar datos para actualizar const updatedUserData: Partial = { ...this.usuario, notificationsEnabled: this.notificationsEnabled, preferences: { ...this.usuario.preferences, notifications: this.notificationsEnabled } }; // Actualizar en el backend this.authService.updateUserProfile(updatedUserData).subscribe({ next: () => { this.mostrarToast( this.notificationsEnabled ? 'Notificaciones activadas' : 'Notificaciones desactivadas', 'success' ); }, error: (error) => { console.error('Error al actualizar preferencias', error); this.mostrarToast('Error al actualizar preferencias', 'danger'); // Revertir el toggle si hubo error this.notificationsEnabled = !this.notificationsEnabled; } }); } } async mostrarAyuda() { const alert = await this.alertController.create({ header: 'Ayuda y Soporte', message: 'Para cualquier consulta o problema con la aplicación, contáctanos en:
soporte@gymapp.com', buttons: ['Entendido'] }); await alert.present(); } async confirmarCerrarSesion() { const alert = await this.alertController.create({ header: 'Cerrar Sesión', message: '¿Estás seguro de que deseas cerrar sesión?', buttons: [ { text: 'Cancelar', role: 'cancel' }, { text: 'Cerrar Sesión', handler: () => { this.cerrarSesion(); } } ] }); await alert.present(); } async cerrarSesion() { try { await this.authService.logout(); this.mostrarToast('Sesión cerrada', 'success'); this.navController.navigateRoot('/auth'); } catch (error) { console.error('Error al cerrar sesión', error); this.mostrarToast('Error al cerrar sesión', 'danger'); } } async mostrarToast(mensaje: string, color: string = 'primary') { const toast = await this.toastController.create({ message: mensaje, duration: 2000, position: 'bottom', color: color }); toast.present(); } } ``` ## 6. Implementación de Notificaciones Locales Para implementar notificaciones locales, primero instalamos el plugin de Capacitor: ```bash npm install @capacitor/local-notifications npx cap sync ``` Ahora implementamos el servicio de notificaciones: **services/notification.service.ts**: ```typescript import { Injectable } from '@angular/core'; import { LocalNotifications } from '@capacitor/local-notifications'; import { Booking } from '../models/booking.model'; import { GymClass } from '../models/gym-class.model'; @Injectable({ providedIn: 'root' }) export class NotificationService { constructor() { this.initialize(); } private async initialize() { try { // Solicitar permisos al inicializar const permResult = await LocalNotifications.requestPermissions(); console.log('Permisos de notificación:', permResult.display); } catch (error) { console.error('Error al inicializar notificaciones', error); } } async scheduleClassReminder(booking: Booking, gymClass: GymClass) { try { // Verificar que tenemos permisos const permResult = await LocalNotifications.requestPermissions(); if (permResult.display !== 'granted') { console.log('Permisos de notificación no concedidos'); return; } // Configurar el tiempo de la notificación (1 hora antes de la clase) const classTime = new Date(gymClass.startTime); const notificationTime = new Date(classTime); notificationTime.setHours(notificationTime.getHours() - 1); // No programar notificaciones en el pasado if (notificationTime <= new Date()) { console.log('No se programó notificación (fecha en el pasado)'); return; } // Crear notificación await LocalNotifications.schedule({ notifications: [ { id: parseInt(booking.id.replace(/\D/g, '').substr(0, 8)) || Math.floor(Math.random() * 100000), title: `¡Recordatorio de clase: ${gymClass.name}!`, body: `Tu clase de ${gymClass.name} con ${gymClass.instructor} comienza en 1 hora.`, schedule: { at: notificationTime }, sound: 'default', actionTypeId: '', extra: { bookingId: booking.id, classId: gymClass.id } } ] }); console.log(`Notificación programada para ${notificationTime.toLocaleString()}`); } catch (error) { console.error('Error al programar notificación', error); } } async cancelNotification(bookingId: string) { try { // Obtener notificaciones pendientes const pendingList = await LocalNotifications.getPending(); // Buscar la notificación asociada a esta reserva const notificationToCancel = pendingList.notifications.find(notification => notification.extra && notification.extra.bookingId === bookingId ); if (notificationToCancel) { // Cancelar la notificación await LocalNotifications.cancel({ notifications: [ { id: notificationToCancel.id } ] }); console.log(`Notificación para reserva ${bookingId} cancelada`); } } catch (error) { console.error('Error al cancelar notificación', error); } } async checkNotificationStatus() { try { // Verificar el estado de los permisos const permResult = await LocalNotifications.checkPermissions(); return permResult.display === 'granted'; } catch (error) { console.error('Error al verificar permisos', error); return false; } } } ``` ## 7. Configuración Final para Deployment ### Ajustes del archivo capacitor.config.ts Configuremos los ajustes de Capacitor para nuestra aplicación: ```typescript import { CapacitorConfig } from '@capacitor/cli'; const config: CapacitorConfig = { appId: 'com.example.gymreservation', appName: 'Gym Reservation', webDir: 'www', server: { androidScheme: 'https' }, plugins: { LocalNotifications: { smallIcon: "ic_stat_icon_config_sample", iconColor: "#488AFF", sound: "beep.wav" }, Camera: { permissions: ['camera', 'photos'] } } }; export default config; ``` ### Compilación para Producción ```bash # Compilar para producción ionic build --prod # Sincronizar con proyectos nativos npx cap sync # Abrir proyecto en Android Studio npx cap open android ``` ## 8. Integración con Plataformas Nativas ### Permisos en Android Para usar la cámara y las notificaciones en Android, necesitamos añadir permisos al archivo `AndroidManifest.xml`: ```xml ``` ## Resumen En este capítulo, hemos completado nuestra aplicación de reservas para gimnasio integrándola con un backend real: 1. Implementamos servicios que interactúan con un backend Node.js 2. Creamos una interfaz de usuario completa para la gestión de clases y reservas 3. Añadimos autenticación de usuarios con registro y login 4. Implementamos la subida de imágenes para perfiles de usuario 5. Configuramos notificaciones locales para recordatorios de clases 6. Preparamos la aplicación para su despliegue en dispositivos móviles La aplicación ahora cuenta con todas las funcionalidades principales planificadas y está lista para ser utilizada en un entorno real. Los usuarios pueden registrarse, explorar clases disponibles, realizar reservas, gestionar su perfil y recibir notificaciones sobre sus próximas clases. ## Próximos Pasos En el siguiente capítulo, exploraremos: 1. Pruebas y depuración en dispositivos reales 2. Optimizaciones de rendimiento 3. Consideraciones para la publicación en tiendas de aplicaciones 4. Posibles extensiones y funcionalidades adicionales # Capítulo 6: Estructura del Backend y Despliegue de la Aplicación En este capítulo, exploraremos la estructura del backend que hemos implementado para nuestra aplicación de reservas de gimnasio y el proceso de despliegue para dispositivos móviles reales. ## 1. Arquitectura del Backend Nuestra aplicación ahora cuenta con un backend real basado en Node.js, Express y PostgreSQL que gestiona todas las operaciones de datos. Este enfoque tiene varias ventajas sobre el almacenamiento local: - Datos centralizados y accesibles desde cualquier dispositivo - Mayor seguridad y control de acceso - Escalabilidad mejorada para manejar más usuarios - Sincronización de datos en tiempo real ### Estructura de Carpetas del Backend ``` backend/ ├── config/ │ └── db.js # Configuración de la base de datos ├── controllers/ │ ├── authController.js # Controlador de autenticación │ ├── bookingController.js # Controlador de reservas │ ├── gymClassController.js # Controlador de clases │ ├── uploadController.js # Controlador para subida de archivos │ └── userController.js # Controlador de usuarios ├── models/ │ ├── Booking.js # Modelo de datos para reservas │ ├── GymClass.js # Modelo de datos para clases │ ├── User.js # Modelo de datos para usuarios │ └── index.js # Exporta todos los modelos ├── routes/ │ ├── authRoutes.js # Rutas de autenticación │ ├── bookingRoutes.js # Rutas de reservas │ ├── gymClassRoutes.js # Rutas de clases │ ├── uploadRoutes.js # Rutas para subida de archivos │ └── userRoutes.js # Rutas de usuarios ├── uploads/ # Directorio para archivos subidos ├── server.js # Punto de entrada principal └── package.json # Dependencias y scripts ``` ### Tecnologías Utilizadas en el Backend - **Node.js**: Entorno de ejecución JavaScript del lado del servidor - **Express**: Framework web para crear APIs REST - **PostgreSQL**: Base de datos relacional robusta para almacenar los datos - **Sequelize**: ORM para modelar los datos y conectar con PostgreSQL - **JWT (JSON Web Tokens)**: Para gestionar la autenticación de usuarios - **Multer**: Para manejar la subida de archivos ## 2. Implementación del Backend ### Configuración de la Base de Datos **config/db.js**: ```javascript const { Sequelize } = require('sequelize'); const dotenv = require('dotenv'); dotenv.config(); // Create Sequelize instance let sequelize; if (process.env.DATABASE_URL) { // Use connection string if available sequelize = new Sequelize(process.env.DATABASE_URL, { logging: process.env.NODE_ENV === 'development' ? console.log : false, define: { freezeTableName: true // Prevent Sequelize from pluralizing table names }, dialectOptions: { ssl: { require: true, rejectUnauthorized: false } }, pool: { max: 5, min: 0, acquire: 30000, idle: 10000 } }); } else { // Fall back to individual parameters sequelize = new Sequelize( process.env.DB_NAME || 'postgres', process.env.DB_USER || 'postgres', process.env.DB_PASSWORD || 'postgres', { host: process.env.DB_HOST || 'localhost', port: process.env.DB_PORT || 5432, dialect: 'postgres', logging: process.env.NODE_ENV === 'development' ? console.log : false, define: { freezeTableName: true // Prevent Sequelize from pluralizing table names }, dialectOptions: { ssl: process.env.NODE_ENV === 'production' ? { require: true, rejectUnauthorized: false } : false }, pool: { max: 5, min: 0, acquire: 30000, idle: 10000 } } ); } // Test the connection const connectDB = async () => { try { await sequelize.authenticate(); console.log('Database connection established successfully.'); } catch (error) { console.error('Unable to connect to the database:', error); process.exit(1); } }; module.exports = { sequelize, connectDB }; ``` ### Modelos de Datos **models/User.js**: ```javascript const { DataTypes } = require('sequelize'); const { sequelize } = require('../config/db'); const bcrypt = require('bcryptjs'); const User = sequelize.define('user', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.STRING, allowNull: false }, email: { type: DataTypes.STRING, allowNull: false, unique: true, validate: { isEmail: true } }, password: { type: DataTypes.STRING, allowNull: false }, profilePicUrl: { type: DataTypes.STRING, defaultValue: '/uploads/default-avatar.jpg' }, notificationsEnabled: { type: DataTypes.BOOLEAN, defaultValue: true } }, { hooks: { beforeCreate: async (user) => { if (user.password) { const salt = await bcrypt.genSalt(10); user.password = await bcrypt.hash(user.password, salt); } }, beforeUpdate: async (user) => { if (user.changed('password')) { const salt = await bcrypt.genSalt(10); user.password = await bcrypt.hash(user.password, salt); } } }, tableName: 'users' // Explicitly set lowercase table name }); // Method to check if password matches User.prototype.matchPassword = async function(enteredPassword) { return await bcrypt.compare(enteredPassword, this.password); }; module.exports = User; ``` **models/GymClass.js**: ```javascript const { DataTypes } = require('sequelize'); const { sequelize } = require('../config/db'); const GymClass = sequelize.define('gymClass', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.STRING, allowNull: false }, description: { type: DataTypes.TEXT, allowNull: false }, instructor: { type: DataTypes.STRING, allowNull: false }, startTime: { type: DataTypes.DATE, allowNull: false }, endTime: { type: DataTypes.DATE, allowNull: false }, maxCapacity: { type: DataTypes.INTEGER, allowNull: false }, currentBookings: { type: DataTypes.INTEGER, defaultValue: 0 }, category: { type: DataTypes.STRING, defaultValue: 'Otros' }, imageUrl: { type: DataTypes.STRING, defaultValue: '/uploads/default-class.jpg' } }, { tableName: 'classes' // Explicitly set lowercase table name }); module.exports = GymClass; ``` **models/Booking.js**: ```javascript const { DataTypes } = require('sequelize'); const { sequelize } = require('../config/db'); const Booking = sequelize.define('booking', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, userId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users', key: 'id' } }, classId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'classes', key: 'id' } }, className: { type: DataTypes.STRING, allowNull: false }, date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, status: { type: DataTypes.ENUM('confirmed', 'cancelled', 'pending'), defaultValue: 'confirmed' } }, { tableName: 'bookings' // Explicitly set lowercase table name }); module.exports = Booking; ``` **models/index.js**: ```javascript const User = require('./User'); const GymClass = require('./GymClass'); const Booking = require('./Booking'); // Define associations User.hasMany(Booking, { foreignKey: 'userId' }); Booking.belongsTo(User, { foreignKey: 'userId' }); GymClass.hasMany(Booking, { foreignKey: 'classId' }); Booking.belongsTo(GymClass, { foreignKey: 'classId' }); module.exports = { User, GymClass, Booking }; ``` ### Controladores **controllers/authController.js**: ```javascript const jwt = require('jsonwebtoken'); const { User } = require('../models'); // Generate JWT Token const generateToken = (id) => { return jwt.sign({ id }, process.env.JWT_SECRET || 'secret_key_12345', { expiresIn: '30d' }); }; // @desc Register a new user // @route POST /api/auth/register // @access Public exports.registerUser = async (req, res) => { try { const { name, email, password } = req.body; // Check if user already exists const existingUser = await User.findOne({ where: { email } }); if (existingUser) { return res.status(400).json({ success: false, message: 'Email is already registered' }); } // Create user const user = await User.create({ name, email, password }); // Generate token const token = generateToken(user.id); res.status(201).json({ success: true, token, user: { id: user.id, name: user.name, email: user.email, profilePicUrl: user.profilePicUrl, notificationsEnabled: user.notificationsEnabled } }); } catch (error) { console.error('Error registering user:', error); res.status(500).json({ success: false, message: 'Server error', error: error.message }); } }; // @desc Login user // @route POST /api/auth/login // @access Public exports.loginUser = async (req, res) => { try { const { email, password } = req.body; // Check for user email const user = await User.findOne({ where: { email } }); if (!user) { return res.status(401).json({ success: false, message: 'Invalid credentials' }); } // Check if password matches const isMatch = await user.matchPassword(password); if (!isMatch) { return res.status(401).json({ success: false, message: 'Invalid credentials' }); } // Generate token const token = generateToken(user.id); res.json({ success: true, token, user: { id: user.id, name: user.name, email: user.email, profilePicUrl: user.profilePicUrl, notificationsEnabled: user.notificationsEnabled } }); } catch (error) { console.error('Error logging in:', error); res.status(500).json({ success: false, message: 'Server error', error: error.message }); } }; ``` ### Rutas API **routes/authRoutes.js**: ```javascript const express = require('express'); const { registerUser, loginUser } = require('../controllers/authController'); const router = express.Router(); router.post('/register', registerUser); router.post('/login', loginUser); module.exports = router; ``` ### Punto de Entrada del Servidor **server.js**: ```javascript const path = require('path'); const express = require('express'); const dotenv = require('dotenv'); const cors = require('cors'); const morgan = require('morgan'); const fs = require('fs'); const { connectDB, sequelize } = require('./config/db'); // Load env variables dotenv.config(); // Import route files const userRoutes = require('./routes/userRoutes'); const gymClassRoutes = require('./routes/gymClassRoutes'); const bookingRoutes = require('./routes/bookingRoutes'); const uploadRoutes = require('./routes/uploadRoutes'); const authRoutes = require('./routes/authRoutes'); // Initialize express app const app = express(); // Middleware app.use(express.json()); app.use(cors()); // Dev logging middleware if (process.env.NODE_ENV === 'development') { app.use(morgan('dev')); } // Create uploads directory if it doesn't exist const uploadsDir = path.join(__dirname, 'uploads'); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } // Set static folder for uploads app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); // Mount routers app.use('/api/users', userRoutes); app.use('/api/classes', gymClassRoutes); app.use('/api/bookings', bookingRoutes); app.use('/api/upload', uploadRoutes); app.use('/api/auth', authRoutes); // Root route app.get('/', (req, res) => { res.json({ message: 'Welcome to Gym API' }); }); // Error handling middleware app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ message: 'Server Error', error: err.message }); }); // Define port const PORT = process.env.PORT || 3000; // Connect to database and start server const startServer = async () => { try { // Connect to database await connectDB(); await sequelize.sync(); console.log('Database synchronized'); // Start server app.listen(PORT, () => { console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`); }); } catch (error) { console.error('Unable to start server:', error); process.exit(1); } }; startServer(); // Handle unhandled promise rejections process.on('unhandledRejection', (err) => { console.log(`Error: ${err.message}`); // Close server & exit process process.exit(1); }); ``` ## 3. Despliegue en Plataformas Móviles Una de las grandes ventajas de usar Ionic con Capacitor es la posibilidad de desplegar la misma base de código en múltiples plataformas. Vamos a revisar el proceso para Android. ### Preparación para Android 1. **Instalar las Dependencias de Android**: - Android Studio - JDK (Java Development Kit) - Configurar variables de entorno (ANDROID_HOME, JAVA_HOME) 2. **Añadir la Plataforma Android a nuestro Proyecto**: ```bash # Instalar @capacitor/android si no está instalado npm install @capacitor/android # Añadir Android al proyecto npx cap add android ``` 3. **Sincronizar el Proyecto**: Después de hacer cambios en el código web, necesitamos construir el proyecto y sincronizarlo con las plataformas nativas: ```bash # Construir la aplicación ionic build --prod # Sincronizar con Android npx cap sync android ``` 4. **Abrir el Proyecto en Android Studio**: ```bash npx cap open android ``` ### Configuración del AndroidManifest.xml El archivo `AndroidManifest.xml` es crucial para definir los permisos y configuraciones de la aplicación Android. Ya cubrimos algunos permisos esenciales: ```xml ``` ### Personalización de Iconos y Splash Screen Para personalizar los iconos y la pantalla de inicio: 1. **Reemplazar los Archivos de Iconos**: - Crear iconos para diferentes resoluciones - Reemplazar los archivos en `android/app/src/main/res/mipmap-*/` 2. **Reemplazar la Pantalla de Inicio (Splash Screen)**: - Crear imágenes para diferentes orientaciones y resoluciones - Reemplazar los archivos en `android/app/src/main/res/drawable-*/splash.png` 3. **Personalizar el Nombre de la Aplicación**: - Editar `android/app/src/main/res/values/strings.xml`: ```xml Gym Reservation Gym Reservation com.example.gymreservation com.example.gymreservation ``` ### Construir un APK de Prueba Para generar un APK que puedas compartir con probadores: 1. En Android Studio, selecciona **Build > Build Bundle(s) / APK(s) > Build APK(s)** 2. Cuando termine, haz clic en **locate** para encontrar el archivo APK 3. Este archivo se puede compartir directamente para instalarlo en dispositivos Android (se debe habilitar la instalación desde fuentes desconocidas) ### Construir un Bundle para Google Play Para publicar en la tienda de Google Play: 1. En Android Studio, selecciona **Build > Generate Signed Bundle / APK** 2. Elige **Android App Bundle** 3. Crea o selecciona una clave de firma (keystore) 4. Completa la información necesaria y genera el bundle (.aab) 5. Este archivo es el que se sube a la Google Play Console ## 4. Consideraciones de Seguridad Al desplegar una aplicación móvil que se comunica con un backend, es crucial considerar aspectos de seguridad: ### Autenticación Segura Nuestra aplicación implementa JWT (JSON Web Tokens) para autenticar usuarios, pero aquí hay algunas mejoras adicionales: - **Tokens de Actualización**: Implementar tokens de corta duración con tokens de actualización - **Almacenamiento Seguro**: Utilizar almacenamiento nativo seguro para tokens - **HTTPS**: Garantizar que todas las comunicaciones API usen HTTPS ### Manejo de Datos Sensibles - **No almacenar contraseñas en texto plano**: Ya estamos usando bcrypt para hash de contraseñas - **Minimizar los datos almacenados localmente**: Solo guardar lo necesario en el dispositivo - **Eliminar datos al cerrar sesión**: Limpiar datos sensibles cuando el usuario cierre sesión ### Permisos de Aplicación - **Solicitar solo los permisos necesarios**: No solicitar permisos que no sean esenciales - **Explicar por qué se necesitan permisos**: Proporcionar contexto cuando se solicitan permisos - **Manejar denegaciones graciosamente**: La aplicación debe funcionar aunque se denieguen permisos no críticos ## 5. Optimizaciones de Rendimiento Para garantizar que nuestra aplicación funcione de manera óptima en dispositivos reales: ### Optimización de Imágenes - **Redimensionar imágenes**: Utilizar tamaños apropiados para dispositivos móviles - **Compresión de imágenes**: Reducir el tamaño de archivo sin perder calidad visible - **Carga progresiva**: Implementar carga progresiva para imágenes grandes ### Optimización de Red - **Implementar caché**: Almacenar en caché respuestas de API para reducir solicitudes de red - **Carga perezosa (lazy loading)**: Cargar datos solo cuando sean necesarios - **Compresión de datos**: Utilizar gzip en el servidor para respuestas API ### Optimización de la UI - **Virtualización de listas**: Para listas largas, solo renderizar elementos visibles - **Animaciones eficientes**: Usar animaciones CSS en lugar de JavaScript cuando sea posible - **Evitar work blocking**: No bloquear el hilo principal con operaciones pesadas ## 6. Pruebas y Depuración Para identificar y resolver problemas en dispositivos reales: ### Herramientas de Depuración - **Chrome Remote Debugging**: Para depurar WebView en dispositivos Android - **Safari Web Inspector**: Para depurar WebView en dispositivos iOS - **Capacitor Logs**: Usar `npx cap logs android` para ver logs en tiempo real ### Pruebas en Dispositivos Reales - **Probar en diferentes tamaños de pantalla**: Asegurar que la UI sea responsiva - **Probar con diferentes velocidades de red**: Simular conexiones lentas - **Pruebas de batería**: Verificar que la aplicación no consuma demasiada batería ## Resumen En este capítulo, hemos explorado: 1. La arquitectura de nuestro backend Node.js/Express/PostgreSQL 2. Los detalles de implementación de los modelos, controladores y rutas del backend 3. El proceso de despliegue para dispositivos Android 4. Consideraciones de seguridad para aplicaciones móviles 5. Técnicas de optimización de rendimiento 6. Herramientas y enfoques para pruebas y depuración Con esto, nuestra aplicación Gym Reservation está lista para ser utilizada por usuarios reales, con un backend robusto y una experiencia móvil optimizada.