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