verssion 0.1

This commit is contained in:
luis cespedes 2025-04-24 12:44:46 -04:00
parent d20150506f
commit 35f04423c4
108 changed files with 5515 additions and 331 deletions

View File

@ -1,3 +1,7 @@
{
"typescript.preferences.autoImportFileExcludePatterns": ["@ionic/angular/common", "@ionic/angular/standalone"]
"typescript.preferences.autoImportFileExcludePatterns": [
"@ionic/angular/common",
"@ionic/angular/standalone"
],
"java.configuration.updateBuildConfiguration": "interactive"
}

101
android/.gitignore vendored Normal file
View File

@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

2
android/app/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

54
android/app/build.gradle Normal file
View File

@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace "com.valposystems.gymreservation"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.valposystems.gymreservation"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@ -0,0 +1,25 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-camera')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-preferences')
implementation project(':capacitor-status-bar')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<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"/>
</manifest>

View File

@ -0,0 +1,5 @@
package com.valposystems.gymreservation;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Gym Reservation</string>
<string name="title_activity_main">Gym Reservation</string>
<string name="package_name">com.valposystems.gymreservation</string>
<string name="custom_url_scheme">com.valposystems.gymreservation</string>
</resources>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

29
android/build.gradle Normal file
View File

@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.9.2'
classpath 'com.google.gms:google-services:4.4.2'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,24 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-camera'
project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')

22
android/gradle.properties Normal file
View File

@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
android/gradlew vendored Executable file
View File

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

5
android/settings.gradle Normal file
View File

@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

16
android/variables.gradle Normal file
View File

@ -0,0 +1,16 @@
ext {
minSdkVersion = 23
compileSdkVersion = 35
targetSdkVersion = 35
androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4'
coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.12.1'
junitVersion = '4.13.2'
androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1'
}

36
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Node.js dependencies
/node_modules
npm-debug.log
yarn-debug.log
yarn-error.log
# Environment variables
.env
.env.local
# Build files
/dist
/build
# Logs
logs
*.log
# OS files
.DS_Store
Thumbs.db
# Editor directories and files
.idea
.vscode
*.swp
*.swo
# Debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Uploads folder content (except default images)
/uploads/*
!/uploads/.gitkeep

115
backend/README.md Normal file
View File

@ -0,0 +1,115 @@
# Gym Backend - Node.js Version
This is a Node.js backend for a Gym application that was converted from a Java/Quarkus application. It uses Express.js and Sequelize with PostgreSQL.
## Características
- Gestión de usuarios
- Gestión de clases de gimnasio
- Sistema de reservas
- Subida de archivos (imágenes)
## Tecnologías
- Node.js & Express
- PostgreSQL con Sequelize
- Autenticación con bcrypt
- Multer para subida de archivos
## Instalación
```bash
# Instalar dependencias
npm install
# Configurar variables de entorno
# Edita el archivo .env con tus credenciales de PostgreSQL
```
## Configuración de la Base de Datos
Puedes usar PostgreSQL local o en Railway. Asegúrate de actualizar las variables de entorno en el archivo .env:
```
PORT=3000
DB_HOST=localhost
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=gymdb
DB_PORT=5432
DB_DIALECT=postgres
NODE_ENV=development
```
## Ejecución de la Aplicación
```bash
# Ejecutar en modo desarrollo
npm run dev
# Ejecutar en modo producción
npm start
# Poblar la base de datos con datos de ejemplo
npm run seed
```
## Endpoints de la API
### Usuarios
- `GET /api/users` - Obtener todos los usuarios
- `GET /api/users/:id` - Obtener usuario por ID
- `POST /api/users` - Crear nuevo usuario
- `PUT /api/users/:id` - Actualizar usuario
### Clases de Gimnasio
- `GET /api/classes` - Obtener todas las clases
- `GET /api/classes/:id` - Obtener clase por ID
- `POST /api/classes` - Crear nueva clase
- `PUT /api/classes/:id` - Actualizar clase
- `DELETE /api/classes/:id` - Eliminar clase
### Reservas
- `GET /api/bookings` - Obtener todas las reservas
- `GET /api/bookings/user/:userId` - Obtener reservas por usuario
- `POST /api/bookings` - Crear nueva reserva
- `PUT /api/bookings/:id/cancel` - Cancelar reserva
### Subida de Archivos
- `POST /api/upload` - Subir un archivo
## Despliegue en Railway
1. Crea una cuenta en [Railway](https://railway.app/) si aún no tienes una
2. Crea un nuevo proyecto en Railway
3. Agrega un servicio de PostgreSQL desde el dashboard
4. Conecta tu repositorio de GitHub o sube el código directamente
5. Configura las variables de entorno en Railway:
```
PORT=3000
DB_HOST=${{ PGHOST }}
DB_USER=${{ PGUSER }}
DB_PASSWORD=${{ PGPASSWORD }}
DB_NAME=${{ PGDATABASE }}
DB_PORT=${{ PGPORT }}
DB_DIALECT=postgres
NODE_ENV=production
```
6. Railway detectará automáticamente que es una aplicación Node.js y ejecutará `npm start`
7. Una vez desplegada, ejecuta el comando de inicialización de datos desde la terminal de Railway:
```
npm run seed
```
## Comparación con la Versión Java/Quarkus
Esta versión Node.js mantiene la misma funcionalidad y estructura de API que la versión original Java/Quarkus, con estas diferencias clave:
1. Usa Sequelize como ORM en lugar de Hibernate/Panache
2. Implementa API RESTful con Express en lugar de JAX-RS
3. Maneja la subida de archivos con Multer en lugar de RESTEasy Multipart
4. Mantiene compatibilidad con PostgreSQL al igual que la versión original
## Licencia
MIT

70
backend/config/db.js Normal file
View File

@ -0,0 +1,70 @@
const { Sequelize } = require('sequelize');
const dotenv = require('dotenv');
dotenv.config();
// Create Sequelize instance
let sequelize;
if (process.env.DATABASE_URL) {
// Use connection string if available
sequelize = new Sequelize(process.env.DATABASE_URL, {
logging: process.env.NODE_ENV === 'development' ? console.log : false,
define: {
freezeTableName: true // Prevent Sequelize from pluralizing table names
},
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
},
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
}
});
} else {
// Fall back to individual parameters
sequelize = new Sequelize(
process.env.DB_NAME || 'postgres',
process.env.DB_USER || 'postgres',
process.env.DB_PASSWORD || 'postgres',
{
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
dialect: 'postgres',
logging: process.env.NODE_ENV === 'development' ? console.log : false,
define: {
freezeTableName: true // Prevent Sequelize from pluralizing table names
},
dialectOptions: {
ssl: process.env.NODE_ENV === 'production' ? {
require: true,
rejectUnauthorized: false
} : false
},
pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
}
}
);
}
// Test the connection
const connectDB = async () => {
try {
await sequelize.authenticate();
console.log('Database connection established successfully.');
} catch (error) {
console.error('Unable to connect to the database:', error);
process.exit(1);
}
};
module.exports = { sequelize, connectDB };

View File

@ -0,0 +1,175 @@
const { Booking, User, GymClass } = require('../models');
// @desc Get all bookings
// @route GET /api/bookings
// @access Public
exports.getBookings = async (req, res) => {
try {
const bookings = await Booking.findAll({
include: [
{
model: User,
attributes: ['id', 'name', 'email']
},
{
model: GymClass
}
]
});
res.status(200).json(bookings);
} catch (error) {
console.error('Error in getBookings:', error);
res.status(500).json({
message: 'Error retrieving bookings',
error: error.message
});
}
};
// @desc Get bookings by user ID
// @route GET /api/bookings/user/:userId
// @access Public
exports.getBookingsByUser = async (req, res) => {
try {
const bookings = await Booking.findAll({
where: { userId: req.params.userId },
include: [
{
model: User,
attributes: ['id', 'name', 'email']
},
{
model: GymClass
}
]
});
res.status(200).json(bookings);
} catch (error) {
console.error('Error in getBookingsByUser:', error);
res.status(500).json({
message: 'Error retrieving user bookings',
error: error.message
});
}
};
// @desc Create new booking
// @route POST /api/bookings
// @access Public
exports.createBooking = async (req, res) => {
try {
const { userId, gymClassId } = req.body;
// Check if user exists
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).json({
message: 'User not found'
});
}
// Check if class exists
const gymClass = await GymClass.findByPk(gymClassId);
if (!gymClass) {
return res.status(404).json({
message: 'Class not found'
});
}
// Check if class has available capacity
if (gymClass.currentBookings >= gymClass.maxCapacity) {
return res.status(409).json({
message: 'No hay plazas disponibles'
});
}
// Create booking
const booking = await Booking.create({
userId,
gymClassId,
status: 'confirmed',
bookingDate: new Date()
});
// Update class capacity
gymClass.currentBookings += 1;
await gymClass.save();
// Return booking with associations
const fullBooking = await Booking.findByPk(booking.id, {
include: [
{
model: User,
attributes: ['id', 'name', 'email']
},
{
model: GymClass
}
]
});
res.status(201).json(fullBooking);
} catch (error) {
console.error('Error in createBooking:', error);
res.status(500).json({
message: 'Error creating booking',
error: error.message
});
}
};
// @desc Cancel booking
// @route PUT /api/bookings/:id/cancel
// @access Public
exports.cancelBooking = async (req, res) => {
try {
const booking = await Booking.findByPk(req.params.id);
if (!booking) {
return res.status(404).json({
message: 'Booking not found'
});
}
// Check if booking is already canceled
if (booking.status !== 'confirmed') {
return res.status(400).json({
message: 'Booking is not in confirmed status'
});
}
// Update booking status
booking.status = 'cancelled';
await booking.save();
// Update class capacity
const gymClass = await GymClass.findByPk(booking.gymClassId);
if (gymClass) {
gymClass.currentBookings -= 1;
await gymClass.save();
}
// Return updated booking with associations
const updatedBooking = await Booking.findByPk(booking.id, {
include: [
{
model: User,
attributes: ['id', 'name', 'email']
},
{
model: GymClass
}
]
});
res.status(200).json(updatedBooking);
} catch (error) {
console.error('Error in cancelBooking:', error);
res.status(500).json({
message: 'Error cancelling booking',
error: error.message
});
}
};

View File

@ -0,0 +1,108 @@
const { GymClass } = require('../models');
// @desc Get all classes
// @route GET /api/classes
// @access Public
exports.getClasses = async (req, res) => {
try {
const classes = await GymClass.findAll();
res.status(200).json(classes);
} catch (error) {
console.error('Error in getClasses:', error);
res.status(500).json({
message: 'Error retrieving classes',
error: error.message
});
}
};
// @desc Get single class
// @route GET /api/classes/:id
// @access Public
exports.getClass = async (req, res) => {
try {
const gymClass = await GymClass.findByPk(req.params.id);
if (!gymClass) {
return res.status(404).json({
message: 'Class not found'
});
}
res.status(200).json(gymClass);
} catch (error) {
console.error('Error in getClass:', error);
res.status(500).json({
message: 'Error retrieving class',
error: error.message
});
}
};
// @desc Create new class
// @route POST /api/classes
// @access Public
exports.createClass = async (req, res) => {
try {
const gymClass = await GymClass.create(req.body);
res.status(201).json(gymClass);
} catch (error) {
console.error('Error in createClass:', error);
res.status(500).json({
message: 'Error creating class',
error: error.message
});
}
};
// @desc Update class
// @route PUT /api/classes/:id
// @access Public
exports.updateClass = async (req, res) => {
try {
const gymClass = await GymClass.findByPk(req.params.id);
if (!gymClass) {
return res.status(404).json({
message: 'Class not found'
});
}
await gymClass.update(req.body);
res.status(200).json(gymClass);
} catch (error) {
console.error('Error in updateClass:', error);
res.status(500).json({
message: 'Error updating class',
error: error.message
});
}
};
// @desc Delete class
// @route DELETE /api/classes/:id
// @access Public
exports.deleteClass = async (req, res) => {
try {
const gymClass = await GymClass.findByPk(req.params.id);
if (!gymClass) {
return res.status(404).json({
message: 'Class not found'
});
}
await gymClass.destroy();
res.status(204).send();
} catch (error) {
console.error('Error in deleteClass:', error);
res.status(500).json({
message: 'Error deleting class',
error: error.message
});
}
};

View File

@ -0,0 +1,34 @@
const path = require('path');
const { v4: uuidv4 } = require('uuid');
// @desc Upload file
// @route POST /api/upload
// @access Public
exports.uploadFile = (req, res) => {
try {
if (!req.file) {
console.log('No se recibió ningún archivo en la petición');
return res.status(400).json({
success: false,
error: 'No file uploaded'
});
}
console.log('Archivo recibido:', req.file);
// Create file URL
const fileUrl = `/uploads/${req.file.filename}`;
console.log('URL generada:', fileUrl);
res.status(200).json({
success: true,
url: fileUrl
});
} catch (error) {
console.error('Error en uploadFile:', error);
res.status(500).json({
success: false,
error: error.message
});
}
};

View File

@ -0,0 +1,112 @@
const { User } = require('../models');
// @desc Get all users
// @route GET /api/users
// @access Public
exports.getUsers = async (req, res) => {
try {
const users = await User.findAll({
attributes: { exclude: ['password'] }
});
res.status(200).json(users);
} catch (error) {
console.error('Error in getUsers:', error);
res.status(500).json({
message: 'Error retrieving users',
error: error.message
});
}
};
// @desc Get single user
// @route GET /api/users/:id
// @access Public
exports.getUser = async (req, res) => {
try {
const user = await User.findByPk(req.params.id, {
attributes: { exclude: ['password'] }
});
if (!user) {
return res.status(404).json({
message: 'User not found'
});
}
res.status(200).json(user);
} catch (error) {
console.error('Error in getUser:', error);
res.status(500).json({
message: 'Error retrieving user',
error: error.message
});
}
};
// @desc Create user
// @route POST /api/users
// @access Public
exports.createUser = async (req, res) => {
try {
// Check if user with email already exists
const existingUser = await User.findOne({ where: { email: req.body.email } });
if (existingUser) {
return res.status(409).json({
message: 'Ya existe un usuario con ese email'
});
}
// Set default password if not provided
if (!req.body.password) {
req.body.password = 'password123';
}
const user = await User.create(req.body);
// Remove password from response
const userResponse = user.toJSON();
delete userResponse.password;
res.status(201).json(userResponse);
} catch (error) {
console.error('Error in createUser:', error);
res.status(500).json({
message: 'Error creating user',
error: error.message
});
}
};
// @desc Update user
// @route PUT /api/users/:id
// @access Public
exports.updateUser = async (req, res) => {
try {
const user = await User.findByPk(req.params.id);
if (!user) {
return res.status(404).json({
message: 'User not found'
});
}
// Don't allow email to be updated
delete req.body.email;
await user.update(req.body);
// Remove password from response
const userResponse = user.toJSON();
delete userResponse.password;
res.status(200).json(userResponse);
} catch (error) {
console.error('Error in updateUser:', error);
res.status(500).json({
message: 'Error updating user',
error: error.message
});
}
};

41
backend/models/Booking.js Normal file
View File

@ -0,0 +1,41 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const Booking = sequelize.define('booking', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
},
gymClassId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'gymclasses',
key: 'id'
}
},
bookingDate: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
},
status: {
type: DataTypes.STRING,
defaultValue: 'confirmed',
validate: {
isIn: [['confirmed', 'cancelled', 'pending']]
}
}
}, {
tableName: 'bookings' // Explicitly set lowercase table name
});
module.exports = Booking;

View File

@ -0,0 +1,53 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const GymClass = sequelize.define('gymclass', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: false
},
instructor: {
type: DataTypes.STRING,
allowNull: false
},
startTime: {
type: DataTypes.DATE,
allowNull: false
},
endTime: {
type: DataTypes.DATE,
allowNull: false
},
maxCapacity: {
type: DataTypes.INTEGER,
allowNull: false
},
currentBookings: {
type: DataTypes.INTEGER,
defaultValue: 0
},
category: {
type: DataTypes.STRING,
allowNull: false,
validate: {
isIn: [['Mente y Cuerpo', 'Cardiovascular', 'Fuerza', 'Baile', 'Otros']]
}
},
imageUrl: {
type: DataTypes.STRING,
defaultValue: '/uploads/default-class.jpg'
}
}, {
tableName: 'gymclasses' // Explicitly set lowercase table name
});
module.exports = GymClass;

58
backend/models/User.js Normal file
View File

@ -0,0 +1,58 @@
const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/db');
const bcrypt = require('bcryptjs');
const User = sequelize.define('user', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true
}
},
password: {
type: DataTypes.STRING,
allowNull: false
},
profilePicUrl: {
type: DataTypes.STRING,
defaultValue: '/uploads/default-avatar.jpg'
},
notificationsEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: true
}
}, {
hooks: {
beforeCreate: async (user) => {
if (user.password) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
},
beforeUpdate: async (user) => {
if (user.changed('password')) {
const salt = await bcrypt.genSalt(10);
user.password = await bcrypt.hash(user.password, salt);
}
}
},
tableName: 'users' // Explicitly set lowercase table name
});
// Method to check if password matches
User.prototype.matchPassword = async function(enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
module.exports = User;

18
backend/models/index.js Normal file
View File

@ -0,0 +1,18 @@
const User = require('./User');
const GymClass = require('./GymClass');
const Booking = require('./Booking');
const { sequelize } = require('../config/db');
// Define associations
User.hasMany(Booking, { foreignKey: 'userId' });
Booking.belongsTo(User, { foreignKey: 'userId' });
GymClass.hasMany(Booking, { foreignKey: 'gymClassId' });
Booking.belongsTo(GymClass, { foreignKey: 'gymClassId' });
module.exports = {
sequelize,
User,
GymClass,
Booking
};

1862
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
backend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "gym-backend-node",
"version": "1.0.0",
"description": "Simple Node.js backend for Gym app with Sequelize and PostgreSQL",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1",
"seed": "node seeder.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.10.0",
"pg-hstore": "^2.3.4",
"sequelize": "^6.30.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^2.0.22"
}
}

View File

@ -0,0 +1,16 @@
const express = require('express');
const { getBookings, getBookingsByUser, createBooking, cancelBooking } = require('../controllers/bookingController');
const router = express.Router();
router.route('/')
.get(getBookings)
.post(createBooking);
router.route('/user/:userId')
.get(getBookingsByUser);
router.route('/:id/cancel')
.put(cancelBooking);
module.exports = router;

View File

@ -0,0 +1,15 @@
const express = require('express');
const { getClasses, getClass, createClass, updateClass, deleteClass } = require('../controllers/gymClassController');
const router = express.Router();
router.route('/')
.get(getClasses)
.post(createClass);
router.route('/:id')
.get(getClass)
.put(updateClass)
.delete(deleteClass);
module.exports = router;

View File

@ -0,0 +1,44 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { uploadFile } = require('../controllers/uploadController');
const router = express.Router();
// Set up storage configuration for multer
const storage = multer.diskStorage({
destination: function(req, file, cb) {
// Usar una ruta absoluta para evitar problemas
const uploadPath = path.join(__dirname, '../uploads');
console.log('Ruta de destino para uploads:', uploadPath);
cb(null, uploadPath);
},
filename: function(req, file, cb) {
// Generar un nombre único para el archivo
const fileExt = path.extname(file.originalname);
const fileName = `${uuidv4()}${fileExt}`;
console.log('Nombre generado para el archivo:', fileName);
cb(null, fileName);
}
});
// File filter
const fileFilter = (req, file, cb) => {
// Accept images only
if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) {
return cb(new Error('Only image files are allowed!'), false);
}
cb(null, true);
};
// Init upload
const upload = multer({
storage: storage,
limits: { fileSize: 1024 * 1024 * 5 }, // 5MB max file size
fileFilter: fileFilter
});
router.post('/', upload.single('file'), uploadFile);
module.exports = router;

View File

@ -0,0 +1,14 @@
const express = require('express');
const { getUsers, getUser, createUser, updateUser } = require('../controllers/userController');
const router = express.Router();
router.route('/')
.get(getUsers)
.post(createUser);
router.route('/:id')
.get(getUser)
.put(updateUser);
module.exports = router;

181
backend/seeder.js Normal file
View File

@ -0,0 +1,181 @@
const fs = require('fs');
const path = require('path');
const { sequelize, User, GymClass } = require('./models');
const dotenv = require('dotenv');
const bcrypt = require('bcryptjs');
// Load env vars
dotenv.config();
// Copy image files from Java project to uploads folder
const copyImages = async () => {
const sourceDir = path.join(__dirname, '..', 'gym-backend', 'uploads');
const destDir = path.join(__dirname, 'uploads');
// Create uploads directory if it doesn't exist
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
// Check if source directory exists
if (fs.existsSync(sourceDir)) {
// Get all image files from source directory
const imageFiles = fs.readdirSync(sourceDir).filter(file =>
file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg') || file.endsWith('.gif')
);
// Copy each file to destination
imageFiles.forEach(file => {
fs.copyFileSync(path.join(sourceDir, file), path.join(destDir, file));
console.log(`Copied ${file} to uploads folder`);
});
} else {
console.log('Source directory not found, skipping image copy');
}
};
// Create default images if they don't exist
const createDefaultImages = async () => {
const defaultImages = {
'default-avatar.jpg': 'Default user avatar image',
'default-class.jpg': 'Default class image',
'yoga.png': 'Yoga class image',
'spinning.png': 'Spinning class image',
'pilates.png': 'Pilates class image',
'zumba.png': 'Zumba class image',
'crossfit.png': 'CrossFit class image'
};
const uploadsDir = path.join(__dirname, 'uploads');
// Create uploads directory if it doesn't exist
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Create each default image if it doesn't exist
Object.entries(defaultImages).forEach(([filename, content]) => {
const filePath = path.join(uploadsDir, filename);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, content);
console.log(`Created placeholder for ${filename}`);
}
});
};
// Import data into DB
const importData = async () => {
try {
// Sync database models without force
await sequelize.sync();
console.log('Database connected');
// Copy image files from Java project and create defaults
await copyImages();
await createDefaultImages();
// Check if demo user already exists
const existingUser = await User.findOne({ where: { email: 'usuario@ejemplo.com' } });
// Create demo user only if it doesn't exist
let demoUser;
if (!existingUser) {
// Hash password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash('password123', salt);
demoUser = await User.create({
name: 'Usuario Demo',
email: 'usuario@ejemplo.com',
password: hashedPassword,
profilePicUrl: '/uploads/default-avatar.jpg',
notificationsEnabled: true
});
console.log('Demo user created with ID:', demoUser.id);
} else {
console.log('Demo user already exists, skipping creation');
demoUser = existingUser;
}
// Create demo classes only if they don't exist
const existingClasses = await GymClass.count();
if (existingClasses === 0) {
const currentDate = new Date();
const classes = [
{
name: 'Yoga',
description: 'Clase de yoga para todos los niveles',
instructor: 'María López',
startTime: new Date(currentDate.getTime() + 24 * 60 * 60 * 1000 + 8 * 60 * 60 * 1000),
endTime: new Date(currentDate.getTime() + 24 * 60 * 60 * 1000 + 9 * 60 * 60 * 1000),
maxCapacity: 15,
currentBookings: 8,
category: 'Mente y Cuerpo',
imageUrl: '/uploads/yoga.png'
},
{
name: 'Spinning',
description: 'Clase de alta intensidad de ciclismo estático',
instructor: 'Juan Pérez',
startTime: new Date(currentDate.getTime() + 24 * 60 * 60 * 1000 + 10 * 60 * 60 * 1000),
endTime: new Date(currentDate.getTime() + 24 * 60 * 60 * 1000 + 11 * 60 * 60 * 1000),
maxCapacity: 20,
currentBookings: 15,
category: 'Cardiovascular',
imageUrl: '/uploads/spinning.png'
},
{
name: 'Pilates',
description: 'Fortalecimiento de core y flexibilidad',
instructor: 'Ana García',
startTime: new Date(currentDate.getTime() + 24 * 60 * 60 * 1000 + 16 * 60 * 60 * 1000),
endTime: new Date(currentDate.getTime() + 24 * 60 * 60 * 1000 + 17 * 60 * 60 * 1000),
maxCapacity: 12,
currentBookings: 5,
category: 'Mente y Cuerpo',
imageUrl: '/uploads/pilates.png'
},
{
name: 'Zumba',
description: 'Baile y ejercicio cardiovascular',
instructor: 'Carlos Martínez',
startTime: new Date(currentDate.getTime() + 48 * 60 * 60 * 1000 + 18 * 60 * 60 * 1000),
endTime: new Date(currentDate.getTime() + 48 * 60 * 60 * 1000 + 19 * 60 * 60 * 1000),
maxCapacity: 25,
currentBookings: 18,
category: 'Baile',
imageUrl: '/uploads/zumba.png'
},
{
name: 'CrossFit',
description: 'Entrenamiento funcional de alta intensidad',
instructor: 'Roberto Sánchez',
startTime: new Date(currentDate.getTime() + 48 * 60 * 60 * 1000 + 9 * 60 * 60 * 1000),
endTime: new Date(currentDate.getTime() + 48 * 60 * 60 * 1000 + 10 * 60 * 60 * 1000),
maxCapacity: 15,
currentBookings: 12,
category: 'Fuerza',
imageUrl: '/uploads/crossfit.png'
}
];
await GymClass.bulkCreate(classes);
console.log(`${classes.length} gym classes created`);
} else {
console.log(`Classes already exist, skipping class creation`);
}
console.log('Data import process completed successfully!');
process.exit();
} catch (err) {
console.error(err);
process.exit(1);
}
};
importData();

88
backend/server.js Normal file
View File

@ -0,0 +1,88 @@
const path = require('path');
const express = require('express');
const dotenv = require('dotenv');
const cors = require('cors');
const morgan = require('morgan');
const fs = require('fs');
const { connectDB, sequelize } = require('./config/db');
// Load env variables
dotenv.config();
// Import route files
const userRoutes = require('./routes/userRoutes');
const gymClassRoutes = require('./routes/gymClassRoutes');
const bookingRoutes = require('./routes/bookingRoutes');
const uploadRoutes = require('./routes/uploadRoutes');
// Initialize express app
const app = express();
// Middleware
app.use(express.json());
app.use(cors());
// Dev logging middleware
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
// Create uploads directory if it doesn't exist
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Set static folder for uploads
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Mount routers
app.use('/api/users', userRoutes);
app.use('/api/classes', gymClassRoutes);
app.use('/api/bookings', bookingRoutes);
app.use('/api/upload', uploadRoutes);
// Root route
app.get('/', (req, res) => {
res.json({ message: 'Welcome to Gym API' });
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
message: 'Server Error',
error: err.message
});
});
// Define port
const PORT = process.env.PORT || 3000;
// Connect to database and start server
const startServer = async () => {
try {
// Connect to database
await connectDB();
await sequelize.sync();
console.log('Database synchronized');
// Start server
app.listen(PORT, () => {
console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
});
} catch (error) {
console.error('Unable to start server:', error);
process.exit(1);
}
};
startServer();
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.log(`Error: ${err.message}`);
// Close server & exit process
process.exit(1);
});

View File

@ -1,9 +1,17 @@
import type { CapacitorConfig } from '@capacitor/cli';
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'io.ionic.starter',
appId: 'com.valposystems.gymreservation',
appName: 'taller',
webDir: 'www'
webDir: 'www',
server: {
androidScheme: 'https'
},
plugins: {
Camera: {
permissions: ['camera', 'photos']
}
}
};
export default config;

30
package-lock.json generated
View File

@ -16,10 +16,13 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@capacitor/android": "^7.2.0",
"@capacitor/app": "7.0.1",
"@capacitor/camera": "^7.0.1",
"@capacitor/core": "7.2.0",
"@capacitor/haptics": "7.0.1",
"@capacitor/keyboard": "7.0.1",
"@capacitor/local-notifications": "^7.0.1",
"@capacitor/preferences": "^7.0.1",
"@capacitor/status-bar": "7.0.1",
"@ionic/angular": "^8.0.0",
@ -2492,6 +2495,15 @@
"node": ">=6.9.0"
}
},
"node_modules/@capacitor/android": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-7.2.0.tgz",
"integrity": "sha512-zdhEy3jZPG5Toe/pGzKtDgIiBGywjaoEuQWnGVjBYPlSAEUtAhpZ2At7V0SCb26yluAuzrAUV0Ue+LQeEtHwFQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^7.2.0"
}
},
"node_modules/@capacitor/app": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-7.0.1.tgz",
@ -2501,6 +2513,15 @@
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/camera": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-7.0.1.tgz",
"integrity": "sha512-gDUFsYlhMra5VVOa4iJV6+MQRhp3VXpTLQY4JDATj7UvoZ8Hv4DG8qplPL9ufUFNoR3QbDDnf8+gbQOsKdkDjg==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/cli": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.2.0.tgz",
@ -2769,6 +2790,15 @@
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/local-notifications": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-7.0.1.tgz",
"integrity": "sha512-GJewoiqiTLXNNRxqeJDi6vxj1Y37jLFI3KSdAM2Omvxew4ewyBSCjwOtXMQaEg+lvzGHtK6FPrSc2v/2EcL0wA==",
"license": "MIT",
"peerDependencies": {
"@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",

View File

@ -8,6 +8,7 @@
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"build:android": "ng build && npx cap sync android && npx cap open android",
"test": "ng test",
"lint": "ng lint"
},
@ -21,10 +22,13 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@capacitor/android": "^7.2.0",
"@capacitor/app": "7.0.1",
"@capacitor/camera": "^7.0.1",
"@capacitor/core": "7.2.0",
"@capacitor/haptics": "7.0.1",
"@capacitor/keyboard": "7.0.1",
"@capacitor/local-notifications": "^7.0.1",
"@capacitor/preferences": "^7.0.1",
"@capacitor/status-bar": "7.0.1",
"@ionic/angular": "^8.0.0",

View File

@ -1,6 +1,7 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';
import { HttpClientModule } from '@angular/common/http';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
@ -9,7 +10,12 @@ import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule],
imports: [
BrowserModule,
IonicModule.forRoot(),
AppRoutingModule,
HttpClientModule
],
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
bootstrap: [AppComponent],
})

View File

@ -1,8 +1,22 @@
export interface Booking {
id: string;
userId: string;
classId: string;
className: string;
date: Date;
classId?: string; // Para compatibilidad con código existente
gymClassId?: string; // Campo que viene del backend
className?: string; // Para compatibilidad con código existente
date?: Date; // Para compatibilidad con código existente
bookingDate?: string; // Campo que viene del backend
status: 'confirmed' | 'cancelled' | 'pending';
gymClass?: { // El objeto completo de la clase que viene del backend
id: string;
name: string;
description: string;
instructor: string;
startTime: Date;
endTime: Date;
maxCapacity: number;
currentBookings: number;
category?: string;
imageUrl?: string;
};
}

View File

@ -2,7 +2,10 @@ export interface User {
id: string;
name: string;
email: string;
profilePic?: string;
profilePic?: string; // URL o data URI de la imagen (para compatibilidad con código existente)
profilePicUrl?: string; // Campo que usa el backend (debe coincidir con el modelo del servidor)
profileImage?: Blob; // Almacenamiento de la imagen como Blob para mejor rendimiento
notificationsEnabled?: boolean; // Estado de notificaciones (debe coincidir con el modelo del servidor)
preferences?: {
notifications: boolean;
favoriteClasses?: string[];

View File

@ -1,11 +1,8 @@
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { NgModule } 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({

View File

@ -1,13 +1,55 @@
<ion-header [translucent]="true">
<ion-toolbar>
<ion-title>bookings</ion-title>
<ion-header>
<ion-toolbar color="primary">
<ion-title>Mis Reservas</ion-title>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">bookings</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)" onerror="this.src='https://cdn-icons-png.flaticon.com/512/42/42829.png'">
</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="/tabs/classes">
<ion-icon name="fitness" slot="start"></ion-icon>
Ver Clases Disponibles
</ion-button>
</div>
</ion-content>

View File

@ -0,0 +1,13 @@
.large-icon {
font-size: 64px;
margin: 20px 0;
color: var(--ion-color-medium);
}
.empty-state {
padding-top: 20%;
}
ion-badge {
margin-top: 8px;
}

View File

@ -1,4 +1,8 @@
import { Component, OnInit } from '@angular/core';
import { AlertController, ToastController } from '@ionic/angular';
import { Booking } from '../../models/booking.model';
import { BookingsService } from '../../services/bookings.service';
import { ClassesService } from '../../services/classes.service';
@Component({
selector: 'app-bookings',
@ -7,10 +11,193 @@ import { Component, OnInit } from '@angular/core';
standalone: false
})
export class BookingsPage implements OnInit {
reservas: Booking[] = [];
cargando = true;
private clasesCache: { [id: string]: any } = {};
constructor() { }
constructor(
private bookingsService: BookingsService,
private classesService: ClassesService,
private alertController: AlertController,
private toastController: ToastController
) { }
ngOnInit() {
this.cargarReservas();
}
ionViewWillEnter() {
this.cargarReservas();
}
cargarReservas() {
this.cargando = true;
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;
}
});
}
private precargarClases() {
// Obtener solo IDs únicos de clases
const classIds = [...new Set(this.reservas.map(booking =>
booking.gymClassId || booking.classId
).filter(id => id !== undefined && id !== null))];
classIds.forEach(id => {
this.classesService.getClassById(id).subscribe(gymClass => {
if (gymClass) {
this.clasesCache[id] = gymClass;
}
});
});
}
obtenerFechaClase(booking: Booking): Date {
// Si tenemos la clase en caché, usamos su fecha real
const classId = booking.gymClassId || booking.classId;
if (classId && this.clasesCache[classId]) {
return this.clasesCache[classId].startTime;
}
// Si no, usamos la fecha de la reserva o bookingDate
return booking.date || (booking.bookingDate ? new Date(booking.bookingDate) : new Date());
}
refrescarReservas(event: any) {
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();
}
});
}
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();
}
async cancelarReserva(bookingId: string) {
this.bookingsService.cancelBooking(bookingId).subscribe({
next: async (success) => {
if (success) {
const toast = await this.toastController.create({
message: 'Reserva cancelada correctamente',
duration: 2000,
position: 'bottom',
color: 'success'
});
toast.present();
this.cargarReservas();
} else {
const toast = await this.toastController.create({
message: 'No se pudo cancelar la reserva',
duration: 2000,
position: 'bottom',
color: 'danger'
});
toast.present();
}
},
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();
}
});
}
getClassImage(booking: Booking): string {
// 1. Primero intentamos obtener la imagen directamente del objeto gymClass (backend)
if (booking.gymClass && booking.gymClass.imageUrl) {
return booking.gymClass.imageUrl;
}
// 2. Si no está en el objeto gymClass, intentamos desde el caché
const classId = booking.gymClassId || booking.classId;
if (classId && this.clasesCache[classId] && this.clasesCache[classId].imageUrl) {
return this.clasesCache[classId].imageUrl;
}
// 3. Si no encontramos la imagen, fallback basado en el nombre
const className = booking.className || (booking.gymClass ? booking.gymClass.name : '');
const nameLower = className.toLowerCase();
if (nameLower.includes('yoga')) {
return 'https://cdn-icons-png.flaticon.com/512/3456/3456464.png';
} else if (nameLower.includes('spin')) {
return 'https://cdn-icons-png.flaticon.com/512/805/805504.png';
} else if (nameLower.includes('pilates')) {
return 'https://cdn-icons-png.flaticon.com/512/625/625454.png';
} else if (nameLower.includes('zumba')) {
return 'https://cdn-icons-png.flaticon.com/512/5776/5776440.png';
} else if (nameLower.includes('cross')) {
return 'https://cdn-icons-png.flaticon.com/512/372/372612.png';
}
// Imagen por defecto
return 'https://cdn-icons-png.flaticon.com/512/42/42829.png';
}
}

View File

@ -15,7 +15,7 @@
<div *ngIf="gymClass && !cargando">
<ion-card>
<ion-img [src]="gymClass.imageUrl || 'assets/classes/default.jpg'" class="class-image"></ion-img>
<ion-img [src]="gymClass.imageUrl || 'https://cdn-icons-png.flaticon.com/512/140/140627.png'" class="class-image"></ion-img>
<ion-card-header>
<ion-badge>{{ gymClass.category }}</ion-badge>

View File

@ -80,7 +80,7 @@ export class ClassDetailPage implements OnInit {
this.reservando = true;
this.bookingsService.addBooking(this.gymClass.id, this.gymClass.name).subscribe({
this.bookingsService.addBooking(this.gymClass.id).subscribe({
next: async (booking) => {
this.mostrarToast(`¡Reserva confirmada para ${this.gymClass?.name}!`, 'success');

View File

@ -30,7 +30,7 @@
<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-img [src]="gymClass.imageUrl || 'https://cdn-icons-png.flaticon.com/512/140/140627.png'"></ion-img>
</ion-thumbnail>
<ion-label>
<h2>{{ gymClass.name }}</h2>

View File

@ -1,11 +1,8 @@
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { NgModule } 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({

View File

@ -1,13 +1,69 @@
<ion-header [translucent]="true">
<ion-toolbar>
<ion-title>profile</ion-title>
<ion-header>
<ion-toolbar color="primary">
<ion-title>Mi Perfil</ion-title>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">profile</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 || usuario?.profilePic || usuario?.profilePicUrl || 'https://avatar.iran.liara.run/public'" 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)="mostrarEditarPerfil()">
<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>

View File

@ -0,0 +1,26 @@
.profile-header {
margin-bottom: 20px;
}
.profile-avatar {
margin: 0 auto;
width: 120px;
height: 120px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.tap-text {
font-size: 12px;
color: var(--ion-color-medium);
margin-top: 8px;
}
.app-info {
margin-top: 40px;
color: var(--ion-color-medium);
font-size: 12px;
}
.version {
font-weight: bold;
}

View File

@ -1,4 +1,12 @@
import { Component, OnInit } from '@angular/core';
import { AlertController, ToastController, NavController, ActionSheetController, LoadingController, ModalController } from '@ionic/angular';
import { User } from '../../models/user.model';
import { AuthService } from '../../services/auth.service';
import { BookingsService } from '../../services/bookings.service';
import { StorageService } from '../../services/storage.service';
import { UploadService } from '../../services/upload.service';
import { environment } from '../../../environments/environment';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
@Component({
selector: 'app-profile',
@ -7,10 +15,352 @@ import { Component, OnInit } from '@angular/core';
standalone: false
})
export class ProfilePage implements OnInit {
usuario: User | null = null;
profileImage: string | undefined;
notificationsEnabled = true;
estadisticas = {
totalReservas: 0,
clasesCompletadas: 0
};
constructor() { }
constructor(
private authService: AuthService,
private bookingsService: BookingsService,
private storageService: StorageService,
private uploadService: UploadService,
private alertController: AlertController,
private toastController: ToastController,
private navController: NavController,
private actionSheetController: ActionSheetController,
private loadingController: LoadingController,
private modalController: ModalController
) { }
ngOnInit() {
this.cargarDatosUsuario();
}
ionViewWillEnter() {
this.cargarDatosUsuario();
this.cargarEstadisticas();
}
async cargarDatosUsuario() {
this.usuario = this.authService.getCurrentUser();
if (this.usuario) {
// Primero intentamos cargar la imagen de perfil local si existe
const localImage = await this.storageService.getProfileImage();
if (localImage) {
this.profileImage = localImage;
} else {
// Intentar usar imagen del usuario (desde cualquier campo disponible)
const userImage = this.usuario.profilePic || this.usuario.profilePicUrl;
if (userImage) {
this.profileImage = userImage;
// Y la guardamos localmente para la próxima vez
await this.storageService.saveProfileImage(userImage);
}
}
// Usar el campo de notificaciones desde cualquiera de los formatos disponibles
this.notificationsEnabled =
this.usuario.notificationsEnabled !== undefined ?
this.usuario.notificationsEnabled :
(this.usuario.preferences?.notifications ?? true);
}
}
cargarEstadisticas() {
this.bookingsService.getUserBookings().subscribe(bookings => {
this.estadisticas.totalReservas = bookings.length;
// Para este ejemplo, simularemos que las clases completadas son un 70% del total
this.estadisticas.clasesCompletadas = Math.floor(bookings.length * 0.7);
});
}
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 {
// 1. Solicitar permisos
const permisos = await Camera.requestPermissions();
if (permisos.photos === 'granted' || permisos.camera === 'granted') {
// 2. Mostrar loading
const loading = await this.loadingController.create({
message: 'Procesando imagen...',
spinner: 'circles'
});
await loading.present();
// 3. Capturar imagen
const imagen = await Camera.getPhoto({
quality: 90, // Mejor calidad
allowEditing: true,
width: 600, // Mayor resolución
height: 600, // Mayor resolución
resultType: CameraResultType.DataUrl,
source: source
});
// Si tenemos la imagen
if (imagen.dataUrl) {
try {
// 4. Guardar localmente primero para garantizar que no se pierda
await this.storageService.saveProfileImage(imagen.dataUrl);
// 5. Actualizar UI inmediatamente con la foto tomada
this.profileImage = imagen.dataUrl;
// 6. Subir directamente el dataUrl al servidor
// 7. Subir archivo al servidor utilizando UploadService
this.uploadService.uploadImageFromDataUrl(
imagen.dataUrl,
`profile_${this.usuario?.id || 'user'}_${Date.now()}.jpeg`
).subscribe({
next: async (response) => {
// Verificar la respuesta y extraer la URL
let imageUrl;
if (response.url) {
imageUrl = response.url;
} else {
console.log('Respuesta del servidor:', response);
imageUrl = response.url || '/uploads/default-avatar.jpg';
}
// Asegurarnos de que la URL sea completa
if (imageUrl && !imageUrl.startsWith('http')) {
imageUrl = `${environment.apiUrl}${imageUrl}`;
}
console.log('URL de imagen recibida:', imageUrl);
// 8. Actualizar el perfil del usuario con la URL del servidor
if (this.usuario) {
this.authService.updateUserProfile({
...this.usuario,
profilePic: imageUrl, // Para el frontend
profilePicUrl: imageUrl // Para el backend
}).subscribe({
next: (updatedUser) => {
this.usuario = updatedUser;
loading.dismiss();
this.mostrarToast('Foto de perfil actualizada', 'success');
},
error: (error) => {
console.error('Error al actualizar el perfil con la nueva imagen', error);
loading.dismiss();
// La imagen ya está guardada localmente, así que mostramos un mensaje menos alarmante
this.mostrarToast('Foto guardada localmente, pero no se pudo actualizar el servidor', 'warning');
}
});
} else {
loading.dismiss();
}
},
error: (error) => {
console.error('Error al subir la imagen al servidor', error);
loading.dismiss();
// La imagen ya está guardada localmente, así que mostramos un mensaje menos alarmante
this.mostrarToast('Foto guardada localmente, pero no se pudo subir al servidor', 'warning');
}
});
} catch (error) {
console.error('Error al procesar la imagen', error);
loading.dismiss();
this.mostrarToast('Error al procesar la imagen', 'danger');
}
} else {
loading.dismiss();
this.mostrarToast('No se pudo obtener la imagen', 'warning');
}
} 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');
}
}
toggleNotifications() {
if (this.usuario) {
const updatedPreferences = {
...this.usuario.preferences,
notifications: this.notificationsEnabled
};
this.authService.updateUserProfile({
...this.usuario,
preferences: updatedPreferences,
notificationsEnabled: this.notificationsEnabled // Campo que espera el backend
}).subscribe({
next: (updatedUser) => {
this.usuario = updatedUser;
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 mostrarEditarPerfil() {
const alert = await this.alertController.create({
header: 'Editar Perfil',
inputs: [
{
name: 'name',
type: 'text',
placeholder: 'Nombre completo',
value: this.usuario?.name || ''
},
{
name: 'email',
type: 'email',
placeholder: 'Correo electrónico',
value: this.usuario?.email || '',
disabled: true // No permitimos cambiar el email en esta demo
}
],
buttons: [
{
text: 'Cancelar',
role: 'cancel'
},
{
text: 'Guardar',
handler: (data) => {
this.actualizarPerfil(data);
}
}
]
});
await alert.present();
}
async actualizarPerfil(data: {name: string, email: string}) {
if (!this.usuario) return;
// Verificar si hay cambios
if (data.name === this.usuario.name) {
this.mostrarToast('No hay cambios que guardar', 'medium');
return;
}
// Mostrar loading
const loading = await this.loadingController.create({
message: 'Actualizando perfil...',
spinner: 'circles'
});
await loading.present();
// Actualizar usuario
this.authService.updateUserProfile({
...this.usuario,
name: data.name
}).subscribe({
next: (updatedUser) => {
this.usuario = updatedUser;
loading.dismiss();
this.mostrarToast('Perfil actualizado correctamente', 'success');
},
error: (error) => {
console.error('Error al actualizar perfil', error);
loading.dismiss();
this.mostrarToast('Error al actualizar perfil', 'danger');
}
});
}
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();
}
cerrarSesion() {
this.authService.logout().subscribe({
next: () => {
// En una app real, redirigir a la pantalla de login
this.mostrarToast('Sesión cerrada', 'success');
// Para este demo, simplemente reiniciamos a la primera pestaña
this.navController.navigateRoot('/tabs/classes');
},
error: (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();
}
}

View File

@ -1,66 +1,111 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { delay, tap } from 'rxjs/operators';
import { catchError, map, tap } from 'rxjs/operators';
import { User } from '../models/user.model';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private apiUrl = `${environment.apiUrl}/api/users`;
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(private http: HttpClient) {
// Cargar usuario inicial (para demo)
this.http.get<User>(`${this.apiUrl}/1`).subscribe(user => {
// Asegurarnos de mantener compatibilidad entre backend y frontend
if (user.profilePicUrl && !user.profilePic) {
user.profilePic = user.profilePicUrl;
} else if (user.profilePic && !user.profilePicUrl) {
user.profilePicUrl = user.profilePic;
}
};
constructor() {
// Simular usuario ya autenticado para el taller
this.currentUserSubject.next(this.demoUser);
// Si existe notificationsEnabled pero no preferences
if (user.notificationsEnabled !== undefined && !user.preferences) {
user.preferences = {
notifications: user.notificationsEnabled,
favoriteClasses: []
};
}
this.currentUserSubject.next(user);
});
}
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);
return of(currentUser as unknown as User);
}
const updatedUser: User = {
...currentUser,
...userData
console.log('Actualizando perfil de usuario con:', userData);
// Asegurar que ambos campos de imagen estén sincronizados
if (userData.profilePic && !userData.profilePicUrl) {
userData.profilePicUrl = userData.profilePic;
} else if (userData.profilePicUrl && !userData.profilePic) {
userData.profilePic = userData.profilePicUrl;
}
// Sincronizar notificaciones entre los dos formatos
if (userData.preferences?.notifications !== undefined && userData.notificationsEnabled === undefined) {
userData.notificationsEnabled = userData.preferences.notifications;
} else if (userData.notificationsEnabled !== undefined &&
(!userData.preferences || userData.preferences.notifications === undefined)) {
if (!userData.preferences) userData.preferences = { notifications: false, favoriteClasses: [] };
userData.preferences.notifications = userData.notificationsEnabled;
}
// Solo enviamos al servidor los campos que espera
const backendUserData = {
name: userData.name,
profilePicUrl: userData.profilePicUrl,
notificationsEnabled: userData.notificationsEnabled ||
(userData.preferences ? userData.preferences.notifications : undefined)
};
return of(updatedUser).pipe(
delay(800), // Simular latencia de red
tap(user => this.currentUserSubject.next(user))
return this.http.put<User>(`${this.apiUrl}/${currentUser.id}`, backendUserData).pipe(
tap(user => {
console.log('Usuario actualizado correctamente:', user);
// Sincronizar campos para mantener compatibilidad
if (user.profilePicUrl && !user.profilePic) {
user.profilePic = user.profilePicUrl;
} else if (user.profilePic && !user.profilePicUrl) {
user.profilePicUrl = user.profilePic;
}
// Mantener los campos que espera el frontend
if (user.notificationsEnabled !== undefined && !user.preferences) {
user.preferences = {
notifications: user.notificationsEnabled,
favoriteClasses: currentUser.preferences?.favoriteClasses || []
};
}
this.currentUserSubject.next(user);
}),
catchError(error => {
console.error('Error al actualizar usuario:', error);
// Devolver el usuario actualizado localmente para que la UI no se rompa
// en caso de error de red
const updatedUser = { ...currentUser, ...userData };
this.currentUserSubject.next(updatedUser);
return of(updatedUser);
})
);
}
logout(): Observable<any> {
// En una aplicación real, aquí se cerraría la sesión en el backend
this.currentUserSubject.next(null);
return of(true);
}
}

View File

@ -1,124 +1,116 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, from, of } from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { Booking } from '../models/booking.model';
import { StorageService } from './storage.service';
import { ClassesService } from './classes.service';
import { environment } from '../../environments/environment';
// URL base para las imágenes
const BASE_IMAGE_URL = environment.apiUrl;
@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
private apiUrl = `${environment.apiUrl}/api/bookings`;
private userId = '1'
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;
}
constructor(private http: HttpClient) { }
getUserBookings(): Observable<Booking[]> {
return from(this.ensureInitialized()).pipe(
switchMap(() => this.bookings$),
map(bookings => bookings.filter(booking => booking.userId === this.userId))
);
}
console.log('Obteniendo reservas del usuario...', `${this.apiUrl}/user/${this.userId}`);
return this.http.get<any[]>(`${this.apiUrl}/user/${this.userId}`).pipe(
map(bookings => {
return bookings.map(booking => {
// El backend devuelve booking.gymclass (minúscula) pero nuestro modelo usa gymClass (capitalizada)
const gymClass = booking.gymclass || booking.gymClass;
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'
return {
...booking,
// Garantizar que tengamos los campos para compatibilidad con código existente
classId: booking.gymClassId || booking.classId,
className: gymClass?.name,
// Convertir fechas si vienen del backend
date: booking.date ? new Date(booking.date) :
booking.bookingDate ? new Date(booking.bookingDate) : new Date(),
// Normalizar el objeto gymClass (asegurarnos que use camelCase)
gymClass: gymClass ? {
...gymClass,
startTime: new Date(gymClass.startTime),
endTime: new Date(gymClass.endTime),
// Convertir la ruta relativa de imagen a URL completa
imageUrl: this.getFullImageUrl(gymClass.imageUrl)
} : undefined
};
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);
addBooking(classId: string): Observable<Booking> {
console.log('Agregando reserva...', this.apiUrl);
return this.http.post<any>(this.apiUrl, {
userId: this.userId,
gymClassId: classId
}).pipe(
map(booking => {
// El backend devuelve booking.gymclass (minúscula) pero nuestro modelo usa gymClass (capitalizada)
const gymClass = booking.gymclass || booking.gymClass;
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)
);
})
);
return {
...booking,
date: booking.date ? new Date(booking.date) : booking.bookingDate ? new Date(booking.bookingDate) : new Date(),
gymClass: gymClass ? {
...gymClass,
startTime: new Date(gymClass.startTime),
endTime: new Date(gymClass.endTime),
imageUrl: this.getFullImageUrl(gymClass.imageUrl)
} : undefined
};
})
);
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.init();
}
cancelBooking(bookingId: string): Observable<Booking> {
console.log('Cancelando reserva...', `${this.apiUrl}/${bookingId}/cancel`);
return this.http.put<any>(`${this.apiUrl}/${bookingId}/cancel`, {}).pipe(
map(booking => {
// El backend devuelve booking.gymclass (minúscula) pero nuestro modelo usa gymClass (capitalizada)
const gymClass = booking.gymclass || booking.gymClass;
return {
...booking,
date: booking.date ? new Date(booking.date) : booking.bookingDate ? new Date(booking.bookingDate) : new Date(),
gymClass: gymClass ? {
...gymClass,
startTime: new Date(gymClass.startTime),
endTime: new Date(gymClass.endTime),
imageUrl: this.getFullImageUrl(gymClass.imageUrl)
} : undefined
};
})
);
}
/**
* Convierte una ruta de imagen relativa a URL completa
* @param imagePath Ruta relativa de la imagen (ej: /uploads/yoga.png)
* @returns URL completa incluyendo el dominio base
*/
private getFullImageUrl(imagePath: string | undefined): string {
if (!imagePath) {
// Imagen por defecto si no hay URL
return 'https://cdn-icons-png.flaticon.com/512/42/42829.png';
}
// Si la imagen ya es una URL completa (comienza con http/https), la devolvemos tal cual
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
return imagePath;
}
// Asegurarnos que la ruta comience con /
const normalizedPath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`;
// Concatenar la URL base con la ruta de la imagen
return `${BASE_IMAGE_URL}${normalizedPath}`;
}
}

View File

@ -1,155 +1,91 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, from, of } from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { GymClass } from '../models/gym-class.model';
import { StorageService } from './storage.service';
import { environment } from '../../environments/environment';
// URL base para las imágenes
const BASE_IMAGE_URL = environment.apiUrl;
@Injectable({
providedIn: 'root'
})
export class ClassesService {
private STORAGE_KEY = 'gym_classes';
private classesSubject = new BehaviorSubject<GymClass[]>([]);
public classes$ = this.classesSubject.asObservable();
private initialized = false;
private apiUrl = `${environment.apiUrl}/api/classes`;
// 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;
}
constructor(private http: HttpClient) { }
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)
);
console.log('Obteniendo clases...', this.apiUrl);
return this.http.get<any[]>(this.apiUrl).pipe(
map(classes => {
return classes.map(gymClass => {
return {
...gymClass,
startTime: new Date(gymClass.startTime),
endTime: new Date(gymClass.endTime),
// Convertir la ruta relativa de imagen a URL completa
imageUrl: this.getFullImageUrl(gymClass.imageUrl)
};
});
})
);
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.init();
}
getClassById(id: string | undefined): Observable<GymClass | null> {
// Si el ID es undefined o null, retornamos null inmediatamente sin hacer la petición API
if (!id) {
console.warn('Se intentó obtener una clase con ID undefined o null');
return new Observable<null>(observer => {
observer.next(null);
observer.complete();
});
}
return this.http.get<any>(`${this.apiUrl}/${id}`).pipe(
map(gymClass => {
if (!gymClass) return null;
return {
...gymClass,
startTime: new Date(gymClass.startTime),
endTime: new Date(gymClass.endTime),
// Convertir la ruta relativa de imagen a URL completa
imageUrl: this.getFullImageUrl(gymClass.imageUrl)
};
})
);
}
updateClassBookings(classId: string, change: number): Observable<boolean> {
// En este caso, no necesitamos implementar esta función directamente
// ya que el backend se encargará de actualizar los contadores cuando
// se creen o cancelen reservas.
return new Observable<boolean>(observer => {
observer.next(true);
observer.complete();
});
}
/**
* Convierte una ruta de imagen relativa a URL completa
* @param imagePath Ruta relativa de la imagen (ej: /uploads/yoga.png)
* @returns URL completa incluyendo el dominio base
*/
private getFullImageUrl(imagePath: string | undefined): string {
if (!imagePath) {
// Imagen por defecto si no hay URL
return 'https://cdn-icons-png.flaticon.com/512/42/42829.png';
}
// Si la imagen ya es una URL completa (comienza con http/https), la devolvemos tal cual
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
return imagePath;
}
// Asegurarnos que la ruta comience con /
const normalizedPath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`;
// Concatenar la URL base con la ruta de la imagen
return `${BASE_IMAGE_URL}${normalizedPath}`;
}
}

View File

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

View File

@ -0,0 +1,107 @@
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), // Convertir a número
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 {
// Cancelar la notificación asociada a la reserva
await LocalNotifications.cancel({
notifications: [
{ id: parseInt(bookingId) }
]
});
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;
}
}
async getPendingNotifications() {
try {
// Obtener notificaciones pendientes
const pendingList = await LocalNotifications.getPending();
return pendingList.notifications;
} catch (error) {
console.error('Error al obtener notificaciones pendientes', error);
return [];
}
}
}

View File

@ -1,6 +1,9 @@
import { Injectable } from '@angular/core';
import { Preferences } from '@capacitor/preferences';
// Clave para almacenar la imagen de perfil en el almacenamiento local
const PROFILE_IMAGE_KEY = 'profileImage';
@Injectable({
providedIn: 'root'
})
@ -9,10 +12,59 @@ export class StorageService {
constructor() { }
async set(key: string, value: any): Promise<void> {
try {
// Manejar blobs y data URIs en objetos complejos
const processedValue = this.prepareValueForStorage(value);
await Preferences.set({
key,
value: JSON.stringify(value)
value: JSON.stringify(processedValue)
});
} catch (error) {
console.error(`Error al guardar en storage (${key}):`, error);
}
}
/**
* Prepara valores para almacenamiento, manejando casos especiales
* como objetos con Blobs que no se pueden serializar
*/
private prepareValueForStorage(value: any): any {
if (value === null || value === undefined) {
return value;
}
// Si es un array, procesar cada elemento
if (Array.isArray(value)) {
return value.map(item => this.prepareValueForStorage(item));
}
// Si es un objeto que no es una fecha ni un blob
if (typeof value === 'object' && !(value instanceof Date) && !(value instanceof Blob)) {
const processed: any = {};
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
// Saltar propiedades blob directamente
if (value[key] instanceof Blob) {
continue;
}
// Para cada propiedad, procesarla recursivamente
processed[key] = this.prepareValueForStorage(value[key]);
}
}
return processed;
}
// Si es una fecha, convertir a string ISO
if (value instanceof Date) {
return value.toISOString();
}
// Cualquier otro valor primitivo
return value;
}
async get(key: string): Promise<any> {
@ -32,4 +84,29 @@ export class StorageService {
async clear(): Promise<void> {
await Preferences.clear();
}
/**
* Guarda la imagen de perfil del usuario en almacenamiento local
* @param dataUrl La imagen en formato data URL
*/
async saveProfileImage(dataUrl: string): Promise<void> {
return this.set(PROFILE_IMAGE_KEY, { dataUrl, timestamp: new Date().toISOString() });
}
/**
* Recupera la imagen de perfil del usuario del almacenamiento local
* @returns La imagen en formato data URL o null si no existe
*/
async getProfileImage(): Promise<string | null> {
const data = await this.get(PROFILE_IMAGE_KEY);
return data?.dataUrl || null;
}
/**
* Comprueba si hay una imagen de perfil almacenada localmente
*/
async hasProfileImage(): Promise<boolean> {
const image = await this.getProfileImage();
return image !== null;
}
}

View File

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

View File

@ -0,0 +1,50 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class UploadService {
private apiUrl = `${environment.apiUrl}/api/upload`;
constructor(private http: HttpClient) { }
uploadImage(file: File): Observable<{url: string}> {
const formData = new FormData();
formData.append('file', file);
return this.http.post<any>(this.apiUrl, formData);
}
/**
* Subir una imagen en formato data URL directamente
* @param dataUrl La imagen en formato data URL
* @param filename Nombre del archivo (opcional)
*/
uploadImageFromDataUrl(dataUrl: string, filename: string = `image_${Date.now()}.jpg`): Observable<{url: string}> {
// Convertir dataUrl a File
const file = this.dataURLtoFile(dataUrl, filename);
// Usar el método existente para subir el archivo
return this.uploadImage(file);
}
/**
* Convierte un Data URL a un objeto File
*/
private dataURLtoFile(dataUrl: string, filename: string): File {
const arr = dataUrl.split(',');
const mime = arr[0].match(/:(.*?);/)![1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, { type: mime });
}
}

Some files were not shown because too many files have changed in this diff Show More