taller-ionic/cap5.md
2025-04-24 15:57:53 -04:00

45 KiB

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:

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<GymClass[]>([]);
  public classes$ = this.classesSubject.asObservable();
  
  constructor(private http: HttpClient) {
    // Inicializar cargando clases
    this.loadClasses();
  }

  private loadClasses(): void {
    this.http.get<GymClass[]>(this.apiUrl)
      .pipe(
        tap(classes => this.classesSubject.next(classes)),
        catchError(error => {
          console.error('Error al cargar clases:', error);
          return throwError(() => error);
        })
      )
      .subscribe();
  }

  getClasses(): Observable<GymClass[]> {
    // Refrescar datos desde el servidor
    this.loadClasses();
    return this.classes$;
  }

  getClassById(id: string): Observable<GymClass | null> {
    return this.http.get<GymClass>(`${this.apiUrl}/${id}`).pipe(
      catchError(error => {
        console.error(`Error al obtener clase con ID ${id}:`, error);
        return throwError(() => error);
      })
    );
  }

  searchClasses(term: string): Observable<GymClass[]> {
    return this.http.get<GymClass[]>(`${this.apiUrl}/search`, { 
      params: { term } 
    }).pipe(
      catchError(error => {
        console.error('Error al buscar clases:', error);
        return throwError(() => error);
      })
    );
  }

  filterByCategory(category: string): Observable<GymClass[]> {
    return this.http.get<GymClass[]>(`${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<GymClass> {
    const url = `${this.apiUrl}/${classId}/bookings`;
    return this.http.patch<GymClass>(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:

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<Booking[]>([]);
  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<Booking[]>(`${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<Booking[]> {
    // Refrescar datos
    this.loadUserBookings();
    return this.bookings$;
  }

  getBookingById(id: string): Observable<Booking> {
    return this.http.get<Booking>(`${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<Booking> {
    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<Booking>(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<Booking> {
    return this.http.patch<Booking>(`${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:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Clases Disponibles</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-searchbar placeholder="Buscar clases" (ionInput)="buscarClases($event)" [debounce]="500"></ion-searchbar>

  <ion-segment (ionChange)="filtrarPorCategoria($event)" value="todas">
    <ion-segment-button value="todas">
      <ion-label>Todas</ion-label>
    </ion-segment-button>
    <ion-segment-button value="Mente y Cuerpo">
      <ion-label>Mente/Cuerpo</ion-label>
    </ion-segment-button>
    <ion-segment-button value="Cardiovascular">
      <ion-label>Cardio</ion-label>
    </ion-segment-button>
    <ion-segment-button value="Fuerza">
      <ion-label>Fuerza</ion-label>
    </ion-segment-button>
  </ion-segment>

  <div *ngIf="cargando" class="ion-text-center ion-padding">
    <ion-spinner></ion-spinner>
    <p>Cargando clases...</p>
  </div>

  <ion-list *ngIf="!cargando">
    <ion-item *ngFor="let gymClass of clasesFiltradas" [routerLink]="['/app/classes', gymClass.id]" detail>
      <ion-thumbnail slot="start">
        <img [src]="gymClass.imageUrl || 'assets/classes/default.jpg'" 
            onerror="this.src='assets/classes/default.jpg'">
      </ion-thumbnail>
      <ion-label>
        <h2>{{ gymClass.name }}</h2>
        <p>{{ gymClass.startTime | date:'EEE, d MMM, h:mm a' }}</p>
        <p>{{ gymClass.instructor }}</p>
        <ion-note>
          <ion-icon name="people-outline"></ion-icon>
          {{ gymClass.currentBookings }}/{{ gymClass.maxCapacity }}
        </ion-note>
      </ion-label>
      <ion-badge slot="end" *ngIf="gymClass.category">{{ gymClass.category }}</ion-badge>
    </ion-item>
  </ion-list>

  <ion-refresher slot="fixed" (ionRefresh)="refrescarClases($event)">
    <ion-refresher-content></ion-refresher-content>
  </ion-refresher>

  <div *ngIf="!cargando && clasesFiltradas.length === 0" class="ion-text-center ion-padding">
    <ion-icon name="sad-outline" style="font-size: 48px;"></ion-icon>
    <h3>No se encontraron clases</h3>
    <p>Intenta con otros filtros o términos de búsqueda.</p>
  </div>
</ion-content>

pages/classes/classes.page.ts:

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:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/app/classes"></ion-back-button>
    </ion-buttons>
    <ion-title>Detalle de Clase</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <div *ngIf="cargando" class="ion-text-center">
    <ion-spinner></ion-spinner>
    <p>Cargando información...</p>
  </div>

  <div *ngIf="gymClass && !cargando">
    <ion-card>
      <ion-img [src]="gymClass.imageUrl || 'assets/classes/default.jpg'" 
               onerror="this.src='assets/classes/default.jpg'"
               class="class-image"></ion-img>
      
      <ion-card-header>
        <ion-badge>{{ gymClass.category }}</ion-badge>
        <ion-card-title class="ion-margin-top">{{ gymClass.name }}</ion-card-title>
        <ion-card-subtitle>Instructor: {{ gymClass.instructor }}</ion-card-subtitle>
      </ion-card-header>
      
      <ion-card-content>
        <p>{{ gymClass.description }}</p>
        
        <ion-list lines="none">
          <ion-item>
            <ion-icon name="time-outline" slot="start" color="primary"></ion-icon>
            <ion-label>
              <h3>Horario</h3>
              <p>{{ gymClass.startTime | date:'EEEE, d MMM, h:mm a' }} - {{ gymClass.endTime | date:'h:mm a' }}</p>
            </ion-label>
          </ion-item>
          
          <ion-item>
            <ion-icon name="people-outline" slot="start" color="primary"></ion-icon>
            <ion-label>
              <h3>Capacidad</h3>
              <p>{{ gymClass.currentBookings }}/{{ gymClass.maxCapacity }} plazas ocupadas</p>
              <ion-progress-bar [value]="gymClass.currentBookings / gymClass.maxCapacity" 
                              [color]="getCapacityColor()"></ion-progress-bar>
            </ion-label>
          </ion-item>
        </ion-list>
      </ion-card-content>
    </ion-card>
    
    <div class="ion-padding">
      <ion-button expand="block" (click)="reservarClase()" [disabled]="isClassFull() || reservando">
        <ion-spinner name="dots" *ngIf="reservando"></ion-spinner>
        <span *ngIf="!reservando">Reservar Plaza</span>
      </ion-button>
      
      <ion-text color="medium" *ngIf="isClassFull()" class="ion-text-center">
        <p>Lo sentimos, esta clase está completa.</p>
      </ion-text>
    </div>
  </div>
  
  <ion-fab vertical="bottom" horizontal="end" slot="fixed" *ngIf="gymClass">
    <ion-fab-button color="light">
      <ion-icon name="share-social"></ion-icon>
    </ion-fab-button>
    <ion-fab-list side="top">
      <ion-fab-button color="primary" (click)="compartir('whatsapp')">
        <ion-icon name="logo-whatsapp"></ion-icon>
      </ion-fab-button>
      <ion-fab-button color="secondary" (click)="compartir('twitter')">
        <ion-icon name="logo-twitter"></ion-icon>
      </ion-fab-button>
      <ion-fab-button color="tertiary" (click)="compartir('email')">
        <ion-icon name="mail"></ion-icon>
      </ion-fab-button>
    </ion-fab-list>
  </ion-fab>
</ion-content>

pages/class-detail/class-detail.page.ts:

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:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Mis Reservas</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <div *ngIf="cargando" class="ion-text-center ion-padding">
    <ion-spinner></ion-spinner>
    <p>Cargando tus reservas...</p>
  </div>

  <ion-list *ngIf="!cargando && reservas.length > 0">
    <ion-list-header>
      <ion-label>Próximas Clases</ion-label>
    </ion-list-header>

    <ion-item-sliding *ngFor="let booking of reservas">
      <ion-item>
        <ion-thumbnail slot="start">
          <img [src]="getClassImage(booking.classId)" 
               onerror="this.src='assets/classes/default.jpg'">
        </ion-thumbnail>
        <ion-label>
          <h2>{{ booking.className }}</h2>
          <p>{{ obtenerFechaClase(booking) | date:'EEE, d MMM, h:mm a' }}</p>
          <ion-badge [color]="getStatusColor(booking.status)">
            {{ getStatusText(booking.status) }}
          </ion-badge>
        </ion-label>
      </ion-item>
      
      <ion-item-options side="end">
        <ion-item-option color="danger" (click)="confirmarCancelacion(booking)" 
                        *ngIf="booking.status === 'confirmed'">
          <ion-icon slot="icon-only" name="trash"></ion-icon>
          Cancelar
        </ion-item-option>
      </ion-item-options>
    </ion-item-sliding>
  </ion-list>
  
  <ion-refresher slot="fixed" (ionRefresh)="refrescarReservas($event)">
    <ion-refresher-content></ion-refresher-content>
  </ion-refresher>
  
  <div *ngIf="!cargando && reservas.length === 0" class="ion-text-center ion-padding empty-state">
    <ion-icon name="calendar-outline" class="large-icon"></ion-icon>
    <h3>No tienes reservas activas</h3>
    <p>Explora las clases disponibles y haz tu primera reserva</p>
    <ion-button expand="block" routerLink="/app/classes">
      <ion-icon name="fitness" slot="start"></ion-icon>
      Ver Clases Disponibles
    </ion-button>
  </div>
</ion-content>

pages/bookings/bookings.page.ts:

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:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Mi Perfil</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <div class="profile-header ion-text-center">
    <ion-avatar class="profile-avatar" (click)="cambiarFotoPerfil()">
      <img [src]="profileImage || 'assets/avatar-placeholder.png'" 
           onerror="this.src='assets/avatar-placeholder.png'"
           alt="Foto de perfil">
    </ion-avatar>
    <p class="tap-text">Toca para cambiar foto</p>
    <h2>{{ usuario?.name || 'Usuario' }}</h2>
    <p>{{ usuario?.email || 'usuario@ejemplo.com' }}</p>
  </div>
  
  <ion-list lines="full" class="ion-margin-top">
    <ion-item button (click)="editarPerfil()">
      <ion-icon name="person-outline" slot="start" color="primary"></ion-icon>
      <ion-label>Editar Perfil</ion-label>
      <ion-icon name="chevron-forward" slot="end" color="medium"></ion-icon>
    </ion-item>
    
    <ion-item>
      <ion-icon name="notifications-outline" slot="start" color="primary"></ion-icon>
      <ion-label>Notificaciones</ion-label>
      <ion-toggle [(ngModel)]="notificationsEnabled" (ionChange)="toggleNotifications()"></ion-toggle>
    </ion-item>
    
    <ion-item-divider>
      <ion-label>Estadísticas</ion-label>
    </ion-item-divider>
    
    <ion-item>
      <ion-icon name="calendar-number-outline" slot="start" color="primary"></ion-icon>
      <ion-label>
        <h2>Clases Reservadas</h2>
        <p>{{ estadisticas.totalReservas }} reservas</p>
      </ion-label>
    </ion-item>
    
    <ion-item>
      <ion-icon name="fitness-outline" slot="start" color="primary"></ion-icon>
      <ion-label>
        <h2>Clases Completadas</h2>
        <p>{{ estadisticas.clasesCompletadas }} clases</p>
      </ion-label>
    </ion-item>
    
    <ion-item-divider>
      <ion-label>Cuenta</ion-label>
    </ion-item-divider>
    
    <ion-item button (click)="mostrarAyuda()">
      <ion-icon name="help-circle-outline" slot="start" color="primary"></ion-icon>
      <ion-label>Ayuda y Soporte</ion-label>
    </ion-item>
    
    <ion-item button (click)="confirmarCerrarSesion()">
      <ion-icon name="log-out-outline" slot="start" color="danger"></ion-icon>
      <ion-label color="danger">Cerrar Sesión</ion-label>
    </ion-item>
  </ion-list>

  <div class="app-info ion-text-center ion-margin-top">
    <p class="version">Versión 1.0.0</p>
    <p>© 2025 Gym Reservations App</p>
  </div>
</ion-content>

pages/profile/profile.page.ts:

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<User> = {
        ...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: <br><strong>soporte@gymapp.com</strong>',
      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:

npm install @capacitor/local-notifications
npx cap sync

Ahora implementamos el servicio de notificaciones:

services/notification.service.ts:

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:

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

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

<!-- Permisos para cámara -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />

<!-- Permisos para notificaciones -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />

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