This commit is contained in:
luis cespedes 2025-04-23 11:57:14 -04:00
parent cf6be05994
commit d20150506f
54 changed files with 1160 additions and 277 deletions

View File

@ -136,7 +136,8 @@
"cli": { "cli": {
"schematicCollections": [ "schematicCollections": [
"@ionic/angular-toolkit" "@ionic/angular-toolkit"
] ],
"analytics": false
}, },
"schematics": { "schematics": {
"@ionic/angular-toolkit:component": { "@ionic/angular-toolkit:component": {

10
package-lock.json generated
View File

@ -20,6 +20,7 @@
"@capacitor/core": "7.2.0", "@capacitor/core": "7.2.0",
"@capacitor/haptics": "7.0.1", "@capacitor/haptics": "7.0.1",
"@capacitor/keyboard": "7.0.1", "@capacitor/keyboard": "7.0.1",
"@capacitor/preferences": "^7.0.1",
"@capacitor/status-bar": "7.0.1", "@capacitor/status-bar": "7.0.1",
"@ionic/angular": "^8.0.0", "@ionic/angular": "^8.0.0",
"ionicons": "^7.0.0", "ionicons": "^7.0.0",
@ -2768,6 +2769,15 @@
"@capacitor/core": ">=7.0.0" "@capacitor/core": ">=7.0.0"
} }
}, },
"node_modules/@capacitor/preferences": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-7.0.1.tgz",
"integrity": "sha512-XF9jOHzvoIBZLwZr/EX6aVaUO1d8Mx7TwBLQS33pYHOliCW5knT5KUkFOXNNYxh9qqODYesee9xuQIKNJpQBag==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/status-bar": { "node_modules/@capacitor/status-bar": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.1.tgz",

View File

@ -25,6 +25,7 @@
"@capacitor/core": "7.2.0", "@capacitor/core": "7.2.0",
"@capacitor/haptics": "7.0.1", "@capacitor/haptics": "7.0.1",
"@capacitor/keyboard": "7.0.1", "@capacitor/keyboard": "7.0.1",
"@capacitor/preferences": "^7.0.1",
"@capacitor/status-bar": "7.0.1", "@capacitor/status-bar": "7.0.1",
"@ionic/angular": "^8.0.0", "@ionic/angular": "^8.0.0",
"ionicons": "^7.0.0", "ionicons": "^7.0.0",

View File

@ -5,6 +5,22 @@ const routes: Routes = [
{ {
path: '', path: '',
loadChildren: () => import('./tabs/tabs.module').then(m => m.TabsPageModule) loadChildren: () => import('./tabs/tabs.module').then(m => m.TabsPageModule)
},
{
path: 'classes',
loadChildren: () => import('./pages/classes/classes.module').then( m => m.ClassesPageModule)
},
{
path: 'class-detail',
loadChildren: () => import('./pages/class-detail/class-detail.module').then( m => m.ClassDetailPageModule)
},
{
path: 'bookings',
loadChildren: () => import('./pages/bookings/bookings.module').then( m => m.BookingsPageModule)
},
{
path: 'profile',
loadChildren: () => import('./pages/profile/profile.module').then( m => m.ProfilePageModule)
} }
]; ];
@NgModule({ @NgModule({

View File

@ -0,0 +1,8 @@
export interface Booking {
id: string;
userId: string;
classId: string;
className: string;
date: Date;
status: 'confirmed' | 'cancelled' | 'pending';
}

View File

@ -0,0 +1,12 @@
export interface GymClass {
id: string;
name: string;
description: string;
instructor: string;
startTime: Date;
endTime: Date;
maxCapacity: number;
currentBookings: number;
category?: string;
imageUrl?: string;
}

View File

@ -0,0 +1,10 @@
export interface User {
id: string;
name: string;
email: string;
profilePic?: string;
preferences?: {
notifications: boolean;
favoriteClasses?: string[];
};
}

View File

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { BookingsPage } from './bookings.page';
const routes: Routes = [
{
path: '',
component: BookingsPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class BookingsPageRoutingModule {}

View File

@ -0,0 +1,20 @@
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { BookingsPageRoutingModule } from './bookings-routing.module';
import { BookingsPage } from './bookings.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
BookingsPageRoutingModule
],
declarations: [BookingsPage],
})
export class BookingsPageModule {}

View File

@ -1,17 +1,13 @@
<ion-header [translucent]="true"> <ion-header [translucent]="true">
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>bookings</ion-title>
Tab 2
</ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content [fullscreen]="true"> <ion-content [fullscreen]="true">
<ion-header collapse="condense"> <ion-header collapse="condense">
<ion-toolbar> <ion-toolbar>
<ion-title size="large">Tab 2</ion-title> <ion-title size="large">bookings</ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<app-explore-container name="Tab 2 page"></app-explore-container>
</ion-content> </ion-content>

View File

@ -0,0 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BookingsPage } from './bookings.page';
describe('BookingsPage', () => {
let component: BookingsPage;
let fixture: ComponentFixture<BookingsPage>;
beforeEach(() => {
fixture = TestBed.createComponent(BookingsPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,16 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-bookings',
templateUrl: './bookings.page.html',
styleUrls: ['./bookings.page.scss'],
standalone: false
})
export class BookingsPage implements OnInit {
constructor() { }
ngOnInit() {
}
}

View File

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ClassDetailPage } from './class-detail.page';
const routes: Routes = [
{
path: '',
component: ClassDetailPage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ClassDetailPageRoutingModule {}

View File

@ -0,0 +1,17 @@
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { ClassDetailPageRoutingModule } from './class-detail-routing.module';
import { ClassDetailPage } from './class-detail.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ClassDetailPageRoutingModule
],
declarations: [ClassDetailPage],
})
export class ClassDetailPageModule {}

View File

@ -0,0 +1,79 @@
<ion-header>
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-back-button defaultHref="/tabs/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'" 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>

View File

@ -0,0 +1,9 @@
.class-image {
height: 200px;
object-fit: cover;
}
ion-progress-bar {
margin-top: 8px;
}

View File

@ -0,0 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ClassDetailPage } from './class-detail.page';
describe('ClassDetailPage', () => {
let component: ClassDetailPage;
let fixture: ComponentFixture<ClassDetailPage>;
beforeEach(() => {
fixture = TestBed.createComponent(ClassDetailPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,150 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NavController, ToastController, AlertController } from '@ionic/angular';
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'],
standalone: false
})
export class ClassDetailPage implements OnInit {
gymClass?: GymClass;
cargando = true;
reservando = false;
constructor(
private route: ActivatedRoute,
private navCtrl: NavController,
private classesService: ClassesService,
private bookingsService: BookingsService,
private toastCtrl: ToastController,
private alertCtrl: AlertController
) { }
ngOnInit() {
this.cargarDatosClase();
}
ionViewWillEnter() {
this.cargarDatosClase();
}
cargarDatosClase() {
const id = this.route.snapshot.paramMap.get('id');
if (!id) {
this.navCtrl.navigateBack('/tabs/classes');
return;
}
this.cargando = true;
this.classesService.getClassById(id).subscribe({
next: (gymClass) => {
if (gymClass) {
this.gymClass = gymClass;
} else {
this.navCtrl.navigateBack('/tabs/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('/tabs/classes');
this.mostrarToast('Error al cargar la información', 'danger');
}
});
}
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;
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 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('/tabs/bookings');
}
}
]
});
await alert.present();
},
error: (error) => {
console.error('Error al reservar', error);
this.mostrarToast('Error al realizar la reserva', 'danger');
this.reservando = false;
}
});
}
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}!`;
switch (medio) {
case 'whatsapp':
// En una app real, integraríamos con el plugin de Social Sharing
this.mostrarToast('Compartiendo por WhatsApp...', 'success');
break;
case 'twitter':
this.mostrarToast('Compartiendo por Twitter...', 'success');
break;
case 'email':
this.mostrarToast('Compartiendo por Email...', 'success');
break;
}
}
}

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ClassesPage } from './classes.page';
const routes: Routes = [
{
path: '',
component: ClassesPage
},
{
path: ':id',
loadChildren: () => import('../class-detail/class-detail.module').then(m => m.ClassDetailPageModule)
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ClassesPageRoutingModule {}

View File

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { ClassesPageRoutingModule } from './classes-routing.module';
import { ClassesPage } from './classes.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ClassesPageRoutingModule
],
declarations: [ClassesPage],
})
export class ClassesPageModule {}

View File

@ -0,0 +1,57 @@
<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]="['/tabs/classes', gymClass.id]" detail>
<ion-thumbnail slot="start">
<ion-img [src]="gymClass.imageUrl || 'assets/classes/default.jpg'"></ion-img>
</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>

View File

@ -0,0 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ClassesPage } from './classes.page';
describe('ClassesPage', () => {
let component: ClassesPage;
let fixture: ComponentFixture<ClassesPage>;
beforeEach(() => {
fixture = TestBed.createComponent(ClassesPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,93 @@
import { Component, OnInit } from '@angular/core';
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'],
standalone: false
})
export class ClassesPage implements OnInit {
clases: GymClass[] = [];
clasesFiltradas: GymClass[] = [];
cargando = true;
terminoBusqueda = '';
categoriaSeleccionada = 'todas';
constructor(private classesService: ClassesService) { }
ngOnInit() {
this.cargarClases();
}
ionViewWillEnter() {
this.cargarClases();
}
cargarClases() {
this.cargando = true;
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;
}
});
}
refrescarClases(event: any) {
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();
}
});
}
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) => {
return a.startTime.getTime() - b.startTime.getTime();
});
this.clasesFiltradas = resultado;
}
}

View File

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ProfilePage } from './profile.page';
const routes: Routes = [
{
path: '',
component: ProfilePage
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ProfilePageRoutingModule {}

View File

@ -0,0 +1,20 @@
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { ProfilePageRoutingModule } from './profile-routing.module';
import { ProfilePage } from './profile.page';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ProfilePageRoutingModule
],
declarations: [ProfilePage]
})
export class ProfilePageModule {}

View File

@ -1,17 +1,13 @@
<ion-header [translucent]="true"> <ion-header [translucent]="true">
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>profile</ion-title>
Tab 3
</ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content [fullscreen]="true"> <ion-content [fullscreen]="true">
<ion-header collapse="condense"> <ion-header collapse="condense">
<ion-toolbar> <ion-toolbar>
<ion-title size="large">Tab 3</ion-title> <ion-title size="large">profile</ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<app-explore-container name="Tab 3 page"></app-explore-container>
</ion-content> </ion-content>

View File

@ -0,0 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProfilePage } from './profile.page';
describe('ProfilePage', () => {
let component: ProfilePage;
let fixture: ComponentFixture<ProfilePage>;
beforeEach(() => {
fixture = TestBed.createComponent(ProfilePage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,16 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-profile',
templateUrl: './profile.page.html',
styleUrls: ['./profile.page.scss'],
standalone: false
})
export class ProfilePage implements OnInit {
constructor() { }
ngOnInit() {
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AuthService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,66 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { delay, tap } from 'rxjs/operators';
import { User } from '../models/user.model';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private currentUserSubject = new BehaviorSubject<User | null>(null);
public currentUser$ = this.currentUserSubject.asObservable();
// Usuario de demostración
private demoUser: User = {
id: 'user123',
name: 'Usuario Demo',
email: 'usuario@ejemplo.com',
preferences: {
notifications: true
}
};
constructor() {
// Simular usuario ya autenticado para el taller
this.currentUserSubject.next(this.demoUser);
}
getCurrentUser(): User | null {
return this.currentUserSubject.value;
}
// Simulación de login
login(email: string, password: string): Observable<User> {
// En una app real, aquí se realizaría la autenticación contra un backend
return of(this.demoUser).pipe(
delay(1000), // Simular latencia de red
tap(user => this.currentUserSubject.next(user))
);
}
// Simulación de logout
logout(): Observable<boolean> {
return of(true).pipe(
delay(500), // Simular latencia de red
tap(() => this.currentUserSubject.next(null))
);
}
// Simulación de actualización de perfil
updateUserProfile(userData: Partial<User>): Observable<User> {
const currentUser = this.getCurrentUser();
if (!currentUser) {
return of(this.demoUser);
}
const updatedUser: User = {
...currentUser,
...userData
};
return of(updatedUser).pipe(
delay(800), // Simular latencia de red
tap(user => this.currentUserSubject.next(user))
);
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { BookingsService } from './bookings.service';
describe('BookingsService', () => {
let service: BookingsService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(BookingsService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,124 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, from, of } from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators';
import { Booking } from '../models/booking.model';
import { StorageService } from './storage.service';
import { ClassesService } from './classes.service';
@Injectable({
providedIn: 'root'
})
export class BookingsService {
private STORAGE_KEY = 'bookings';
private bookingsSubject = new BehaviorSubject<Booking[]>([]);
public bookings$ = this.bookingsSubject.asObservable();
private initialized = false;
private userId = 'user123'; // En una app real, vendría de la autenticación
constructor(
private storageService: StorageService,
private classesService: ClassesService
) {
this.init();
}
private async init() {
if (this.initialized) return;
const storedBookings = await this.storageService.get(this.STORAGE_KEY);
if (storedBookings) {
// Convertir las fechas de string a objetos Date
const bookings = storedBookings.map((booking: any) => ({
...booking,
date: new Date(booking.date)
}));
this.bookingsSubject.next(bookings);
} else {
this.bookingsSubject.next([]);
await this.storageService.set(this.STORAGE_KEY, []);
}
this.initialized = true;
}
getUserBookings(): Observable<Booking[]> {
return from(this.ensureInitialized()).pipe(
switchMap(() => this.bookings$),
map(bookings => bookings.filter(booking => booking.userId === this.userId))
);
}
addBooking(classId: string, className: string): Observable<Booking> {
return from(this.ensureInitialized()).pipe(
switchMap(() => {
// Actualizar el contador de reservas de la clase
return this.classesService.updateClassBookings(classId, 1).pipe(
switchMap(success => {
if (!success) {
throw new Error('No se pudo actualizar la clase');
}
const newBooking: Booking = {
id: Date.now().toString(),
userId: this.userId,
classId,
className,
date: new Date(),
status: 'confirmed'
};
const currentBookings = this.bookingsSubject.value;
const updatedBookings = [...currentBookings, newBooking];
return from(this.storageService.set(this.STORAGE_KEY, updatedBookings)).pipe(
tap(() => this.bookingsSubject.next(updatedBookings)),
map(() => newBooking)
);
})
);
})
);
}
cancelBooking(bookingId: string): Observable<boolean> {
return from(this.ensureInitialized()).pipe(
switchMap(() => {
const currentBookings = this.bookingsSubject.value;
const index = currentBookings.findIndex(b => b.id === bookingId);
if (index === -1) return of(false);
const booking = currentBookings[index];
// No permitir cancelar reservas ya canceladas
if (booking.status === 'cancelled') return of(false);
// Crear copia actualizada
const updatedBooking = { ...booking, status: 'cancelled' as 'cancelled' };
const updatedBookings = [...currentBookings];
updatedBookings[index] = updatedBooking;
// Actualizar contador de clase
return this.classesService.updateClassBookings(booking.classId, -1).pipe(
switchMap(success => {
if (!success) {
return of(false);
}
return from(this.storageService.set(this.STORAGE_KEY, updatedBookings)).pipe(
tap(() => this.bookingsSubject.next(updatedBookings)),
map(() => true)
);
})
);
})
);
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.init();
}
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ClassesService } from './classes.service';
describe('ClassesService', () => {
let service: ClassesService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ClassesService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,155 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, from, of } from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators';
import { GymClass } from '../models/gym-class.model';
import { StorageService } from './storage.service';
@Injectable({
providedIn: 'root'
})
export class ClassesService {
private STORAGE_KEY = 'gym_classes';
private classesSubject = new BehaviorSubject<GymClass[]>([]);
public classes$ = this.classesSubject.asObservable();
private initialized = false;
// Datos iniciales para mock
private initialClasses: GymClass[] = [
{
id: '1',
name: 'Yoga',
description: 'Clase de yoga para todos los niveles',
instructor: 'María López',
startTime: new Date('2025-04-24T08:00:00'),
endTime: new Date('2025-04-24T09:00:00'),
maxCapacity: 15,
currentBookings: 8,
category: 'Mente y Cuerpo',
imageUrl: 'https://cdn-icons-png.flaticon.com/512/3456/3456464.png'
},
{
id: '2',
name: 'Spinning',
description: 'Clase de alta intensidad de ciclismo estático',
instructor: 'Juan Pérez',
startTime: new Date('2025-04-24T10:00:00'),
endTime: new Date('2025-04-24T11:00:00'),
maxCapacity: 20,
currentBookings: 15,
category: 'Cardiovascular',
imageUrl: 'https://cdn-icons-png.flaticon.com/512/805/805504.png'
},
{
id: '3',
name: 'Pilates (Pesas de agarre)',
description: 'Fortalecimiento de core y flexibilidad',
instructor: 'Ana García',
startTime: new Date('2025-04-24T16:00:00'),
endTime: new Date('2025-04-24T17:00:00'),
maxCapacity: 12,
currentBookings: 5,
category: 'Mente y Cuerpo',
imageUrl: 'https://cdn-icons-png.flaticon.com/512/625/625454.png'
},
{
id: '4',
name: 'Zumba',
description: 'Baile y ejercicio cardiovascular',
instructor: 'Carlos Martínez',
startTime: new Date('2025-04-25T18:00:00'),
endTime: new Date('2025-04-25T19:00:00'),
maxCapacity: 25,
currentBookings: 18,
category: 'Baile',
imageUrl: 'https://cdn-icons-png.flaticon.com/512/5776/5776440.png'
},
{
id: '5',
name: 'CrossFit',
description: 'Entrenamiento funcional de alta intensidad',
instructor: 'Roberto Sánchez',
startTime: new Date('2025-04-25T09:00:00'),
endTime: new Date('2025-04-25T10:00:00'),
maxCapacity: 15,
currentBookings: 12,
category: 'Fuerza',
imageUrl: 'https://cdn-icons-png.flaticon.com/512/372/372612.png'
}
];
constructor(private storageService: StorageService) {
this.init();
}
private async init() {
if (this.initialized) return;
// Intentar cargar datos desde almacenamiento
const storedClasses = await this.storageService.get(this.STORAGE_KEY);
if (storedClasses && storedClasses.length > 0) {
// Convertir las fechas de string a objetos Date
const classes = storedClasses.map((cls: any) => ({
...cls,
startTime: new Date(cls.startTime),
endTime: new Date(cls.endTime)
}));
this.classesSubject.next(classes);
} else {
// Si no hay datos almacenados, usar datos iniciales
await this.storageService.set(this.STORAGE_KEY, this.initialClasses);
this.classesSubject.next(this.initialClasses);
}
this.initialized = true;
}
getClasses(): Observable<GymClass[]> {
return from(this.ensureInitialized()).pipe(
switchMap(() => this.classes$)
);
}
getClassById(id: string): Observable<GymClass | undefined> {
return this.getClasses().pipe(
map(classes => classes.find(c => c.id === id))
);
}
updateClassBookings(classId: string, change: number): Observable<boolean> {
return from(this.ensureInitialized()).pipe(
switchMap(() => {
const currentClasses = this.classesSubject.value;
const index = currentClasses.findIndex(c => c.id === classId);
if (index === -1) return of(false);
const updatedClass = { ...currentClasses[index] };
updatedClass.currentBookings += change;
// Verificar límites
if (updatedClass.currentBookings < 0) {
updatedClass.currentBookings = 0;
}
if (updatedClass.currentBookings > updatedClass.maxCapacity) {
return of(false);
}
const updatedClasses = [...currentClasses];
updatedClasses[index] = updatedClass;
return from(this.storageService.set(this.STORAGE_KEY, updatedClasses)).pipe(
tap(() => this.classesSubject.next(updatedClasses)),
map(() => true)
);
})
);
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.init();
}
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { StorageService } from './storage.service';
describe('StorageService', () => {
let service: StorageService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(StorageService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,35 @@
import { Injectable } from '@angular/core';
import { Preferences } from '@capacitor/preferences';
@Injectable({
providedIn: 'root'
})
export class StorageService {
constructor() { }
async set(key: string, value: any): Promise<void> {
await Preferences.set({
key,
value: JSON.stringify(value)
});
}
async get(key: string): Promise<any> {
const { value } = await Preferences.get({ key });
if (value) {
return JSON.parse(value);
}
return null;
}
async remove(key: string): Promise<void> {
await Preferences.remove({ key });
}
async clear(): Promise<void> {
await Preferences.clear();
}
}

View File

@ -1,16 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Tab1Page } from './tab1.page';
const routes: Routes = [
{
path: '',
component: Tab1Page,
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class Tab1PageRoutingModule {}

View File

@ -1,20 +0,0 @@
import { IonicModule } from '@ionic/angular';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Tab1Page } from './tab1.page';
import { ExploreContainerComponentModule } from '../explore-container/explore-container.module';
import { Tab1PageRoutingModule } from './tab1-routing.module';
@NgModule({
imports: [
IonicModule,
CommonModule,
FormsModule,
ExploreContainerComponentModule,
Tab1PageRoutingModule
],
declarations: [Tab1Page]
})
export class Tab1PageModule {}

View File

@ -1,17 +0,0 @@
<ion-header [translucent]="true">
<ion-toolbar>
<ion-title>
Tab 1
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">Tab 1</ion-title>
</ion-toolbar>
</ion-header>
<app-explore-container name="Tab 1 page"></app-explore-container>
</ion-content>

View File

@ -1,26 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { ExploreContainerComponentModule } from '../explore-container/explore-container.module';
import { Tab1Page } from './tab1.page';
describe('Tab1Page', () => {
let component: Tab1Page;
let fixture: ComponentFixture<Tab1Page>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [Tab1Page],
imports: [IonicModule.forRoot(), ExploreContainerComponentModule]
}).compileComponents();
fixture = TestBed.createComponent(Tab1Page);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,13 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-tab1',
templateUrl: 'tab1.page.html',
styleUrls: ['tab1.page.scss'],
standalone: false,
})
export class Tab1Page {
constructor() {}
}

View File

@ -1,16 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Tab2Page } from './tab2.page';
const routes: Routes = [
{
path: '',
component: Tab2Page,
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class Tab2PageRoutingModule {}

View File

@ -1,20 +0,0 @@
import { IonicModule } from '@ionic/angular';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Tab2Page } from './tab2.page';
import { ExploreContainerComponentModule } from '../explore-container/explore-container.module';
import { Tab2PageRoutingModule } from './tab2-routing.module';
@NgModule({
imports: [
IonicModule,
CommonModule,
FormsModule,
ExploreContainerComponentModule,
Tab2PageRoutingModule
],
declarations: [Tab2Page]
})
export class Tab2PageModule {}

View File

@ -1,26 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { ExploreContainerComponentModule } from '../explore-container/explore-container.module';
import { Tab2Page } from './tab2.page';
describe('Tab2Page', () => {
let component: Tab2Page;
let fixture: ComponentFixture<Tab2Page>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [Tab2Page],
imports: [IonicModule.forRoot(), ExploreContainerComponentModule]
}).compileComponents();
fixture = TestBed.createComponent(Tab2Page);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,13 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-tab2',
templateUrl: 'tab2.page.html',
styleUrls: ['tab2.page.scss'],
standalone: false,
})
export class Tab2Page {
constructor() {}
}

View File

@ -1,16 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Tab3Page } from './tab3.page';
const routes: Routes = [
{
path: '',
component: Tab3Page,
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class Tab3PageRoutingModule {}

View File

@ -1,20 +0,0 @@
import { IonicModule } from '@ionic/angular';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Tab3Page } from './tab3.page';
import { ExploreContainerComponentModule } from '../explore-container/explore-container.module';
import { Tab3PageRoutingModule } from './tab3-routing.module';
@NgModule({
imports: [
IonicModule,
CommonModule,
FormsModule,
ExploreContainerComponentModule,
Tab3PageRoutingModule
],
declarations: [Tab3Page]
})
export class Tab3PageModule {}

View File

@ -1,26 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IonicModule } from '@ionic/angular';
import { ExploreContainerComponentModule } from '../explore-container/explore-container.module';
import { Tab3Page } from './tab3.page';
describe('Tab3Page', () => {
let component: Tab3Page;
let fixture: ComponentFixture<Tab3Page>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [Tab3Page],
imports: [IonicModule.forRoot(), ExploreContainerComponentModule]
}).compileComponents();
fixture = TestBed.createComponent(Tab3Page);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,13 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-tab3',
templateUrl: 'tab3.page.html',
styleUrls: ['tab3.page.scss'],
standalone: false,
})
export class Tab3Page {
constructor() {}
}

View File

@ -8,27 +8,27 @@ const routes: Routes = [
component: TabsPage, component: TabsPage,
children: [ children: [
{ {
path: 'tab1', path: 'classes',
loadChildren: () => import('../tab1/tab1.module').then(m => m.Tab1PageModule) loadChildren: () => import('../pages/classes/classes.module').then(m => m.ClassesPageModule)
}, },
{ {
path: 'tab2', path: 'bookings',
loadChildren: () => import('../tab2/tab2.module').then(m => m.Tab2PageModule) loadChildren: () => import('../pages/bookings/bookings.module').then(m => m.BookingsPageModule)
}, },
{ {
path: 'tab3', path: 'profile',
loadChildren: () => import('../tab3/tab3.module').then(m => m.Tab3PageModule) loadChildren: () => import('../pages/profile/profile.module').then(m => m.ProfilePageModule)
}, },
{ {
path: '', path: '',
redirectTo: '/tabs/tab1', redirectTo: '/tabs/classes',
pathMatch: 'full' pathMatch: 'full'
} }
] ]
}, },
{ {
path: '', path: '',
redirectTo: '/tabs/tab1', redirectTo: '/tabs/classes',
pathMatch: 'full' pathMatch: 'full'
} }
]; ];
@ -36,4 +36,4 @@ const routes: Routes = [
@NgModule({ @NgModule({
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],
}) })
export class TabsPageRoutingModule {} export class TabsPageRoutingModule {}

View File

@ -1,20 +1,18 @@
<ion-tabs> <ion-tabs>
<ion-tab-bar slot="bottom"> <ion-tab-bar slot="bottom">
<ion-tab-button tab="tab1" href="/tabs/tab1"> <ion-tab-button tab="classes">
<ion-icon aria-hidden="true" name="triangle"></ion-icon> <ion-icon name="calendar"></ion-icon>
<ion-label>Tab 1</ion-label> <ion-label>Clases</ion-label>
</ion-tab-button> </ion-tab-button>
<ion-tab-button tab="tab2" href="/tabs/tab2"> <ion-tab-button tab="bookings">
<ion-icon aria-hidden="true" name="ellipse"></ion-icon> <ion-icon name="bookmark"></ion-icon>
<ion-label>Tab 2</ion-label> <ion-label>Mis Reservas</ion-label>
</ion-tab-button> </ion-tab-button>
<ion-tab-button tab="tab3" href="/tabs/tab3"> <ion-tab-button tab="profile">
<ion-icon aria-hidden="true" name="square"></ion-icon> <ion-icon name="person"></ion-icon>
<ion-label>Tab 3</ion-label> <ion-label>Perfil</ion-label>
</ion-tab-button> </ion-tab-button>
</ion-tab-bar> </ion-tab-bar>
</ion-tabs> </ion-tabs>