# 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