1495 lines
45 KiB
Markdown
1495 lines
45 KiB
Markdown
# 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<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**:
|
|
|
|
```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<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**:
|
|
|
|
```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**:
|
|
|
|
```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
|
|
<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**:
|
|
|
|
```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
|
|
<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**:
|
|
|
|
```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
|
|
<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**:
|
|
|
|
```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<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:
|
|
|
|
```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
|
|
<!-- 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 |